mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 03:03:04 +00:00
Compare commits
95
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0edd8c39b9 | ||
|
|
4bfb682cd9 | ||
|
|
c4dc701411 | ||
|
|
f73f65cd78 | ||
|
|
bffb33ed32 | ||
|
|
8bfae15855 | ||
|
|
c778c47b30 | ||
|
|
6da9fa43fa | ||
|
|
cd8c868b32 | ||
|
|
6f6466d4cb | ||
|
|
6147991519 | ||
|
|
bbe3f17327 | ||
|
|
c914bcb44b | ||
|
|
54299a2e6c | ||
|
|
f4b5f32862 | ||
|
|
7db654aa0a | ||
|
|
4092c29265 | ||
|
|
5cedc210a8 | ||
|
|
4724d1d9b7 | ||
|
|
8fdca94ead | ||
|
|
88f410567d | ||
|
|
f998ee1691 | ||
|
|
7a47ec0ee9 | ||
|
|
4f796181bf | ||
|
|
f517493591 | ||
|
|
fb74f766c8 | ||
|
|
e268d1a894 | ||
|
|
b5f5832214 | ||
|
|
b8a31034ca | ||
|
|
68275f1e0f | ||
|
|
3979371901 | ||
|
|
71e375c2bc | ||
|
|
35cde075ae | ||
|
|
2124115c8e | ||
|
|
4094eeb566 | ||
|
|
8d92e1cc17 | ||
|
|
5123b23f41 | ||
|
|
c3594b2079 | ||
|
|
dc9fb9aa1b | ||
|
|
7c430777bf | ||
|
|
acad9f6881 | ||
|
|
fc214b20f1 | ||
|
|
c998b24c5f | ||
|
|
28df3f259a | ||
|
|
cc2012be7c | ||
|
|
b139046a3e | ||
|
|
482bbb2910 | ||
|
|
4a5a8d469c | ||
|
|
c60fa098ac | ||
|
|
33cf6cf337 | ||
|
|
65fbe38dd3 | ||
|
|
a69dca2907 | ||
|
|
3029b5991a | ||
|
|
b1455fb4b3 | ||
|
|
b4e0ff7685 | ||
|
|
4c6b79dc44 | ||
|
|
a687c42f33 | ||
|
|
7beff9cb0b | ||
|
|
c763b80824 | ||
|
|
8905ad4160 | ||
|
|
60a5344cc6 | ||
|
|
8f0c35e924 | ||
|
|
956f6ea7ad | ||
|
|
bb53e7835b | ||
|
|
02f59b1a26 | ||
|
|
022c27a8a1 | ||
|
|
f660fcf2e5 | ||
|
|
50480d8c9d | ||
|
|
75b0eaf8a8 | ||
|
|
2caecdaae8 | ||
|
|
29883cb797 | ||
|
|
97bdbebd7f | ||
|
|
c527b203c4 | ||
|
|
602860625c | ||
|
|
c032df5b3d | ||
|
|
e1805ef542 | ||
|
|
a7f3bf7abb | ||
|
|
04624cb419 | ||
|
|
bc02cfd8cd | ||
|
|
32d6b18dbf | ||
|
|
cf4664ddf1 | ||
|
|
c3d37eba4b | ||
|
|
33224ace47 | ||
|
|
efa25ad3fe | ||
|
|
28a963795b | ||
|
|
3f7188bc1c | ||
|
|
b54c8e493a | ||
|
|
19a7d26349 | ||
|
|
2a0a0ff656 | ||
|
|
5d7afd59ae | ||
|
|
959da4acb8 | ||
|
|
9163aed3f1 | ||
|
|
c6b04f302a | ||
|
|
975dbd433b | ||
|
|
4dbd26c88c |
@@ -348,12 +348,12 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
run: npx playwright install chromium
|
run: npx playwright install chromium
|
||||||
|
|
||||||
- name: Build app dependencies
|
|
||||||
run: npm run build:app-deps
|
|
||||||
|
|
||||||
- name: Build server stack
|
- name: Build server stack
|
||||||
run: npm run build:server
|
run: npm run build:server
|
||||||
|
|
||||||
|
- name: Build native app dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/expo-two-way-audio
|
||||||
|
|
||||||
- name: Install agent CLIs for provider tests
|
- name: Install agent CLIs for provider tests
|
||||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||||
|
|
||||||
|
|||||||
+76
-35
@@ -1,67 +1,108 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.5.0-beta.3 - 2026-08-20
|
## 0.6.0 - 2026-08-25
|
||||||
|
|
||||||
### Fixed
|
### Changed
|
||||||
|
|
||||||
- Fixed the composer retaining sent text on iOS and Android ([#3564](https://github.com/getpaseo/paseo/pull/3564))
|
- Restored the dedicated Explorer sidebar with Files and Changes by default, replacing the empty user-directed Side panel introduced in 0.5.0 ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||||
- Fixed pull request checkout in fork workflows where the target repository is configured as `upstream` ([#2997](https://github.com/getpaseo/paseo/pull/2997) by [@mcowger](https://github.com/mcowger))
|
- Changed content opened to the side to use an ordinary closable workspace pane instead of the Explorer sidebar ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||||
|
- Restored the resizable combined Explorer dock on tablets ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||||
## 0.5.0-beta.2 - 2026-08-19
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added Android Studio to Open in editor ([#3531](https://github.com/getpaseo/paseo/pull/3531) by [@caikovsky](https://github.com/caikovsky))
|
- Added per-action settings for opening files, changes, subagents, and pull requests in the main panel or on the side ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||||
|
- Added New tab and drag support for compatible files, diffs, agents, terminals, pull requests, and plugin panels in Explorer ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||||
|
- Added standalone Diff tabs while keeping an optional persisted inline-diff mode in Changes ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||||
|
- Added custom Windows and Linux window controls ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed `npm install @getpaseo/cli@beta` failing with a 404 because the plugin SDK it depends on was never published
|
- Fixed OpenCode turns failing when the first event-stream connection stalls during startup ([#3814](https://github.com/getpaseo/paseo/pull/3814))
|
||||||
- Renamed the plugin SDK package to `@getpaseo/plugin`, with `@paseo/plugin` imports still resolving for existing plugins
|
- Fixed OpenCode prompts starting before the provider reports a ready connection ([#3821](https://github.com/getpaseo/paseo/pull/3821))
|
||||||
- Fixed the old docked file explorer sidebar staying open next to the explorer pane after upgrading to 0.5.0-beta.1, with no way to dismiss it on Windows and Linux
|
|
||||||
- Fixed new agents, terminals, and browser tabs opening in a background pane instead of the focused one
|
|
||||||
|
|
||||||
## 0.5.0-beta.1 - 2026-08-18
|
## 0.5.2 - 2026-08-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed workspaces crashing on Android tablets and foldables in non-compact layouts ([#3760](https://github.com/getpaseo/paseo/pull/3760) by [@jeid64](https://github.com/jeid64))
|
||||||
|
- Fixed app settings resetting when builds sharing a profile recognize different settings ([#3787](https://github.com/getpaseo/paseo/pull/3787))
|
||||||
|
|
||||||
|
## 0.5.1 - 2026-08-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed multiline composers stopping resizing after workspace and agent transitions on iOS and Android ([#3740](https://github.com/getpaseo/paseo/pull/3740))
|
||||||
|
|
||||||
|
## 0.5.0 - 2026-08-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Add experimental support for plugins ([See docs](/docs/plugins), [#3222](https://github.com/getpaseo/paseo/pull/3222), [#3446](https://github.com/getpaseo/paseo/pull/3446), [#3465](https://github.com/getpaseo/paseo/pull/3465))
|
- Added experimental trusted local plugins ([#3222](https://github.com/getpaseo/paseo/pull/3222), [#3446](https://github.com/getpaseo/paseo/pull/3446), [#3465](https://github.com/getpaseo/paseo/pull/3465))
|
||||||
- Added workspace labels to organize the sidebar ([#3510](https://github.com/getpaseo/paseo/pull/3510) by [@cleiter](https://github.com/cleiter))
|
- Added plugin-contributed application themes to Settings → Appearance ([#3602](https://github.com/getpaseo/paseo/pull/3602) by [@ragokan](https://github.com/ragokan))
|
||||||
- Added steering for Codex and Claude — send into a running turn instead of interrupting it ([#3394](https://github.com/getpaseo/paseo/pull/3394))
|
- Added a user-directed Side panel with independent tabs ([#3287](https://github.com/getpaseo/paseo/pull/3287), [#3605](https://github.com/getpaseo/paseo/pull/3605))
|
||||||
- Reworked the explorer into a persistent pane with its own tabs, so files, changes, and the pull request stay open next to the chat ([#3287](https://github.com/getpaseo/paseo/pull/3287))
|
- Added browser-style New tabs with an anchored pane-local chooser ([#3715](https://github.com/getpaseo/paseo/pull/3715), [#3735](https://github.com/getpaseo/paseo/pull/3735))
|
||||||
- Changed Cmd/Ctrl+T to open a menu so you can choose what the new tab opens
|
- Added active-turn steering for Claude, Codex, and OpenCode ([#3394](https://github.com/getpaseo/paseo/pull/3394), [#3580](https://github.com/getpaseo/paseo/pull/3580) by [@mcowger](https://github.com/mcowger))
|
||||||
|
- Added workspace labels for sidebar organization and filtering ([#3510](https://github.com/getpaseo/paseo/pull/3510) by [@cleiter](https://github.com/cleiter))
|
||||||
|
- Added live workspace change counts above active agent composers ([#3682](https://github.com/getpaseo/paseo/pull/3682))
|
||||||
|
- Added workspace panel, tab, and pane actions to the Command Center ([#3685](https://github.com/getpaseo/paseo/pull/3685))
|
||||||
- Added `paseo project` to create, list, rename, and delete projects from the terminal ([#3460](https://github.com/getpaseo/paseo/pull/3460))
|
- Added `paseo project` to create, list, rename, and delete projects from the terminal ([#3460](https://github.com/getpaseo/paseo/pull/3460))
|
||||||
- Added `paseo reload` to apply `config.json` changes without restarting the daemon ([#3365](https://github.com/getpaseo/paseo/pull/3365))
|
- Added `paseo reload` to apply runtime-safe `config.json` changes without restarting the daemon ([#3365](https://github.com/getpaseo/paseo/pull/3365))
|
||||||
- Added Paseo skill management to Host → Agents, including remote hosts ([#3451](https://github.com/getpaseo/paseo/pull/3451))
|
- Added Paseo skill management to Host → Agents, including remote hosts ([#3451](https://github.com/getpaseo/paseo/pull/3451))
|
||||||
|
- Added project filtering to the sidebar display menu ([#3563](https://github.com/getpaseo/paseo/pull/3563) by [@cleiter](https://github.com/cleiter))
|
||||||
- Added optional branch and project names on workspace rows ([#3445](https://github.com/getpaseo/paseo/pull/3445))
|
- Added optional branch and project names on workspace rows ([#3445](https://github.com/getpaseo/paseo/pull/3445))
|
||||||
- Added drag-and-drop reordering for pinned workspaces ([#3341](https://github.com/getpaseo/paseo/pull/3341))
|
- Added drag-and-drop reordering for pinned workspaces ([#3341](https://github.com/getpaseo/paseo/pull/3341))
|
||||||
- Added Nix and Svelte syntax highlighting ([#3110](https://github.com/getpaseo/paseo/pull/3110) by [@Strainy](https://github.com/Strainy), [#3487](https://github.com/getpaseo/paseo/pull/3487) by [@fiorelorenzo](https://github.com/fiorelorenzo))
|
- Added one-click agent profile creation from the model chooser ([#3533](https://github.com/getpaseo/paseo/pull/3533))
|
||||||
|
- Added Android Studio to Open in editor ([#3531](https://github.com/getpaseo/paseo/pull/3531), [#3614](https://github.com/getpaseo/paseo/pull/3614) by [@caikovsky](https://github.com/caikovsky))
|
||||||
|
- Added MiniMax Code to the one-click ACP provider catalog ([#3457](https://github.com/getpaseo/paseo/pull/3457) by [@hetaoBackend](https://github.com/hetaoBackend))
|
||||||
|
- Added Nix syntax highlighting ([#3110](https://github.com/getpaseo/paseo/pull/3110) by [@Strainy](https://github.com/Strainy))
|
||||||
|
- Added Svelte syntax highlighting ([#3487](https://github.com/getpaseo/paseo/pull/3487), [#3534](https://github.com/getpaseo/paseo/pull/3534) by [@fiorelorenzo](https://github.com/fiorelorenzo))
|
||||||
|
- Added guided Hub setup that connects a daemon and deploys a compatible starter workflow ([#3651](https://github.com/getpaseo/paseo/pull/3651), [#3657](https://github.com/getpaseo/paseo/pull/3657), [#3677](https://github.com/getpaseo/paseo/pull/3677))
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Improved checks summary to the pull request pane, grouped by status with failures first ([#3483](https://github.com/getpaseo/paseo/pull/3483))
|
- Restored cached projects, workspaces, agents, and timelines immediately while hosts reconnect ([#3259](https://github.com/getpaseo/paseo/pull/3259), [#3329](https://github.com/getpaseo/paseo/pull/3329))
|
||||||
- Projects, workspaces, and agents show up instantly when the app opens ([#3259](https://github.com/getpaseo/paseo/pull/3259), [#3329](https://github.com/getpaseo/paseo/pull/3329))
|
- Reduced workspace-switch stalls on desktop and Android with long chats ([#3447](https://github.com/getpaseo/paseo/pull/3447), [#3610](https://github.com/getpaseo/paseo/pull/3610))
|
||||||
- Offline hosts keep showing their projects and workspaces while they reconnect ([#3259](https://github.com/getpaseo/paseo/pull/3259))
|
- Kept composer typing within the frame budget on web and desktop ([#3450](https://github.com/getpaseo/paseo/pull/3450))
|
||||||
- Large diffs stay responsive while expanding, scrolling, and commenting ([#3422](https://github.com/getpaseo/paseo/pull/3422))
|
- Kept large diffs responsive while expanding, scrolling, and commenting ([#3422](https://github.com/getpaseo/paseo/pull/3422))
|
||||||
- Switching workspaces with keyboard shortcuts no longer lags ([#3447](https://github.com/getpaseo/paseo/pull/3447))
|
- Kept large read-only source previews responsive with bounded rendering ([#3665](https://github.com/getpaseo/paseo/pull/3665))
|
||||||
- Typing in the composer no longer lags on web and desktop ([#3450](https://github.com/getpaseo/paseo/pull/3450))
|
- Removed complete-transcript disk rewrites from long-running agent timelines ([#3647](https://github.com/getpaseo/paseo/pull/3647))
|
||||||
- Moved the subagents and tasks trackers into pills above the composer ([#3482](https://github.com/getpaseo/paseo/pull/3482))
|
- Added outcome summaries and failure-first grouping to pull request checks ([#3483](https://github.com/getpaseo/paseo/pull/3483))
|
||||||
|
- Moved subagent and task trackers into pills above the composer ([#3482](https://github.com/getpaseo/paseo/pull/3482))
|
||||||
- Simplified mobile agent configuration into one options sheet ([#3424](https://github.com/getpaseo/paseo/pull/3424))
|
- Simplified mobile agent configuration into one options sheet ([#3424](https://github.com/getpaseo/paseo/pull/3424))
|
||||||
- Archive finished now archives every finished subagent in the track ([#3368](https://github.com/getpaseo/paseo/pull/3368))
|
- Opened summarized tool-call groups in live sheets on compact layouts ([#3619](https://github.com/getpaseo/paseo/pull/3619))
|
||||||
- The text size setting now scales the whole interface
|
- Added separate Content text sizing for chat, the composer, Markdown, and review prose ([#3637](https://github.com/getpaseo/paseo/pull/3637))
|
||||||
|
- Updated Pi usage and context meters during active turns ([#3532](https://github.com/getpaseo/paseo/pull/3532))
|
||||||
|
- Changed Archive finished to archive every finished subagent in the track ([#3368](https://github.com/getpaseo/paseo/pull/3368))
|
||||||
|
- Changed automatic Setup tabs to appear only after workspace setup fails ([#3634](https://github.com/getpaseo/paseo/pull/3634))
|
||||||
|
- Changed Default send in Settings to a dropdown menu ([#3644](https://github.com/getpaseo/paseo/pull/3644))
|
||||||
|
- Added password-equivalent warnings to pairing links in the desktop app and CLI ([#3734](https://github.com/getpaseo/paseo/pull/3734))
|
||||||
|
- Corrected Russian UI translations and terminology ([#3586](https://github.com/getpaseo/paseo/pull/3586) by [@timz](https://github.com/timz))
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed local plugin install on the packaged desktop app: esbuild can spawn from `app.asar.unpacked`, and the plugin subprocess loads `@paseo/plugin/server` instead of the React client barrel
|
- Fixed CJK IME composition being cancelled in text fields and mobile terminals ([#2811](https://github.com/getpaseo/paseo/pull/2811), [#3343](https://github.com/getpaseo/paseo/pull/3343), [#3391](https://github.com/getpaseo/paseo/pull/3391), [#3462](https://github.com/getpaseo/paseo/pull/3462), [#3517](https://github.com/getpaseo/paseo/pull/3517) by [@northsea4](https://github.com/northsea4), [@jimersylee](https://github.com/jimersylee), [@ZacharyZcR](https://github.com/ZacharyZcR), [@chulmin-dev](https://github.com/chulmin-dev))
|
||||||
- Fixed CJK IME composition being cancelled in text fields and the mobile terminal ([#3517](https://github.com/getpaseo/paseo/pull/3517), [#2811](https://github.com/getpaseo/paseo/pull/2811) by [@northsea4](https://github.com/northsea4), [#3462](https://github.com/getpaseo/paseo/pull/3462) by [@chulmin-dev](https://github.com/chulmin-dev), [#3343](https://github.com/getpaseo/paseo/pull/3343) by [@jimersylee](https://github.com/jimersylee), [#3391](https://github.com/getpaseo/paseo/pull/3391) by [@ZacharyZcR](https://github.com/ZacharyZcR), [@ljy900825-dev](https://github.com/ljy900825-dev))
|
- Fixed OpenCode turns failing when their event stream dropped ([#3395](https://github.com/getpaseo/paseo/pull/3395))
|
||||||
- Fixed OpenCode turns failing when its event stream dropped ([#3395](https://github.com/getpaseo/paseo/pull/3395))
|
- Fixed OpenCode agents timing out during slow or plugin-heavy startup ([#3578](https://github.com/getpaseo/paseo/pull/3578), [#3621](https://github.com/getpaseo/paseo/pull/3621) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
|
||||||
- Fixed an unrelated merged pull request archiving a workspace ([#3425](https://github.com/getpaseo/paseo/pull/3425))
|
- Fixed an unrelated merged pull request archiving a workspace ([#3425](https://github.com/getpaseo/paseo/pull/3425))
|
||||||
- Fixed Annotate element and Screenshot element doing nothing on loaded desktop browser pages ([#3187](https://github.com/getpaseo/paseo/pull/3187) by [@dgk-dev](https://github.com/dgk-dev))
|
- Fixed Annotate element and Screenshot element doing nothing on loaded desktop browser pages ([#3187](https://github.com/getpaseo/paseo/pull/3187) by [@dgk-dev](https://github.com/dgk-dev))
|
||||||
- Fixed repeated copy and fork footers after heartbeat runs ([#3484](https://github.com/getpaseo/paseo/pull/3484))
|
- Fixed repeated copy and fork footers after heartbeat runs ([#3484](https://github.com/getpaseo/paseo/pull/3484))
|
||||||
- Fixed Cursor plan usage on hosts signed in only through `cursor-agent` ([#3486](https://github.com/getpaseo/paseo/pull/3486) by [@Lite-G](https://github.com/Lite-G))
|
- Fixed Cursor plan usage on hosts signed in only through `cursor-agent` ([#3486](https://github.com/getpaseo/paseo/pull/3486) by [@Lite-G](https://github.com/Lite-G))
|
||||||
- Fixed resumed subagents splitting into separate rows
|
- Fixed mobile composers retaining sent text after submission ([#3564](https://github.com/getpaseo/paseo/pull/3564))
|
||||||
- Fixed a detected pull request revealing the explorer pane and stealing focus, which then made new agents, terminals, and browser tabs open inside the explorer pane instead of the main one
|
- Fixed multiline composers retaining stale heights across workspace and draft lifecycles ([#3681](https://github.com/getpaseo/paseo/pull/3681), [#3740](https://github.com/getpaseo/paseo/pull/3740))
|
||||||
- Fixed workspace pull request status being lost on reload
|
- Fixed pull request checkout when the target repository is configured as `upstream` ([#2997](https://github.com/getpaseo/paseo/pull/2997) by [@mcowger](https://github.com/mcowger))
|
||||||
- Fixed hidden terminal tabs stopping output in the focused workspace
|
- Fixed composer steers remaining unread while Claude or Codex waited on a permission ([#3585](https://github.com/getpaseo/paseo/pull/3585) by [@cleiter](https://github.com/cleiter))
|
||||||
|
- Fixed Changes opening an empty comparison after checkout state changes ([#3636](https://github.com/getpaseo/paseo/pull/3636))
|
||||||
|
- Fixed Pi chats ending in an error state during a successful automatic retry ([#3639](https://github.com/getpaseo/paseo/pull/3639))
|
||||||
|
- Fixed Claude usage appearing unavailable when macOS Keychain contains multiple credential items ([#3597](https://github.com/getpaseo/paseo/pull/3597) by [@t-benoit](https://github.com/t-benoit))
|
||||||
|
- Fixed provider usage checks overwriting Claude or Codex credential files ([#3442](https://github.com/getpaseo/paseo/pull/3442) by [@danberindei](https://github.com/danberindei))
|
||||||
|
- Fixed active Codex agents failing to open when their native thread was archived outside Paseo ([#3334](https://github.com/getpaseo/paseo/pull/3334))
|
||||||
|
- Fixed agents remaining impossible to stop after the provider had already settled their turn ([#3742](https://github.com/getpaseo/paseo/pull/3742))
|
||||||
|
- Fixed worktree creation rejecting Git-valid branch names containing uppercase letters, underscores, or dots ([#3591](https://github.com/getpaseo/paseo/pull/3591))
|
||||||
|
- Fixed project icons showing stale images in New Workspace ([#3600](https://github.com/getpaseo/paseo/pull/3600))
|
||||||
|
- Fixed malformed SVG project icons crashing the iOS app ([#3711](https://github.com/getpaseo/paseo/pull/3711) by [@omercnet](https://github.com/omercnet))
|
||||||
|
- Fixed pinning a project's only workspace hiding its New workspace action ([#3722](https://github.com/getpaseo/paseo/pull/3722) by [@yugui923](https://github.com/yugui923))
|
||||||
|
- Fixed workspace layout changes bypassing timeline retry backoff ([#3736](https://github.com/getpaseo/paseo/pull/3736))
|
||||||
|
- Fixed fresh installs resolving provider SDK versions that were not validated with the release ([#3678](https://github.com/getpaseo/paseo/pull/3678))
|
||||||
|
|
||||||
## 0.4.0 - 2026-08-13
|
## 0.4.0 - 2026-08-13
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
|||||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||||
|
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and routing preferences |
|
||||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||||
|
|||||||
+13
-2
@@ -35,7 +35,16 @@ be in flight.
|
|||||||
|
|
||||||
### Cancellation
|
### Cancellation
|
||||||
|
|
||||||
Cancellation changes lifecycle state only after the provider acknowledges the interrupt or emits a terminal turn event. If the interrupt is rejected or times out, the agent remains `running` with its active foreground turn intact. Follow-up actions such as replacement, reload, rewind, and Stop must report that failure instead of accepting work they cannot perform. Synthesizing a local cancellation without provider acknowledgment creates a split-brain session: Paseo accepts a new prompt while the provider still owns the previous foreground turn.
|
Provider interruption is idempotent at the `AgentSession` boundary. It resolves when the prior
|
||||||
|
foreground turn can no longer run, including when the provider reports that it is already idle. It
|
||||||
|
rejects only when the provider may still own the turn. Provider adapters translate native errors
|
||||||
|
into that contract; lifecycle callers do not interpret provider-specific errors.
|
||||||
|
|
||||||
|
After an acknowledged interrupt, the manager settles the captured run even when no terminal event
|
||||||
|
arrives or the run was still waiting for its provider turn id. The captured run token prevents an
|
||||||
|
older cancellation from settling a newer turn. If interruption is rejected or times out, the agent
|
||||||
|
keeps its active foreground turn and replacement, reload, rewind, and Stop report the failure.
|
||||||
|
Accepting new work after an ambiguous interruption would create a split-brain session.
|
||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
|
|
||||||
@@ -103,7 +112,9 @@ recoverable from History. Opening one pins its tab and renders the archived-agen
|
|||||||
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
|
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
|
||||||
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
|
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
|
||||||
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
|
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
|
||||||
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow.
|
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. A
|
||||||
|
provider session can be archived outside Paseo while its Paseo agent remains active. Interactive
|
||||||
|
resume repairs that drift through the provider's native unarchive hook; history resume does not.
|
||||||
|
|
||||||
Provider session connection owns every process it spawns until the session is registered with
|
Provider session connection owns every process it spawns until the session is registered with
|
||||||
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
|
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ initializing → idle ⇄ running
|
|||||||
client-side dedup; the default fetch page is 200 items.
|
client-side dedup; the default fetch page is 200 items.
|
||||||
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
|
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
|
||||||
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
|
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
|
||||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id.
|
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`. Timeline rows are runtime memory; provider history is the durable transcript authority and resumed agents rebuild from it. That storage path is derived from `cwd`, not from workspace id.
|
||||||
|
|
||||||
## Right-sidebar boundary: directory-backed vs workspace-owned
|
## Right-sidebar boundary: directory-backed vs workspace-owned
|
||||||
|
|
||||||
@@ -402,7 +402,7 @@ Providers that can accept native tool definitions should set `supportsNativePase
|
|||||||
|
|
||||||
```
|
```
|
||||||
$PASEO_HOME/
|
$PASEO_HOME/
|
||||||
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows
|
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record
|
||||||
├── projects/projects.json # Project registry
|
├── projects/projects.json # Project registry
|
||||||
├── projects/workspaces.json # Workspace registry
|
├── projects/workspaces.json # Workspace registry
|
||||||
├── projects/icons/ # Custom project icon images
|
├── projects/icons/ # Custom project icon images
|
||||||
|
|||||||
+7
-1
@@ -30,7 +30,7 @@ Before adding a new component, read `components/ui/`. The primitive usually exis
|
|||||||
|
|
||||||
Hierarchy is conveyed through weight and color, not size. Most interface text is `fontSize.base`; compact metadata and hints use `fontSize.sm`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
|
Hierarchy is conveyed through weight and color, not size. Most interface text is `fontSize.base`; compact metadata and hints use `fontSize.sm`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
|
||||||
|
|
||||||
The authored ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Base size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Code size remains independent.
|
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 15px on every platform. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
|
||||||
|
|
||||||
Weight has three tiers, applied by role:
|
Weight has three tiers, applied by role:
|
||||||
|
|
||||||
@@ -62,6 +62,10 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
|
|||||||
|
|
||||||
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
|
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
|
||||||
|
|
||||||
|
Header and toolbar controls use `interactionHighlight` for hovered, pressed, open, and selected
|
||||||
|
backgrounds. It is a translucent semantic fill so the same control works over the main surface and
|
||||||
|
the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control also fades its content.
|
||||||
|
|
||||||
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
||||||
|
|
||||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||||
@@ -155,6 +159,8 @@ On a narrow desktop route, app navigation yields to the rendered content topolog
|
|||||||
|
|
||||||
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
|
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
|
||||||
|
|
||||||
|
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
|
||||||
|
|
||||||
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+23
-2
@@ -242,13 +242,13 @@ focused invalidation capture; React Native's generated stacks make that mode hig
|
|||||||
Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
|
Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
|
||||||
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
|
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
|
||||||
|
|
||||||
For the desktop explorer toggle, run the app against the root checkout's daemon and use:
|
For the desktop Explorer sidebar toggle, run the app against the root checkout's daemon and use:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run profile:explorer-toggle --workspace=@getpaseo/app
|
npm run profile:explorer-toggle --workspace=@getpaseo/app
|
||||||
```
|
```
|
||||||
|
|
||||||
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the explorer pane,
|
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Explorer pane,
|
||||||
records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports
|
records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports
|
||||||
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
|
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
|
||||||
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
|
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
|
||||||
@@ -278,6 +278,17 @@ That report includes input and composer height changes plus React work grouped i
|
|||||||
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
|
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
|
||||||
boundaries are nested. A printable key after an empty newline should not change either height.
|
boundaries are nested. A printable key after an empty newline should not change either height.
|
||||||
|
|
||||||
|
### Preview Windows and Linux window controls on macOS
|
||||||
|
|
||||||
|
Desktop development can replace native macOS traffic lights with Paseo's custom controls:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PASEO_DESKTOP_WINDOW_CONTROLS=windows npm run dev:desktop
|
||||||
|
PASEO_DESKTOP_WINDOW_CONTROLS=linux npm run dev:desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
The override is rejected in packaged builds. Restart the desktop process when changing it.
|
||||||
|
|
||||||
### Desktop macOS compositor watchdog
|
### Desktop macOS compositor watchdog
|
||||||
|
|
||||||
macOS display sleep can leave Chromium's GPU-process display link — the vsync
|
macOS display sleep can leave Chromium's GPU-process display link — the vsync
|
||||||
@@ -502,6 +513,16 @@ For tighter loops, you can rebuild a single workspace:
|
|||||||
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
|
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
|
||||||
- Changed app build dependencies: `npm run build:app-deps`.
|
- Changed app build dependencies: `npm run build:app-deps`.
|
||||||
|
|
||||||
|
## Dependency patches
|
||||||
|
|
||||||
|
`patches/*.patch` are applied by `scripts/postinstall-patches.mjs` on every install. A patch only
|
||||||
|
runs when its package is actually present, so add the package to that script's `patchedPackages`
|
||||||
|
list when you introduce a new patch — otherwise the file sits in `patches/` and never applies.
|
||||||
|
Regenerate a patch with `npx patch-package <package>` after editing `node_modules/<package>`, and
|
||||||
|
patch every build the consumers use: Metro resolves the `react-native` field of a package
|
||||||
|
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
|
||||||
|
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
|
||||||
|
|
||||||
## ACP provider catalog versions
|
## ACP provider catalog versions
|
||||||
|
|
||||||
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
|
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Explorer sidebar and side pane
|
||||||
|
|
||||||
|
The Explorer sidebar and the side pane share panel implementations, but they have different shell
|
||||||
|
contracts.
|
||||||
|
|
||||||
|
| Surface | Purpose | Lifecycle |
|
||||||
|
| ---------------- | ---------------------------- | ------------------------------------------ |
|
||||||
|
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
|
||||||
|
| Side pane | Ordinary workspace content | Created and closed like any workspace pane |
|
||||||
|
|
||||||
|
## Panel host contract
|
||||||
|
|
||||||
|
Every desktop panel registers its supported `PaneHost` values and presentation. Launchers derive
|
||||||
|
fixed-target labels and icons from that registration, filter by host, and never substitute one
|
||||||
|
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
|
||||||
|
a compatible pane.
|
||||||
|
|
||||||
|
Files and Changes are the Explorer defaults and its singleton navigation views. Other compatible
|
||||||
|
tabs, including agents, terminals, files, and diffs, can move between Explorer and main panes.
|
||||||
|
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
|
||||||
|
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
|
||||||
|
|
||||||
|
## Explorer sidebar
|
||||||
|
|
||||||
|
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
|
||||||
|
On desktop, the shell is rendered outside the workspace split canvas so it divides the full
|
||||||
|
workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
|
||||||
|
never read or modify that width.
|
||||||
|
|
||||||
|
The persisted layout still contains the Explorer pane so tabs survive reloads. The renderer removes
|
||||||
|
that pane from the workspace split tree and docks it separately. Persisted identifiers retain the
|
||||||
|
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
|
||||||
|
|
||||||
|
The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and
|
||||||
|
toggles the singleton Files and Changes views. Individual tab menus close instances or move
|
||||||
|
compatible tabs to main. Explorer tabs can be reordered, but the dock cannot be split. Selecting
|
||||||
|
an Explorer tab does not change workspace focus.
|
||||||
|
|
||||||
|
Cmd+E selects Changes for Git workspaces and Files otherwise. Compact layouts use the combined
|
||||||
|
full-screen Explorer overlay and close it after a file opens. Wide native layouts without pane
|
||||||
|
splits use the same combined content in a resizable inline dock; opening a file leaves that dock
|
||||||
|
visible. Both presentations keep their selection in the panel store and reuse the layout store's
|
||||||
|
per-workspace Explorer width. They do not create a second Explorer lifecycle.
|
||||||
|
|
||||||
|
## Side pane
|
||||||
|
|
||||||
|
`packages/app/src/workspace-tabs/open-beside.ts` owns content opened beside the user's work. The
|
||||||
|
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
|
||||||
|
right split around the workspace root; later side opens reuse it.
|
||||||
|
|
||||||
|
Closing the pane or moving away its final tab removes it normally and clears the remembered id. A
|
||||||
|
later side open creates a new pane. There is no hidden side-pane lifecycle.
|
||||||
|
|
||||||
|
Placement intent still controls existing tabs:
|
||||||
|
|
||||||
|
| Mode | New target | Existing target |
|
||||||
|
| --------- | --------------------------- | --------------------------------- |
|
||||||
|
| `pane` | opens in the requested pane | moves to the requested pane |
|
||||||
|
| `prefer` | opens in the requested pane | stays where the user placed it |
|
||||||
|
| `focused` | opens in the focused pane | focuses it where it already lives |
|
||||||
|
| `ambient` | opens in a compatible pane | focuses it where it already lives |
|
||||||
|
|
||||||
|
Explicit **Open to Side** uses `pane`. Implicit opens use `prefer`, so a preference affects only a
|
||||||
|
new target and never yanks an existing tab out of a user-selected pane.
|
||||||
|
|
||||||
|
## Routing preferences
|
||||||
|
|
||||||
|
Desktop **Settings → Layout → Open in side pane** has independent switches for Explorer Files,
|
||||||
|
Explorer Changes, chat files, diff files, subagents, pull requests, and Changes links. All switches
|
||||||
|
default off. Mobile ignores them.
|
||||||
|
|
||||||
|
Panels request an implicit open through the narrow `openPreferredTarget(target, source)` pane
|
||||||
|
contract. Entry points outside panels use `openPreferredWorkspaceTarget`. Do not branch on a
|
||||||
|
specific shell inside a panel.
|
||||||
+3
-1
@@ -19,6 +19,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
|||||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||||
|
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||||
|
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||||
@@ -43,7 +45,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
|||||||
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
|
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
|
||||||
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
|
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
|
||||||
- **Composer track** — A contextual lane at the foot of a pane, by the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**, **Tasks track**. The subagents and tasks tracks are pills floating over the transcript, each opening a panel with its rows; the queue track is a lane inside the composer itself. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), pill and panel frame in `packages/app/src/composer/tracks.tsx`, `SubagentsTrack` (`packages/app/src/subagents/track.tsx`), `AgentTaskList` (`packages/app/src/composer/task-list/index.tsx`).
|
- **Composer track** — A contextual lane at the foot of a pane, by the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**, **Tasks track**. The subagents and tasks tracks are pills floating over the transcript, each opening a panel with its rows; the queue track is a lane inside the composer itself. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), pill and panel frame in `packages/app/src/composer/tracks.tsx`, `SubagentsTrack` (`packages/app/src/subagents/track.tsx`), `AgentTaskList` (`packages/app/src/composer/task-list/index.tsx`).
|
||||||
- **Steer** — A follow-up admitted into an agent's already-running turn instead of interrupting it. Interrupting the turn discards steers the provider has not read yet; there is no take-back short of that, and no provider offers one. The stop button and Escape both interrupt. A steer the provider never read keeps its transcript row, matching Codex. UI: "Send and steer". Code: `steerActiveTurn` (`packages/server/src/server/agent/agent-sdk-types.ts`). Don't confuse with **Queue track**, which holds messages client-side until the turn ends and never enters the running turn. Forbidden: "Delivery", "Queued prompt".
|
- **Steer** — A follow-up admitted into an agent's already-running turn instead of interrupting it. Interrupting the turn discards steers the provider has not read yet; there is no take-back short of that, and no provider offers one. The stop button and Escape both interrupt. A steer the provider never read keeps its transcript row, matching Codex. Sending while the agent waits on a permission denies that request first, so the message reaches the same turn instead of stalling behind it. UI: "Send and steer". Code: `steerActiveTurn` (`packages/server/src/server/agent/agent-sdk-types.ts`). Don't confuse with **Queue track**, which holds messages client-side until the turn ends and never enters the running turn. Forbidden: "Delivery", "Queued prompt".
|
||||||
- **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term.
|
- **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term.
|
||||||
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
|
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
|
||||||
- **Fork** — Starting a NEW agent session seeded with a copy of another session's curated chat history. The history rides along as a `chat_history` composer attachment on a draft; the fork does **not** resume or branch the provider-side session, and the source session keeps running untouched. Two targets, one surface, one RPC (`agent.fork_context`, gated on the `agentForkContext` host feature). UI: the fork menu lives in the assistant turn footer and offers "Fork in a new tab" / "Fork in a new workspace". On a **completed** turn it sits beside the copy button and pins a boundary to that turn, so the fork stops where the turn ended. On the **in-flight** turn it sits beside the progress loader and elapsed timer, and omits the boundary entirely — `selectForkContextRows` projects the whole timeline when no boundary is given, so the fork captures everything **up to now**, including the response still streaming. That omission is what makes mid-run forking work; pinning a boundary there would silently drop the live response. Forbidden on provider-subagent (read-only) panes. Code: `useForkAgent` (`packages/app/src/hooks/use-fork-agent.ts`), `selectForkContextRows` (`packages/server/src/server/agent/activity-curator.ts`). Don't confuse with: git fork (unrelated; Paseo has no such concept), **Worktree** (filesystem isolation, not history copying).
|
- **Fork** — Starting a NEW agent session seeded with a copy of another session's curated chat history. The history rides along as a `chat_history` composer attachment on a draft; the fork does **not** resume or branch the provider-side session, and the source session keeps running untouched. Two targets, one surface, one RPC (`agent.fork_context`, gated on the `agentForkContext` host feature). UI: the fork menu lives in the assistant turn footer and offers "Fork in a new tab" / "Fork in a new workspace". On a **completed** turn it sits beside the copy button and pins a boundary to that turn, so the fork stops where the turn ended. On the **in-flight** turn it sits beside the progress loader and elapsed timer, and omits the boundary entirely — `selectForkContextRows` projects the whole timeline when no boundary is given, so the fork captures everything **up to now**, including the response still streaming. That omission is what makes mid-run forking work; pinning a boundary there would silently drop the live response. Forbidden on provider-subagent (read-only) panes. Code: `useForkAgent` (`packages/app/src/hooks/use-fork-agent.ts`), `selectForkContextRows` (`packages/server/src/server/agent/activity-curator.ts`). Don't confuse with: git fork (unrelated; Paseo has no such concept), **Worktree** (filesystem isolation, not history copying).
|
||||||
|
|||||||
+30
-3
@@ -1,7 +1,7 @@
|
|||||||
# Local plugins
|
# Local plugins
|
||||||
|
|
||||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||||
and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||||
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
||||||
this first slice does not sandbox it.
|
this first slice does not sandbox it.
|
||||||
|
|
||||||
@@ -165,6 +165,10 @@ RPC. Snapshot DTOs are deeply readonly and frozen at runtime so plugin code cann
|
|||||||
app state or a memoized selection. Panels use one persisted
|
app state or a memoized selection. Panels use one persisted
|
||||||
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
|
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
|
||||||
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
|
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
|
||||||
|
Panels declare `locations: ["workspace", "explorer"]` to opt into Explorer hosting; omission means
|
||||||
|
workspace only. Location controls hosting, not context. An agent panel target keeps its `agentId`
|
||||||
|
when moved between hosts. Explorer configuration can create workspace-context panels and remove
|
||||||
|
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
|
||||||
|
|
||||||
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
|
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
|
||||||
They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the
|
They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the
|
||||||
@@ -194,5 +198,28 @@ on several hosts are not coalesced. The selected snapshot submits as a text atta
|
|||||||
external-resource presentation, so it remains readable if the plugin is removed or an older peer
|
external-resource presentation, so it remains readable if the plugin is removed or an older peer
|
||||||
drops the optional presentation fields.
|
drops the optional presentation fields.
|
||||||
|
|
||||||
See `plugin-examples/local-plugin` for a native surface and `plugin-examples/linear` for a complete
|
## Contribute a theme
|
||||||
attachment-source example.
|
|
||||||
|
`addTheme` takes a small light or dark palette and a display name. Paseo expands it through the
|
||||||
|
same semantic builders as the built-in themes, so plugins do not depend on the complete app token
|
||||||
|
contract. Unistyles needs every theme name at `StyleSheet.configure` time, so
|
||||||
|
`packages/app/src/styles/theme.ts` reserves one light and one dark plugin slot. The appearance
|
||||||
|
provider rewrites the matching slot when the selection changes. See [unistyles.md](unistyles.md)
|
||||||
|
for the runtime-patching rules the appearance settings share.
|
||||||
|
|
||||||
|
`addTheme` is a client registration, so the compiler strips it from the backend bundle. A daemon
|
||||||
|
that predates it does not, and the plugin fails to start there. Daemons advertise
|
||||||
|
`features.pluginThemes` in `server_info`; the plugin theme catalog is the one place the app reads it, and
|
||||||
|
a host without it contributes no themes.
|
||||||
|
|
||||||
|
The selection persists as `theme: "plugin"` plus a `pluginThemeId` of `<pluginId>/theme/<themeId>`,
|
||||||
|
so equal themes on several hosts coalesce the way sidebar contributions do. Two hosts can answer
|
||||||
|
that id with different palettes, so picking a theme records its host through
|
||||||
|
`rememberPluginContributionHost` and resolution prefers it; a peer connecting or dropping then does
|
||||||
|
not repaint the app. Without a preference the sorted registry snapshot decides, so the result is
|
||||||
|
stable rather than arrival-ordered. The app resolves that id
|
||||||
|
against the installed catalog on every change; an id nothing contributes falls back to the default
|
||||||
|
preference instead of painting the reserved slot's placeholder colors.
|
||||||
|
|
||||||
|
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
|
||||||
|
attachment-source example, and `plugin-examples/catppuccin` for a theme.
|
||||||
|
|||||||
+7
-1
@@ -81,10 +81,12 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
|
|||||||
|
|
||||||
`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId` set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe.
|
`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId` set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe.
|
||||||
|
|
||||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. The released OpenCode v1 adapter does not implement this operation.
|
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. A missing session reports `unavailable` and uses the normal interrupt fallback.
|
||||||
|
|
||||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`.
|
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`.
|
||||||
|
|
||||||
|
`SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text.
|
||||||
|
|
||||||
Rewind accepts the canonical wire `messageId` and resolves it to the provider identity before calling the adapter. A submitted prompt cannot be rewound until its provider echo supplies that identity.
|
Rewind accepts the canonical wire `messageId` and resolves it to the provider identity before calling the adapter. A submitted prompt cannot be rewound until its provider echo supplies that identity.
|
||||||
|
|
||||||
Submitted user-message wire items carry the same Paseo ID in `messageId` and `clientMessageId`. Provider adapters attach `clientMessageId` only to the echo for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
|
Submitted user-message wire items carry the same Paseo ID in `messageId` and `clientMessageId`. Provider adapters attach `clientMessageId` only to the echo for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
|
||||||
@@ -145,6 +147,10 @@ Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `
|
|||||||
|
|
||||||
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
|
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
|
||||||
|
|
||||||
|
### Usage fetchers are read-only on credentials
|
||||||
|
|
||||||
|
A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ACP Provider Checklist
|
## ACP Provider Checklist
|
||||||
|
|||||||
@@ -186,9 +186,13 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
|||||||
- For full-suite confidence, push to CI and check GitHub Actions.
|
- For full-suite confidence, push to CI and check GitHub Actions.
|
||||||
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
||||||
- App Playwright shares one warmed Metro server per run and gives every Playwright worker its own isolated daemon and `PASEO_HOME`. Spec files run concurrently without exposing one file's projects, agents, terminals, history, or provider configuration to another worker; tests within a file remain together so file-level setup is not repeated.
|
- App Playwright shares one warmed Metro server per run and gives every Playwright worker its own isolated daemon and `PASEO_HOME`. Spec files run concurrently without exposing one file's projects, agents, terminals, history, or provider configuration to another worker; tests within a file remain together so file-level setup is not repeated.
|
||||||
|
- Playwright specs that exercise only the daemon import `daemonTest` from the shared fixtures so they do not create a browser context or page.
|
||||||
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
|
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
|
||||||
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
|
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
|
||||||
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
|
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
|
||||||
|
- The app Playwright harness boots on Windows as well as POSIX. Spawn Node entrypoints through `process.execPath`, not `npx` or `node_modules/.bin` shims: Node refuses to spawn `.cmd` or `.bat` without `shell: true`, and shell mode concatenates argv without escaping and sends kill signals to `cmd.exe` instead of the real child.
|
||||||
|
- Teardown kills the process tree, because a Windows signal reaches only the direct child and leaves forked workers holding the listening port.
|
||||||
|
- The `asdf`-backed local Elixir relay stays POSIX-only, so the `relay-deployment` Playwright project is unavailable on Windows.
|
||||||
|
|
||||||
## Pull-request test routing
|
## Pull-request test routing
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ Agent chat delivery has two paths:
|
|||||||
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
|
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.
|
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
|
||||||
|
|
||||||
|
The daemon keeps canonical rows only for its runtime. Provider history is the durable transcript
|
||||||
|
authority and repopulates those rows when an agent resumes.
|
||||||
|
|
||||||
The invariants are:
|
The invariants are:
|
||||||
|
|
||||||
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
||||||
@@ -12,7 +15,7 @@ The invariants are:
|
|||||||
> through backward pagination.
|
> through backward pagination.
|
||||||
|
|
||||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
||||||
to 64 KiB, and the same bounded item is used for durable timeline rows and live stream events.
|
to 64 KiB, and the same bounded item is used for runtime timeline rows and live stream events.
|
||||||
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
|
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
|
||||||
tool payload.
|
tool payload.
|
||||||
|
|
||||||
@@ -46,7 +49,7 @@ A failed catch-up or subscription reconcile retries on its own, doubling from 1s
|
|||||||
A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active
|
A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active
|
||||||
writer, say — into a request and a log line every second on an idle app. The delay resets on success,
|
writer, say — into a request and a log line every second on an idle app. The delay resets on success,
|
||||||
reconnect, delivery-mode change, and visibility change, so recovery is still immediate once the
|
reconnect, delivery-mode change, and visibility change, so recovery is still immediate once the
|
||||||
condition clears.
|
condition clears. Republishing the same visibility set is a no-op and never bypasses backoff.
|
||||||
|
|
||||||
Background retries are silent. The retry the user presses in the sync-error callout is a fallible
|
Background retries are silent. The retry the user presses in the sync-error callout is a fallible
|
||||||
user action and owns its pending state: `retrying` is a status the sync model publishes, not a React
|
user action and owns its pending state: `retrying` is a status the sync model publishes, not a React
|
||||||
|
|||||||
+3
-2
@@ -335,15 +335,16 @@ If we ever need to avoid the transition entirely, store at least the theme prefe
|
|||||||
|
|
||||||
## Runtime Theme Patching For User Preferences
|
## Runtime Theme Patching For User Preferences
|
||||||
|
|
||||||
Appearance settings (UI/mono font family, font sizes, syntax-highlight theme) are applied by patching every registered theme at runtime with `UnistylesRuntime.updateTheme(name, updater)` — not by threading preference reads through components. `applyAppearance` in `packages/app/src/screens/settings/appearance/apply-appearance.ts` runs from a `ProvidersWrapper` effect on settings load/change and loops all six theme keys, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
|
Appearance settings (theme, UI/mono font family, font sizes, syntax-highlight theme) are owned by `packages/app/src/appearance`. Its provider subscribes once and synchronizes Unistyles when settings or plugin contributions change. `applyAppearance` patches every key in `REGISTERED_THEMES`, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
|
||||||
|
|
||||||
This works without `useUnistyles()` because every consumer already reads these tokens through `StyleSheet.create((theme) => …)` (or the `withUnistyles`/`uniProps` path for the markdown renderer), so patching the theme repaints tracked views through the native ShadowRegistry with no React re-render.
|
This works without `useUnistyles()` because every consumer already reads these tokens through `StyleSheet.create((theme) => …)` (or the `withUnistyles`/`uniProps` path for the markdown renderer), so patching the theme repaints tracked views through the native ShadowRegistry with no React re-render.
|
||||||
|
|
||||||
Gotchas:
|
Gotchas:
|
||||||
|
|
||||||
- **Patch all themes, not just the active one.** The active theme can change and adaptive mode can flip light/dark; patching every key keeps the active key current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. The effect depends on the settings values (not on `theme`), so it cannot loop.
|
- **Patch all themes, not just the active one.** The active theme can change and adaptive mode can flip light/dark; patching every key keeps the active key current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. The effect depends on the settings values (not on `theme`), so it cannot loop.
|
||||||
|
- **The reserved plugin keys are the exception to that ordering.** A plugin-contributed theme is rebuilt from its palette, which discards the appearance patch, so the appearance provider writes the matching light or dark slot before applying font and syntax preferences. See [plugins.md](plugins.md).
|
||||||
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
||||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||||
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
||||||
- **The app shell uses one `AppearanceStyleBoundary`.** Runtime-patched numeric theme values are baked into Unistyles web classes rather than CSS variables, while parsed/memoized content also does not naturally re-run when appearance tokens change. The boundary sits below stable runtime providers in `app/_layout.tsx` and remounts the visual shell once. `applyAppearance` patches the active theme before inactive registry entries so its subscribers receive the committed values in the same update. Do not add local appearance keys or nested boundaries.
|
- **The app shell uses one `AppearanceStyleBoundary`.** Runtime-patched numeric theme values are baked into Unistyles web classes rather than CSS variables, while parsed/memoized content also does not naturally re-run when appearance tokens change. The boundary sits below stable runtime providers in `app/_layout.tsx` and remounts the visual shell once. `applyAppearance` patches the active theme before inactive registry entries so its subscribers receive the committed values in the same update. Do not add local appearance keys or nested boundaries.
|
||||||
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
|
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-FhcGrCB3+Nsr/RCEQClXwTZW+jyRptNpNjIzAmnb3pI=
|
sha256-gZ6egDKIDiDi9jmDlPamM+or2Tthwr17DbJ+P66t4RE=
|
||||||
|
|||||||
Generated
+27
-27
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.5.0-beta.3",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.5.0-beta.3",
|
"version": "0.6.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -36140,7 +36140,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.5.0-beta.3",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
@@ -37192,12 +37192,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.5.0-beta.3",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.5.0-beta.3",
|
"@getpaseo/client": "0.6.0",
|
||||||
"@getpaseo/protocol": "0.5.0-beta.3",
|
"@getpaseo/protocol": "0.6.0",
|
||||||
"@getpaseo/server": "0.5.0-beta.3",
|
"@getpaseo/server": "0.6.0",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -37444,10 +37444,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.5.0-beta.3",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.5.0-beta.3",
|
"@getpaseo/protocol": "0.6.0",
|
||||||
"@getpaseo/relay": "0.5.0-beta.3",
|
"@getpaseo/relay": "0.6.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -37458,7 +37458,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.5.0-beta.3",
|
"version": "0.6.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -37701,7 +37701,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.5.0-beta.3",
|
"version": "0.6.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -38597,7 +38597,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.5.0-beta.3",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -38626,12 +38626,12 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.5.0-beta.3",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.5.0-beta.3",
|
"@getpaseo/client": "0.6.0",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -38640,14 +38640,14 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.5.0-beta.3",
|
"@getpaseo/client": "0.6.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.5.0-beta.3",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -38660,7 +38660,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.5.0-beta.3",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -38878,19 +38878,19 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.5.0-beta.3",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@babel/parser": "^7.29.8",
|
"@babel/parser": "^7.29.8",
|
||||||
"@getpaseo/client": "0.5.0-beta.3",
|
"@getpaseo/client": "0.6.0",
|
||||||
"@getpaseo/highlight": "0.5.0-beta.3",
|
"@getpaseo/highlight": "0.6.0",
|
||||||
"@getpaseo/plugin": "0.5.0-beta.3",
|
"@getpaseo/plugin": "0.6.0",
|
||||||
"@getpaseo/protocol": "0.5.0-beta.3",
|
"@getpaseo/protocol": "0.6.0",
|
||||||
"@getpaseo/relay": "0.5.0-beta.3",
|
"@getpaseo/relay": "0.6.0",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
"@xterm/headless": "^6.0.0",
|
"@xterm/headless": "^6.0.0",
|
||||||
"ai": "5.0.78",
|
"ai": "5.0.78",
|
||||||
@@ -39940,7 +39940,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.5.0-beta.3",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.5.0-beta.3",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
} from "../support/helpers/settings";
|
} from "../support/helpers/settings";
|
||||||
|
|
||||||
const ACP_PROVIDER = {
|
const ACP_PROVIDER = {
|
||||||
id: "hermes",
|
id: "minimax-code",
|
||||||
name: "Hermes",
|
name: "MiniMax Code",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ProviderCatalogDaemonClient {
|
interface ProviderCatalogDaemonClient {
|
||||||
@@ -22,7 +22,7 @@ interface ProviderCatalogDaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("ACP provider catalog", () => {
|
test.describe("ACP provider catalog", () => {
|
||||||
test("adds a catalog provider from settings", async ({ page }) => {
|
test("adds MiniMax Code from settings", async ({ page }) => {
|
||||||
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
|
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
|
||||||
clientIdPrefix: "provider-catalog-e2e",
|
clientIdPrefix: "provider-catalog-e2e",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,54 +66,21 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
|
|||||||
test.describe("Add Project command-center flow", () => {
|
test.describe("Add Project command-center flow", () => {
|
||||||
test.describe.configure({ timeout: 180_000 });
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
test("method selection has no search field", async ({ page }) => {
|
test("method selection shows the daemon's available project sources without search", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
|
|
||||||
await openAddProjectFlow(page);
|
await openAddProjectFlow(page);
|
||||||
|
|
||||||
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
|
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
|
||||||
|
await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub");
|
||||||
|
await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory");
|
||||||
await expect(addProjectFlowInput(page)).toHaveCount(0);
|
await expect(addProjectFlowInput(page)).toHaveCount(0);
|
||||||
await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0);
|
await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0);
|
await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the back arrow, search input, and result glyph share one left edge", async ({ page }) => {
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await openAddProjectFlow(page);
|
|
||||||
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
await expectAddProjectPage(page, "directory-search");
|
|
||||||
await addProjectFlowInput(page).fill("/tmp");
|
|
||||||
|
|
||||||
const backGlyph = addProjectFlowBack(page).locator("svg");
|
|
||||||
const resultGlyph = addProjectFlow(page)
|
|
||||||
.locator('[data-testid^="add-project-flow-path-"]')
|
|
||||||
.first()
|
|
||||||
.locator("svg");
|
|
||||||
await expect(resultGlyph).toBeVisible();
|
|
||||||
|
|
||||||
const [backBox, inputBox, resultBox, titleBox, resultsBox, footerBox] = await Promise.all([
|
|
||||||
backGlyph.boundingBox(),
|
|
||||||
addProjectFlowInput(page).boundingBox(),
|
|
||||||
resultGlyph.boundingBox(),
|
|
||||||
page.getByTestId("add-project-flow-title").boundingBox(),
|
|
||||||
page.getByTestId("add-project-flow-results").boundingBox(),
|
|
||||||
page.getByTestId("add-project-flow-footer").boundingBox(),
|
|
||||||
]);
|
|
||||||
expect(backBox).not.toBeNull();
|
|
||||||
expect(inputBox).not.toBeNull();
|
|
||||||
expect(resultBox).not.toBeNull();
|
|
||||||
expect(titleBox).not.toBeNull();
|
|
||||||
expect(resultsBox).not.toBeNull();
|
|
||||||
expect(footerBox).not.toBeNull();
|
|
||||||
if (!backBox || !inputBox || !resultBox || !titleBox || !resultsBox || !footerBox) return;
|
|
||||||
|
|
||||||
expect(Math.abs(backBox.x - inputBox.x)).toBeLessThanOrEqual(2);
|
|
||||||
expect(Math.abs(resultBox.x - inputBox.x)).toBeLessThanOrEqual(2);
|
|
||||||
expect(titleBox.height).toBeLessThanOrEqual(24);
|
|
||||||
expect(resultsBox.y + resultsBox.height).toBeLessThanOrEqual(footerBox.y + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an offline extra host neither appears nor forces host selection", async ({ page }) => {
|
test("an offline extra host neither appears nor forces host selection", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await addOfflineHostAndReload(page, {
|
await addOfflineHostAndReload(page, {
|
||||||
@@ -273,14 +240,6 @@ test.describe("Add Project command-center flow", () => {
|
|||||||
await expectProjectHasNoWorkspaces(projectId);
|
await expectProjectHasNoWorkspaces(projectId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the current daemon advertises Clone from GitHub and New directory", async ({ page }) => {
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await openAddProjectFlow(page);
|
|
||||||
|
|
||||||
await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub");
|
|
||||||
await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a complete repository URL remains selectable without a GitHub search result", async ({
|
test("a complete repository URL remains selectable without a GitHub search result", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -442,21 +442,40 @@ function formatFrames(frames: TurnFrame[], centers: number | number[]): string {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submittedRowContentTop(frame: TurnFrame | undefined): number | undefined {
|
||||||
|
const viewportTop = frame?.userRow.rect?.top;
|
||||||
|
const scrollTop = frame?.scroll.scrollTop;
|
||||||
|
return viewportTop == null || scrollTop == null ? undefined : viewportTop + scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
function expectRowContinuity(frames: TurnFrame[]): void {
|
function expectRowContinuity(frames: TurnFrame[]): void {
|
||||||
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
|
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
|
||||||
const baseline = frames[first]?.userRow.rect?.top;
|
const baselineFrame = frames[first];
|
||||||
|
const baselineContentTop = submittedRowContentTop(baselineFrame);
|
||||||
const disappeared = frames.findIndex(
|
const disappeared = frames.findIndex(
|
||||||
(frame, index) => index > first && !hasPaintedLayout(frame.userRow),
|
(frame, index) => index > first && !hasPaintedLayout(frame.userRow),
|
||||||
);
|
);
|
||||||
const reappeared = frames.findIndex(
|
const reappeared = frames.findIndex(
|
||||||
(frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow),
|
(frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow),
|
||||||
);
|
);
|
||||||
const shifted = frames.findIndex(
|
const contentShifted = frames.findIndex((frame, index) => {
|
||||||
(frame, index) =>
|
const contentTop = submittedRowContentTop(frame);
|
||||||
|
return (
|
||||||
index > first &&
|
index > first &&
|
||||||
hasPaintedLayout(frame.userRow) &&
|
hasPaintedLayout(frame.userRow) &&
|
||||||
baseline !== undefined &&
|
baselineContentTop !== undefined &&
|
||||||
Math.abs((frame.userRow.rect?.top ?? baseline) - baseline) > 1,
|
contentTop !== undefined &&
|
||||||
|
Math.abs(contentTop - baselineContentTop) > 1
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const viewportChanged = frames.findIndex(
|
||||||
|
(frame, index) =>
|
||||||
|
index > first &&
|
||||||
|
[
|
||||||
|
geometryChanged(frame.scroll.rect?.top, baselineFrame?.scroll.rect?.top),
|
||||||
|
geometryChanged(frame.scroll.rect?.height, baselineFrame?.scroll.rect?.height),
|
||||||
|
geometryChanged(frame.scroll.clientHeight, baselineFrame?.scroll.clientHeight),
|
||||||
|
].some(Boolean),
|
||||||
);
|
);
|
||||||
const attachmentMissing = frames.findIndex(
|
const attachmentMissing = frames.findIndex(
|
||||||
(frame, index) => index >= first && !hasPaintedLayout(frame.attachment),
|
(frame, index) => index >= first && !hasPaintedLayout(frame.attachment),
|
||||||
@@ -468,16 +487,18 @@ function expectRowContinuity(frames: TurnFrame[]): void {
|
|||||||
`submitted row disappeared for ${Math.max(0, reappeared - disappeared)} recorded frames (${Math.max(0, (frames[reappeared]?.at ?? frames.at(-1)?.at ?? 0) - frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`,
|
`submitted row disappeared for ${Math.max(0, reappeared - disappeared)} recorded frames (${Math.max(0, (frames[reappeared]?.at ?? frames.at(-1)?.at ?? 0) - frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(shifted >= 0
|
...(contentShifted >= 0
|
||||||
? [
|
? [
|
||||||
`submitted row moved from ${baseline?.toFixed(1)} to ${frames[shifted].userRow.rect?.top.toFixed(1)}`,
|
`submitted row moved in scroll content from ${baselineContentTop?.toFixed(1)} to ${submittedRowContentTop(frames[contentShifted])?.toFixed(1)}`,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(viewportChanged >= 0 ? ["scroll viewport geometry changed"] : []),
|
||||||
...(attachmentMissing >= 0 ? ["submitted image attachment left the painted layout"] : []),
|
...(attachmentMissing >= 0 ? ["submitted image attachment left the painted layout"] : []),
|
||||||
];
|
];
|
||||||
let failure = Math.max(0, first);
|
let failure = Math.max(0, first);
|
||||||
if (attachmentMissing >= 0) failure = attachmentMissing;
|
if (attachmentMissing >= 0) failure = attachmentMissing;
|
||||||
if (shifted >= 0) failure = shifted;
|
if (viewportChanged >= 0) failure = viewportChanged;
|
||||||
|
if (contentShifted >= 0) failure = contentShifted;
|
||||||
if (disappeared >= 0) failure = disappeared;
|
if (disappeared >= 0) failure = disappeared;
|
||||||
expect(
|
expect(
|
||||||
violations,
|
violations,
|
||||||
@@ -726,7 +747,7 @@ async function recordDelayedRunningTransition(
|
|||||||
gate.holdNextAgentStreamEvent("turn_started");
|
gate.holdNextAgentStreamEvent("turn_started");
|
||||||
gate.holdNextAgentUpdate(agent.agentId, "running");
|
gate.holdNextAgentUpdate(agent.agentId, "running");
|
||||||
gate.holdNextServerMessage("send_agent_message_response");
|
gate.holdNextServerMessage("send_agent_message_response");
|
||||||
gate.setAgentStreamItemSuppressed("assistant_message", true);
|
gate.setAgentStreamSuppressed(true);
|
||||||
await recordTurnFrames(page, prompt);
|
await recordTurnFrames(page, prompt);
|
||||||
await installActivityContinuityOracle(page, prompt);
|
await installActivityContinuityOracle(page, prompt);
|
||||||
await submitMessage(page, prompt);
|
await submitMessage(page, prompt);
|
||||||
@@ -779,13 +800,13 @@ async function recordDelayedRunningTransition(
|
|||||||
const checkpoints = await readActivityContinuityOracle(page);
|
const checkpoints = await readActivityContinuityOracle(page);
|
||||||
expectActivityContinuity(checkpoints);
|
expectActivityContinuity(checkpoints);
|
||||||
expect(elapsedBeforeAuthoritative).toBe(0);
|
expect(elapsedBeforeAuthoritative).toBe(0);
|
||||||
gate.setAgentStreamItemSuppressed("assistant_message", false);
|
gate.setAgentStreamSuppressed(false);
|
||||||
return { frames, checkpoints };
|
return { frames, checkpoints };
|
||||||
} finally {
|
} finally {
|
||||||
if (!turnReleased) gate.releaseHeldAgentStreamEvent("turn_started");
|
if (!turnReleased) gate.releaseHeldAgentStreamEvent("turn_started");
|
||||||
if (!snapshotReleased) gate.releaseHeldAgentUpdate(agent.agentId, "running");
|
if (!snapshotReleased) gate.releaseHeldAgentUpdate(agent.agentId, "running");
|
||||||
if (!responseReleased) gate.releaseHeldServerMessage("send_agent_message_response");
|
if (!responseReleased) gate.releaseHeldServerMessage("send_agent_message_response");
|
||||||
gate.setAgentStreamItemSuppressed("assistant_message", false);
|
gate.setAgentStreamSuppressed(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,6 +835,8 @@ test("keeps the first prompt of a new agent in place through authoritative hydra
|
|||||||
await expectComposerVisible(page);
|
await expectComposerVisible(page);
|
||||||
|
|
||||||
const prompt = "Delay synthetic user message by 300ms.";
|
const prompt = "Delay synthetic user message by 300ms.";
|
||||||
|
gate.holdNextServerMessage("fetch_agent_timeline_response");
|
||||||
|
gate.setAgentStreamEventSuppressed("timeline", true);
|
||||||
await attachImageFromMenu(page, FIRST_PROMPT_IMAGE);
|
await attachImageFromMenu(page, FIRST_PROMPT_IMAGE);
|
||||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||||
await recordTurnFrames(page, prompt);
|
await recordTurnFrames(page, prompt);
|
||||||
@@ -821,7 +844,10 @@ test("keeps the first prompt of a new agent in place through authoritative hydra
|
|||||||
|
|
||||||
const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }).first();
|
const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }).first();
|
||||||
await expect(submittedRow).toBeVisible();
|
await expect(submittedRow).toBeVisible();
|
||||||
await gate.waitForServerMessage("fetch_agent_timeline_response");
|
await gate.waitForHeldServerMessage("fetch_agent_timeline_response");
|
||||||
|
gate.truncateHeldTimelineAfterLast("user_message");
|
||||||
|
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
|
||||||
|
await expect(submittedRow.getByTestId("rewind-menu-trigger")).toBeVisible();
|
||||||
await recordPaintsFor(page, 80);
|
await recordPaintsFor(page, 80);
|
||||||
expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page));
|
expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,30 +1,9 @@
|
|||||||
import type { Locator } from "@playwright/test";
|
import type { Locator } from "@playwright/test";
|
||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
||||||
import {
|
import { cancelAgent, expectComposerVisible, submitMessage } from "../support/helpers/composer";
|
||||||
cancelAgent,
|
|
||||||
expectComposerDraft,
|
|
||||||
expectComposerVisible,
|
|
||||||
submitMessage,
|
|
||||||
} from "../support/helpers/composer";
|
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
|
||||||
async function completeSubmittedTurn(
|
|
||||||
page: Page,
|
|
||||||
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
|
|
||||||
prompt: string,
|
|
||||||
): Promise<Locator> {
|
|
||||||
await submitMessage(page, prompt);
|
|
||||||
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
|
|
||||||
await expect(userMessage).toBeVisible();
|
|
||||||
|
|
||||||
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
|
|
||||||
expect(finish.status).toBe("idle");
|
|
||||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
|
||||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
|
||||||
return userMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rewindConversation(page: Page, userMessage: Locator, prompt: string): Promise<void> {
|
async function rewindConversation(page: Page, userMessage: Locator, prompt: string): Promise<void> {
|
||||||
await userMessage.getByText(prompt, { exact: true }).hover();
|
await userMessage.getByText(prompt, { exact: true }).hover();
|
||||||
await userMessage.getByRole("button", { name: "Rewind to this message", exact: true }).click();
|
await userMessage.getByRole("button", { name: "Rewind to this message", exact: true }).click();
|
||||||
@@ -42,29 +21,6 @@ async function expectTurnCompletesNormally(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Agent message rewind", () => {
|
test.describe("Agent message rewind", () => {
|
||||||
test("rewinds a submitted prompt and restores it to the composer", async ({ page }) => {
|
|
||||||
test.setTimeout(90_000);
|
|
||||||
const prompt = "Restore this submitted prompt after rewind.";
|
|
||||||
const agent = await seedMockAgentWorkspace({
|
|
||||||
repoPrefix: "message-rewind-roundtrip-",
|
|
||||||
title: "Message rewind roundtrip",
|
|
||||||
model: "ten-second-stream",
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await openAgentRoute(page, agent);
|
|
||||||
await expectComposerVisible(page);
|
|
||||||
const userMessage = await completeSubmittedTurn(page, agent, prompt);
|
|
||||||
|
|
||||||
await rewindConversation(page, userMessage, prompt);
|
|
||||||
|
|
||||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
|
|
||||||
await expectComposerDraft(page, prompt);
|
|
||||||
} finally {
|
|
||||||
await agent.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps a pre-acknowledgement turn running after rewind is rejected", async ({ page }) => {
|
test("keeps a pre-acknowledgement turn running after rewind is rejected", async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const prompt = "Delay synthetic user message by 2000ms.";
|
const prompt = "Delay synthetic user message by 2000ms.";
|
||||||
|
|||||||
@@ -315,10 +315,11 @@ async function configureInterruptInSettings(page: Page): Promise<void> {
|
|||||||
|
|
||||||
async function selectSendBehaviorInSettings(
|
async function selectSendBehaviorInSettings(
|
||||||
page: Page,
|
page: Page,
|
||||||
buttonName: string,
|
behaviorLabel: string,
|
||||||
stored: string,
|
stored: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await page.getByRole("button", { name: buttonName, exact: true }).click();
|
await page.getByRole("button", { name: /^Default send: / }).click();
|
||||||
|
await page.getByRole("menuitem", { name: behaviorLabel, exact: true }).click();
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
|
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
|
||||||
|
|||||||
@@ -103,6 +103,18 @@ async function readProviderModePreference(page: Page, provider: string): Promise
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readProviderFeaturePreferences(page: Page, provider: string): Promise<unknown> {
|
||||||
|
return page.evaluate(
|
||||||
|
({ preferencesKey, providerId }) => {
|
||||||
|
const raw = localStorage.getItem(preferencesKey);
|
||||||
|
if (!raw) return null;
|
||||||
|
const preferences = JSON.parse(raw) as FormPreferences;
|
||||||
|
return preferences.providerPreferences?.[providerId]?.featureValues ?? null;
|
||||||
|
},
|
||||||
|
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("Agent profiles repair modeless provider preferences", () => {
|
test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||||
test.describe.configure({ timeout: 240_000 });
|
test.describe.configure({ timeout: 240_000 });
|
||||||
|
|
||||||
@@ -207,6 +219,9 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
|||||||
|
|
||||||
await openModelPicker(page);
|
await openModelPicker(page);
|
||||||
await applyProfileFromPicker(page, "Fast profile");
|
await applyProfileFromPicker(page, "Fast profile");
|
||||||
|
await expect
|
||||||
|
.poll(() => readProviderFeaturePreferences(page, featureProviderId), { timeout: 10_000 })
|
||||||
|
.toEqual({ fast_mode: true });
|
||||||
await submitNewWorkspacePrompt(page, "Create an agent with profile features.");
|
await submitNewWorkspacePrompt(page, "Create an agent with profile features.");
|
||||||
|
|
||||||
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
||||||
|
|||||||
@@ -91,14 +91,16 @@ test.describe("Agent timeline pagination", () => {
|
|||||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||||
await expectTimelinePromptCentered(page, agent.newestPrompt);
|
await expectTimelinePromptCentered(page, agent.newestPrompt);
|
||||||
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
||||||
|
await expectTimelinePromptNotMounted(page, agent.initialTailOldestPrompt);
|
||||||
|
|
||||||
await userScrollsTimelineToHistoryStart(page);
|
await userScrollsTimelineToHistoryStart(page);
|
||||||
|
await expectTimelinePromptVisible(page, agent.initialTailOldestPrompt);
|
||||||
await history.expectRequestedPages(1);
|
await history.expectRequestedPages(1);
|
||||||
history.releasePage(1);
|
history.releasePage(1);
|
||||||
await history.expectSettledWithRequestedPages(1);
|
await history.expectSettledWithRequestedPages(1);
|
||||||
await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt);
|
|
||||||
|
|
||||||
await userScrollsTimelineToHistoryStart(page);
|
await userScrollsTimelineToHistoryStart(page);
|
||||||
|
await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt);
|
||||||
await history.expectRequestedPages(2);
|
await history.expectRequestedPages(2);
|
||||||
} finally {
|
} finally {
|
||||||
await agent.cleanup();
|
await agent.cleanup();
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("applies the base font size to settings text", async ({ page }) => {
|
test("applies the interface font size to settings text", async ({ page }) => {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiBaseFontSize: 21 }));
|
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiBaseFontSize: 21 }));
|
||||||
});
|
});
|
||||||
@@ -56,11 +56,14 @@ test("applies the base font size to settings text", async ({ page }) => {
|
|||||||
const sectionTitle = page.getByText("Theme", { exact: true }).first();
|
const sectionTitle = page.getByText("Theme", { exact: true }).first();
|
||||||
await expect(sectionTitle).toHaveCSS("font-size", "18px");
|
await expect(sectionTitle).toHaveCSS("font-size", "18px");
|
||||||
|
|
||||||
const fontSizeInput = page.getByLabel("Base font size");
|
const interfaceSizeInput = page.getByLabel("Interface font size");
|
||||||
await expect(fontSizeInput).toHaveValue("21");
|
const contentSizeInput = page.getByLabel("Content font size");
|
||||||
await fontSizeInput.fill("12");
|
await expect(interfaceSizeInput).toHaveValue("21");
|
||||||
await fontSizeInput.press("Tab");
|
await expect(contentSizeInput).toHaveValue("21");
|
||||||
|
await interfaceSizeInput.fill("12");
|
||||||
|
await interfaceSizeInput.press("Tab");
|
||||||
|
|
||||||
await expect(fontSizeInput).toHaveValue("12");
|
await expect(interfaceSizeInput).toHaveValue("12");
|
||||||
|
await expect(contentSizeInput).toHaveValue("21");
|
||||||
await expect(sectionTitle).toHaveCSS("font-size", "10px");
|
await expect(sectionTitle).toHaveCSS("font-size", "10px");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
|
|||||||
repoPrefix: "bottom-sheet-reopen-",
|
repoPrefix: "bottom-sheet-reopen-",
|
||||||
title: "Bottom sheet reopen e2e",
|
title: "Bottom sheet reopen e2e",
|
||||||
initialPrompt: "Prepare a bottom sheet reopen test agent.",
|
initialPrompt: "Prepare a bottom sheet reopen test agent.",
|
||||||
|
model: "ten-second-stream",
|
||||||
});
|
});
|
||||||
await openAgentRoute(page, session);
|
await openAgentRoute(page, session);
|
||||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
|
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
|
||||||
@@ -22,16 +23,6 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withMobileMockAgent(page: Page, run: () => Promise<void>) {
|
|
||||||
const session = await openMockAgentAtMobileBreakpoint(page);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await run();
|
|
||||||
} finally {
|
|
||||||
await session.cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bottomSheetBackdrop(page: Page) {
|
function bottomSheetBackdrop(page: Page) {
|
||||||
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
||||||
}
|
}
|
||||||
@@ -108,35 +99,36 @@ async function openAndCloseModelSelectorTwice(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("mobile bottom sheet reopen", () => {
|
test.describe("mobile bottom sheet reopen", () => {
|
||||||
test("tab switcher can open, close, reopen, and close again", async ({ page }) => {
|
test("sheets reopen and model search returns to configuration", async ({ page }) => {
|
||||||
await withMobileMockAgent(page, async () => {
|
const session = await openMockAgentAtMobileBreakpoint(page);
|
||||||
await openAndCloseTabSwitcherTwice(page);
|
try {
|
||||||
});
|
await test.step("tab switcher opens, closes, and reopens", async () => {
|
||||||
});
|
await openAndCloseTabSwitcherTwice(page);
|
||||||
|
|
||||||
test("model selector can open, close, reopen, and close again", async ({ page }) => {
|
|
||||||
await withMobileMockAgent(page, async () => {
|
|
||||||
await openAndCloseModelSelectorTwice(page);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("search selection returns to configuration options", async ({ page }) => {
|
|
||||||
await withMobileMockAgent(page, async () => {
|
|
||||||
await openModelSelector(page);
|
|
||||||
const sheet = page.getByTestId("agent-controls-model-sheet");
|
|
||||||
|
|
||||||
await page.getByTestId("model-search-all-input").click();
|
|
||||||
const model = page.getByRole("button", { name: /^Ten second stream/ });
|
|
||||||
await expect(model).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await model.click();
|
await test.step("model selector opens, closes, and reopens", async () => {
|
||||||
|
await openAndCloseModelSelectorTwice(page);
|
||||||
|
});
|
||||||
|
|
||||||
await expect(sheet).toBeVisible();
|
await test.step("model search returns to configuration", async () => {
|
||||||
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
|
await openModelSelector(page);
|
||||||
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
|
const sheet = page.getByTestId("agent-controls-model-sheet");
|
||||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
|
|
||||||
});
|
await page.getByTestId("model-search-all-input").click();
|
||||||
|
const model = page.getByRole("button", { name: /^Ten second stream/ });
|
||||||
|
await expect(model).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await model.click();
|
||||||
|
|
||||||
|
await expect(sheet).toBeVisible();
|
||||||
|
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
|
||||||
|
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,38 +46,6 @@ test.describe("Branch switcher", () => {
|
|||||||
// releases stale sessions from the previous spec; one retry stabilizes it.
|
// releases stale sessions from the previous spec; one retry stabilizes it.
|
||||||
test.describe.configure({ retries: 1 });
|
test.describe.configure({ retries: 1 });
|
||||||
|
|
||||||
test("switches the workspace branch from the git diff panel for an opaque workspace id", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(90_000);
|
|
||||||
const serverId = getServerId();
|
|
||||||
const workspace = await seedWorkspace({
|
|
||||||
repoPrefix: "branch-switch-",
|
|
||||||
repo: { branches: ["main", "dev"] },
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarHydration(page);
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
|
|
||||||
|
|
||||||
await openChangesPanel(page);
|
|
||||||
await expectWorkspaceBranch(page, "main");
|
|
||||||
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
|
|
||||||
await expectWorkspaceBranch(page, "dev");
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () =>
|
|
||||||
(await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch,
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe("dev");
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({
|
test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -30,11 +30,7 @@ const cleanupTasks: CleanupTask[] = [];
|
|||||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
function changesTree(page: Page) {
|
function changesTree(page: Page) {
|
||||||
return page.getByTestId("changes-tree-rail-tree");
|
return page.getByTestId("changes-file-tree").filter({ visible: true });
|
||||||
}
|
|
||||||
|
|
||||||
function changesContent(page: Page) {
|
|
||||||
return page.getByTestId("changes-tree-rail-content");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readFileIfPresent(filePath: string): Promise<string | null> {
|
async function readFileIfPresent(filePath: string): Promise<string | null> {
|
||||||
@@ -258,6 +254,63 @@ test.afterEach(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithCommittedDiff();
|
||||||
|
|
||||||
|
await openWorkspaceChangesSurface(page, workspace);
|
||||||
|
|
||||||
|
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
|
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||||
|
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||||
|
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||||
|
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||||
|
await expect(mode).toContainText("Uncommitted");
|
||||||
|
|
||||||
|
await mode.click();
|
||||||
|
await page.getByTestId("changes-diff-mode-committed").click();
|
||||||
|
await expect(mode).toContainText("Committed");
|
||||||
|
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toBeVisible();
|
||||||
|
|
||||||
|
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
|
||||||
|
execFileSync("git", ["commit", "-m", "Commit working changes"], { cwd: workspace.repoPath });
|
||||||
|
await expect(mode).toContainText("Committed");
|
||||||
|
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeFile(path.join(workspace.repoPath, "new-working-change.txt"), "uncommitted\n");
|
||||||
|
await expect(mode).toContainText("Uncommitted", { timeout: 30_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty Changes comparison links to the populated comparison", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithCommittedDiff();
|
||||||
|
await openWorkspaceChangesSurface(page, workspace);
|
||||||
|
|
||||||
|
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
|
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||||
|
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||||
|
await mode.click();
|
||||||
|
await page.getByTestId("changes-diff-mode-committed").click();
|
||||||
|
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await mode.click();
|
||||||
|
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||||
|
|
||||||
|
await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
||||||
|
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
|
||||||
|
await expect(seeCommitted).toBeVisible();
|
||||||
|
await seeCommitted.click();
|
||||||
|
await expect(mode).toContainText("Committed");
|
||||||
|
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("changes file actions open below the right-click without a reserved kebab", async ({
|
test("changes file actions open below the right-click without a reserved kebab", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -266,7 +319,7 @@ test("changes file actions open below the right-click without a reserved kebab",
|
|||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||||
const deletedFileName = page.getByText("zz-deleted.ts", { exact: true });
|
const deletedFileName = page.getByTestId("diff-file-1-name");
|
||||||
await expect(deletedFileName).toHaveCSS("user-select", "none");
|
await expect(deletedFileName).toHaveCSS("user-select", "none");
|
||||||
await deletedFileName.dblclick();
|
await deletedFileName.dblclick();
|
||||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||||
@@ -377,7 +430,6 @@ test("changes context menus duplicate files and folders", async ({ page }) => {
|
|||||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
|
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
|
||||||
.toBe(AFTER);
|
.toBe(AFTER);
|
||||||
|
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
|
||||||
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||||
await page.getByTestId("diff-folder-src-duplicate").click();
|
await page.getByTestId("diff-folder-src-duplicate").click();
|
||||||
await expect
|
await expect
|
||||||
@@ -392,8 +444,6 @@ test("changes tree aligns every file status after its diff stat", async ({ page
|
|||||||
});
|
});
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
|
||||||
|
|
||||||
const tree = changesTree(page);
|
const tree = changesTree(page);
|
||||||
const modifiedRow = tree.getByTestId("diff-tree-file-0");
|
const modifiedRow = tree.getByTestId("diff-tree-file-0");
|
||||||
const deletedRow = tree.getByTestId("diff-tree-file-1");
|
const deletedRow = tree.getByTestId("diff-tree-file-1");
|
||||||
@@ -416,7 +466,6 @@ test("changes context menu recursively collapses descendant folders", async ({ p
|
|||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
|
||||||
const tree = changesTree(page);
|
const tree = changesTree(page);
|
||||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||||
@@ -461,7 +510,6 @@ test("changes context menus expose folder revert and restore a file after confir
|
|||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
|
||||||
const tree = changesTree(page);
|
const tree = changesTree(page);
|
||||||
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||||
const folderRevert = page.getByTestId("diff-folder-src-revert");
|
const folderRevert = page.getByTestId("diff-folder-src-revert");
|
||||||
@@ -594,6 +642,11 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
|||||||
|
|
||||||
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
await expect(visiblePanel).toBeVisible();
|
await expect(visiblePanel).toBeVisible();
|
||||||
|
await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0);
|
||||||
|
await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0);
|
||||||
|
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
|
||||||
|
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
|
||||||
|
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
||||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||||
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||||
@@ -603,34 +656,30 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
|||||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
|
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
|
||||||
|
|
||||||
// Collapse-all and the layout switch are overflow-menu items, not toolbar icons.
|
await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0);
|
||||||
const diffOptions = visiblePanel.getByRole("button", { name: "Diff options" });
|
|
||||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
|
|
||||||
await diffOptions.click();
|
|
||||||
// A workspace tab has nowhere to open itself, so the menu withholds that item.
|
|
||||||
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
|
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
|
||||||
await page.getByTestId("changes-toggle-collapse-all").click();
|
const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all");
|
||||||
await diffOptions.click();
|
await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files");
|
||||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toContainText("Expand all files");
|
await collapseFiles.click();
|
||||||
await page.getByTestId("changes-toggle-collapse-all").click();
|
await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files");
|
||||||
|
await collapseFiles.click();
|
||||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||||
|
|
||||||
const layoutItem = page.getByTestId("changes-toggle-layout");
|
const layout = visiblePanel.getByTestId("changes-toggle-layout");
|
||||||
await diffOptions.click();
|
await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff");
|
||||||
await expect(layoutItem).toContainText("Side-by-side diff");
|
await layout.click();
|
||||||
// Leading icon only: the layout is still unified, so no trailing check.
|
await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff");
|
||||||
await expect(layoutItem.locator("svg")).toHaveCount(1);
|
|
||||||
await layoutItem.click();
|
|
||||||
await diffOptions.click();
|
|
||||||
await expect(layoutItem.locator("svg")).toHaveCount(2);
|
|
||||||
|
|
||||||
await expect(page.getByText("Hide whitespace", { exact: true })).toBeVisible();
|
const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace");
|
||||||
await expect(page.getByText("Wrap long lines", { exact: true })).toBeVisible();
|
await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace");
|
||||||
await expect(page.getByTestId("changes-refresh")).toHaveText("Refresh");
|
const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines");
|
||||||
await page.getByText("Wrap long lines", { exact: true }).click();
|
await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines");
|
||||||
await diffOptions.click();
|
await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute(
|
||||||
await expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible();
|
"aria-label",
|
||||||
await page.keyboard.press("Escape");
|
"Refresh",
|
||||||
|
);
|
||||||
|
await wrapLines.click();
|
||||||
|
await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines");
|
||||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||||
|
|
||||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||||
@@ -646,96 +695,53 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
|||||||
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
|
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("desktop Changes toggles a navigation tree beside the expanded diff document", async ({
|
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
await page.setViewportSize({ width: 480, height: 900 });
|
||||||
|
|
||||||
await expectFlatFileList(page);
|
const compactChangesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||||
await expect(page.getByTestId("changes-layout-unified")).toHaveCount(0);
|
if (!(await compactChangesTab.isVisible())) {
|
||||||
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
|
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||||
// The tree toggle is the one icon action that stays outside the overflow menu.
|
}
|
||||||
await expect(page.getByTestId("changes-toggle-tree")).toBeVisible();
|
await expect(compactChangesTab).toBeVisible();
|
||||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
|
await compactChangesTab.click();
|
||||||
await expect(page.getByTestId("changes-toggle-layout")).toHaveCount(0);
|
const compactExplorer = page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||||
|
await expect(compactExplorer.getByTestId("changes-header")).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("changes-options-menu").click();
|
const actions = compactExplorer.getByTestId("changes-actions-menu-trigger");
|
||||||
await expect(page.getByTestId("changes-options-menu-content")).toBeVisible();
|
const options = compactExplorer.getByRole("button", { name: "Diff options" });
|
||||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toContainText("Collapse all files");
|
const [actionsBox, optionsBox, glyphBox] = await Promise.all([
|
||||||
await expect(page.getByTestId("changes-toggle-layout")).toContainText("Side-by-side diff");
|
actions.boundingBox(),
|
||||||
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Hide whitespace");
|
options.boundingBox(),
|
||||||
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Wrap long lines");
|
options.locator("svg").boundingBox(),
|
||||||
await expect(page.getByTestId("changes-refresh")).toHaveText("Refresh");
|
]);
|
||||||
await page.getByTestId("changes-toggle-whitespace").click();
|
if (!actionsBox || !optionsBox || !glyphBox) {
|
||||||
await page.getByTestId("changes-options-menu").click();
|
throw new Error("Compact Changes toolbar geometry could not be measured");
|
||||||
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Show whitespace");
|
}
|
||||||
|
expect(actionsBox.width).toBe(48);
|
||||||
|
expect(actionsBox.height).toBe(28);
|
||||||
|
expect(optionsBox.width).toBe(32);
|
||||||
|
expect(optionsBox.height).toBe(32);
|
||||||
|
expect(glyphBox.width).toBe(18);
|
||||||
|
expect(glyphBox.height).toBe(18);
|
||||||
|
|
||||||
|
await expect(actions).not.toContainText("Commit");
|
||||||
|
await expect(actions.locator("svg")).toHaveCount(2);
|
||||||
|
await actions.click();
|
||||||
|
await expect(page.getByTestId("changes-primary-cta-menu")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("changes-menu-commit")).toContainText("Commit");
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
await scrollToLowerUnwrappedDiffRows(page);
|
await options.click();
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
const wrapLines = page.getByText("Wrap long lines", { exact: true });
|
||||||
const tree = changesTree(page);
|
await expect(wrapLines).toBeVisible();
|
||||||
const content = changesContent(page);
|
await wrapLines.click();
|
||||||
await expect(tree.getByTestId("diff-folder-src")).toBeVisible();
|
await options.click();
|
||||||
await expect(tree.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS(
|
await expect(
|
||||||
"user-select",
|
page.getByTestId("changes-options-menu-content").getByTestId("changes-toggle-wrap-lines"),
|
||||||
"none",
|
).toContainText("Scroll long lines");
|
||||||
);
|
|
||||||
await expect(tree.getByTestId("diff-tree-file-0")).toBeVisible();
|
|
||||||
await expect(tree.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1);
|
|
||||||
const folderToggleBounds = await tree.getByTestId("diff-folder-src-toggle").boundingBox();
|
|
||||||
const folderChevronBounds = await tree
|
|
||||||
.getByTestId("diff-folder-src-toggle")
|
|
||||||
.locator("svg")
|
|
||||||
.boundingBox();
|
|
||||||
expect(folderToggleBounds).not.toBeNull();
|
|
||||||
expect(folderChevronBounds).not.toBeNull();
|
|
||||||
expect(folderChevronBounds!.y + folderChevronBounds!.height / 2).toBeCloseTo(
|
|
||||||
folderToggleBounds!.y + folderToggleBounds!.height / 2,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const folderLabelBounds = await tree
|
|
||||||
.getByTestId("diff-folder-src")
|
|
||||||
.getByText("src", { exact: true })
|
|
||||||
.boundingBox();
|
|
||||||
const fileLabelBounds = await tree
|
|
||||||
.getByTestId("diff-tree-file-0")
|
|
||||||
.getByText("use-mounted-tab-set.ts", { exact: true })
|
|
||||||
.boundingBox();
|
|
||||||
expect(folderLabelBounds).not.toBeNull();
|
|
||||||
expect(fileLabelBounds).not.toBeNull();
|
|
||||||
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(12, 0);
|
|
||||||
|
|
||||||
const folderToggle = tree.getByTestId("diff-folder-src-toggle");
|
|
||||||
await folderToggle.click();
|
|
||||||
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
|
||||||
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
|
|
||||||
await folderToggle.click();
|
|
||||||
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
|
||||||
await expect(tree.getByTestId("diff-tree-file-0")).toBeVisible();
|
|
||||||
|
|
||||||
const fileToggle = tree.getByTestId("diff-tree-file-0-toggle");
|
|
||||||
await fileToggle.click({ button: "right" });
|
|
||||||
await expect(fileToggle).toHaveAttribute("aria-selected", "true");
|
|
||||||
await expect(folderToggle).toHaveAttribute("aria-selected", "false");
|
|
||||||
await expect(page.getByTestId("diff-tree-file-0-context-menu")).toBeVisible();
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
|
|
||||||
await fileToggle.click();
|
|
||||||
await expect(content.getByTestId("diff-file-0-body")).toBeVisible();
|
|
||||||
const contentBounds = await content.boundingBox();
|
|
||||||
const focusedFileBounds = await content.getByTestId("diff-file-0").boundingBox();
|
|
||||||
expect(contentBounds).not.toBeNull();
|
|
||||||
expect(focusedFileBounds).not.toBeNull();
|
|
||||||
expect(focusedFileBounds!.y).toBeGreaterThanOrEqual(contentBounds!.y);
|
|
||||||
expect(focusedFileBounds!.y).toBeLessThan(contentBounds!.y + 80);
|
|
||||||
|
|
||||||
await tree.getByTestId("diff-folder-src-toggle").click();
|
|
||||||
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
|
||||||
await expectFlatFileList(page);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
|
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
|
||||||
@@ -745,7 +751,9 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
|
|||||||
|
|
||||||
const canvas = page.getByTestId("git-diff-canvas");
|
const canvas = page.getByTestId("git-diff-canvas");
|
||||||
const root = page.getByTestId("git-diff-canvas-root");
|
const root = page.getByTestId("git-diff-canvas-root");
|
||||||
const handle = page.getByTestId("workspace-split-resize-handle").getByRole("separator");
|
const handle = page
|
||||||
|
.getByTestId("workspace-explorer-sidebar-resize-handle")
|
||||||
|
.getByRole("separator");
|
||||||
await expect(handle).toBeVisible();
|
await expect(handle).toBeVisible();
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -766,11 +774,11 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
if (!handleBounds) throw new Error("Workspace split resize handle has no bounds");
|
if (!handleBounds) throw new Error("Explorer sidebar resize handle has no bounds");
|
||||||
|
|
||||||
await page.mouse.move(handleBounds.x + handleBounds.width / 2, handleBounds.y + 120);
|
await page.mouse.move(handleBounds.x + handleBounds.width / 2, handleBounds.y + 120);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.mouse.move(handleBounds.x - 120, handleBounds.y + 120);
|
await page.mouse.move(handleBounds.x + 120, handleBounds.y + 120);
|
||||||
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
|
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
|
||||||
|
|
||||||
const duringDrag = await Promise.all([
|
const duringDrag = await Promise.all([
|
||||||
@@ -869,12 +877,30 @@ test("canvas diff creates, edits, and deletes an inline review without DOM code
|
|||||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
|
||||||
|
const focusedBackground = await changesTab.evaluate(
|
||||||
|
(element) => getComputedStyle(element).backgroundColor,
|
||||||
|
);
|
||||||
|
|
||||||
|
await startReviewOnFirstChangedLine(page);
|
||||||
|
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||||
|
await expect
|
||||||
|
.poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||||
|
.toBe(focusedBackground);
|
||||||
|
});
|
||||||
|
|
||||||
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
|
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
await setOpenChangesPresentation(page, { layout: "split", wrapLines: false });
|
||||||
await startReviewOnFirstChangedLine(page, "right");
|
await startReviewOnFirstChangedLine(page, "right");
|
||||||
const [editor, body] = await Promise.all([
|
const [editor, body] = await Promise.all([
|
||||||
page.getByTestId("inline-review-editor").boundingBox(),
|
page.getByTestId("inline-review-editor").boundingBox(),
|
||||||
@@ -918,6 +944,7 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
|
|||||||
if (!bounds) throw new Error("Diff overlay scrollbar thumb has no bounds");
|
if (!bounds) throw new Error("Diff overlay scrollbar thumb has no bounds");
|
||||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
|
await expect(grab).toHaveCSS("cursor", "grabbing");
|
||||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 180);
|
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 180);
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
|
|
||||||
@@ -967,7 +994,7 @@ test("canvas diff copies a dragged character selection without opening a review"
|
|||||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the first click after a canvas selection only dismisses the selection", async ({ page }) => {
|
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
|
||||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openSelectionWorkspaceChanges(page, workspace);
|
await openSelectionWorkspaceChanges(page, workspace);
|
||||||
@@ -977,7 +1004,7 @@ test("the first click after a canvas selection only dismisses the selection", as
|
|||||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||||
|
|
||||||
await clickFirstChangedLine(page);
|
await clickFirstChangedLine(page);
|
||||||
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("canvas diff replaces a selection with forward and backward drags", async ({
|
test("canvas diff replaces a selection with forward and backward drags", async ({
|
||||||
@@ -1004,6 +1031,7 @@ test("canvas diff copies only the selected split side", async ({ context, page }
|
|||||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
|
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
|
||||||
await openSelectionWorkspaceChanges(page, workspace);
|
await openSelectionWorkspaceChanges(page, workspace);
|
||||||
|
await setOpenChangesPresentation(page, { layout: "split", wrapLines: false });
|
||||||
const before = await readSelectionPaintSamples(page, "right");
|
const before = await readSelectionPaintSamples(page, "right");
|
||||||
await dragAddedTextRange(page, {
|
await dragAddedTextRange(page, {
|
||||||
lines,
|
lines,
|
||||||
@@ -1027,6 +1055,7 @@ test("canvas diff copies exact wrapped fragments", async ({ context, page }) =>
|
|||||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
await configureDiffPresentation(page, { layout: "unified", wrapLines: true });
|
await configureDiffPresentation(page, { layout: "unified", wrapLines: true });
|
||||||
await openSelectionWorkspaceChanges(page, workspace);
|
await openSelectionWorkspaceChanges(page, workspace);
|
||||||
|
await setOpenChangesPresentation(page, { layout: "unified", wrapLines: true });
|
||||||
await dragAddedTextRange(page, {
|
await dragAddedTextRange(page, {
|
||||||
lines: [content],
|
lines: [content],
|
||||||
wrapped: true,
|
wrapped: true,
|
||||||
@@ -1151,6 +1180,27 @@ async function configureDiffPresentation(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setOpenChangesPresentation(
|
||||||
|
page: Page,
|
||||||
|
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
|
const layoutItem = diffPanel.getByTestId("changes-toggle-layout");
|
||||||
|
const currentLayout =
|
||||||
|
(await layoutItem.getAttribute("aria-label")) === "Switch to unified diff"
|
||||||
|
? "split"
|
||||||
|
: "unified";
|
||||||
|
if (currentLayout !== requestedPresentation.layout) {
|
||||||
|
await layoutItem.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapItem = diffPanel.getByTestId("changes-toggle-wrap-lines");
|
||||||
|
const currentWrapLines = (await wrapItem.getAttribute("aria-label")) === "Scroll long lines";
|
||||||
|
if (currentWrapLines !== requestedPresentation.wrapLines) {
|
||||||
|
await wrapItem.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function holdBrowserFontLoads(page: Page): Promise<void> {
|
async function holdBrowserFontLoads(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const fontSet = document.fonts;
|
const fontSet = document.fonts;
|
||||||
@@ -1182,12 +1232,6 @@ async function releaseBrowserFontLoads(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectFlatFileList(page: Page): Promise<void> {
|
|
||||||
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
|
|
||||||
await expect(page.getByTestId("diff-file-0")).toContainText("src");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
||||||
const canvas = page.getByTestId("git-diff-canvas");
|
const canvas = page.getByTestId("git-diff-canvas");
|
||||||
await expect
|
await expect
|
||||||
@@ -1265,6 +1309,28 @@ async function createWorkspaceWithMountedTabDiff(
|
|||||||
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
|
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
|
||||||
|
const repo = await createTempGitRepo("changes-committed-", {
|
||||||
|
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
|
||||||
|
});
|
||||||
|
const client = await connectSeedClient();
|
||||||
|
cleanupTasks.push({
|
||||||
|
run: async () => {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await repo.cleanup().catch(() => undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path });
|
||||||
|
await writeFile(path.join(repo.path, "committed-only.ts"), "export const committed = true;\n");
|
||||||
|
execFileSync("git", ["add", "committed-only.ts"], { cwd: repo.path });
|
||||||
|
execFileSync("git", ["commit", "-m", "Add committed-only file"], { cwd: repo.path });
|
||||||
|
|
||||||
|
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||||
|
if (!created.workspace) throw new Error(created.error ?? "Failed to create committed workspace");
|
||||||
|
return { id: created.workspace.id, repoPath: repo.path };
|
||||||
|
}
|
||||||
|
|
||||||
async function createWorkspaceWithExactSelectionDiff(content: string): Promise<DirtyWorkspace> {
|
async function createWorkspaceWithExactSelectionDiff(content: string): Promise<DirtyWorkspace> {
|
||||||
const repo = await createTempGitRepo("changes-canvas-selection-", {
|
const repo = await createTempGitRepo("changes-canvas-selection-", {
|
||||||
files: [{ path: "src/selection.ts", content: "" }],
|
files: [{ path: "src/selection.ts", content: "" }],
|
||||||
@@ -1286,22 +1352,44 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
|
|||||||
await page.setViewportSize({ width: 1400, height: 900 });
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||||
await openChangesInVisibleExplorer(page);
|
await openChangesInVisibleExplorer(page);
|
||||||
await expectExpandedMountedTabDiff(page);
|
await expectExpandedMountedTabDiff(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openWorkspaceChangesSurface(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await openChangesPanel(page);
|
||||||
|
}
|
||||||
|
|
||||||
async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||||
await page.setViewportSize({ width: 1400, height: 900 });
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await openChangesPanel(page);
|
await openChangesPanel(page);
|
||||||
await expect(page.getByText("selection.ts")).toBeVisible({ timeout: 30_000 });
|
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
|
await expect(diffPanel.getByTestId("diff-file-0-name")).toHaveText("selection.ts", {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
await expectExpandedMountedTabDiff(page);
|
await expectExpandedMountedTabDiff(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||||
await openChangesPanel(page);
|
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||||
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
|
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||||
|
const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first();
|
||||||
|
await changesTab.click();
|
||||||
|
const changedFile = explorer
|
||||||
|
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first();
|
||||||
|
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||||
|
await changedFile.click();
|
||||||
|
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||||
@@ -1353,7 +1441,8 @@ async function startReviewOnFirstChangedLine(
|
|||||||
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
||||||
const lineHeight = Math.round(fontSize * 1.5);
|
const lineHeight = Math.round(fontSize * 1.5);
|
||||||
const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x;
|
const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x;
|
||||||
await page.mouse.click(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
|
await page.mouse.move(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
|
||||||
|
await page.getByRole("button", { name: "Add review comment" }).click();
|
||||||
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { expect, test } from "../support/fixtures";
|
||||||
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
|
const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`;
|
||||||
|
|
||||||
|
test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-explorer-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
|
await page.keyboard.press(CHANGES_SHORTCUT);
|
||||||
|
|
||||||
|
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||||
|
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
clickChatOutlineRowEdge,
|
clickChatOutlineRowEdge,
|
||||||
disableChatOutlineFromAppearance,
|
disableChatOutlineFromAppearance,
|
||||||
expectActiveChatOutlinePrompt,
|
expectActiveChatOutlinePrompt,
|
||||||
|
expectActiveChatOutlinePromptMovedFrom,
|
||||||
expectChatOutlinePreview,
|
expectChatOutlinePreview,
|
||||||
expectChatOutlinePrompts,
|
expectChatOutlinePrompts,
|
||||||
expectChatOutlinePromptToRemainBare,
|
expectChatOutlinePromptToRemainBare,
|
||||||
@@ -29,7 +30,6 @@ import {
|
|||||||
openAgentTimeline,
|
openAgentTimeline,
|
||||||
scrollThroughOlderHistoryPages,
|
scrollThroughOlderHistoryPages,
|
||||||
scrollTimelineToNewestLoadedEdge,
|
scrollTimelineToNewestLoadedEdge,
|
||||||
scrollTimelineToOldestLoadedEdge,
|
|
||||||
seedLongMockAgentTimeline,
|
seedLongMockAgentTimeline,
|
||||||
type LongTimelineAgent,
|
type LongTimelineAgent,
|
||||||
} from "../support/helpers/timeline-pagination";
|
} from "../support/helpers/timeline-pagination";
|
||||||
@@ -119,6 +119,7 @@ test.describe("desktop chat outline", () => {
|
|||||||
test("previews and jumps to the focused prompt from the keyboard", async ({ page }) => {
|
test("previews and jumps to the focused prompt from the keyboard", async ({ page }) => {
|
||||||
await focusChatOutlinePrompt(page, 1);
|
await focusChatOutlinePrompt(page, 1);
|
||||||
await expectChatOutlinePreview(page, agent.prompts[0]);
|
await expectChatOutlinePreview(page, agent.prompts[0]);
|
||||||
|
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
||||||
|
|
||||||
await pressEnterOnFocusedPrompt(page);
|
await pressEnterOnFocusedPrompt(page);
|
||||||
await expectTimelinePromptLandedBelowTop(page, agent.oldestPrompt);
|
await expectTimelinePromptLandedBelowTop(page, agent.oldestPrompt);
|
||||||
@@ -129,11 +130,12 @@ test.describe("desktop chat outline", () => {
|
|||||||
await expectOneActiveChatOutlinePrompt(page);
|
await expectOneActiveChatOutlinePrompt(page);
|
||||||
|
|
||||||
await clickChatOutlineRowEdge(page, 4);
|
await clickChatOutlineRowEdge(page, 4);
|
||||||
await expectTimelinePromptVisible(page, agent.prompts[3]);
|
await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]);
|
||||||
await expectActiveChatOutlinePrompt(page, 4);
|
await expectActiveChatOutlinePrompt(page, 4);
|
||||||
|
|
||||||
await scrollTimelineToOldestLoadedEdge(page);
|
await scrollTimelineToNewestLoadedEdge(page);
|
||||||
await expectActiveChatOutlinePrompt(page, 1);
|
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||||
|
await expectActiveChatOutlinePromptMovedFrom(page, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
|
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { closeCommandCenter, openCommandCenter } from "../support/helpers/comman
|
|||||||
import {
|
import {
|
||||||
applyCommandCenterAgentControls,
|
applyCommandCenterAgentControls,
|
||||||
chooseCommandCenterAgentControl,
|
chooseCommandCenterAgentControl,
|
||||||
|
openCommandCenterForAgent,
|
||||||
expectCommandCenterAgentControlSelected,
|
expectCommandCenterAgentControlSelected,
|
||||||
expectFocusedAgentControls,
|
expectFocusedAgentControls,
|
||||||
expectWorkspaceAgentConfiguration,
|
expectWorkspaceAgentConfiguration,
|
||||||
@@ -12,7 +13,9 @@ import {
|
|||||||
} from "../support/helpers/command-center-agent-controls";
|
} from "../support/helpers/command-center-agent-controls";
|
||||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { buildSchedulesRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||||
|
|
||||||
@@ -76,6 +79,107 @@ test.describe("Command Center agent controls", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
|
||||||
|
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
|
||||||
|
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
|
||||||
|
// model. Asserting both model and mode catches either row winning.
|
||||||
|
const workspace = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "command-center-relevance-",
|
||||||
|
title: "Command Center relevance",
|
||||||
|
model: "five-minute-stream",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||||
|
const input = await openCommandCenterForAgent(page, workspace);
|
||||||
|
await input.fill("load");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
await expectWorkspaceAgentConfiguration(workspace, {
|
||||||
|
id: workspace.agentId,
|
||||||
|
provider: "mock",
|
||||||
|
model: "five-minute-stream",
|
||||||
|
modeId: "load-test",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches query tokens in any order", async ({ page }) => {
|
||||||
|
// "Mode › Load test" flattens to "mode load test", so the reversed query "test load" is never
|
||||||
|
// a substring of it. Only per-token matching finds this row.
|
||||||
|
const workspace = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "command-center-tokenized-",
|
||||||
|
title: "Command Center tokenized search",
|
||||||
|
model: "five-minute-stream",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
|
||||||
|
await openAgentRoute(page, {
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
agentId: workspace.agentId,
|
||||||
|
});
|
||||||
|
await openCommandCenter(page);
|
||||||
|
await chooseCommandCenterAgentControl({
|
||||||
|
page,
|
||||||
|
query: "test load",
|
||||||
|
choice: "Mode › Load test",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectWorkspaceAgentConfiguration(workspace, {
|
||||||
|
id: workspace.agentId,
|
||||||
|
provider: "mock",
|
||||||
|
model: "five-minute-stream",
|
||||||
|
modeId: "load-test",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps an action first when the action is the best match", async ({ page }) => {
|
||||||
|
const workspace = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "command-center-action-priority-",
|
||||||
|
title: "Command Center action priority",
|
||||||
|
model: "five-minute-stream",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const input = await openCommandCenterForAgent(page, workspace);
|
||||||
|
await input.fill("sched");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 });
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drops an arrow-key selection once the query changes", async ({ page }) => {
|
||||||
|
const workspace = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "command-center-active-reset-",
|
||||||
|
title: "Command Center active reset",
|
||||||
|
model: "five-minute-stream",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||||
|
const input = await openCommandCenterForAgent(page, workspace);
|
||||||
|
await input.fill("loa");
|
||||||
|
// Moves the highlight onto a model row, which still matches after the next keystroke.
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
await input.fill("load");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
await expectWorkspaceAgentConfiguration(workspace, {
|
||||||
|
id: workspace.agentId,
|
||||||
|
provider: "mock",
|
||||||
|
model: "five-minute-stream",
|
||||||
|
modeId: "load-test",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("applies draft model and setting choices to the created agent", async ({ page }) => {
|
test("applies draft model and setting choices to the created agent", async ({ page }) => {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
|
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
|
||||||
await seedMockDraftPreferences(page);
|
await seedMockDraftPreferences(page);
|
||||||
|
|||||||
@@ -1,53 +1,14 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { expect } from "@playwright/test";
|
|
||||||
import { test } from "../support/fixtures";
|
import { test } from "../support/fixtures";
|
||||||
import { gotoAppShell } from "../support/helpers/app";
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||||
import { openCommandCenter } from "../support/helpers/command-center";
|
import { openCommandCenter } from "../support/helpers/command-center";
|
||||||
import { addOfflineHostAndReload } from "../support/helpers/hosts";
|
|
||||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
import { getServerId } from "../support/helpers/server-id";
|
|
||||||
|
|
||||||
const PRIMARY_HOST_LABEL = "Primary Host";
|
|
||||||
const SECONDARY_HOST_ID = "host-command-center-secondary";
|
|
||||||
|
|
||||||
test.describe("Command center host labels", () => {
|
test.describe("Command center host labels", () => {
|
||||||
test.describe.configure({ timeout: 180_000 });
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
test("agent results show the host they live on when more than one host exists", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
|
|
||||||
const title = `cc-host-${randomUUID().slice(0, 8)}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agent = await createIdleAgent(seeded.client, {
|
|
||||||
cwd: seeded.repoPath,
|
|
||||||
workspaceId: seeded.workspaceId,
|
|
||||||
title,
|
|
||||||
});
|
|
||||||
|
|
||||||
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await addOfflineHostAndReload(page, {
|
|
||||||
serverId: SECONDARY_HOST_ID,
|
|
||||||
label: "Secondary Host",
|
|
||||||
primaryLabel: PRIMARY_HOST_LABEL,
|
|
||||||
});
|
|
||||||
|
|
||||||
const panel = await openCommandCenter(page);
|
|
||||||
|
|
||||||
// The shared daemon may carry agents from other specs, so target this agent by its id.
|
|
||||||
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(row).toContainText(title);
|
|
||||||
await expect(row).toContainText(PRIMARY_HOST_LABEL);
|
|
||||||
} finally {
|
|
||||||
await seeded.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("selecting an agent result opens its workspace tab", async ({ page }) => {
|
test("selecting an agent result opens its workspace tab", async ({ page }) => {
|
||||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
|
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
|
||||||
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
|
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { Locator } from "@playwright/test";
|
import type { Locator } from "@playwright/test";
|
||||||
import { test, expect } from "../support/fixtures";
|
import { test, expect } from "../support/fixtures";
|
||||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
const COMMIT_SUBJECT = "Show commit timestamps";
|
const COMMIT_SUBJECT = "Show commit timestamps";
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ test("commit history explains when the workspace has no commits ahead of its bas
|
|||||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
|
||||||
await workspace.navigateTo();
|
await workspace.navigateTo();
|
||||||
|
|
||||||
await openChangesPanel(page);
|
await openChangesTreePanel(page);
|
||||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||||
await commitsSection.click();
|
await commitsSection.click();
|
||||||
@@ -35,7 +35,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
|||||||
await page.setViewportSize({ width: 1400, height: 900 });
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
await workspace.navigateTo();
|
await workspace.navigateTo();
|
||||||
|
|
||||||
await openChangesPanel(page);
|
await openChangesTreePanel(page);
|
||||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||||
await commitsSection.click();
|
await commitsSection.click();
|
||||||
@@ -51,6 +51,19 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
|||||||
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
|
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
|
||||||
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
||||||
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
|
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
|
||||||
|
const [commitToolbarBox, layoutToggleBox, layoutToggleGlyphBox] = await Promise.all([
|
||||||
|
panel.getByTestId("commit-diff-header").boundingBox(),
|
||||||
|
layoutToggle.boundingBox(),
|
||||||
|
layoutToggle.locator("svg").boundingBox(),
|
||||||
|
]);
|
||||||
|
if (!commitToolbarBox || !layoutToggleBox || !layoutToggleGlyphBox) {
|
||||||
|
throw new Error("Commit-diff toolbar geometry could not be measured");
|
||||||
|
}
|
||||||
|
expect(commitToolbarBox.height).toBe(36);
|
||||||
|
expect(layoutToggleBox.width).toBe(20);
|
||||||
|
expect(layoutToggleBox.height).toBe(20);
|
||||||
|
expect(layoutToggleGlyphBox.width).toBe(14);
|
||||||
|
expect(layoutToggleGlyphBox.height).toBe(14);
|
||||||
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||||
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||||
await expectCommitDiffHeaderGeometry(panel);
|
await expectCommitDiffHeaderGeometry(panel);
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { rm, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
|
function visibleMainPane(page: Page) {
|
||||||
|
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedChangedAgent(repoPrefix: string) {
|
||||||
|
const workspace = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix,
|
||||||
|
title: "Composer diff stat",
|
||||||
|
repo: { withRemote: true },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await rm(path.join(workspace.cwd, "remote.git"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(workspace.cwd, "README.md"),
|
||||||
|
"# Temp Repo\nexport const one = 1;\nexport const two = 2;\n",
|
||||||
|
);
|
||||||
|
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const workspaces = await workspace.client.fetchWorkspaces();
|
||||||
|
return (
|
||||||
|
workspaces.entries.find((entry) => entry.id === workspace.workspaceId)?.diffStat ?? null
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toEqual({ additions: 2, deletions: 0 });
|
||||||
|
return workspace;
|
||||||
|
} catch (error) {
|
||||||
|
await workspace.cleanup();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("composer diff stat opens Changes in the configured side pane", async ({ page }) => {
|
||||||
|
await page.addInitScript((settingsKey) => {
|
||||||
|
localStorage.setItem(settingsKey, JSON.stringify({ openInSidePane: { changesLinks: true } }));
|
||||||
|
}, APP_SETTINGS_KEY);
|
||||||
|
const workspace = await seedChangedAgent("composer-diff-stat-side-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await openAgentRoute(page, {
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
agentId: workspace.agentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pill = page.getByTestId("composer-diff-stat-pill");
|
||||||
|
await expect(pill).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(pill).toContainText("+2");
|
||||||
|
await expect(pill).toContainText("-0");
|
||||||
|
await pill.click();
|
||||||
|
|
||||||
|
const sidePane = page
|
||||||
|
.locator('[data-testid^="workspace-pane-"]')
|
||||||
|
.filter({ visible: true })
|
||||||
|
.filter({ has: page.getByTestId("working-diff-panel") });
|
||||||
|
await expect(sidePane.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(sidePane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(visibleMainPane(page).getByTestId("working-diff-panel")).toHaveCount(0);
|
||||||
|
|
||||||
|
await test.step("Explorer navigation does not replace the side pane", async () => {
|
||||||
|
await openFilesPanel(page);
|
||||||
|
await expect(page.getByTestId("workspace-explorer-sidebar")).toContainText("Files");
|
||||||
|
|
||||||
|
await pill.click();
|
||||||
|
await expect(sidePane.getByTestId("working-diff-panel")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("composer diff stat opens the compact explorer instead of a Changes tab", async ({ page }) => {
|
||||||
|
const workspace = await seedChangedAgent("composer-diff-stat-compact-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await openAgentRoute(page, {
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
agentId: workspace.agentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("composer-diff-stat-pill").click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("composer diff stat opens Changes in the focused pane by default", async ({ page }) => {
|
||||||
|
const workspace = await seedChangedAgent("composer-diff-stat-tab-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await openAgentRoute(page, {
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
agentId: workspace.agentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("composer-diff-stat-pill").click();
|
||||||
|
|
||||||
|
const mainPane = visibleMainPane(page);
|
||||||
|
await expect(mainPane.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(mainPane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid^="workspace-pane-"]').filter({ visible: true }),
|
||||||
|
).toHaveCount(1);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -99,6 +99,7 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
|
|||||||
repoPrefix: "composer-bottom-anchor-",
|
repoPrefix: "composer-bottom-anchor-",
|
||||||
title: "Composer bottom anchor",
|
title: "Composer bottom anchor",
|
||||||
initialPrompt: "Produce enough content to make the chat scrollable.",
|
initialPrompt: "Produce enough content to make the chat scrollable.",
|
||||||
|
model: "ten-second-stream",
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
|||||||
await page.setViewportSize({ width: 1400, height: 900 });
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
await openChangesPanel(page);
|
||||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await expectSingleCanvasSurface(page);
|
await expectSingleCanvasSurface(page);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { expect, test } from "../support/fixtures";
|
import { expect, test } from "../support/fixtures";
|
||||||
|
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||||
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
|
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
test.describe("Direct terminal shortcut pane placement", () => {
|
test.describe("Direct terminal shortcut pane placement", () => {
|
||||||
test("opens a terminal in the focused pane", async ({ page }) => {
|
test("opens a terminal in the focused pane", async ({ page }) => {
|
||||||
@@ -11,21 +12,31 @@ test.describe("Direct terminal shortcut pane placement", () => {
|
|||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
await openChangesPanel(page);
|
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||||
const focusedPane = page
|
const focusedPaneChild = page
|
||||||
.getByTestId("workspace-tabs-row")
|
.getByTestId("split-group-child")
|
||||||
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
|
.filter({ has: page.getByTestId("workspace-new-tab-panel") });
|
||||||
.first();
|
const originalPaneChild = page
|
||||||
const primaryPane = page
|
.getByTestId("split-group-child")
|
||||||
.getByTestId("workspace-tabs-row")
|
.filter({ hasNot: page.getByTestId("workspace-new-tab-panel") });
|
||||||
.filter({ hasNot: page.getByTestId("workspace-tab-working_diff") })
|
await expect(focusedPaneChild).toBeVisible();
|
||||||
.first();
|
const focusedPaneId = await focusedPaneChild
|
||||||
|
.locator('[data-testid^="workspace-pane-"]')
|
||||||
|
.getAttribute("data-testid");
|
||||||
|
const originalPaneId = await originalPaneChild
|
||||||
|
.locator('[data-testid^="workspace-pane-"]')
|
||||||
|
.getAttribute("data-testid");
|
||||||
|
expect(focusedPaneId).not.toBeNull();
|
||||||
|
expect(originalPaneId).not.toBeNull();
|
||||||
|
const focusedPane = page.getByTestId(focusedPaneId!);
|
||||||
|
const originalPane = page.getByTestId(originalPaneId!);
|
||||||
|
|
||||||
await pressDirectNewTabShortcut(page, "t");
|
await pressDirectNewTabShortcut(page, "t");
|
||||||
|
|
||||||
await expect(focusedPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(1, {
|
await expect(focusedPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(1, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(primaryPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
await expect(originalPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { expect, type Locator, type Page } from "@playwright/test";
|
||||||
|
import { test } from "../support/fixtures";
|
||||||
|
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||||
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
|
function visibleNewTabPanel(page: Page): Locator {
|
||||||
|
return page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectLauncherSelection(launcher: Locator, name: string): Promise<void> {
|
||||||
|
await expect(launcher.getByRole("button", { name, exact: true })).toBeFocused();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openExplorerWithKeyboard(page: Page): Promise<Locator> {
|
||||||
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
|
await page.keyboard.press(`${modifier}+E`);
|
||||||
|
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||||
|
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||||
|
return explorer;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("New tab keyboard launcher", () => {
|
||||||
|
test("Cmd+E reveals the repository Changes tree", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "explorer-keyboard-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
|
const explorer = await openExplorerWithKeyboard(page);
|
||||||
|
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
|
||||||
|
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a new split pane focuses its launcher and supports arrow navigation", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-split-keyboard-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||||
|
|
||||||
|
const launcher = visibleNewTabPanel(page);
|
||||||
|
await expect(launcher).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expectLauncherSelection(launcher, "Agent");
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
await expectLauncherSelection(launcher, "Terminal");
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
await expectLauncherSelection(launcher, "Diff");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("workspace-tab-working_diff").filter({ visible: true }),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,14 +2,16 @@
|
|||||||
// 1) with the explorer pane focused, a newly appearing agent must auto-open into the
|
// 1) with the explorer pane focused, a newly appearing agent must auto-open into the
|
||||||
// MAIN pane in the background — the explorer pane is a background surface and must
|
// MAIN pane in the background — the explorer pane is a background surface and must
|
||||||
// never swallow entity tabs or lose the user's focus, and
|
// never swallow entity tabs or lose the user's focus, and
|
||||||
// 2) splitting a tab out of a pane leaves that pane empty, and the app must stay
|
// 2) splitting a tab out of a pane leaves a New tab behind, and the app must stay
|
||||||
// interactive in the [agent | empty main | explorer] state (the reporter hit an
|
// interactive in the [agent | New | explorer] state.
|
||||||
// app-wide lockup here with no way to close the empty pane).
|
|
||||||
import { expect, type Locator, type Page } from "@playwright/test";
|
import { expect, type Locator, type Page } from "@playwright/test";
|
||||||
import { test } from "../support/fixtures";
|
import { test } from "../support/fixtures";
|
||||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
import { ensureExplorerPane, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
import {
|
||||||
|
openChangesTreePanel,
|
||||||
|
waitForWorkspaceTabsVisible,
|
||||||
|
} from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
function visible(page: Page, testId: string): Locator {
|
function visible(page: Page, testId: string): Locator {
|
||||||
return page.getByTestId(testId).filter({ visible: true });
|
return page.getByTestId(testId).filter({ visible: true });
|
||||||
@@ -23,24 +25,18 @@ function draftTabChip(page: Page): Locator {
|
|||||||
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
|
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The explorer pane is the only tab row carrying the Changes tab. */
|
/** The Explorer has its own fixed tab rail, separate from workspace pane rows. */
|
||||||
function explorerTabRow(page: Page): Locator {
|
function explorerTabRow(page: Page): Locator {
|
||||||
return visible(page, "workspace-tabs-row").filter({
|
return visible(page, "explorer-sidebar-tab-rail");
|
||||||
has: page.getByTestId("workspace-tab-working_diff"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mainTabRow(page: Page): Locator {
|
function mainTabRow(page: Page): Locator {
|
||||||
return visible(page, "workspace-tabs-row").filter({
|
return visible(page, "workspace-tabs-row");
|
||||||
hasNot: page.getByTestId("workspace-tab-working_diff"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function focusExplorerPane(page: Page): Promise<void> {
|
async function selectExplorerChanges(page: Page): Promise<void> {
|
||||||
await ensureExplorerPane(page);
|
await openChangesTreePanel(page);
|
||||||
const changesTab = visible(page, "workspace-tab-working_diff").first();
|
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
|
||||||
await changesTab.click({ position: { x: 12, y: 13 } });
|
|
||||||
await expect(changesTab).toHaveAttribute("aria-selected", "true");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeSeededDraftInMainPane(page: Page): Promise<void> {
|
async function closeSeededDraftInMainPane(page: Page): Promise<void> {
|
||||||
@@ -54,15 +50,19 @@ async function closeSeededDraftInMainPane(page: Page): Promise<void> {
|
|||||||
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
|
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function emptyPaneBox(page: Page) {
|
function newTabPaneChild(page: Page): Locator {
|
||||||
const paneChild = page
|
return page
|
||||||
.getByTestId("split-group-child")
|
.getByTestId("split-group-child")
|
||||||
.filter({ hasText: "No tabs in this pane." })
|
.filter({ has: page.getByTestId("workspace-new-tab-panel") })
|
||||||
.first();
|
.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emptyPaneBox(page: Page) {
|
||||||
|
const paneChild = newTabPaneChild(page);
|
||||||
await expect(paneChild).toBeVisible({ timeout: 15_000 });
|
await expect(paneChild).toBeVisible({ timeout: 15_000 });
|
||||||
const box = await paneChild.boundingBox();
|
const box = await paneChild.boundingBox();
|
||||||
expect(box).not.toBeNull();
|
expect(box).not.toBeNull();
|
||||||
if (!box) throw new Error("Empty pane has no bounding box");
|
if (!box) throw new Error("New tab pane has no bounding box");
|
||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ async function dragChipTo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("explorer pane tab placement", () => {
|
test.describe("explorer pane tab placement", () => {
|
||||||
test("agents open in the main pane and empty-pane splits keep the app usable", async ({
|
test("agents open in the main pane and New-tab splits keep the app usable", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
const consoleErrors: string[] = [];
|
const consoleErrors: string[] = [];
|
||||||
@@ -113,8 +113,8 @@ test.describe("explorer pane tab placement", () => {
|
|||||||
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
|
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("focus the explorer pane", async () => {
|
await test.step("select Changes in Explorer", async () => {
|
||||||
await focusExplorerPane(page);
|
await selectExplorerChanges(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
|
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
|
||||||
@@ -138,11 +138,8 @@ test.describe("explorer pane tab placement", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
// The background open must not steal the explorer pane's focus.
|
// The background open must not change the selected Explorer view.
|
||||||
await expect(visible(page, "workspace-tab-working_diff").first()).toHaveAttribute(
|
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
|
||||||
"aria-selected",
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("close the draft; the agent is the main pane's only tab", async () => {
|
await test.step("close the draft; the agent is the main pane's only tab", async () => {
|
||||||
@@ -168,9 +165,10 @@ test.describe("explorer pane tab placement", () => {
|
|||||||
body: await page.screenshot(),
|
body: await page.screenshot(),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
// The split must have taken: agent pane + empty main + explorer = 3 tab rows.
|
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
|
||||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
|
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
|
||||||
await expect(page.getByText("No tabs in this pane.")).toBeVisible();
|
await expect(explorerTabRow(page)).toHaveCount(1);
|
||||||
|
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("app must stay interactive after the split", async () => {
|
await test.step("app must stay interactive after the split", async () => {
|
||||||
@@ -183,19 +181,12 @@ test.describe("explorer pane tab placement", () => {
|
|||||||
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
|
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
|
||||||
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
|
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
|
||||||
|
|
||||||
// Clicking inside the empty pane (focuses it) must not lock anything.
|
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
|
||||||
const emptyPane = page
|
// above the vertically centred launcher, which would open a tab instead.
|
||||||
.getByTestId("split-group-child")
|
const emptyPane = newTabPaneChild(page);
|
||||||
.filter({ hasText: "No tabs in this pane." })
|
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
|
||||||
.first();
|
|
||||||
await emptyPane.click({ timeout: 5_000 });
|
|
||||||
|
|
||||||
// The empty pane's + menu must open and dismiss cleanly.
|
// A second drag must work: center-drop the agent tab back into the New pane.
|
||||||
const emptyPaneTrigger = emptyPane.getByTestId("workspace-new-tab-menu-trigger").first();
|
|
||||||
await emptyPaneTrigger.click({ timeout: 5_000 });
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
|
|
||||||
// A second drag must work: center-drop the agent tab back into the empty pane.
|
|
||||||
const target = await emptyPaneBox(page);
|
const target = await emptyPaneBox(page);
|
||||||
await dragChipTo(page, chip, {
|
await dragChipTo(page, chip, {
|
||||||
x: target.x + target.width / 2,
|
x: target.x + target.width / 2,
|
||||||
@@ -206,8 +197,10 @@ test.describe("explorer pane tab placement", () => {
|
|||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
|
|
||||||
// The agent chip must have moved into the previously empty pane and stay clickable.
|
// The agent chip must have replaced New in that pane and stay clickable.
|
||||||
await expect(page.getByText("No tabs in this pane.")).toHaveCount(0, { timeout: 10_000 });
|
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
await agentTabChip(page, agentId)
|
await agentTabChip(page, agentId)
|
||||||
.first()
|
.first()
|
||||||
.click({ position: { x: 12, y: 13 } });
|
.click({ position: { x: 12, y: 13 } });
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { expect, test } from "../support/fixtures";
|
||||||
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import {
|
||||||
|
ensureExplorerSidebar,
|
||||||
|
openFilesPanel,
|
||||||
|
waitForWorkspaceTabsVisible,
|
||||||
|
} from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
|
function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||||
|
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Explorer sidebar", () => {
|
||||||
|
test("starts with Files and Changes, switches views, and toggles without changing main", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "explorer-sidebar-defaults-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
const mainTabsBefore = await page
|
||||||
|
.getByTestId("workspace-pane-main")
|
||||||
|
.locator('[data-testid^="workspace-tab-"]')
|
||||||
|
.count();
|
||||||
|
|
||||||
|
const explorer = await ensureExplorerSidebar(page);
|
||||||
|
await expect(explorer.getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||||
|
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
|
||||||
|
await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(0);
|
||||||
|
|
||||||
|
await openFilesPanel(page);
|
||||||
|
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||||
|
|
||||||
|
await explorer.getByTestId("explorer-sidebar-tab-changes_tree").click();
|
||||||
|
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||||
|
await expect(explorerSidebar(page)).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("workspace-pane-main").locator('[data-testid^="workspace-tab-"]'),
|
||||||
|
).toHaveCount(mainTabsBefore);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,16 +19,8 @@ function readPersistedPanelState(page: Page): Promise<string | null> {
|
|||||||
return page.evaluate(() => localStorage.getItem("panel-state"));
|
return page.evaluate(() => localStorage.getItem("panel-state"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only the docked sidebar ever rendered `explorer-header` on a wide layout. */
|
function explorerSidebar(page: Page): Locator {
|
||||||
function dockedSidebar(page: Page): Locator {
|
return visible(page, "workspace-explorer-sidebar");
|
||||||
return visible(page, "explorer-header");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The explorer pane is the tab row carrying the Changes tab. */
|
|
||||||
function explorerTabRow(page: Page): Locator {
|
|
||||||
return visible(page, "workspace-tabs-row").filter({
|
|
||||||
has: page.getByTestId("workspace-tab-working_diff"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
|
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
|
||||||
@@ -67,17 +59,18 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
|||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
await test.step("no docked sidebar, on load or after toggling", async () => {
|
await test.step("the retired flag does not reveal Explorer", async () => {
|
||||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
await expect(explorerSidebar(page)).toHaveCount(0);
|
||||||
|
|
||||||
const toggle = visible(page, "workspace-explorer-toggle").first();
|
const toggle = visible(page, "workspace-explorer-toggle").first();
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
|
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
await expect(explorerTabRow(page)).toHaveCount(0, { timeout: 15_000 });
|
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
|
||||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("the retired keys are migrated out of storage", async () => {
|
await test.step("the retired keys are migrated out of storage", async () => {
|
||||||
@@ -108,20 +101,22 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
|||||||
|
|
||||||
await test.step("open the explorer so both sides are showing", async () => {
|
await test.step("open the explorer so both sides are showing", async () => {
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
|
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||||
|
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||||
await expect(agentList).toHaveCount(1);
|
await expect(agentList).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("the shortcut collapses both", async () => {
|
await test.step("the shortcut collapses both", async () => {
|
||||||
await page.keyboard.press(`${modifier}+.`);
|
await page.keyboard.press(`${modifier}+.`);
|
||||||
await expect(explorerTabRow(page)).toHaveCount(0, { timeout: 15_000 });
|
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
|
||||||
await expect(agentList).toHaveCount(0);
|
await expect(agentList).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("the shortcut restores both", async () => {
|
await test.step("the shortcut restores both", async () => {
|
||||||
await page.keyboard.press(`${modifier}+.`);
|
await page.keyboard.press(`${modifier}+.`);
|
||||||
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
|
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
|
||||||
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
|
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||||
|
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
|
||||||
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
const RED_PIXEL = Buffer.from(
|
const RED_PIXEL = Buffer.from(
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -90,6 +92,87 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("CodeMirror workspace file editing", () => {
|
test.describe("CodeMirror workspace file editing", () => {
|
||||||
|
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "file-source-lockfile-",
|
||||||
|
title: "Large source",
|
||||||
|
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||||
|
});
|
||||||
|
const lockfile = `${'{"packages":['}${Array.from({ length: 42_000 }, (_, index) => `{"name":"package-${index}","version":"1.0.0"}`).join(",")}]}`;
|
||||||
|
await writeFile(path.join(session.cwd, "package-lock.json"), lockfile, "utf8");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await openWorkspaceFile(page, "package-lock.json");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
await expect(editor(page)).toContainText('"package-0"');
|
||||||
|
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the app interactive around a plain 11 MB source", async ({ page }) => {
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "file-source-plain-",
|
||||||
|
title: "Plain large source",
|
||||||
|
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
path.join(session.cwd, "plain.txt"),
|
||||||
|
"plain source\n".repeat(1_050_000),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await openWorkspaceFile(page, "plain.txt");
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
await expect(editor(page)).toContainText("plain source");
|
||||||
|
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
|
||||||
|
|
||||||
|
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
|
||||||
|
await expect(page.getByTestId("message-input-root")).toBeVisible();
|
||||||
|
await page.getByTestId("workspace-tab-file_plain.txt").first().click();
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses a file above the display budget and keeps its tab recoverable", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "file-source-unsupported-",
|
||||||
|
title: "Unsupported large source",
|
||||||
|
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
path.join(session.cwd, "too-large.txt"),
|
||||||
|
Buffer.alloc(51 * 1024 * 1024),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await openWorkspaceFile(page, "too-large.txt");
|
||||||
|
await expect(page.getByTestId("file-source-too-large")).toContainText(
|
||||||
|
"This file is too large to display",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
|
||||||
|
await expect(page.getByTestId("message-input-root")).toBeVisible();
|
||||||
|
await page.getByTestId("workspace-tab-file_too-large.txt").first().click();
|
||||||
|
await expect(page.getByTestId("file-source-too-large")).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
||||||
const target = "target.ts:42";
|
const target = "target.ts:42";
|
||||||
const session = await seedAgentWithFileLink({
|
const session = await seedAgentWithFileLink({
|
||||||
@@ -117,7 +200,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
|||||||
|
|
||||||
const sourceEditor = editor(page);
|
const sourceEditor = editor(page);
|
||||||
await sourceEditor.click();
|
await sourceEditor.click();
|
||||||
await sourceEditor.press("Control+Home");
|
await sourceEditor.press("ControlOrMeta+Home");
|
||||||
await expect(page.getByLabel(/^Line 1, column \d+$/)).toBeVisible();
|
await expect(page.getByLabel(/^Line 1, column \d+$/)).toBeVisible();
|
||||||
|
|
||||||
await page
|
await page
|
||||||
@@ -137,6 +220,12 @@ test.describe("CodeMirror workspace file editing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
|
test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
|
||||||
|
await page.addInitScript((settingsKey) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
settingsKey,
|
||||||
|
JSON.stringify({ openInSidePane: { explorerFiles: true } }),
|
||||||
|
);
|
||||||
|
}, APP_SETTINGS_KEY);
|
||||||
const target = "target.ts:42";
|
const target = "target.ts:42";
|
||||||
const session = await seedAgentWithFileLink({
|
const session = await seedAgentWithFileLink({
|
||||||
target,
|
target,
|
||||||
@@ -241,13 +330,13 @@ test.describe("CodeMirror workspace file editing", () => {
|
|||||||
const initialModeBox = await modeControl.boundingBox();
|
const initialModeBox = await modeControl.boundingBox();
|
||||||
expect(initialModeBox).not.toBeNull();
|
expect(initialModeBox).not.toBeNull();
|
||||||
const initialModeX = initialModeBox!.x;
|
const initialModeX = initialModeBox!.x;
|
||||||
await content.press("Control+End");
|
await content.press("ControlOrMeta+End");
|
||||||
await expect(page.getByLabel(/Line 12, column \d+/)).toBeVisible();
|
await expect(page.getByLabel(/Line 12, column \d+/)).toBeVisible();
|
||||||
const movedModeBox = await modeControl.boundingBox();
|
const movedModeBox = await modeControl.boundingBox();
|
||||||
expect(movedModeBox).not.toBeNull();
|
expect(movedModeBox).not.toBeNull();
|
||||||
expect(movedModeBox!.x).toBe(initialModeX);
|
expect(movedModeBox!.x).toBe(initialModeX);
|
||||||
|
|
||||||
await content.press("Control+a");
|
await content.press("ControlOrMeta+a");
|
||||||
const selection = editorHost.locator(".cm-selectionBackground").first();
|
const selection = editorHost.locator(".cm-selectionBackground").first();
|
||||||
await expect(selection).toBeVisible();
|
await expect(selection).toBeVisible();
|
||||||
await expect(selection).toHaveCSS("background-color", "rgba(255, 255, 255, 0.2)");
|
await expect(selection).toHaveCSS("background-color", "rgba(255, 255, 255, 0.2)");
|
||||||
@@ -413,10 +502,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
|||||||
.filter({ visible: true })
|
.filter({ visible: true })
|
||||||
.first()
|
.first()
|
||||||
.click({ button: "right" });
|
.click({ button: "right" });
|
||||||
await page
|
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
|
||||||
.getByTestId("workspace-tab-context-file_draft.ts-close")
|
|
||||||
.filter({ visible: true })
|
|
||||||
.click();
|
|
||||||
expect(closePrompt).toContain("Closing it will discard the draft.");
|
expect(closePrompt).toContain("Closing it will discard the draft.");
|
||||||
|
|
||||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { expect } from "@playwright/test";
|
||||||
|
import { test } from "../support/fixtures";
|
||||||
|
import { delayFileReadResponse } from "../support/helpers/file-read-gate";
|
||||||
|
import {
|
||||||
|
expandFolder,
|
||||||
|
openFileExplorer,
|
||||||
|
openFileFromExplorer,
|
||||||
|
} from "../support/helpers/file-explorer";
|
||||||
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||||
|
|
||||||
|
let workspace: SeededWorkspace;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
workspace = await seedWorkspace({
|
||||||
|
repoPrefix: "file-transition-",
|
||||||
|
repo: {
|
||||||
|
files: [
|
||||||
|
{ path: "src/a.ts", content: "export const sourceA = true;\n" },
|
||||||
|
{ path: "src/b.ts", content: "export const sourceB = true;\n" },
|
||||||
|
{ path: "docs/b.md", content: "# Markdown B\n" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => workspace?.cleanup());
|
||||||
|
|
||||||
|
async function openSourceA(page: import("@playwright/test").Page) {
|
||||||
|
await openFileExplorer(page);
|
||||||
|
await expandFolder(page, "src");
|
||||||
|
await openFileFromExplorer(page, "a.ts");
|
||||||
|
await expect(page.getByText("export const sourceA = true;")).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertHeldFileTransition(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
targetPath: string,
|
||||||
|
finalText: string,
|
||||||
|
gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
|
||||||
|
) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const originalChip = document.querySelector('[data-testid="workspace-tab-file_src/a.ts"]');
|
||||||
|
(
|
||||||
|
window as typeof window & { __fileTransitionOriginalChip?: Element | null }
|
||||||
|
).__fileTransitionOriginalChip = originalChip;
|
||||||
|
});
|
||||||
|
await page.evaluate((tabTestId) => {
|
||||||
|
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
|
||||||
|
const sample = () => {
|
||||||
|
const next = {
|
||||||
|
chip: Boolean(document.querySelector(`[data-testid="${tabTestId}"]`)),
|
||||||
|
loading: Boolean(document.querySelector('[data-testid="file-preview-loading"]')),
|
||||||
|
unavailable: Boolean(document.querySelector('[data-testid="file-preview-unsupported"]')),
|
||||||
|
};
|
||||||
|
if (JSON.stringify(timeline.at(-1)) !== JSON.stringify(next)) timeline.push(next);
|
||||||
|
};
|
||||||
|
sample();
|
||||||
|
const observer = new MutationObserver(sample);
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||||||
|
(
|
||||||
|
window as typeof window & { __fileTransitionTimeline?: typeof timeline }
|
||||||
|
).__fileTransitionTimeline = timeline;
|
||||||
|
(
|
||||||
|
window as typeof window & { __fileTransitionObserver?: MutationObserver }
|
||||||
|
).__fileTransitionObserver = observer;
|
||||||
|
}, `workspace-tab-file_${targetPath}`);
|
||||||
|
await openFileFromExplorer(page, targetPath.split("/").pop() as string);
|
||||||
|
await gate.waitUntilHeld();
|
||||||
|
|
||||||
|
const targetChip = page.getByTestId(`workspace-tab-file_${targetPath}`).first();
|
||||||
|
await expect(targetChip).toBeVisible();
|
||||||
|
await expect(targetChip).toContainText(targetPath.split("/").pop() as string);
|
||||||
|
await expect(page.getByTestId("file-preview-loading")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
|
||||||
|
await expect(page.getByText("export const sourceA = true;")).toHaveCount(0);
|
||||||
|
|
||||||
|
const chipIdentity = await page.evaluate((tabTestId) => {
|
||||||
|
const windowWithOriginal = window as typeof window & {
|
||||||
|
__fileTransitionOriginalChip?: Element | null;
|
||||||
|
};
|
||||||
|
const originalChip = windowWithOriginal.__fileTransitionOriginalChip ?? null;
|
||||||
|
const currentChip = document.querySelector(`[data-testid="${tabTestId}"]`);
|
||||||
|
return {
|
||||||
|
originalStillConnected: originalChip?.isConnected ?? false,
|
||||||
|
sameNode: originalChip === currentChip,
|
||||||
|
};
|
||||||
|
}, `workspace-tab-file_${targetPath}`);
|
||||||
|
expect(chipIdentity).toEqual({ originalStillConnected: true, sameNode: true });
|
||||||
|
|
||||||
|
const heldTimeline = await page.evaluate(
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
window as typeof window & {
|
||||||
|
__fileTransitionTimeline?: Array<{
|
||||||
|
chip: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
unavailable: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
).__fileTransitionTimeline ?? [],
|
||||||
|
);
|
||||||
|
expect(heldTimeline).toContainEqual(expect.objectContaining({ chip: true, loading: true }));
|
||||||
|
expect(heldTimeline).not.toContainEqual(expect.objectContaining({ unavailable: true }));
|
||||||
|
const descriptorIndex = heldTimeline.findIndex((sample) => sample.chip);
|
||||||
|
expect(heldTimeline.slice(descriptorIndex)).not.toContainEqual(
|
||||||
|
expect.objectContaining({ chip: false }),
|
||||||
|
);
|
||||||
|
|
||||||
|
gate.release();
|
||||||
|
await expect(page.getByText(finalText)).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId("file-preview-loading")).toHaveCount(0);
|
||||||
|
const timeline = await page.evaluate(() => {
|
||||||
|
const windowWithTimeline = window as typeof window & {
|
||||||
|
__fileTransitionTimeline?: Array<{ chip: boolean; loading: boolean; unavailable: boolean }>;
|
||||||
|
__fileTransitionObserver?: MutationObserver;
|
||||||
|
};
|
||||||
|
windowWithTimeline.__fileTransitionObserver?.disconnect();
|
||||||
|
return windowWithTimeline.__fileTransitionTimeline ?? [];
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
timeline.filter((sample, index) => sample.loading && !timeline[index - 1]?.loading),
|
||||||
|
).toHaveLength(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("File target transitions", () => {
|
||||||
|
test("keeps the Markdown descriptor and loading state stable through a real delayed read", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const gate = await delayFileReadResponse(page, "docs/b.md");
|
||||||
|
await page.reload();
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await openSourceA(page);
|
||||||
|
await expandFolder(page, "docs");
|
||||||
|
await assertHeldFileTransition(page, "docs/b.md", "Markdown B", gate);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the source descriptor and loading state stable through a real delayed read", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const gate = await delayFileReadResponse(page, "src/b.ts");
|
||||||
|
await page.reload();
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await openSourceA(page);
|
||||||
|
await assertHeldFileTransition(page, "src/b.ts", "export const sourceB = true;", gate);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { test, expect } from "../support/fixtures";
|
|
||||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
|
||||||
import {
|
|
||||||
expectExplorerNodeRetained,
|
|
||||||
hideExplorerSplit,
|
|
||||||
measureExplorerSplit,
|
|
||||||
openExplorerSplit,
|
|
||||||
resizeExplorerSplit,
|
|
||||||
stampExplorerNode,
|
|
||||||
} from "../support/helpers/split-pane";
|
|
||||||
import {
|
|
||||||
createAgentTabFromMenu,
|
|
||||||
waitForWorkspaceTabsVisible,
|
|
||||||
} from "../support/helpers/workspace-tabs";
|
|
||||||
|
|
||||||
test.describe("Hidden split pane retention", () => {
|
|
||||||
test("releases all layout space without unmounting and restores its dragged width", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "hidden-split-pane-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await createAgentTabFromMenu(page);
|
|
||||||
await openExplorerSplit(page);
|
|
||||||
|
|
||||||
// Drag to a non-default width first: restoring the default would prove nothing.
|
|
||||||
await resizeExplorerSplit(page, 137);
|
|
||||||
await stampExplorerNode(page);
|
|
||||||
const open = await measureExplorerSplit(page);
|
|
||||||
expect(open.explorerWidth).toBeGreaterThan(250);
|
|
||||||
expect(open.separatorCount).toBe(1);
|
|
||||||
|
|
||||||
await hideExplorerSplit(page);
|
|
||||||
const hidden = await measureExplorerSplit(page);
|
|
||||||
expect(hidden.explorerWidth).toBe(0);
|
|
||||||
// The sibling must absorb the whole row, not just collapse the hidden pane.
|
|
||||||
expect(hidden.visibleWidth).toBeCloseTo(hidden.containerWidth, 5);
|
|
||||||
expect(hidden.occupiedWidth).toBeCloseTo(hidden.containerWidth, 5);
|
|
||||||
expect(hidden.separatorCount).toBe(0);
|
|
||||||
// Same DOM node, so a teardown-and-rebuild cannot pass as retention.
|
|
||||||
await expectExplorerNodeRetained(page);
|
|
||||||
|
|
||||||
await openExplorerSplit(page);
|
|
||||||
await expectExplorerNodeRetained(page);
|
|
||||||
const reopened = await measureExplorerSplit(page);
|
|
||||||
expect(reopened.explorerWidth).toBeCloseTo(open.explorerWidth, 5);
|
|
||||||
expect(reopened.separatorCount).toBe(1);
|
|
||||||
|
|
||||||
await testInfo.attach("split-pane-geometry", {
|
|
||||||
body: JSON.stringify({ open, hidden, reopened }, null, 2),
|
|
||||||
contentType: "application/json",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { test, expect } from "../support/fixtures";
|
import { test, expect } from "../support/fixtures";
|
||||||
|
import type { Locator } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
gotoWorkspace,
|
gotoWorkspace,
|
||||||
assertNewChatTileVisible,
|
assertNewChatTileVisible,
|
||||||
assertNewTabMenuTriggerVisible,
|
assertNewTabMenuTriggerVisible,
|
||||||
assertSingleNewTabButton,
|
assertSingleNewTabButton,
|
||||||
openNewTabMenuWithShortcut,
|
openNewTabMenuWithShortcut,
|
||||||
pressDirectNewTabShortcut,
|
|
||||||
clickNewChat,
|
clickNewChat,
|
||||||
clickNewTerminal,
|
clickNewTerminal,
|
||||||
countTabsOfKind,
|
countTabsOfKind,
|
||||||
waitForTabBar,
|
waitForTabBar,
|
||||||
pressNewTabShortcut,
|
pressNewTabShortcut,
|
||||||
|
pressDirectNewTabShortcut,
|
||||||
getTabTestIds,
|
getTabTestIds,
|
||||||
waitForTabWithTitle,
|
waitForTabWithTitle,
|
||||||
measureTileTransition,
|
measureTileTransition,
|
||||||
@@ -45,6 +46,16 @@ const EMPTY_PROMPT_PROFILE: TerminalProfile = {
|
|||||||
args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"],
|
args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function tabTestIds(tabs: Locator): Promise<(string | null)[]> {
|
||||||
|
return tabs.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => element.getAttribute("data-testid")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabIdentityKey(snapshot: Array<{ id: string }>): string {
|
||||||
|
return JSON.stringify(snapshot.map(({ id }) => id));
|
||||||
|
}
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
|
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
|
||||||
const created = await workspace.client.createWorkspace({
|
const created = await workspace.client.createWorkspace({
|
||||||
@@ -66,14 +77,12 @@ test.afterAll(async () => {
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
test.describe("Tab creation", () => {
|
test.describe("Tab creation", () => {
|
||||||
test("Cmd+T keeps opening the New tab menu after workspace switches", async ({ page }) => {
|
test("Cmd+T keeps creating a New tab after workspace switches", async ({ page }) => {
|
||||||
if (!secondWorkspaceId) {
|
if (!secondWorkspaceId) {
|
||||||
throw new Error("Secondary workspace was not created");
|
throw new Error("Secondary workspace was not created");
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
const newTabMenuItem = page.getByRole("menuitem", { name: /^Agent/ });
|
|
||||||
|
|
||||||
const switchWorkspaceRow = async (workspaceId: string) => {
|
const switchWorkspaceRow = async (workspaceId: string) => {
|
||||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
|
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
@@ -92,11 +101,12 @@ test.describe("Tab creation", () => {
|
|||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
await switchWorkspaceRow(sequence[i % sequence.length]);
|
await switchWorkspaceRow(sequence[i % sequence.length]);
|
||||||
await pressNewTabShortcut(page);
|
await pressNewTabShortcut(page);
|
||||||
await expect(newTabMenuItem, `New tab menu did not open after switch ${i}`).toBeVisible({
|
await expect(
|
||||||
|
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||||
|
`New tab did not open after switch ${i}`,
|
||||||
|
).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
});
|
});
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await expect(newTabMenuItem).toBeHidden({ timeout: 5_000 });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,72 +114,91 @@ test.describe("Tab creation", () => {
|
|||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
const workspaceUrl = page.url();
|
const workspaceUrl = page.url();
|
||||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
|
const newTabCountBefore = await countTabsOfKind(page, "new_tab");
|
||||||
|
|
||||||
await page.keyboard.press(`${modifier}+Comma`);
|
await page.keyboard.press(`${modifier}+Comma`);
|
||||||
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible();
|
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible();
|
||||||
|
|
||||||
await page.keyboard.press(`${modifier}+t`);
|
await page.keyboard.press(`${modifier}+t`);
|
||||||
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.goto(workspaceUrl);
|
await page.goto(workspaceUrl);
|
||||||
await assertNewTabMenuTriggerVisible(page);
|
await assertNewTabMenuTriggerVisible(page);
|
||||||
const countBefore = await countTabsOfKind(page, "draft");
|
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore);
|
||||||
await pressDirectNewTabShortcut(page, "a");
|
|
||||||
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore + 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Cmd+T opens the New tab menu without creating an agent", async ({ page }) => {
|
test("Cmd+T creates a New tab without creating an agent", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
const countBefore = await countTabsOfKind(page, "draft");
|
const countBefore = await countTabsOfKind(page, "draft");
|
||||||
|
|
||||||
await openNewTabMenuWithShortcut(page);
|
await openNewTabMenuWithShortcut(page);
|
||||||
|
|
||||||
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore);
|
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore);
|
||||||
await expect(page.getByRole("button", { name: "New tab", expanded: true })).toBeVisible();
|
await expect(
|
||||||
|
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("opening two new tabs creates two draft tabs", async ({ page }) => {
|
test("clicking + opens its menu without creating a New tab", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
const countBefore = await countTabsOfKind(page, "new_tab");
|
||||||
|
|
||||||
|
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("workspace-new-tab-menu").filter({ visible: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(countBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opening two New tabs creates two independent tab identities", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
const countBefore = await countTabsOfKind(page, "draft");
|
const newTabs = page
|
||||||
|
.locator('[data-testid^="workspace-tab-tab_"]')
|
||||||
|
.filter({ hasText: "New tab" });
|
||||||
|
const countBefore = await newTabs.count();
|
||||||
|
|
||||||
await pressDirectNewTabShortcut(page, "a");
|
await pressNewTabShortcut(page);
|
||||||
await expect
|
await expect(newTabs).toHaveCount(countBefore + 1);
|
||||||
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
|
const firstIds = await tabTestIds(newTabs);
|
||||||
.toBe(countBefore + 1);
|
|
||||||
const countAfterFirst = await countTabsOfKind(page, "draft");
|
|
||||||
|
|
||||||
await pressDirectNewTabShortcut(page, "a");
|
await pressNewTabShortcut(page);
|
||||||
await expect
|
await expect(newTabs).toHaveCount(countBefore + 2);
|
||||||
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
|
const ids = await tabTestIds(newTabs);
|
||||||
.toBe(countAfterFirst + 1);
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
expect(ids.filter((id) => !firstIds.includes(id))).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("New tab menu exposes shortcuts and supports arrow navigation", async ({ page }) => {
|
test("New tab exposes shortcuts and supports arrow navigation after refocus", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await openNewTabMenuWithShortcut(page);
|
await openNewTabMenuWithShortcut(page);
|
||||||
|
|
||||||
const agent = page.getByRole("menuitem", { name: /^Agent/ });
|
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
|
||||||
const terminal = page.getByRole("menuitem", { name: /^Terminal/ });
|
const agent = panel.getByRole("button", { name: /^Agent/ });
|
||||||
const changes = page.getByRole("menuitem", { name: /Changes/ });
|
const terminal = panel.getByRole("button", { name: /^Terminal/ });
|
||||||
const files = page.getByRole("menuitem", { name: /Files/ });
|
const diff = panel.getByRole("button", { name: /Diff/ });
|
||||||
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
|
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
|
||||||
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
|
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
|
||||||
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
|
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
|
||||||
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*C`));
|
await expect(diff).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
|
||||||
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
|
|
||||||
await expect(agent).toBeFocused();
|
await expect(agent).toBeFocused();
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown");
|
await page.keyboard.press("ArrowDown");
|
||||||
await expect(terminal).toBeFocused();
|
await expect(terminal).toBeFocused();
|
||||||
await page.keyboard.press("ArrowUp");
|
await page.keyboard.press("ArrowUp");
|
||||||
await expect(agent).toBeFocused();
|
await expect(agent).toBeFocused();
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await expect(page.getByRole("button", { name: "New tab" })).toBeFocused();
|
await pressDirectNewTabShortcut(page, "e");
|
||||||
await expect(page.getByRole("button", { name: "New tab" })).toHaveAttribute(
|
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||||
"aria-expanded",
|
await expect(
|
||||||
"false",
|
page.getByTestId("file-explorer-tree-scroll").filter({ visible: true }),
|
||||||
);
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator("body").click({ position: { x: 1, y: 1 } });
|
||||||
|
await panel.click({ position: { x: 20, y: 20 } });
|
||||||
|
await expect(agent).toBeFocused();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
||||||
@@ -205,8 +234,12 @@ test.describe("Tab creation", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
|
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||||
await page.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name }).click();
|
await page
|
||||||
|
.getByTestId("workspace-new-tab-menu")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name })
|
||||||
|
.click();
|
||||||
|
|
||||||
await expectTerminalOutputContains(page, "prompt-args: 0");
|
await expectTerminalOutputContains(page, "prompt-args: 0");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -214,6 +247,20 @@ test.describe("Tab creation", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("terminal profiles are grouped with a settings action", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||||
|
|
||||||
|
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||||
|
await expect(menu.getByText("Terminal profiles", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
const editProfiles = menu.getByTestId("workspace-new-tab-menu-edit-terminal-profiles");
|
||||||
|
await expect(editProfiles).toHaveAccessibleName("Edit profiles");
|
||||||
|
|
||||||
|
await editProfiles.click();
|
||||||
|
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
|
||||||
|
});
|
||||||
|
|
||||||
test("tab bar shows action buttons per pane", async ({ page }) => {
|
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await assertSingleNewTabButton(page);
|
await assertSingleNewTabButton(page);
|
||||||
@@ -226,15 +273,13 @@ test.describe("Tab creation", () => {
|
|||||||
|
|
||||||
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
|
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
|
||||||
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
|
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
|
||||||
const newTabButtonInScroll = await scrollArea
|
const newTabButtonInScroll = await scrollArea.getByTestId("workspace-new-tab-button").count();
|
||||||
.getByTestId("workspace-new-tab-menu-trigger")
|
|
||||||
.count();
|
|
||||||
const scrollShades = await tabRow
|
const scrollShades = await tabRow
|
||||||
.locator('[data-testid^="workspace-tabs-scroll-shade-"]')
|
.locator('[data-testid^="workspace-tabs-scroll-shade-"]')
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true);
|
expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true);
|
||||||
await expect(tabRow.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible();
|
await expect(tabRow.getByTestId("workspace-new-tab-button")).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -333,28 +378,34 @@ test.describe("Terminal title propagation", () => {
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
test.describe("Tab transitions (no flash)", () => {
|
test.describe("Tab transitions (no flash)", () => {
|
||||||
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
|
test("New agent tab transition has no blank intermediate tab state", async ({
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
|
const isolatedWorkspace = await withWorkspace({ prefix: "launcher-no-flash-" });
|
||||||
|
await isolatedWorkspace.navigateTo();
|
||||||
|
await pressNewTabShortcut(page);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// Sample tabs at high frequency across the transition
|
// Sample the single New → Agent replacement, not the separate action that
|
||||||
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page));
|
// creates the New tab in the first place.
|
||||||
|
const snapshots = await sampleTabsDuringTransition(page, async () => {
|
||||||
|
await page.getByTestId("workspace-new-tab-agent").filter({ visible: true }).first().click();
|
||||||
|
});
|
||||||
|
|
||||||
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
||||||
for (const snapshot of snapshots) {
|
for (const snapshot of snapshots) {
|
||||||
expect(snapshot.length).toBeGreaterThanOrEqual(1);
|
expect(snapshot.length).toBeGreaterThanOrEqual(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab count should never spike excessively (no duplicate flash from add-then-remove).
|
// Replacement is atomic: the set of identities changes once, while its size stays fixed.
|
||||||
// When running in-suite, previous tests may have created tabs on the shared workspace,
|
|
||||||
// so we allow +2 tolerance for accumulated state and React render batching.
|
|
||||||
const counts = snapshots.map((snapshot) => snapshot.length);
|
const counts = snapshots.map((snapshot) => snapshot.length);
|
||||||
const maxCount = Math.max(...counts);
|
|
||||||
const initialCount = counts[0] ?? 0;
|
const initialCount = counts[0] ?? 0;
|
||||||
|
|
||||||
expect(maxCount).toBeLessThanOrEqual(initialCount + 2);
|
expect(counts.every((count) => count === initialCount)).toBe(true);
|
||||||
expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot))).size).toBeLessThanOrEqual(
|
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
|
||||||
2,
|
|
||||||
);
|
|
||||||
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
|
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { expect } from "@playwright/test";
|
||||||
|
import { test } from "../support/fixtures";
|
||||||
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
|
||||||
|
test("a workspace with an existing agent never presents New during startup", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "new-tab-startup-agent-" });
|
||||||
|
const agent = await workspace.client.createAgent({
|
||||||
|
provider: "mock",
|
||||||
|
cwd: workspace.repoPath,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
title: "Existing startup agent",
|
||||||
|
modeId: "load-test",
|
||||||
|
model: "ten-second-stream",
|
||||||
|
initialPrompt: "ready",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
Reflect.set(globalThis, "__paseoSawVisibleNewTab", false);
|
||||||
|
function recordVisibleNewTab() {
|
||||||
|
const panel = document.querySelector('[data-testid="workspace-new-tab-panel"]');
|
||||||
|
if (panel?.getClientRects().length) {
|
||||||
|
Reflect.set(globalThis, "__paseoSawVisibleNewTab", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
|
new MutationObserver(recordVisibleNewTab).observe(document.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
recordVisibleNewTab();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
|
||||||
|
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(await page.evaluate(() => Reflect.get(globalThis, "__paseoSawVisibleNewTab"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -64,6 +64,7 @@ test.describe("New workspace: launch target memory", () => {
|
|||||||
await test.step("launching a terminal profile preselects it the next time New workspace opens", async () => {
|
await test.step("launching a terminal profile preselects it the next time New workspace opens", async () => {
|
||||||
await openNewWorkspace();
|
await openNewWorkspace();
|
||||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||||
|
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||||
await fillTerminalPrompt(page, "remember me");
|
await fillTerminalPrompt(page, "remember me");
|
||||||
await submitTerminalLaunch(page);
|
await submitTerminalLaunch(page);
|
||||||
await expectWorkspaceOpensWithTerminalTab(page);
|
await expectWorkspaceOpensWithTerminalTab(page);
|
||||||
@@ -88,6 +89,7 @@ test.describe("New workspace: launch target memory", () => {
|
|||||||
|
|
||||||
await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => {
|
await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => {
|
||||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||||
|
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||||
await fillTerminalPrompt(page, "second run");
|
await fillTerminalPrompt(page, "second run");
|
||||||
await submitTerminalLaunch(page);
|
await submitTerminalLaunch(page);
|
||||||
await expectWorkspaceOpensWithTerminalTab(page);
|
await expectWorkspaceOpensWithTerminalTab(page);
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { mkdtempSync, realpathSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test } from "../support/fixtures";
|
||||||
|
import { submitMessage } from "../support/helpers/composer";
|
||||||
|
import { waitForPermissionPrompt } from "../support/helpers/permissions";
|
||||||
|
import { cleanupRewindFlow, type AgentHandle, launchAgent } from "../support/helpers/rewind-flow";
|
||||||
|
|
||||||
|
const scenarios = [
|
||||||
|
{
|
||||||
|
provider: "claude" as const,
|
||||||
|
providerConfig: { model: "haiku", modeId: "plan" },
|
||||||
|
planPrompt:
|
||||||
|
"Do not ask questions. Produce a concise plan to append the line 'Hello' to README.md, then present it for approval. Do not implement it.",
|
||||||
|
planText: "README.md",
|
||||||
|
steerPrompt: "Do not implement anything. Reply exactly CLAUDE_STEER_RECEIVED and stop.",
|
||||||
|
reply: "CLAUDE_STEER_RECEIVED",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "codex" as const,
|
||||||
|
providerConfig: { featureValues: { plan_mode: true } },
|
||||||
|
planPrompt:
|
||||||
|
"Produce a concise implementation plan with exactly these steps: Inspect permission steering; Implement permission steering; Verify permission steering. Do not implement it.",
|
||||||
|
planText: "Inspect permission steering",
|
||||||
|
steerPrompt: "Do not produce another plan. Reply exactly CODEX_STEER_RECEIVED and stop.",
|
||||||
|
reply: "CODEX_STEER_RECEIVED",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test.describe("composer steer supersedes plan approval", () => {
|
||||||
|
for (const scenario of scenarios) {
|
||||||
|
test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.setTimeout(420_000);
|
||||||
|
const cwd = realpathSync(
|
||||||
|
mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)),
|
||||||
|
);
|
||||||
|
let handle: AgentHandle | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
handle = await launchAgent({
|
||||||
|
page,
|
||||||
|
provider: scenario.provider,
|
||||||
|
cwd,
|
||||||
|
mode: "full-access",
|
||||||
|
providerConfig: scenario.providerConfig,
|
||||||
|
});
|
||||||
|
await submitMessage(page, scenario.planPrompt);
|
||||||
|
await waitForPermissionPrompt(page, 180_000);
|
||||||
|
|
||||||
|
const pendingPlan = page.getByTestId("permission-plan-card");
|
||||||
|
await expect(pendingPlan).toContainText(scenario.planText);
|
||||||
|
const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`);
|
||||||
|
await page.screenshot({ path: pendingScreenshot });
|
||||||
|
await testInfo.attach(`${scenario.provider} pending plan`, {
|
||||||
|
path: pendingScreenshot,
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
|
||||||
|
await submitMessage(page, scenario.steerPrompt);
|
||||||
|
await expect(pendingPlan).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
const rejectedPlan = page.getByTestId("timeline-plan-card");
|
||||||
|
await expect(rejectedPlan).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(rejectedPlan).toContainText(scenario.planText);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("assistant-message").filter({ hasText: scenario.reply }),
|
||||||
|
).toBeVisible({
|
||||||
|
timeout: 180_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`);
|
||||||
|
await page.screenshot({ path: rejectedScreenshot });
|
||||||
|
await testInfo.attach(`${scenario.provider} rejected plan`, {
|
||||||
|
path: rejectedScreenshot,
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await cleanupRewindFlow({ handle, cwd });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test } from "../support/fixtures";
|
||||||
|
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||||
|
import { openSettingsSection } from "../support/helpers/settings";
|
||||||
|
|
||||||
|
const PLUGIN_ID = "plugin-theme-e2e";
|
||||||
|
|
||||||
|
// Catppuccin Mocha: base, text, surface0, surface1, mauve, subtext0, overlay0.
|
||||||
|
const PLUGIN_SOURCE = `export default function contribute(plugin) {
|
||||||
|
plugin.addTheme({
|
||||||
|
id: "mocha",
|
||||||
|
name: "Catppuccin Mocha",
|
||||||
|
appearance: "dark",
|
||||||
|
colors: {
|
||||||
|
background: "#1e1e2e",
|
||||||
|
foreground: "#cdd6f4",
|
||||||
|
raised: "#313244",
|
||||||
|
control: "#45475a",
|
||||||
|
border: "#45475a",
|
||||||
|
accent: "#cba6f7",
|
||||||
|
mutedForeground: "#a6adc8",
|
||||||
|
ring: "#6c7086",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
plugin.addTheme({
|
||||||
|
id: "latte",
|
||||||
|
name: "Catppuccin Latte",
|
||||||
|
appearance: "light",
|
||||||
|
colors: {
|
||||||
|
background: "#eff1f5",
|
||||||
|
foreground: "#4c4f69",
|
||||||
|
raised: "#e6e9ef",
|
||||||
|
control: "#dce0e8",
|
||||||
|
border: "#ccd0da",
|
||||||
|
accent: "#8839ef",
|
||||||
|
mutedForeground: "#6c6f85",
|
||||||
|
ring: "#9ca0b0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => {};
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// settingsStyles.sectionHeaderTitle paints from foregroundMuted, so the section heading proves the
|
||||||
|
// contributed palette reached the semantic tokens rather than just the swatch.
|
||||||
|
const MOCHA_MUTED_FOREGROUND = "rgb(166, 173, 200)";
|
||||||
|
const LATTE_MUTED_FOREGROUND = "rgb(108, 111, 133)";
|
||||||
|
|
||||||
|
test("applies a contributed theme and falls back when its plugin is gone", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-e2e-"));
|
||||||
|
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||||
|
const previousConfig = await client.getDaemonConfig();
|
||||||
|
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||||
|
await writeFile(path.join(directory, "index.ts"), PLUGIN_SOURCE);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||||
|
await client.installDirectoryPlugin(directory);
|
||||||
|
await page.goto("/settings");
|
||||||
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||||
|
await openSettingsSection(page, "appearance");
|
||||||
|
|
||||||
|
const sectionTitle = page.getByText("Theme", { exact: true }).first();
|
||||||
|
await page.getByLabel("Theme: System", { exact: true }).click();
|
||||||
|
const mochaItem = page.getByText("Catppuccin Mocha", { exact: true });
|
||||||
|
await expect(mochaItem).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.screenshot({
|
||||||
|
path: testInfo.outputPath("plugin-theme-picker.png"),
|
||||||
|
animations: "disabled",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("a contributed light theme uses the light palette", async () => {
|
||||||
|
await page.getByText("Catppuccin Latte", { exact: true }).click();
|
||||||
|
await expect(page.getByLabel("Theme: Catppuccin Latte", { exact: true })).toBeVisible();
|
||||||
|
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
|
||||||
|
await page.getByLabel("Theme: Catppuccin Latte", { exact: true }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await mochaItem.click();
|
||||||
|
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible();
|
||||||
|
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||||
|
await page.screenshot({
|
||||||
|
path: testInfo.outputPath("plugin-theme-applied.png"),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("the selection survives a reload", async () => {
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("removing the plugin falls back to the default theme", async () => {
|
||||||
|
await client.removePlugin(PLUGIN_ID);
|
||||||
|
await expect(page.getByLabel("Theme: System", { exact: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(sectionTitle).not.toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||||
|
await page.screenshot({
|
||||||
|
path: testInfo.outputPath("plugin-theme-fallback.png"),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||||
|
await client
|
||||||
|
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -153,6 +153,7 @@ test.describe("Rewind sheet", () => {
|
|||||||
repoPrefix: "rewind-e2e-",
|
repoPrefix: "rewind-e2e-",
|
||||||
title: "Rewind e2e",
|
title: "Rewind e2e",
|
||||||
initialPrompt: firstPrompt,
|
initialPrompt: firstPrompt,
|
||||||
|
model: "ten-second-stream",
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -70,73 +70,55 @@ test.describe("History search", () => {
|
|||||||
await tempRepo?.cleanup();
|
await tempRepo?.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("typing narrows history to matching sessions and clearing restores them", async ({
|
test("searches, ranks, highlights, and clears session history", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await resetSeededPageState(page);
|
await resetSeededPageState(page);
|
||||||
await openSessions(page);
|
await openSessions(page);
|
||||||
|
|
||||||
// Seeded newest-first, and at rest history is chronological.
|
await test.step("narrows history and restores chronological grouping", async () => {
|
||||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
|
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
await search(page, `${NONCE} billing`);
|
await search(page, `${NONCE} billing`);
|
||||||
await expectVisibleTitles(page, [TITLES.billing]);
|
await expectVisibleTitles(page, [TITLES.billing]);
|
||||||
|
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
// Ranked results are one flat list — a day heading would claim an order
|
await page.getByTestId("sessions-search-clear").click();
|
||||||
// the list no longer has.
|
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
|
||||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, { timeout: 30_000 });
|
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||||
|
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await page.getByTestId("sessions-search-clear").click();
|
await test.step("ranks whole-word matches above substrings", async () => {
|
||||||
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
|
await search(page, `${NONCE} bill`);
|
||||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
|
||||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
|
});
|
||||||
});
|
|
||||||
|
|
||||||
test("ranks a whole-word hit above one buried inside a word", async ({ page }) => {
|
await test.step("highlights exact and typo-resolved matches", async () => {
|
||||||
await resetSeededPageState(page);
|
await search(page, `${NONCE} billing`);
|
||||||
await openSessions(page);
|
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||||
|
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
// "bill" starts a word in "billing" and hides inside "unbilled", so the
|
await search(page, `${NONCE} bulling`);
|
||||||
// stronger match leads even though both sessions are equally recent.
|
await expectVisibleTitles(page, [TITLES.billing]);
|
||||||
await search(page, `${NONCE} bill`);
|
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
|
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({
|
||||||
});
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("marks the characters each result matched on", async ({ page }) => {
|
await test.step("distinguishes no matches from empty history", async () => {
|
||||||
await resetSeededPageState(page);
|
await search(page, `${NONCE} kubernetes`);
|
||||||
await openSessions(page);
|
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
// The mark is a nested Text run, so the matched slice is its own element.
|
await page.getByText("Clear search").click();
|
||||||
await search(page, `${NONCE} billing`);
|
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||||
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
});
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
// A typo has no characters in the text to point at, so the whole word it
|
|
||||||
// resolved to is marked.
|
|
||||||
await search(page, `${NONCE} bulling`);
|
|
||||||
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
|
||||||
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("finds a session through a typo", async ({ page }) => {
|
|
||||||
await resetSeededPageState(page);
|
|
||||||
await openSessions(page);
|
|
||||||
|
|
||||||
await search(page, `${NONCE} bulling`);
|
|
||||||
await expectVisibleTitles(page, [TITLES.billing]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("says the query found nothing, not that history is empty", async ({ page }) => {
|
|
||||||
await resetSeededPageState(page);
|
|
||||||
await openSessions(page);
|
|
||||||
|
|
||||||
await search(page, `${NONCE} kubernetes`);
|
|
||||||
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
await page.getByText("Clear search").click();
|
|
||||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -121,6 +121,41 @@ test.describe("Settings sidebar navigation", () => {
|
|||||||
await clickSettingsBackToWorkspace(page);
|
await clickSettingsBackToWorkspace(page);
|
||||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("pressing Escape closes settings", async ({ page }) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openSettings(page);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Escape lets settings dropdowns and modals close before leaving settings", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openSettings(page);
|
||||||
|
|
||||||
|
await test.step("a dropdown owns Escape", async () => {
|
||||||
|
await openSettingsSection(page, "appearance");
|
||||||
|
await page.getByLabel(/Theme:/).click();
|
||||||
|
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
|
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(/\/settings(\/|$)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("a modal owns Escape", async () => {
|
||||||
|
await openAddHostFlow(page);
|
||||||
|
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
|
await expect(page.getByText("Add connection", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(/\/settings(\/|$)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Settings — compact master-detail", () => {
|
test.describe("Settings — compact master-detail", () => {
|
||||||
|
|||||||
@@ -70,9 +70,28 @@ test.describe("Settings toggle tab regression", () => {
|
|||||||
await pressSettingsToggleShortcut(page);
|
await pressSettingsToggleShortcut(page);
|
||||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Queue", exact: true }).click();
|
const defaultSendTrigger = page.getByRole("button", {
|
||||||
|
name: "Default send: Steer",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(defaultSendTrigger).toBeVisible();
|
||||||
|
await expect(page.getByRole("menuitem", { name: "Queue", exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
|
await defaultSendTrigger.click();
|
||||||
|
await expect(page.getByRole("menuitem", { name: "Steer", exact: true })).toHaveAttribute(
|
||||||
|
"aria-checked",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await page.getByRole("menuitem", { name: "Queue", exact: true }).click();
|
||||||
await expectSendBehavior(page, "queue");
|
await expectSendBehavior(page, "queue");
|
||||||
await page.getByRole("button", { name: "Interrupt", exact: true }).click();
|
const queuedDefaultSendTrigger = page.getByRole("button", {
|
||||||
|
name: "Default send: Queue",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(queuedDefaultSendTrigger).toBeVisible();
|
||||||
|
|
||||||
|
await queuedDefaultSendTrigger.click();
|
||||||
|
await page.getByRole("menuitem", { name: "Interrupt", exact: true }).click();
|
||||||
await expectSendBehavior(page, "interrupt");
|
await expectSendBehavior(page, "interrupt");
|
||||||
|
|
||||||
await pressSettingsToggleShortcut(page);
|
await pressSettingsToggleShortcut(page);
|
||||||
|
|||||||
@@ -41,46 +41,37 @@ async function expectExternalPage(
|
|||||||
await popup.close();
|
await popup.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
test("opens troubleshooting tools from the sidebar help menu", async ({ page }) => {
|
test("opens troubleshooting, support, and release destinations", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await expect(page.getByTestId("sidebar-help")).toBeVisible();
|
await expect(page.getByTestId("sidebar-help")).toBeVisible();
|
||||||
|
|
||||||
await openHelpMenu(page);
|
await test.step("opens diagnostics and keyboard shortcuts", async () => {
|
||||||
const triggerBox = await page.getByTestId("sidebar-help").evaluate((element) => {
|
await openHelpMenu(page);
|
||||||
const { y, height } = element.getBoundingClientRect();
|
await expect(page.getByText("Help", { exact: true })).toBeVisible();
|
||||||
return { y, height };
|
await expect(page.getByText("Report an issue", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("What's new", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION);
|
||||||
|
|
||||||
|
await page.getByTestId("sidebar-help-diagnostics").click();
|
||||||
|
await expectDiagnosticReport(page);
|
||||||
|
await closeSheet(page, "app-diagnostic-sheet");
|
||||||
|
|
||||||
|
await openHelpMenu(page);
|
||||||
|
await page.getByTestId("sidebar-help-shortcuts").click();
|
||||||
|
await expect(page.getByTestId("keyboard-shortcuts-dialog")).toBeVisible();
|
||||||
|
await closeSheet(page, "keyboard-shortcuts-dialog");
|
||||||
});
|
});
|
||||||
const menuBox = await page.getByTestId("sidebar-help-menu").evaluate((element) => {
|
|
||||||
const { y, height } = element.getBoundingClientRect();
|
await test.step("opens support and release pages", async () => {
|
||||||
return { y, height };
|
await openHelpMenu(page);
|
||||||
|
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
|
||||||
|
|
||||||
|
await openHelpMenu(page);
|
||||||
|
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
|
||||||
|
|
||||||
|
await openHelpMenu(page);
|
||||||
|
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
|
||||||
});
|
});
|
||||||
expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(triggerBox.y);
|
|
||||||
await expect(page.getByText("Help", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByText("Report an issue", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByText("What's new", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION);
|
|
||||||
|
|
||||||
await page.getByTestId("sidebar-help-diagnostics").click();
|
|
||||||
await expectDiagnosticReport(page);
|
|
||||||
await closeSheet(page, "app-diagnostic-sheet");
|
|
||||||
|
|
||||||
await openHelpMenu(page);
|
|
||||||
await page.getByTestId("sidebar-help-shortcuts").click();
|
|
||||||
await expect(page.getByTestId("keyboard-shortcuts-dialog")).toBeVisible();
|
|
||||||
await closeSheet(page, "keyboard-shortcuts-dialog");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opens support and release destinations", async ({ page }) => {
|
|
||||||
await gotoAppShell(page);
|
|
||||||
|
|
||||||
await openHelpMenu(page);
|
|
||||||
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
|
|
||||||
|
|
||||||
await openHelpMenu(page);
|
|
||||||
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
|
|
||||||
|
|
||||||
await openHelpMenu(page);
|
|
||||||
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => {
|
test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => {
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { expect } from "@playwright/test";
|
||||||
|
import { test } from "../support/fixtures";
|
||||||
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
|
import {
|
||||||
|
closeSidebarDisplayPreferences,
|
||||||
|
openSidebarProjectFilter,
|
||||||
|
pinWorkspaceFromSidebar,
|
||||||
|
selectAllProjectsFilter,
|
||||||
|
selectSidebarStatusGrouping,
|
||||||
|
toggleProjectFilter,
|
||||||
|
} from "../support/helpers/sidebar";
|
||||||
|
|
||||||
|
test.describe("Sidebar project filter", () => {
|
||||||
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
|
test("pins the sidebar to one project across both grouping modes", async ({ page }) => {
|
||||||
|
// Two temp repos means two projects, which is also what makes the `Project ›` row appear.
|
||||||
|
const alpha = await seedWorkspace({ repoPrefix: "project-filter-alpha-", title: "Alpha work" });
|
||||||
|
const beta = await seedWorkspace({ repoPrefix: "project-filter-beta-", title: "Beta work" });
|
||||||
|
const serverId = getServerId();
|
||||||
|
const alphaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${alpha.workspaceId}`);
|
||||||
|
const betaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${beta.workspaceId}`);
|
||||||
|
const filterTrigger = page.getByTestId("sidebar-display-project-filter");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(betaRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await openSidebarProjectFilter(page);
|
||||||
|
await expect(page.getByTestId("sidebar-project-filter-all")).toBeVisible();
|
||||||
|
await toggleProjectFilter(page, alpha.projectKey);
|
||||||
|
await closeSidebarDisplayPreferences(page);
|
||||||
|
|
||||||
|
await expect(alphaRow).toBeVisible();
|
||||||
|
await expect(betaRow).toHaveCount(0, { timeout: 10_000 });
|
||||||
|
|
||||||
|
// The indicator reads the filter as it is applied, so it must be on here.
|
||||||
|
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||||
|
await expect(filterTrigger).toBeVisible();
|
||||||
|
await expect(filterTrigger.getByTestId("menu-sub-indicator")).toBeVisible();
|
||||||
|
await closeSidebarDisplayPreferences(page);
|
||||||
|
|
||||||
|
// Status grouping builds its rows from the workspace entries rather than the projects
|
||||||
|
// array, so a filter applied in only one of the two places passes every check above and
|
||||||
|
// silently fails right here.
|
||||||
|
await selectSidebarStatusGrouping(page);
|
||||||
|
await closeSidebarDisplayPreferences(page);
|
||||||
|
await expect(alphaRow).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(betaRow).toHaveCount(0);
|
||||||
|
|
||||||
|
// The filter is view state and survives a cold load.
|
||||||
|
await page.reload();
|
||||||
|
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(betaRow).toHaveCount(0);
|
||||||
|
|
||||||
|
await openSidebarProjectFilter(page);
|
||||||
|
await selectAllProjectsFilter(page);
|
||||||
|
await closeSidebarDisplayPreferences(page);
|
||||||
|
await expect(alphaRow).toBeVisible();
|
||||||
|
await expect(betaRow).toBeVisible({ timeout: 15_000 });
|
||||||
|
} finally {
|
||||||
|
await beta.cleanup();
|
||||||
|
await alpha.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the display menu reachable when the pinned section swallows the filtered project", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Pinning hoists a chat out of its project, and a project whose chats are ALL hoisted is
|
||||||
|
// dropped from the project list entirely. Filter to that project and the list body has no
|
||||||
|
// project rows left — so the header, which carries the only route back to the filter page,
|
||||||
|
// has to survive on the strength of the filter alone.
|
||||||
|
const alpha = await seedWorkspace({
|
||||||
|
repoPrefix: "project-filter-pinned-",
|
||||||
|
title: "Pinned work",
|
||||||
|
});
|
||||||
|
const beta = await seedWorkspace({ repoPrefix: "project-filter-other-", title: "Other work" });
|
||||||
|
const serverId = getServerId();
|
||||||
|
const alphaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${alpha.workspaceId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await openSidebarProjectFilter(page);
|
||||||
|
await toggleProjectFilter(page, alpha.projectKey);
|
||||||
|
await closeSidebarDisplayPreferences(page);
|
||||||
|
|
||||||
|
await pinWorkspaceFromSidebar(page, alpha.workspaceId);
|
||||||
|
await expect(alphaRow).toBeVisible();
|
||||||
|
|
||||||
|
// The way out of the filter is still on screen.
|
||||||
|
await expect(page.getByTestId("sidebar-display-preferences-menu")).toBeVisible();
|
||||||
|
await openSidebarProjectFilter(page);
|
||||||
|
await selectAllProjectsFilter(page);
|
||||||
|
await closeSidebarDisplayPreferences(page);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`sidebar-workspace-row-${serverId}:${beta.workspaceId}`),
|
||||||
|
).toBeVisible({ timeout: 15_000 });
|
||||||
|
} finally {
|
||||||
|
await beta.cleanup();
|
||||||
|
await alpha.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hides the filter row when there is only one project", async ({ page }) => {
|
||||||
|
const only = await seedWorkspace({ repoPrefix: "project-filter-solo-", title: "Solo work" });
|
||||||
|
const serverId = getServerId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`sidebar-workspace-row-${serverId}:${only.workspaceId}`),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||||
|
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("sidebar-display-project-filter")).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await only.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import type { Locator } from "@playwright/test";
|
|
||||||
import { expect, test, type Page } from "../support/fixtures";
|
|
||||||
|
|
||||||
test.use({ viewport: { width: 1600, height: 900 } });
|
|
||||||
|
|
||||||
async function expectBorderHighlight(
|
|
||||||
page: Page,
|
|
||||||
testID: string,
|
|
||||||
hoverTarget: Locator,
|
|
||||||
expectedWidth: string,
|
|
||||||
) {
|
|
||||||
const handle = page.getByTestId(testID);
|
|
||||||
await expect(handle).toBeVisible();
|
|
||||||
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
|
|
||||||
|
|
||||||
await hoverTarget.hover();
|
|
||||||
|
|
||||||
const highlight = page.getByTestId(`${testID}-highlight`);
|
|
||||||
await expect(highlight).toBeVisible();
|
|
||||||
await expect(highlight).toHaveCSS("width", expectedWidth);
|
|
||||||
await expect(highlight).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
|
||||||
}
|
|
||||||
|
|
||||||
test("sidebar and workspace pane borders highlight on hover", async ({ page, withWorkspace }) => {
|
|
||||||
const workspace = await withWorkspace({ prefix: "sidebar-resize-handle-" });
|
|
||||||
await workspace.navigateTo();
|
|
||||||
|
|
||||||
const sidebarHandle = page.getByTestId("left-sidebar-resize-handle");
|
|
||||||
await expectBorderHighlight(page, "left-sidebar-resize-handle", sidebarHandle, "1px");
|
|
||||||
|
|
||||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
|
||||||
const splitHandle = page.getByTestId("workspace-split-resize-handle");
|
|
||||||
await expectBorderHighlight(
|
|
||||||
page,
|
|
||||||
"workspace-split-resize-handle",
|
|
||||||
splitHandle.getByRole("separator"),
|
|
||||||
"3px",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { test, expect, type Page } from "../support/fixtures";
|
import { test, expect, type Page } from "../support/fixtures";
|
||||||
import { gotoAppShell } from "../support/helpers/app";
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||||
|
import {
|
||||||
|
expectNewWorkspaceProjectSelected,
|
||||||
|
openNewWorkspaceComposer,
|
||||||
|
} from "../support/helpers/new-workspace";
|
||||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||||
import { getServerId } from "../support/helpers/server-id";
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||||
@@ -142,6 +146,42 @@ async function installPinRpcGate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Pin workspace shortcut", () => {
|
test.describe("Pin workspace shortcut", () => {
|
||||||
|
test("dispatches pin once and keeps project creation reachable", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({
|
||||||
|
repoPrefix: "pin-project-creation-",
|
||||||
|
title: "Pinned workspace",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gate = await installPinRpcGate(page);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
|
await test.step("sends one RPC for each pin transition", async () => {
|
||||||
|
await page.keyboard.press(PIN_SHORTCUT);
|
||||||
|
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||||
|
expect(gate.sentCount()).toBe(1);
|
||||||
|
|
||||||
|
await page.keyboard.press(PIN_SHORTCUT);
|
||||||
|
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
||||||
|
expect(gate.sentCount()).toBe(2);
|
||||||
|
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("keeps the pinned project available to workspace creation", async () => {
|
||||||
|
await page.keyboard.press(PIN_SHORTCUT);
|
||||||
|
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||||
|
expect(gate.sentCount()).toBe(3);
|
||||||
|
|
||||||
|
await openNewWorkspaceComposer(page, workspace);
|
||||||
|
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("pins the active workspace while its project section is collapsed", async ({ page }) => {
|
test("pins the active workspace while its project section is collapsed", async ({ page }) => {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-collapsed-" });
|
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-collapsed-" });
|
||||||
|
|
||||||
@@ -184,34 +224,6 @@ test.describe("Pin workspace shortcut", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sends exactly one pin RPC per press when the row is rendered and selected", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-expanded-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const gate = await installPinRpcGate(page);
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await openWorkspace(page, workspace.workspaceId);
|
|
||||||
|
|
||||||
await page.keyboard.press(PIN_SHORTCUT);
|
|
||||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
|
||||||
// Counting frames catches a press that produces zero or two RPCs — a misfiring in-flight
|
|
||||||
// guard, or a second dispatch path. It cannot detect a duplicate handler registration:
|
|
||||||
// `keyboardActionDispatcher.dispatch` returns at the first handler that returns true, so a
|
|
||||||
// shadowed second handler is unobservable from outside by design.
|
|
||||||
expect(gate.sentCount()).toBe(1);
|
|
||||||
|
|
||||||
await page.keyboard.press(PIN_SHORTCUT);
|
|
||||||
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
|
||||||
expect(gate.sentCount()).toBe(2);
|
|
||||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("pins the active workspace while its status group is collapsed", async ({ page }) => {
|
test("pins the active workspace while its status group is collapsed", async ({ page }) => {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
|
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
|
||||||
|
|
||||||
|
|||||||
@@ -118,19 +118,6 @@ test.describe("Sidebar workspace list", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("project shows workspace under it", async ({ page }) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-workspace-under-project-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoAppShell(page);
|
|
||||||
|
|
||||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
|
||||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("non-git project shows directory name", async ({ page }) => {
|
test("non-git project shows directory name", async ({ page }) => {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false });
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false });
|
||||||
|
|
||||||
@@ -319,7 +306,7 @@ test.describe("Half-screen desktop layout", () => {
|
|||||||
await expect(closedIcon).toBeVisible();
|
await expect(closedIcon).toBeVisible();
|
||||||
const closedBounds = await closedIcon.boundingBox();
|
const closedBounds = await closedIcon.boundingBox();
|
||||||
expect(closedBounds).not.toBeNull();
|
expect(closedBounds).not.toBeNull();
|
||||||
expect(closedBounds?.x).toBeCloseTo(12, 0);
|
expect(closedBounds?.x).toBeCloseTo(9, 0);
|
||||||
expect(closedBounds?.y).toBe(openBounds?.y);
|
expect(closedBounds?.y).toBe(openBounds?.y);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -342,16 +329,19 @@ test.describe("Half-screen desktop layout", () => {
|
|||||||
|
|
||||||
await openFilesPanel(page);
|
await openFilesPanel(page);
|
||||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
await expect(
|
||||||
await expect(explorerToggle).toHaveAccessibleName("Close explorer");
|
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
|
||||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
await expect(page.getByTestId("explorer-sidebar-tab-rail")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(1);
|
||||||
|
|
||||||
await explorerToggle.click();
|
await explorerToggle.click();
|
||||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
|
await expect(
|
||||||
0,
|
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
|
||||||
);
|
).toHaveCount(0);
|
||||||
await expect(explorerToggle).toHaveAccessibleName("Open explorer");
|
await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar");
|
||||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness
|
|||||||
beforeXterm.rows,
|
beforeXterm.rows,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Split the pane downward. This focuses the new empty pane, so the terminal
|
// Split the pane downward. This focuses the new tab pane, so the terminal
|
||||||
// pane is unfocused at the exact moment its container shrinks.
|
// pane is unfocused at the exact moment its container shrinks.
|
||||||
await runWorkspaceActionFromCommandCenter(page, "Split pane down");
|
await runWorkspaceActionFromCommandCenter(page, "Split pane down");
|
||||||
|
|
||||||
|
|||||||
@@ -59,8 +59,12 @@ async function createTerminalViaMenu(page: Page): Promise<void> {
|
|||||||
// Workspaces always render a hidden explorer companion pane alongside the
|
// Workspaces always render a hidden explorer companion pane alongside the
|
||||||
// main pane, so an unscoped testid locator matches both; scope to the
|
// main pane, so an unscoped testid locator matches both; scope to the
|
||||||
// visible one.
|
// visible one.
|
||||||
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
|
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||||
await page.getByTestId("workspace-new-tab-menu-terminal").click();
|
await page
|
||||||
|
.getByTestId("workspace-new-tab-menu-terminal")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listTerminalIds(harness: TerminalE2EHarness): Promise<string[]> {
|
async function listTerminalIds(harness: TerminalE2EHarness): Promise<string[]> {
|
||||||
@@ -128,7 +132,7 @@ test.describe("terminal PTY size claim under lost window focus", () => {
|
|||||||
|
|
||||||
await page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId));
|
await page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId));
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }),
|
page.getByTestId("workspace-new-tab-button").filter({ visible: true }),
|
||||||
).toBeVisible({
|
).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { test, expect } from "../support/fixtures";
|
||||||
|
import { expectComposerVisible } from "../support/helpers/composer";
|
||||||
|
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||||
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
|
||||||
|
type WebSocketMessage = string | Buffer;
|
||||||
|
|
||||||
|
const COMPACT_VIEWPORT = { width: 390, height: 520 };
|
||||||
|
|
||||||
|
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||||
|
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||||
|
try {
|
||||||
|
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
||||||
|
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||||
|
? (envelope.message as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolCallStatus(
|
||||||
|
message: WebSocketMessage,
|
||||||
|
agentId: string,
|
||||||
|
): { callId: string; status: string } | null {
|
||||||
|
const sessionMessage = parseSessionMessage(message);
|
||||||
|
if (sessionMessage?.type !== "agent_stream") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
|
||||||
|
if (payload?.agentId !== agentId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const event = payload.event as Record<string, unknown> | undefined;
|
||||||
|
const item = event?.item as Record<string, unknown> | undefined;
|
||||||
|
return event?.type === "timeline" &&
|
||||||
|
item?.type === "tool_call" &&
|
||||||
|
typeof item.callId === "string" &&
|
||||||
|
typeof item.status === "string"
|
||||||
|
? { callId: item.callId, status: item.status }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneToolCallWithId(
|
||||||
|
message: WebSocketMessage,
|
||||||
|
callId: string,
|
||||||
|
sequenceOffset: number,
|
||||||
|
): string {
|
||||||
|
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||||
|
const envelope = JSON.parse(raw) as {
|
||||||
|
message?: { payload?: { seq?: number; event?: { item?: { callId?: string } } } };
|
||||||
|
};
|
||||||
|
const payload = envelope.message?.payload;
|
||||||
|
const item = payload?.event?.item;
|
||||||
|
if (!payload || !item?.callId || typeof payload.seq !== "number") {
|
||||||
|
throw new Error("Expected a tool-call session message");
|
||||||
|
}
|
||||||
|
item.callId = callId;
|
||||||
|
payload.seq += sequenceOffset;
|
||||||
|
return JSON.stringify(envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftTimelineSequence(
|
||||||
|
message: WebSocketMessage,
|
||||||
|
sequenceOffset: number,
|
||||||
|
): WebSocketMessage {
|
||||||
|
if (sequenceOffset === 0) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||||
|
try {
|
||||||
|
const envelope = JSON.parse(raw) as { message?: { payload?: { seq?: number } } };
|
||||||
|
const payload = envelope.message?.payload;
|
||||||
|
if (typeof payload?.seq !== "number") {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
payload.seq += sequenceOffset;
|
||||||
|
return JSON.stringify(envelope);
|
||||||
|
} catch {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string) {
|
||||||
|
let firstCallId: string | null = null;
|
||||||
|
let firstCompletedMessage: WebSocketMessage | null = null;
|
||||||
|
let isHolding = false;
|
||||||
|
let sequenceOffset = 0;
|
||||||
|
const heldMessages: WebSocketMessage[] = [];
|
||||||
|
let sendToPage: ((message: WebSocketMessage) => void) | null = null;
|
||||||
|
let resolveFirstCompleted!: () => void;
|
||||||
|
const firstCompleted = new Promise<void>((resolve) => {
|
||||||
|
resolveFirstCompleted = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
|
const server = ws.connectToServer();
|
||||||
|
sendToPage = (message) => ws.send(message);
|
||||||
|
ws.onMessage((message) => server.send(message));
|
||||||
|
server.onMessage((message) => {
|
||||||
|
if (isHolding) {
|
||||||
|
heldMessages.push(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(shiftTimelineSequence(message, sequenceOffset));
|
||||||
|
const toolCall = getToolCallStatus(message, agentId);
|
||||||
|
firstCallId ??= toolCall?.callId ?? null;
|
||||||
|
if (toolCall?.callId === firstCallId && toolCall.status === "completed") {
|
||||||
|
firstCompletedMessage = message;
|
||||||
|
isHolding = true;
|
||||||
|
resolveFirstCompleted();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
waitForFirstCompleted: () => firstCompleted,
|
||||||
|
release(extraCompletedCalls = 0) {
|
||||||
|
isHolding = false;
|
||||||
|
if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) {
|
||||||
|
throw new Error("Expected the first completed tool call before release");
|
||||||
|
}
|
||||||
|
for (let index = 0; index < extraCompletedCalls; index += 1) {
|
||||||
|
sendToPage?.(
|
||||||
|
cloneToolCallWithId(
|
||||||
|
firstCompletedMessage as WebSocketMessage,
|
||||||
|
`${firstCallId}:${index}`,
|
||||||
|
index + 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
sequenceOffset = extraCompletedCalls;
|
||||||
|
for (const message of heldMessages.splice(0)) {
|
||||||
|
sendToPage?.(shiftTimelineSequence(message, sequenceOffset));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configureOverviewToolCalls(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"@paseo:app-settings",
|
||||||
|
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOverviewAgent(page: Page, title: string) {
|
||||||
|
await configureOverviewToolCalls(page);
|
||||||
|
return seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "tool-call-overview-sheet-",
|
||||||
|
title,
|
||||||
|
model: "ten-second-stream",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openOverviewAgent(
|
||||||
|
page: Page,
|
||||||
|
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
|
||||||
|
): Promise<void> {
|
||||||
|
await openAgentRoute(page, {
|
||||||
|
workspaceId: agent.workspaceId,
|
||||||
|
agentId: agent.agentId,
|
||||||
|
});
|
||||||
|
await expectComposerVisible(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasScrolledToLatest(root: HTMLElement): boolean {
|
||||||
|
for (const node of root.querySelectorAll<HTMLElement>("*")) {
|
||||||
|
if (node.scrollHeight <= node.clientHeight) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const maximumScrollTop = node.scrollHeight - node.clientHeight;
|
||||||
|
return maximumScrollTop > 0 && Math.abs(node.scrollTop - maximumScrollTop) <= 1;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readScrollMetrics(root: HTMLElement) {
|
||||||
|
const badgeElements = root.querySelectorAll<HTMLElement>("[data-testid=tool-call-badge]");
|
||||||
|
const latestBadge = badgeElements.item(badgeElements.length - 1);
|
||||||
|
const scrollables = [];
|
||||||
|
for (const node of root.querySelectorAll<HTMLElement>("*")) {
|
||||||
|
if (node.scrollHeight <= node.clientHeight) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
scrollables.push({
|
||||||
|
className: node.className,
|
||||||
|
clientHeight: node.clientHeight,
|
||||||
|
rect: node.getBoundingClientRect().toJSON(),
|
||||||
|
scrollHeight: node.scrollHeight,
|
||||||
|
scrollTop: node.scrollTop,
|
||||||
|
tagName: node.tagName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
latestBadgeRect: latestBadge?.getBoundingClientRect().toJSON(),
|
||||||
|
rootRect: root.getBoundingClientRect().toJSON(),
|
||||||
|
scrollables,
|
||||||
|
viewport: { height: window.innerHeight, width: window.innerWidth },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("compact overview tool calls", () => {
|
||||||
|
test.use({ viewport: COMPACT_VIEWPORT, hasTouch: true });
|
||||||
|
|
||||||
|
test("keeps a live group sheet current and stacks individual details above it", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const agent = await createOverviewAgent(page, "Compact overview tool calls");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
|
||||||
|
await openOverviewAgent(page, agent);
|
||||||
|
await agent.client.sendAgentMessage(agent.agentId, "Exercise the overview tool-call sheet.");
|
||||||
|
await gate.waitForFirstCompleted();
|
||||||
|
|
||||||
|
const group = page.getByTestId("tool-call-group").first();
|
||||||
|
await expect(group).toBeVisible();
|
||||||
|
await group.click();
|
||||||
|
|
||||||
|
const sheet = page.getByTestId("tool-call-group-sheet");
|
||||||
|
const summary = page.getByTestId("tool-call-group-sheet-summary");
|
||||||
|
await expect(sheet).toBeVisible();
|
||||||
|
const initialSummary = await summary.innerText();
|
||||||
|
|
||||||
|
gate.release(10);
|
||||||
|
const badges = sheet.getByTestId("tool-call-badge");
|
||||||
|
await expect.poll(() => badges.count(), { timeout: 30_000 }).toBeGreaterThanOrEqual(11);
|
||||||
|
await expect(summary).not.toHaveText(initialSummary);
|
||||||
|
await expect.poll(() => sheet.evaluate(hasScrolledToLatest)).toBe(true);
|
||||||
|
const scrollMetrics = await sheet.evaluate(readScrollMetrics);
|
||||||
|
await testInfo.attach("compact-overview-scroll-metrics", {
|
||||||
|
body: JSON.stringify(scrollMetrics, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
await badges.last().click();
|
||||||
|
const detailClose = page.getByTestId("tool-call-sheet-close");
|
||||||
|
await expect(detailClose).toBeVisible();
|
||||||
|
|
||||||
|
await detailClose.click();
|
||||||
|
await expect(detailClose).toBeHidden();
|
||||||
|
await expect(sheet).toBeVisible();
|
||||||
|
|
||||||
|
await testInfo.attach("compact-overview-tool-call-group", {
|
||||||
|
body: await page.screenshot(),
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("tool-call-group-sheet-close").click();
|
||||||
|
await expect(sheet).toBeHidden();
|
||||||
|
await expect(group).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps overview tool calls inline on desktop", async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const agent = await createOverviewAgent(page, "Desktop overview tool calls");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openOverviewAgent(page, agent);
|
||||||
|
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
|
||||||
|
const group = page.getByTestId("tool-call-group").first();
|
||||||
|
await expect(group).toBeVisible();
|
||||||
|
|
||||||
|
await group.click();
|
||||||
|
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||||
|
await expect(group.getByTestId("tool-call-badge").first()).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -133,7 +133,7 @@ test.describe("Workspace agent title handoff", () => {
|
|||||||
.toBe(promptTitle);
|
.toBe(promptTitle);
|
||||||
await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 });
|
await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 });
|
||||||
await agentTab.click({ button: "right" });
|
await agentTab.click({ button: "right" });
|
||||||
await expect(page.getByTestId(`workspace-tab-context-agent_${agentId}-rename`)).toBeVisible({
|
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible({
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ test.describe("Workspace labels", () => {
|
|||||||
|
|
||||||
// Clear restores the complete sidebar without leaving the filter page.
|
// Clear restores the complete sidebar without leaving the filter page.
|
||||||
await page.getByTestId("sidebar-label-filter-clear").click();
|
await page.getByTestId("sidebar-label-filter-clear").click();
|
||||||
await expect(page.getByTestId("sidebar-label-filter-empty-state")).toBeHidden();
|
await expect(page.getByTestId("sidebar-filter-empty-state")).toBeHidden();
|
||||||
await expect(page.getByTestId("sidebar-label-filter-clear")).toBeHidden();
|
await expect(page.getByTestId("sidebar-label-filter-clear")).toBeHidden();
|
||||||
await expect(labelledRow).toBeVisible();
|
await expect(labelledRow).toBeVisible();
|
||||||
await expect(unlabelledRow).toBeVisible();
|
await expect(unlabelledRow).toBeVisible();
|
||||||
|
|||||||
@@ -11,35 +11,37 @@ test.describe("Workspace lifecycle", () => {
|
|||||||
// One retry is enough for the daemon to stabilize.
|
// One retry is enough for the daemon to stabilize.
|
||||||
test.describe.configure({ retries: 1 });
|
test.describe.configure({ retries: 1 });
|
||||||
|
|
||||||
test.describe("Main checkout", () => {
|
test("main checkout creates an agent chat and terminal in the project root", async ({
|
||||||
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
page,
|
||||||
test.setTimeout(60_000);
|
withWorkspace,
|
||||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" });
|
}) => {
|
||||||
await workspace.navigateTo();
|
test.setTimeout(60_000);
|
||||||
|
const workspace = await withWorkspace({ prefix: "lifecycle-main-" });
|
||||||
|
await workspace.navigateTo();
|
||||||
|
|
||||||
|
await test.step("creates an agent chat", async () => {
|
||||||
await createAgentChatFromLauncher(page);
|
await createAgentChatFromLauncher(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
await test.step("creates a terminal in the project root", async () => {
|
||||||
test.setTimeout(60_000);
|
|
||||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" });
|
|
||||||
await workspace.navigateTo();
|
|
||||||
await createStandaloneTerminalFromLauncher(page);
|
await createStandaloneTerminalFromLauncher(page);
|
||||||
await expectTerminalCwd(page, workspace.repoPath);
|
await expectTerminalCwd(page, workspace.repoPath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Worktree workspace", () => {
|
test("worktree creates an agent chat and terminal in its directory", async ({
|
||||||
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
page,
|
||||||
test.setTimeout(90_000);
|
withWorkspace,
|
||||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" });
|
}) => {
|
||||||
await workspace.navigateTo();
|
test.setTimeout(90_000);
|
||||||
|
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-" });
|
||||||
|
await workspace.navigateTo();
|
||||||
|
|
||||||
|
await test.step("creates an agent chat", async () => {
|
||||||
await createAgentChatFromLauncher(page);
|
await createAgentChatFromLauncher(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
await test.step("creates a terminal in the worktree", async () => {
|
||||||
test.setTimeout(90_000);
|
|
||||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" });
|
|
||||||
await workspace.navigateTo();
|
|
||||||
await createStandaloneTerminalFromLauncher(page);
|
await createStandaloneTerminalFromLauncher(page);
|
||||||
await expectTerminalCwd(page, workspace.repoPath);
|
await expectTerminalCwd(page, workspace.repoPath);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { execSync, spawn, type ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
import { once } from "node:events";
|
|
||||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -23,6 +22,7 @@ import {
|
|||||||
submitNewWorkspaceEmpty,
|
submitNewWorkspaceEmpty,
|
||||||
} from "../support/helpers/new-workspace";
|
} from "../support/helpers/new-workspace";
|
||||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||||
|
import { killProcessTree, spawnTsx } from "../support/helpers/spawn-node";
|
||||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||||
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
|
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
@@ -195,9 +195,11 @@ async function getAvailablePort(): Promise<number> {
|
|||||||
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let lastConnectionError: unknown = null;
|
let lastConnectionError: unknown = null;
|
||||||
while (Date.now() - startedAt < 20_000) {
|
while (Date.now() - startedAt < 90_000) {
|
||||||
if (child.exitCode !== null) {
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
throw new Error(`Restart test daemon exited before listening (exit ${child.exitCode}).`);
|
throw new Error(
|
||||||
|
`Restart test daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@@ -226,21 +228,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopProcess(child: ChildProcess): Promise<void> {
|
|
||||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
if (child.exitCode === null && child.signalCode === null) {
|
|
||||||
child.kill("SIGKILL");
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
try {
|
|
||||||
await once(child, "exit");
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startRestartDaemon(input: {
|
async function startRestartDaemon(input: {
|
||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
origin: string;
|
origin: string;
|
||||||
@@ -251,8 +238,7 @@ async function startRestartDaemon(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serverDir = path.resolve(__dirname, "../../../server");
|
const serverDir = path.resolve(__dirname, "../../../server");
|
||||||
const tsxBin = execSync("which tsx").toString().trim();
|
const child = spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], {
|
||||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
|
||||||
cwd: serverDir,
|
cwd: serverDir,
|
||||||
env: withDisabledE2ESpeechEnv({
|
env: withDisabledE2ESpeechEnv({
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -276,7 +262,7 @@ async function startRestartDaemon(input: {
|
|||||||
try {
|
try {
|
||||||
await waitForServer(port, child);
|
await waitForServer(port, child);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||||
{ cause: error },
|
{ cause: error },
|
||||||
@@ -285,7 +271,7 @@ async function startRestartDaemon(input: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
port,
|
port,
|
||||||
close: () => stopProcess(child),
|
close: () => killProcessTree(child),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,22 +356,6 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
|
|||||||
return decodeWorkspaceIdFromPathSegment(match[1]);
|
return decodeWorkspaceIdFromPathSegment(match[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectWorkspaceRowHasOnlyIndicator(
|
|
||||||
page: Page,
|
|
||||||
input: { serverId: string; workspaceId: string; indicator: string },
|
|
||||||
) {
|
|
||||||
const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
for (const indicator of ["attention", "done", "failed", "loading", "needs_input", "running"]) {
|
|
||||||
const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`);
|
|
||||||
if (indicator === input.indicator) {
|
|
||||||
await expect(locator).toBeVisible({ timeout: 30_000 });
|
|
||||||
} else {
|
|
||||||
await expect(locator).toHaveCount(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectWorkspaceRowDoesNotShowIndicator(
|
async function expectWorkspaceRowDoesNotShowIndicator(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { serverId: string; workspaceId: string; indicator: string },
|
input: { serverId: string; workspaceId: string; indicator: string },
|
||||||
@@ -458,11 +428,6 @@ test.describe("Workspace model restart regressions", () => {
|
|||||||
|
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
|
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
|
||||||
serverId,
|
|
||||||
workspaceId: seeded.workspaceA,
|
|
||||||
indicator: "running",
|
|
||||||
});
|
|
||||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: seeded.workspaceB,
|
workspaceId: seeded.workspaceB,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import type { Locator } from "@playwright/test";
|
import type { Locator, Page } from "@playwright/test";
|
||||||
import { expect, test } from "../support/fixtures";
|
import { expect, test } from "../support/fixtures";
|
||||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||||
import { expectComposerVisible } from "../support/helpers/composer";
|
import { expectComposerVisible } from "../support/helpers/composer";
|
||||||
@@ -35,10 +35,22 @@ async function expectSameRenderedNode(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectNodeConnected(node: Awaited<ReturnType<typeof captureRenderedNode>>) {
|
||||||
|
expect(await node.evaluate((candidate) => candidate.isConnected)).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSettingsShortcut(page: Page) {
|
||||||
|
return page.evaluate(() =>
|
||||||
|
navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForWorkspaceRoute(page: Page, route: string) {
|
||||||
|
await page.waitForURL((url) => url.pathname === route);
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("Workspace pane mounting", () => {
|
test.describe("Workspace pane mounting", () => {
|
||||||
test("opening the first split pane keeps the existing agent composer mounted", async ({
|
test("workspace navigation keeps the existing agent composer mounted", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
|
|
||||||
@@ -65,53 +77,35 @@ test.describe("Workspace pane mounting", () => {
|
|||||||
.first()
|
.first()
|
||||||
.elementHandle();
|
.elementHandle();
|
||||||
expect(originalComposer).not.toBeNull();
|
expect(originalComposer).not.toBeNull();
|
||||||
|
await test.step("opening the first split pane preserves the composer", async () => {
|
||||||
|
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
|
||||||
|
|
||||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
|
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
|
||||||
2,
|
emptyPanesBeforeSplit + 1,
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
);
|
);
|
||||||
|
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
|
||||||
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
|
1,
|
||||||
expect(originalStillConnected).toBe(true);
|
);
|
||||||
} finally {
|
await expectNodeConnected(originalComposer!);
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opening Settings hides workspace overlays and keeps the agent composer mounted", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(90_000);
|
|
||||||
const serverId = getServerId();
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "settings-pane-retention-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agent = await createIdleAgent(workspace.client, {
|
|
||||||
cwd: workspace.repoPath,
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
title: `settings-pane-retention-${Date.now()}`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||||
const originalComposer = await captureRenderedNode(composer);
|
await test.step("desktop Settings closes overlays and preserves the composer", async () => {
|
||||||
|
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
|
||||||
|
await tab.click({ button: "right" });
|
||||||
|
await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click();
|
||||||
|
const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`);
|
||||||
|
await expect(renameInput).toBeVisible();
|
||||||
|
|
||||||
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
|
const settingsShortcut = await getSettingsShortcut(page);
|
||||||
await tab.click({ button: "right" });
|
await page.keyboard.press(settingsShortcut);
|
||||||
await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click();
|
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||||
const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`);
|
await expect(renameInput).not.toBeVisible();
|
||||||
await expect(renameInput).toBeVisible();
|
await clickSettingsBackToWorkspace(page);
|
||||||
|
await expectSameRenderedNode(originalComposer!, composer);
|
||||||
const settingsShortcut = await page.evaluate(() =>
|
});
|
||||||
navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,",
|
|
||||||
);
|
|
||||||
await page.keyboard.press(settingsShortcut);
|
|
||||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
|
||||||
await expect(renameInput).not.toBeVisible();
|
|
||||||
await clickSettingsBackToWorkspace(page);
|
|
||||||
await expectSameRenderedNode(originalComposer, composer);
|
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
@@ -138,18 +132,17 @@ test.describe("Workspace pane mounting", () => {
|
|||||||
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||||
const originalComposer = await captureRenderedNode(composer);
|
const originalComposer = await captureRenderedNode(composer);
|
||||||
|
|
||||||
await openCompactSettings(page, buildHostWorkspaceRoute(serverId, workspace.workspaceId));
|
const workspaceRoute = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
|
||||||
|
await openCompactSettings(page, workspaceRoute);
|
||||||
await page.goBack();
|
await page.goBack();
|
||||||
await page.waitForURL(
|
await waitForWorkspaceRoute(page, workspaceRoute);
|
||||||
(url) => url.pathname === buildHostWorkspaceRoute(serverId, workspace.workspaceId),
|
|
||||||
);
|
|
||||||
await expectSameRenderedNode(originalComposer, composer);
|
await expectSameRenderedNode(originalComposer, composer);
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("switching away from a terminal tab keeps its emulator mounted", async ({ page }) => {
|
test("workspace navigation keeps the terminal emulator mounted", async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "terminal-pane-retention-" });
|
const workspace = await seedWorkspace({ repoPrefix: "terminal-pane-retention-" });
|
||||||
@@ -168,39 +161,20 @@ test.describe("Workspace pane mounting", () => {
|
|||||||
const terminalSurface = terminalSurfaceLocator(page);
|
const terminalSurface = terminalSurfaceLocator(page);
|
||||||
const originalTerminal = await captureRenderedNode(terminalSurface);
|
const originalTerminal = await captureRenderedNode(terminalSurface);
|
||||||
|
|
||||||
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
|
await test.step("switching tabs preserves the terminal", async () => {
|
||||||
await expectComposerVisible(page);
|
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
|
||||||
expect(await originalTerminal.evaluate((node) => node.isConnected)).toBe(true);
|
await expectComposerVisible(page);
|
||||||
|
await expectNodeConnected(originalTerminal);
|
||||||
|
|
||||||
await clickFirstTerminalTab(page);
|
await clickFirstTerminalTab(page);
|
||||||
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opening Settings and returning keeps the terminal emulator mounted", async ({ page }) => {
|
|
||||||
test.setTimeout(90_000);
|
|
||||||
const serverId = getServerId();
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "settings-terminal-retention-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agent = await createIdleAgent(workspace.client, {
|
|
||||||
cwd: workspace.repoPath,
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
title: `settings-terminal-retention-${Date.now()}`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
await test.step("opening Settings preserves the terminal", async () => {
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await openSettings(page);
|
||||||
await clickNewTerminal(page);
|
await clickSettingsBackToWorkspace(page);
|
||||||
await expectTerminalSurfaceVisible(page);
|
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
||||||
const terminalSurface = terminalSurfaceLocator(page);
|
});
|
||||||
const originalTerminal = await captureRenderedNode(terminalSurface);
|
|
||||||
|
|
||||||
await openSettings(page);
|
|
||||||
await clickSettingsBackToWorkspace(page);
|
|
||||||
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { test, expect } from "../support/fixtures";
|
import { daemonTest, test, expect } from "../support/fixtures";
|
||||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||||
import {
|
import {
|
||||||
|
closeSetupTab,
|
||||||
waitForWorkspaceTabsVisible,
|
waitForWorkspaceTabsVisible,
|
||||||
|
expectFailedSetupTabSeededInMainPane,
|
||||||
|
expectSetupTabNotSeeded,
|
||||||
expectNoTerminalTabs,
|
expectNoTerminalTabs,
|
||||||
clickFirstTerminalTab,
|
clickFirstTerminalTab,
|
||||||
expectFirstTerminalTabContains,
|
expectFirstTerminalTabContains,
|
||||||
@@ -19,6 +22,7 @@ import {
|
|||||||
expectSetupPanel,
|
expectSetupPanel,
|
||||||
openHomeWithProject,
|
openHomeWithProject,
|
||||||
navigateToWorkspaceViaSidebar,
|
navigateToWorkspaceViaSidebar,
|
||||||
|
returnHomeFromWorkspace,
|
||||||
openWorkspaceScriptsMenu,
|
openWorkspaceScriptsMenu,
|
||||||
startWorkspaceScriptFromMenu,
|
startWorkspaceScriptFromMenu,
|
||||||
closeWorkspaceScriptsMenu,
|
closeWorkspaceScriptsMenu,
|
||||||
@@ -39,7 +43,7 @@ interface WorkspaceScriptStarter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Workspace setup streaming", () => {
|
test.describe("Workspace setup streaming", () => {
|
||||||
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
|
test("does not seed the setup tab while workspace setup is running", async ({ page }) => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const client = await connectWorkspaceSetupClient();
|
||||||
const repo = await createTempGitRepo("setup-open-", {
|
const repo = await createTempGitRepo("setup-open-", {
|
||||||
paseoConfig: {
|
paseoConfig: {
|
||||||
@@ -60,7 +64,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
await openHomeWithProject(page, repo.path);
|
await openHomeWithProject(page, repo.path);
|
||||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||||
|
|
||||||
await expectSetupPanel(page);
|
await expectSetupTabNotSeeded(page, workspace.id);
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await client.close();
|
||||||
await repo.cleanup();
|
await repo.cleanup();
|
||||||
@@ -99,6 +103,8 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
await openHomeWithProject(page, repo.path);
|
await openHomeWithProject(page, repo.path);
|
||||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||||
|
|
||||||
|
await expectSetupTabNotSeeded(page, workspace.id);
|
||||||
|
await expectSetupPanel(page);
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await clickNewChat(page);
|
await clickNewChat(page);
|
||||||
await expectComposerVisible(page, { timeout: 30_000 });
|
await expectComposerVisible(page, { timeout: 30_000 });
|
||||||
@@ -111,7 +117,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("streams running and completed setup snapshots for a successful setup", async () => {
|
daemonTest("streams running and completed setup snapshots for a successful setup", async () => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const client = await connectWorkspaceSetupClient();
|
||||||
const repo = await createTempGitRepo("setup-success-", {
|
const repo = await createTempGitRepo("setup-success-", {
|
||||||
paseoConfig: {
|
paseoConfig: {
|
||||||
@@ -156,7 +162,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("streams a failed setup snapshot when setup fails", async () => {
|
test("seeds a failed setup tab once in the main pane", async ({ page }) => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const client = await connectWorkspaceSetupClient();
|
||||||
const repo = await createTempGitRepo("setup-failure-", {
|
const repo = await createTempGitRepo("setup-failure-", {
|
||||||
paseoConfig: {
|
paseoConfig: {
|
||||||
@@ -173,7 +179,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
|
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
|
||||||
);
|
);
|
||||||
|
|
||||||
await createWorkspaceThroughDaemon(client, {
|
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||||
cwd: repo.path,
|
cwd: repo.path,
|
||||||
worktreeSlug: "workspace-setup-failure",
|
worktreeSlug: "workspace-setup-failure",
|
||||||
});
|
});
|
||||||
@@ -182,13 +188,23 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
expect(failedPayload.detail.log).toContain("starting setup");
|
expect(failedPayload.detail.log).toContain("starting setup");
|
||||||
expect(failedPayload.detail.log).toContain("setup failed");
|
expect(failedPayload.detail.log).toContain("setup failed");
|
||||||
expect(failedPayload.error).toMatch(/failed/i);
|
expect(failedPayload.error).toMatch(/failed/i);
|
||||||
|
|
||||||
|
await openHomeWithProject(page, repo.path);
|
||||||
|
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expectFailedSetupTabSeededInMainPane(page, workspace.id);
|
||||||
|
|
||||||
|
await closeSetupTab(page, workspace.id);
|
||||||
|
await returnHomeFromWorkspace(page);
|
||||||
|
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||||
|
await expectSetupTabNotSeeded(page, workspace.id);
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await client.close();
|
||||||
await repo.cleanup();
|
await repo.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("emits a completed empty snapshot when no setup commands exist", async () => {
|
daemonTest("emits a completed empty snapshot when no setup commands exist", async () => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const client = await connectWorkspaceSetupClient();
|
||||||
const repo = await createTempGitRepo("setup-none-");
|
const repo = await createTempGitRepo("setup-none-");
|
||||||
|
|
||||||
@@ -267,7 +283,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("launches workspace scripts through an explicit daemon request", async () => {
|
daemonTest("launches workspace scripts through an explicit daemon request", async () => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const client = await connectWorkspaceSetupClient();
|
||||||
const repo = await createTempGitRepo("setup-scripts-", {
|
const repo = await createTempGitRepo("setup-scripts-", {
|
||||||
paseoConfig: {
|
paseoConfig: {
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ test.describe("Workspace terminal tab rename", () => {
|
|||||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
await tab.click({ button: "right" });
|
await tab.click({ button: "right" });
|
||||||
const copyTerminalId = page.getByTestId(
|
const copyTerminalId = page.getByRole("menuitem", {
|
||||||
`workspace-tab-context-terminal_${terminalId}-copy-terminal-id`,
|
name: /^Copy terminal id/,
|
||||||
);
|
});
|
||||||
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
|
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
|
||||||
await copyTerminalId.click();
|
await copyTerminalId.click();
|
||||||
|
|
||||||
@@ -99,10 +99,7 @@ test.describe("Workspace terminal tab rename", () => {
|
|||||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
await tab.click({ button: "right" });
|
await tab.click({ button: "right" });
|
||||||
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
|
const renameItem = page.getByRole("menuitem", { name: "Rename", exact: true });
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
|
|
||||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||||
await renameItem.click();
|
await renameItem.click();
|
||||||
|
|
||||||
|
|||||||
@@ -39,28 +39,6 @@ test.describe("Workspace archive with worktree backing", () => {
|
|||||||
await tempRepo?.cleanup().catch(() => undefined);
|
await tempRepo?.cleanup().catch(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
|
|
||||||
const serverId = getServerId();
|
|
||||||
await openProjectViaDaemon(client, tempRepo.path);
|
|
||||||
const worktree = await createWorktreeViaDaemon(client, {
|
|
||||||
cwd: tempRepo.path,
|
|
||||||
slug: `archive-${Date.now()}`,
|
|
||||||
});
|
|
||||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
|
||||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarHydration(page);
|
|
||||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
|
||||||
|
|
||||||
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
|
|
||||||
|
|
||||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
|
||||||
await expect
|
|
||||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
|
||||||
.toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
|
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
await openProjectViaDaemon(client, tempRepo.path);
|
await openProjectViaDaemon(client, tempRepo.path);
|
||||||
|
|||||||
@@ -32,16 +32,8 @@ const metroTest = base.extend({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const test = metroTest.extend<
|
const daemonTest = metroTest.extend<
|
||||||
{
|
{ projectOwnership: void },
|
||||||
paseoE2ESetup: void;
|
|
||||||
projectOwnership: void;
|
|
||||||
outdatedDaemon: OutdatedDaemon;
|
|
||||||
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
|
||||||
relayConfigOutdatedDaemon: OutdatedDaemon;
|
|
||||||
projectPickerFixture: TrackedProjectPickerFixture;
|
|
||||||
withWorkspace: WithWorkspace;
|
|
||||||
},
|
|
||||||
{ e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
|
{ e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
|
||||||
>({
|
>({
|
||||||
e2eForkProviders: [[], { scope: "worker", option: true }],
|
e2eForkProviders: [[], { scope: "worker", option: true }],
|
||||||
@@ -94,6 +86,16 @@ const test = metroTest.extend<
|
|||||||
},
|
},
|
||||||
{ auto: true },
|
{ auto: true },
|
||||||
],
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const test = daemonTest.extend<{
|
||||||
|
paseoE2ESetup: void;
|
||||||
|
outdatedDaemon: OutdatedDaemon;
|
||||||
|
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
||||||
|
relayConfigOutdatedDaemon: OutdatedDaemon;
|
||||||
|
projectPickerFixture: TrackedProjectPickerFixture;
|
||||||
|
withWorkspace: WithWorkspace;
|
||||||
|
}>({
|
||||||
paseoE2ESetup: [
|
paseoE2ESetup: [
|
||||||
async ({ page }, provide, testInfo) => {
|
async ({ page }, provide, testInfo) => {
|
||||||
const daemonPort = getE2EDaemonPort();
|
const daemonPort = getE2EDaemonPort();
|
||||||
@@ -227,4 +229,4 @@ const test = metroTest.extend<
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export { test, metroTest, expect, type Page };
|
export { daemonTest, test, metroTest, expect, type Page };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import { once } from "node:events";
|
import { killProcessTree } from "./helpers/spawn-node";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
@@ -148,28 +148,11 @@ export async function warmMetro(port: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
|
||||||
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
try {
|
|
||||||
await Promise.race([
|
|
||||||
once(child, "exit"),
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
||||||
resolve();
|
|
||||||
}, 5_000);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
if (timeout) clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
|
function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
|
||||||
const appDir = path.resolve(__dirname, "../..");
|
const appDir = path.resolve(__dirname, "../..");
|
||||||
const child = spawn("npx", ["expo", "start", "--web", "--port", String(port)], {
|
const expoCli = require.resolve("expo/bin/cli");
|
||||||
|
// Spawns Node directly to bypass Windows .cmd shim execution restrictions without shell: true.
|
||||||
|
const child = spawn(process.execPath, [expoCli, "start", "--web", "--port", String(port)], {
|
||||||
cwd: appDir,
|
cwd: appDir,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -216,11 +199,11 @@ export default async function globalSetup() {
|
|||||||
console.log(`[e2e] Metro warmed on port ${metroPort}`);
|
console.log(`[e2e] Metro warmed on port ${metroPort}`);
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
await stopProcess(metroProcess);
|
await killProcessTree(metroProcess);
|
||||||
console.log("[e2e] Metro stopped");
|
console.log("[e2e] Metro stopped");
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await stopProcess(metroProcess);
|
await killProcessTree(metroProcess);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promi
|
|||||||
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
|
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
|
||||||
}
|
}
|
||||||
if (draft.notes !== undefined) {
|
if (draft.notes !== undefined) {
|
||||||
await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes);
|
await modal.getByRole("textbox", { name: "When to use", exact: true }).fill(draft.notes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,11 +329,18 @@ export async function expectAgentProfileForm(
|
|||||||
const modal = editModal(page);
|
const modal = editModal(page);
|
||||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
|
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
|
||||||
|
const notesHint = modal.getByTestId("agent-profile-notes-field-hint");
|
||||||
|
await expect(notesHint).toHaveText(
|
||||||
|
"Helps agents choose this profile when starting another agent.",
|
||||||
|
);
|
||||||
|
expect(await notesHint.evaluate((element) => getComputedStyle(element).webkitLineClamp)).not.toBe(
|
||||||
|
"1",
|
||||||
|
);
|
||||||
for (const [field, value] of Object.entries(expected)) {
|
for (const [field, value] of Object.entries(expected)) {
|
||||||
if (field === "notes") {
|
if (field === "notes") {
|
||||||
await expect(
|
await expect(modal.getByRole("textbox", { name: "When to use", exact: true })).toHaveValue(
|
||||||
modal.getByRole("textbox", { name: "Notes for agents", exact: true }),
|
value,
|
||||||
).toHaveValue(value);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { escapeRegex } from "./regex";
|
import { escapeRegex } from "./regex";
|
||||||
import { openChangesPanel as openWorkspaceChangesPanel } from "./workspace-tabs";
|
import { openChangesTreePanel } from "./workspace-tabs";
|
||||||
|
|
||||||
// The branch switcher lives in the Changes tab, not in the workspace header. It renders as a button whose
|
// The branch switcher lives in the Changes tab, not in the workspace header. It renders as a button whose
|
||||||
// accessible name carries the current branch ("Current branch: <name>. Press to
|
// accessible name carries the current branch ("Current branch: <name>. Press to
|
||||||
// switch branch."). Matching on the accessible name keeps these helpers tied to
|
// switch branch."). Matching on the accessible name keeps these helpers tied to
|
||||||
// what a screen reader user hears, and it proves the panel resolved a real
|
// what a screen reader user hears, and it proves the panel resolved a real
|
||||||
// checkout directory from the opaque workspace id. Scoping to the changes header
|
// checkout directory from the opaque workspace id. Scoping to the repository
|
||||||
// keeps the matcher unambiguous even when the header is shared with diff actions.
|
// header keeps the matcher aligned with the row that owns repository identity.
|
||||||
function branchSwitcherTrigger(page: Page, branchName: string) {
|
function branchSwitcherTrigger(page: Page, branchName: string) {
|
||||||
return page
|
return page
|
||||||
.getByTestId("changes-header")
|
.getByTestId("changes-repository-header")
|
||||||
.getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) })
|
.getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) })
|
||||||
.filter({ visible: true })
|
.filter({ visible: true })
|
||||||
.first();
|
.first();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opens the fixed pane and lands on the Changes tab, where the switcher and diff live.
|
// Opens the fixed Explorer Changes view, where the switcher lives.
|
||||||
export async function openChangesPanel(page: Page): Promise<void> {
|
export async function openChangesPanel(page: Page): Promise<void> {
|
||||||
await openWorkspaceChangesPanel(page);
|
await openChangesTreePanel(page);
|
||||||
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
|
await expect(
|
||||||
timeout: 30_000,
|
page.getByTestId("changes-repository-header").filter({ visible: true }).first(),
|
||||||
});
|
).toBeVisible({ timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectWorkspaceBranch(page: Page, branchName: string): Promise<void> {
|
export async function expectWorkspaceBranch(page: Page, branchName: string): Promise<void> {
|
||||||
|
|||||||
@@ -140,6 +140,15 @@ export async function expectActiveChatOutlinePrompt(page: Page, position: number
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectActiveChatOutlinePromptMovedFrom(
|
||||||
|
page: Page,
|
||||||
|
position: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const activePrompt = chatOutlineRail(page).getByRole("tab", { selected: true });
|
||||||
|
await expect(activePrompt).toHaveCount(1);
|
||||||
|
await expect(activePrompt).not.toHaveAccessibleName(new RegExp(`^${position} of `));
|
||||||
|
}
|
||||||
|
|
||||||
export async function expectLiveTurnPromptAboveFoldAndActive(
|
export async function expectLiveTurnPromptAboveFoldAndActive(
|
||||||
page: Page,
|
page: Page,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Locator, type Page } from "@playwright/test";
|
||||||
import type { SeededWorkspace } from "./seed-client";
|
import type { SeededWorkspace } from "./seed-client";
|
||||||
import { openCommandCenter } from "./command-center";
|
import { openCommandCenter } from "./command-center";
|
||||||
|
import { openAgentRoute, type MockAgentWorkspace } from "./mock-agent";
|
||||||
|
|
||||||
export interface CommandCenterAgentControlChoice {
|
export interface CommandCenterAgentControlChoice {
|
||||||
query: string;
|
query: string;
|
||||||
choice: string;
|
choice: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focuses a seeded mock agent — which is what makes its model and mode contributions register —
|
||||||
|
* opens the Command Center over it, and hands back the query input. Seeding stays in the test so
|
||||||
|
* the workspace lifecycle is owned by the `try`/`finally` that cleans it up.
|
||||||
|
*/
|
||||||
|
export async function openCommandCenterForAgent(
|
||||||
|
page: Page,
|
||||||
|
workspace: Pick<MockAgentWorkspace, "workspaceId" | "agentId">,
|
||||||
|
): Promise<Locator> {
|
||||||
|
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||||
|
const panel = await openCommandCenter(page);
|
||||||
|
return panel.getByTestId("command-center-input");
|
||||||
|
}
|
||||||
|
|
||||||
export async function chooseCommandCenterAgentControl(input: {
|
export async function chooseCommandCenterAgentControl(input: {
|
||||||
page: Page;
|
page: Page;
|
||||||
query: string;
|
query: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { fork, type ChildProcess } from "node:child_process";
|
import { fork, type ChildProcess } from "node:child_process";
|
||||||
import { once } from "node:events";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { killProcessTree } from "./spawn-node";
|
||||||
|
|
||||||
export interface OutdatedDaemon {
|
export interface OutdatedDaemon {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
@@ -55,14 +55,10 @@ export async function startOutdatedDaemon(options?: {
|
|||||||
endpoint: ready.endpoint,
|
endpoint: ready.endpoint,
|
||||||
label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host",
|
label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host",
|
||||||
serverId: ready.serverId,
|
serverId: ready.serverId,
|
||||||
async close() {
|
close: () => killProcessTree(child),
|
||||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
await once(child, "exit");
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
child.kill("SIGTERM");
|
await killProcessTree(child);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -19,11 +19,19 @@ async function createFakeEditorBin(): Promise<string> {
|
|||||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
||||||
let realGhPath = "";
|
let realGhPath = "";
|
||||||
try {
|
try {
|
||||||
realGhPath = execSync("which gh").toString().trim();
|
const locator = process.platform === "win32" ? "where.exe" : "which";
|
||||||
|
const candidates = execFileSync(locator, ["gh"], { encoding: "utf8" })
|
||||||
|
.split(/\r?\n/u)
|
||||||
|
.map((candidate) => candidate.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
realGhPath =
|
||||||
|
candidates.find(
|
||||||
|
(candidate) =>
|
||||||
|
process.platform !== "win32" || !/\.(?:cmd|bat)$/iu.test(path.extname(candidate)),
|
||||||
|
) ?? "";
|
||||||
} catch {
|
} catch {
|
||||||
// The local GitHub fixture remains usable without a system gh binary.
|
// The local GitHub fixture remains usable without a system gh binary.
|
||||||
}
|
}
|
||||||
|
|
||||||
const fakeEditorSource = `#!/usr/bin/env node
|
const fakeEditorSource = `#!/usr/bin/env node
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
@@ -41,6 +49,9 @@ if (recordPath) {
|
|||||||
const editorPath = path.join(binDir, editorCommand);
|
const editorPath = path.join(binDir, editorCommand);
|
||||||
await writeFile(editorPath, fakeEditorSource);
|
await writeFile(editorPath, fakeEditorSource);
|
||||||
await chmod(editorPath, 0o755);
|
await chmod(editorPath, 0o755);
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
await writeFile(`${editorPath}.cmd`, `@node "%~dp0${editorCommand}" %*\r\n`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fakeGhPath = path.join(binDir, "gh");
|
const fakeGhPath = path.join(binDir, "gh");
|
||||||
@@ -106,6 +117,9 @@ process.exit(result.status ?? 1);
|
|||||||
`;
|
`;
|
||||||
await writeFile(fakeGhPath, fakeGhSource);
|
await writeFile(fakeGhPath, fakeGhSource);
|
||||||
await chmod(fakeGhPath, 0o755);
|
await chmod(fakeGhPath, 0o755);
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
await writeFile(`${fakeGhPath}.cmd`, '@node "%~dp0gh" %*\r\n');
|
||||||
|
}
|
||||||
return binDir;
|
return binDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { daemonWsRoutePattern } from "./daemon-port";
|
||||||
|
|
||||||
|
type WebSocketMessage = string | Buffer;
|
||||||
|
|
||||||
|
function fileReadRequest(message: WebSocketMessage, path: string): boolean {
|
||||||
|
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||||
|
try {
|
||||||
|
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
||||||
|
const session =
|
||||||
|
envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||||
|
? (envelope.message as { type?: unknown; mode?: unknown; path?: unknown })
|
||||||
|
: (envelope as { type?: unknown; mode?: unknown; path?: unknown });
|
||||||
|
return (
|
||||||
|
session.type === "file_explorer_request" && session.mode === "file" && session.path === path
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function delayFileReadResponse(page: Page, path: string) {
|
||||||
|
let delayed: (() => void) | null = null;
|
||||||
|
let resolveDelayed: (() => void) | null = null;
|
||||||
|
const delayedResponse = new Promise<void>((resolve) => {
|
||||||
|
resolveDelayed = resolve;
|
||||||
|
});
|
||||||
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
|
const server = ws.connectToServer();
|
||||||
|
server.onMessage((message) => {
|
||||||
|
ws.send(message);
|
||||||
|
});
|
||||||
|
// Hold the outgoing JSON request. The corresponding body response travels as
|
||||||
|
// binary frames, so intercepting the response cannot identify this read.
|
||||||
|
ws.onMessage((message) => {
|
||||||
|
if (delayed || !fileReadRequest(message, path)) {
|
||||||
|
server.send(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delayed = () => server.send(message);
|
||||||
|
resolveDelayed?.();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
waitUntilHeld: () => delayedResponse,
|
||||||
|
release: () => delayed?.(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,16 +1,10 @@
|
|||||||
import { once } from "node:events";
|
import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||||
import {
|
|
||||||
spawn,
|
|
||||||
execFileSync,
|
|
||||||
execSync,
|
|
||||||
type ChildProcess,
|
|
||||||
type SpawnOptions,
|
|
||||||
} from "node:child_process";
|
|
||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||||
|
import { killProcessTree, spawnTsx } from "./spawn-node";
|
||||||
|
|
||||||
export interface IsolatedHostDaemon {
|
export interface IsolatedHostDaemon {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -48,12 +42,14 @@ async function getAvailablePort(): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||||
const deadline = Date.now() + 20_000;
|
const deadline = Date.now() + 90_000;
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
if (child.exitCode !== null) {
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
throw new Error(`Isolated host daemon exited before listening (exit ${child.exitCode})`);
|
throw new Error(
|
||||||
|
`Isolated host daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@@ -81,19 +77,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopProcess(child: ChildProcess): Promise<void> {
|
|
||||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
||||||
}, 5_000);
|
|
||||||
try {
|
|
||||||
await once(child, "exit");
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startIsolatedHostDaemon(
|
export async function startIsolatedHostDaemon(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
options: IsolatedHostDaemonOptions = {},
|
options: IsolatedHostDaemonOptions = {},
|
||||||
@@ -114,11 +97,17 @@ export async function startIsolatedHostDaemon(
|
|||||||
path.join(publishedPackageRoot, "package.json"),
|
path.join(publishedPackageRoot, "package.json"),
|
||||||
`${JSON.stringify({ private: true })}\n`,
|
`${JSON.stringify({ private: true })}\n`,
|
||||||
);
|
);
|
||||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
||||||
try {
|
try {
|
||||||
|
const npmCli = process.env.npm_execpath;
|
||||||
|
if (!npmCli || path.basename(npmCli).toLowerCase() !== "npm-cli.js") {
|
||||||
|
throw new Error(
|
||||||
|
"Published-version E2E requires npm_execpath from npm. Start it through `npm run test:e2e`.",
|
||||||
|
);
|
||||||
|
}
|
||||||
execFileSync(
|
execFileSync(
|
||||||
npmCommand,
|
process.execPath,
|
||||||
[
|
[
|
||||||
|
npmCli,
|
||||||
"install",
|
"install",
|
||||||
"--no-audit",
|
"--no-audit",
|
||||||
"--no-fund",
|
"--no-fund",
|
||||||
@@ -128,6 +117,9 @@ export async function startIsolatedHostDaemon(
|
|||||||
{ cwd: publishedPackageRoot, stdio: "ignore" },
|
{ cwd: publishedPackageRoot, stdio: "ignore" },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!options.preserveHome) {
|
||||||
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
await rm(publishedPackageRoot, { recursive: true, force: true });
|
await rm(publishedPackageRoot, { recursive: true, force: true });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -155,7 +147,6 @@ export async function startIsolatedHostDaemon(
|
|||||||
const serverDir = publishedPackageRoot
|
const serverDir = publishedPackageRoot
|
||||||
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
|
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
|
||||||
: path.resolve(__dirname, "../../../../server");
|
: path.resolve(__dirname, "../../../../server");
|
||||||
const tsxBin = execSync("which tsx").toString().trim();
|
|
||||||
const spawnDaemon = async (): Promise<ChildProcess> => {
|
const spawnDaemon = async (): Promise<ChildProcess> => {
|
||||||
const spawnOptions: SpawnOptions = {
|
const spawnOptions: SpawnOptions = {
|
||||||
cwd: serverDir,
|
cwd: serverDir,
|
||||||
@@ -175,7 +166,7 @@ export async function startIsolatedHostDaemon(
|
|||||||
};
|
};
|
||||||
const child = publishedPackageRoot
|
const child = publishedPackageRoot
|
||||||
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
|
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
|
||||||
: spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], spawnOptions);
|
: spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
|
||||||
|
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
child.stderr?.on("data", (chunk: Buffer) => {
|
child.stderr?.on("data", (chunk: Buffer) => {
|
||||||
@@ -187,7 +178,7 @@ export async function startIsolatedHostDaemon(
|
|||||||
await waitForServer(port, child);
|
await waitForServer(port, child);
|
||||||
return child;
|
return child;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||||
{ cause: error },
|
{ cause: error },
|
||||||
@@ -216,13 +207,13 @@ export async function startIsolatedHostDaemon(
|
|||||||
getPid: () => child.pid,
|
getPid: () => child.pid,
|
||||||
restart: async () => {
|
restart: async () => {
|
||||||
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
child = await spawnDaemon();
|
child = await spawnDaemon();
|
||||||
},
|
},
|
||||||
close: async () => {
|
close: async () => {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
closed = true;
|
closed = true;
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
if (!options.preserveHome) {
|
if (!options.preserveHome) {
|
||||||
await rm(paseoHome, { recursive: true, force: true });
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
|||||||
|
|
||||||
// ─── Tab actions ───────────────────────────────────────────────────────────
|
// ─── Tab actions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open the focused pane's New tab menu. */
|
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to create a New tab in the focused pane. */
|
||||||
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
await page.keyboard.press(`${modifier}+t`);
|
await page.keyboard.press(`${modifier}+t`);
|
||||||
@@ -70,7 +70,7 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
|
|||||||
|
|
||||||
export async function openNewTabMenuWithShortcut(page: Page): Promise<void> {
|
export async function openNewTabMenuWithShortcut(page: Page): Promise<void> {
|
||||||
await pressNewTabShortcut(page);
|
await pressNewTabShortcut(page);
|
||||||
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toBeVisible();
|
await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pressDirectNewTabShortcut(page: Page, key: string): Promise<void> {
|
export async function pressDirectNewTabShortcut(page: Page, key: string): Promise<void> {
|
||||||
@@ -83,30 +83,27 @@ export async function pressDirectNewTabShortcut(page: Page, key: string): Promis
|
|||||||
/** Assert the inline plus button is visible in the tab bar. */
|
/** Assert the inline plus button is visible in the tab bar. */
|
||||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
|
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Assert the new-tab dropdown trigger is visible in the tab bar. */
|
/** Assert the New tab button is visible in the tab bar. */
|
||||||
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
|
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
|
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tab creation actions ─────────────────────────────────────────────────
|
// ─── Tab creation actions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Open the new-tab menu and click "New agent" to create a draft/chat tab. */
|
/** Choose Agent from the pane-local `+` menu. */
|
||||||
export async function clickNewChat(page: Page): Promise<void> {
|
export async function clickNewChat(page: Page): Promise<void> {
|
||||||
await createAgentTabFromMenu(page);
|
await createAgentTabFromMenu(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the new-tab menu and click "New terminal". */
|
/** Choose Terminal from the pane-local `+` menu. */
|
||||||
export async function clickNewTerminal(page: Page): Promise<void> {
|
export async function clickNewTerminal(page: Page): Promise<void> {
|
||||||
const trigger = page
|
const trigger = page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first();
|
||||||
.getByTestId("workspace-new-tab-menu-trigger")
|
|
||||||
.filter({ visible: true })
|
|
||||||
.first();
|
|
||||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||||
await trigger.click();
|
await trigger.click();
|
||||||
const item = page
|
const item = page
|
||||||
@@ -137,7 +134,7 @@ export async function waitForTabWithTitle(
|
|||||||
|
|
||||||
/** Assert the inline plus button is visible in the tab bar. */
|
/** Assert the inline plus button is visible in the tab bar. */
|
||||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||||
const buttons = page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true });
|
const buttons = page.getByTestId("workspace-new-tab-button").filter({ visible: true });
|
||||||
const count = await buttons.count();
|
const count = await buttons.count();
|
||||||
expect(count).toBeGreaterThanOrEqual(1);
|
expect(count).toBeGreaterThanOrEqual(1);
|
||||||
}
|
}
|
||||||
@@ -178,7 +175,7 @@ export async function sampleTabsDuringTransition(
|
|||||||
function sample() {
|
function sample() {
|
||||||
const tabs = Array.from(
|
const tabs = Array.from(
|
||||||
document.querySelectorAll<HTMLElement>(
|
document.querySelectorAll<HTMLElement>(
|
||||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])',
|
'[data-testid^="workspace-tab-"][role="button"][aria-selected]',
|
||||||
),
|
),
|
||||||
).filter((element) => element.getClientRects().length > 0);
|
).filter((element) => element.getClientRects().length > 0);
|
||||||
scope.__paseoTabTrackFrames?.push(
|
scope.__paseoTabTrackFrames?.push(
|
||||||
@@ -191,7 +188,10 @@ export async function sampleTabsDuringTransition(
|
|||||||
requestAnimationFrame(sample);
|
requestAnimationFrame(sample);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
requestAnimationFrame(sample);
|
// Establish the known-good pre-action state synchronously. Starting on the
|
||||||
|
// next animation frame lets the action race the first sample, which can
|
||||||
|
// misclassify a not-yet-painted test harness frame as a transition blank.
|
||||||
|
sample();
|
||||||
}, durationMs);
|
}, durationMs);
|
||||||
await action();
|
await action();
|
||||||
await page.waitForTimeout(durationMs + 100);
|
await page.waitForTimeout(durationMs + 100);
|
||||||
@@ -224,7 +224,7 @@ export async function expectTabTitleFits(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function terminalSurfaceLocator(page: Page) {
|
export function terminalSurfaceLocator(page: Page) {
|
||||||
return page.locator('[data-testid="terminal-surface"]').first();
|
return page.locator('[data-testid="terminal-surface"]').filter({ visible: true }).first();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
|
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import { once } from "node:events";
|
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { killProcessTree } from "./spawn-node";
|
||||||
|
|
||||||
export interface LocalElixirRelay {
|
export interface LocalElixirRelay {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
@@ -27,19 +27,6 @@ async function availablePort(): Promise<number> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopProcess(child: ChildProcess): Promise<void> {
|
|
||||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
const force = setTimeout(() => {
|
|
||||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
||||||
}, 5_000);
|
|
||||||
try {
|
|
||||||
await once(child, "exit");
|
|
||||||
} finally {
|
|
||||||
clearTimeout(force);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitUntilReady(
|
async function waitUntilReady(
|
||||||
port: number,
|
port: number,
|
||||||
child: ChildProcess,
|
child: ChildProcess,
|
||||||
@@ -48,9 +35,9 @@ async function waitUntilReady(
|
|||||||
const deadline = Date.now() + 60_000;
|
const deadline = Date.now() + 60_000;
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
if (child.exitCode !== null) {
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Elixir relay exited before becoming ready (exit ${child.exitCode}).\n${recentOutput()}`,
|
`Elixir relay exited before becoming ready (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).\n${recentOutput()}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -70,6 +57,12 @@ async function waitUntilReady(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
|
export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
throw new Error(
|
||||||
|
"The local Elixir relay requires asdf, which is not available on Windows; the relay-deployment Playwright project is POSIX-only.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const relayRoot =
|
const relayRoot =
|
||||||
process.env.PASEO_RELAY_CHECKOUT ?? path.resolve(__dirname, "../../../../../..", "paseo-relay");
|
process.env.PASEO_RELAY_CHECKOUT ?? path.resolve(__dirname, "../../../../../..", "paseo-relay");
|
||||||
if (!existsSync(path.join(relayRoot, "mix.exs"))) {
|
if (!existsSync(path.join(relayRoot, "mix.exs"))) {
|
||||||
@@ -107,14 +100,14 @@ export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
|
|||||||
try {
|
try {
|
||||||
await waitUntilReady(port, child, () => output.join("\n"));
|
await waitUntilReady(port, child, () => output.join("\n"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stop = async () => {
|
const stop = async () => {
|
||||||
if (!child) return;
|
if (!child) return;
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
child = null;
|
child = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
|
|||||||
export interface MockAgentOptions {
|
export interface MockAgentOptions {
|
||||||
repoPrefix: string;
|
repoPrefix: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
repo?: Parameters<typeof seedWorkspace>[0]["repo"];
|
||||||
port?: number;
|
port?: number;
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
@@ -29,7 +30,11 @@ export interface MockAgentOptions {
|
|||||||
export async function seedMockAgentWorkspace(
|
export async function seedMockAgentWorkspace(
|
||||||
options: MockAgentOptions,
|
options: MockAgentOptions,
|
||||||
): Promise<MockAgentWorkspace> {
|
): Promise<MockAgentWorkspace> {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: options.port });
|
const workspace = await seedWorkspace({
|
||||||
|
repoPrefix: options.repoPrefix,
|
||||||
|
repo: options.repo,
|
||||||
|
port: options.port,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const agent = await workspace.client.createAgent({
|
const agent = await workspace.client.createAgent({
|
||||||
provider: "mock",
|
provider: "mock",
|
||||||
@@ -37,7 +42,7 @@ export async function seedMockAgentWorkspace(
|
|||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
title: options.title,
|
title: options.title,
|
||||||
modeId: options.modeId ?? "load-test",
|
modeId: options.modeId ?? "load-test",
|
||||||
model: options.model ?? "ten-second-stream",
|
model: options.model ?? "e2e-fast-stream",
|
||||||
initialPrompt: options.initialPrompt,
|
initialPrompt: options.initialPrompt,
|
||||||
featureValues: options.featureValues,
|
featureValues: options.featureValues,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ export async function declineRelay(page: Page): Promise<void> {
|
|||||||
export async function enableRelayAndExpectOffer(page: Page): Promise<void> {
|
export async function enableRelayAndExpectOffer(page: Page): Promise<void> {
|
||||||
const enableButton = page.getByRole("button", { name: "Enable relay", exact: true });
|
const enableButton = page.getByRole("button", { name: "Enable relay", exact: true });
|
||||||
await enableButton.click();
|
await enableButton.click();
|
||||||
await expect(page.getByRole("button", { name: "Enabling...", exact: true })).toBeDisabled();
|
|
||||||
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
|
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
|
||||||
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
|
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
|
||||||
await expect(page.getByText("Enable relay?", { exact: true })).toHaveCount(0);
|
await expect(page.getByText("Enable relay?", { exact: true })).toHaveCount(0);
|
||||||
@@ -114,6 +113,11 @@ export async function enableRelayAndExpectOffer(page: Page): Promise<void> {
|
|||||||
export async function expectPairingOffer(page: Page): Promise<void> {
|
export async function expectPairingOffer(page: Page): Promise<void> {
|
||||||
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
|
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
|
||||||
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
|
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("alert").filter({
|
||||||
|
hasText: "Treat this pairing link like a password. Anyone with it can access this daemon.",
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface SeedWorkspaceDescriptor {
|
|||||||
projectDisplayName: string;
|
projectDisplayName: string;
|
||||||
projectRootPath: string;
|
projectRootPath: string;
|
||||||
workspaceDirectory: string;
|
workspaceDirectory: string;
|
||||||
|
diffStat: { additions: number; deletions: number } | null;
|
||||||
labels?: string[];
|
labels?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,27 @@ export async function openSidebarDisplayPage(page: Page, branchTestID: string):
|
|||||||
await page.getByTestId(branchTestID).click();
|
await page.getByTestId(branchTestID).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Project filters live a page below the display-preferences root, like the host filters.
|
||||||
|
export async function openSidebarProjectFilter(page: Page): Promise<void> {
|
||||||
|
await openSidebarDisplayPage(page, "sidebar-display-project-filter");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The filter rows are keyed by the project's sidebar view key. A project seeded from its own temp
|
||||||
|
// repo has a `projectKey` unique on the host, so the view key IS that key — see
|
||||||
|
// `buildWorkspaceStructureProjects`.
|
||||||
|
export async function toggleProjectFilter(page: Page, projectViewKey: string): Promise<void> {
|
||||||
|
await page.getByTestId(`sidebar-project-filter-${projectViewKey}`).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectAllProjectsFilter(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId("sidebar-project-filter-all").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeSidebarDisplayPreferences(page: Page): Promise<void> {
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByTestId("sidebar-display-preferences-content")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
export async function selectSidebarStatusGrouping(page: Page): Promise<void> {
|
export async function selectSidebarStatusGrouping(page: Page): Promise<void> {
|
||||||
await openSidebarDisplayPage(page, "sidebar-display-grouping");
|
await openSidebarDisplayPage(page, "sidebar-display-grouping");
|
||||||
await page.getByTestId("sidebar-grouping-status").click();
|
await page.getByTestId("sidebar-grouping-status").click();
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||||
|
import { once } from "node:events";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a repo-local TypeScript entrypoint through Node's own tsx loader instead
|
||||||
|
* of the `node_modules/.bin/tsx` shim.
|
||||||
|
*
|
||||||
|
* The shim is a platform trap, not a convenience: on Windows it is `tsx.cmd`,
|
||||||
|
* and Node refuses to spawn `.cmd`/`.bat` without `shell: true` (EINVAL, from
|
||||||
|
* the CVE-2024-27980 mitigation). Opting into `shell: true` to get around that
|
||||||
|
* trades one bug for three — argv is concatenated unescaped (DEP0190), a path
|
||||||
|
* containing spaces splits, and the kill signal lands on `cmd.exe` while the
|
||||||
|
* real child keeps the port. Spawning `process.execPath` has none of those
|
||||||
|
* properties and is byte-identical on POSIX, so there is no platform branch.
|
||||||
|
*/
|
||||||
|
export function spawnTsx(entrypoint: string, args: string[], options: SpawnOptions): ChildProcess {
|
||||||
|
if (entrypoint.startsWith("-")) {
|
||||||
|
throw new Error(`TypeScript entrypoint must be a path, received ${entrypoint}`);
|
||||||
|
}
|
||||||
|
return spawn(process.execPath, ["--import", "tsx", "--", entrypoint, ...args], options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminates a child and everything it forked, then waits for the exit.
|
||||||
|
*
|
||||||
|
* `child.kill()` on Windows maps to `TerminateProcess` against the direct child
|
||||||
|
* only. Metro and the daemon supervisor both fork workers, so the signal leaves
|
||||||
|
* grandchildren holding the listening port and the next run fails to bind.
|
||||||
|
* `taskkill /T` walks the tree; POSIX keeps the SIGTERM-then-SIGKILL ladder.
|
||||||
|
*/
|
||||||
|
export async function killProcessTree(child: ChildProcess | null): Promise<void> {
|
||||||
|
if (!child || hasExited(child)) return;
|
||||||
|
const exited = once(child, "exit").then(() => undefined);
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const pid = child.pid;
|
||||||
|
if (pid === undefined) {
|
||||||
|
throw new Error("Cannot terminate a Windows process tree without a PID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the exit listener before taskkill to avoid missing a fast exit.
|
||||||
|
// Bound taskkill itself so a stuck system utility cannot hang teardown.
|
||||||
|
const completed = Promise.withResolvers<Error | null>();
|
||||||
|
execFile("taskkill", ["/pid", String(pid), "/T", "/F"], { timeout: 5_000 }, (error) =>
|
||||||
|
completed.resolve(error),
|
||||||
|
);
|
||||||
|
const taskkillError = await completed.promise;
|
||||||
|
if (taskkillError && !hasExited(child)) {
|
||||||
|
try {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// The child can exit between the state check and the direct kill.
|
||||||
|
}
|
||||||
|
await waitForExitOrTimeout(exited, 5_000);
|
||||||
|
throw new Error(`Failed to terminate process tree for PID ${pid}`, { cause: taskkillError });
|
||||||
|
}
|
||||||
|
if (!hasExited(child) && !(await waitForExitOrTimeout(exited, 5_000))) {
|
||||||
|
throw new Error(`Process tree for PID ${pid} did not exit after taskkill`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
if (await waitForExitOrTimeout(exited, 5_000)) return;
|
||||||
|
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
if (!(await waitForExitOrTimeout(exited, 5_000))) {
|
||||||
|
throw new Error(`Process ${String(child.pid)} did not exit after SIGKILL`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExited(child: ChildProcess): boolean {
|
||||||
|
return child.exitCode !== null || child.signalCode !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForExitOrTimeout(exited: Promise<void>, timeoutMs: number): Promise<boolean> {
|
||||||
|
const timedOut = Promise.withResolvers<boolean>();
|
||||||
|
const timeout = setTimeout(() => timedOut.resolve(false), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await Promise.race([exited.then(() => true), timedOut.promise]);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { expect, type Locator, type Page } from "@playwright/test";
|
|
||||||
|
|
||||||
export interface SplitPaneGeometry {
|
|
||||||
containerWidth: number;
|
|
||||||
explorerWidth: number;
|
|
||||||
visibleWidth: number;
|
|
||||||
separatorCount: number;
|
|
||||||
occupiedWidth: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitSeparator(page: Page): Locator {
|
|
||||||
return page.getByRole("separator", { name: "" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openExplorerSplit(page: Page): Promise<void> {
|
|
||||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
|
||||||
await expect(splitSeparator(page)).toHaveCount(1, { timeout: 30_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hideExplorerSplit(page: Page): Promise<void> {
|
|
||||||
await page.getByRole("button", { name: "Close explorer" }).click();
|
|
||||||
await expect(splitSeparator(page)).toHaveCount(0, { timeout: 30_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resizeExplorerSplit(page: Page, deltaX: number): Promise<void> {
|
|
||||||
const separator = splitSeparator(page);
|
|
||||||
await expect(separator).toHaveCount(1);
|
|
||||||
const box = await separator.boundingBox();
|
|
||||||
expect(box).not.toBeNull();
|
|
||||||
if (!box) throw new Error("Split separator has no bounding box");
|
|
||||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(box.x + box.width / 2 + deltaX, box.y + box.height / 2, { steps: 10 });
|
|
||||||
await page.mouse.up();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function stampExplorerNode(page: Page): Promise<void> {
|
|
||||||
await page
|
|
||||||
.getByTestId("split-group-child")
|
|
||||||
.last()
|
|
||||||
.evaluate((node) => {
|
|
||||||
Reflect.set(window, "__splitPaneExplorerNode", node);
|
|
||||||
node.setAttribute("data-split-pane-identity", "retained");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectExplorerNodeRetained(page: Page): Promise<void> {
|
|
||||||
const stampedNode = page.locator('[data-split-pane-identity="retained"]');
|
|
||||||
await expect(stampedNode).toHaveCount(1);
|
|
||||||
expect(
|
|
||||||
await stampedNode.evaluate((node) => Reflect.get(window, "__splitPaneExplorerNode") === node),
|
|
||||||
).toBe(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function measureExplorerSplit(page: Page): Promise<SplitPaneGeometry> {
|
|
||||||
const explorerChild = page.locator('[data-split-pane-identity="retained"]');
|
|
||||||
await expect(explorerChild).toHaveCount(1);
|
|
||||||
|
|
||||||
return explorerChild.evaluate((explorerNode) => {
|
|
||||||
const visibleNode = Array.from(explorerNode.parentElement?.children ?? [])
|
|
||||||
.filter(
|
|
||||||
(candidate) =>
|
|
||||||
candidate !== explorerNode &&
|
|
||||||
(candidate as HTMLElement).dataset.testid === "split-group-child",
|
|
||||||
)
|
|
||||||
.sort(
|
|
||||||
(left, right) => right.getBoundingClientRect().width - left.getBoundingClientRect().width,
|
|
||||||
)[0];
|
|
||||||
if (!(visibleNode instanceof HTMLElement)) throw new Error("Visible split child not found");
|
|
||||||
const visible = visibleNode.getBoundingClientRect();
|
|
||||||
const explorer = explorerNode.getBoundingClientRect();
|
|
||||||
const container = explorerNode.parentElement?.getBoundingClientRect();
|
|
||||||
if (!container) throw new Error("Split child has no container");
|
|
||||||
const separatorCount =
|
|
||||||
explorerNode.parentElement?.querySelectorAll('[role="separator"]').length ?? 0;
|
|
||||||
return {
|
|
||||||
containerWidth: container.width,
|
|
||||||
explorerWidth: explorer.width,
|
|
||||||
visibleWidth: visible.width,
|
|
||||||
separatorCount,
|
|
||||||
occupiedWidth: explorer.width + visible.width,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -532,30 +532,42 @@ export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void
|
|||||||
export async function userScrollsTimelineToHistoryStart(page: Page): Promise<void> {
|
export async function userScrollsTimelineToHistoryStart(page: Page): Promise<void> {
|
||||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||||
await scroll.hover();
|
await scroll.hover();
|
||||||
for (let step = 0; step < 60; step += 1) {
|
await returnTimelineToSettledHistoryStart(page, () => page.mouse.wheel(0, -1_000));
|
||||||
if ((await readTimelineViewport(page)).scrollTop <= HISTORY_START_THRESHOLD_PX) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await page.mouse.wheel(0, -1_000);
|
|
||||||
await page.evaluate(
|
|
||||||
() =>
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
requestAnimationFrame(() => resolve());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
|
||||||
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function userNavigatesTimelineToHistoryStartWithKeyboard(page: Page): Promise<void> {
|
export async function userNavigatesTimelineToHistoryStartWithKeyboard(page: Page): Promise<void> {
|
||||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||||
await scroll.focus();
|
await scroll.focus();
|
||||||
await page.keyboard.press("Home");
|
await returnTimelineToSettledHistoryStart(page, () => page.keyboard.press("Home"));
|
||||||
await expect
|
}
|
||||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
|
||||||
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
|
async function returnTimelineToSettledHistoryStart(
|
||||||
|
page: Page,
|
||||||
|
moveUp: () => Promise<unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const loadingSpinner = page.getByTestId("load-older-history-spinner");
|
||||||
|
// A caller may only start a new history traversal after its prior page has settled.
|
||||||
|
await expect(loadingSpinner).toBeHidden();
|
||||||
|
let previous = await readTimelineViewport(page);
|
||||||
|
while (true) {
|
||||||
|
await moveUp();
|
||||||
|
await waitForTimelineGeometryToSettle(page);
|
||||||
|
let viewport = await readTimelineViewport(page);
|
||||||
|
// Tests that hold the response need to observe the requested page before releasing it.
|
||||||
|
if (await loadingSpinner.isVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (viewport.scrollTop <= HISTORY_START_THRESHOLD_PX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await expect(loadingSpinner).toBeHidden();
|
||||||
|
await waitForTimelineGeometryToSettle(page);
|
||||||
|
viewport = await readTimelineViewport(page);
|
||||||
|
expect(
|
||||||
|
viewport.scrollTop < previous.scrollTop || viewport.scrollHeight > previous.scrollHeight,
|
||||||
|
).toBe(true);
|
||||||
|
previous = viewport;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scrollTimelineToNewestLoadedEdge(page: Page): Promise<void> {
|
export async function scrollTimelineToNewestLoadedEdge(page: Page): Promise<void> {
|
||||||
|
|||||||
@@ -276,6 +276,11 @@ export async function navigateToWorkspaceViaSidebar(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function returnHomeFromWorkspace(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Home" }).click();
|
||||||
|
await expect(page).not.toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
|
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
|
||||||
await page.getByTestId("workspace-scripts-button").click();
|
await page.getByTestId("workspace-scripts-button").click();
|
||||||
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
|
||||||
|
|||||||
@@ -13,14 +13,52 @@ export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupTabTestId(workspaceId: string): string {
|
||||||
|
return `workspace-tab-setup_${workspaceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSetupToReachWorkspace(page: Page): Promise<void> {
|
||||||
|
const actionsButton = page.getByTestId("workspace-header-menu-trigger");
|
||||||
|
await expect(actionsButton).toBeVisible({ timeout: 30_000 });
|
||||||
|
await actionsButton.click();
|
||||||
|
await expect(page.getByTestId("workspace-header-show-setup")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSetupTabNotSeeded(page: Page, workspaceId: string): Promise<void> {
|
||||||
|
await waitForSetupToReachWorkspace(page);
|
||||||
|
const tab = page.getByTestId(setupTabTestId(workspaceId));
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
await expect(tab).toHaveCount(0);
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectFailedSetupTabSeededInMainPane(
|
||||||
|
page: Page,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const tabId = setupTabTestId(workspaceId);
|
||||||
|
await expect(page.getByTestId(tabId).filter({ visible: true }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
const explorer = await ensureExplorerSidebar(page);
|
||||||
|
await expect(explorer.getByTestId(tabId)).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeSetupTab(page: Page, workspaceId: string): Promise<void> {
|
||||||
|
const tabId = setupTabTestId(workspaceId);
|
||||||
|
await page.getByTestId(tabId).filter({ visible: true }).first().click({ button: "right" });
|
||||||
|
await page.getByTestId(`workspace-tab-context-setup_${workspaceId}-close`).click();
|
||||||
|
await expect(page.getByTestId(tabId)).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
function visibleTestId(page: Page, testId: string) {
|
function visibleTestId(page: Page, testId: string) {
|
||||||
return page.getByTestId(testId).filter({ visible: true });
|
return page.getByTestId(testId).filter({ visible: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function explorerPaneTabRow(page: Page) {
|
function explorerSidebar(page: Page) {
|
||||||
return visibleTestId(page, "workspace-tabs-row")
|
return visibleTestId(page, "workspace-explorer-sidebar").first();
|
||||||
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
|
|
||||||
.first();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
||||||
@@ -32,35 +70,55 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
|||||||
await expect(tab).toHaveAttribute("aria-selected", "true");
|
await expect(tab).toHaveAttribute("aria-selected", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureExplorerPane(page: Page) {
|
/** Reveal the Explorer sidebar without changing its selected view. */
|
||||||
|
export async function ensureExplorerSidebar(page: Page): Promise<Locator> {
|
||||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||||
const tabRow = explorerPaneTabRow(page);
|
const explorer = explorerSidebar(page);
|
||||||
if ((await tabRow.count()) === 0) {
|
if ((await explorer.count()) === 0) {
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
}
|
}
|
||||||
await expect(tabRow).toBeVisible({ timeout: 30_000 });
|
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||||
return tabRow;
|
return explorer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reveals the Explorer sidebar and selects one of its fixed navigation views. */
|
||||||
|
async function openExplorerView(
|
||||||
|
page: Page,
|
||||||
|
view: { tabTestId: string; contentTestId: string; timeout?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
const explorer = await ensureExplorerSidebar(page);
|
||||||
|
const tab = explorer.getByTestId(view.tabTestId);
|
||||||
|
await tab.click();
|
||||||
|
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
|
||||||
|
timeout: view.timeout ?? 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openChangesTreePanel(page: Page): Promise<void> {
|
||||||
|
await openExplorerView(page, {
|
||||||
|
tabTestId: "explorer-sidebar-tab-changes_tree",
|
||||||
|
contentTestId: "changes-tree-panel",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openChangesPanel(page: Page): Promise<void> {
|
export async function openChangesPanel(page: Page): Promise<void> {
|
||||||
const tabRow = await ensureExplorerPane(page);
|
await openChangesTreePanel(page);
|
||||||
await selectWorkspaceTab(tabRow.getByTestId("workspace-tab-working_diff"));
|
const changedFile = page
|
||||||
|
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first();
|
||||||
|
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||||
|
await changedFile.click();
|
||||||
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
|
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openFilesPanel(page: Page): Promise<void> {
|
export async function openFilesPanel(page: Page): Promise<void> {
|
||||||
const tabRow = await ensureExplorerPane(page);
|
await openExplorerView(page, {
|
||||||
const filesTab = tabRow.getByTestId("workspace-tab-files");
|
tabTestId: "explorer-sidebar-tab-files",
|
||||||
if ((await filesTab.count()) === 0) {
|
contentTestId: "file-explorer-tree-scroll",
|
||||||
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
|
|
||||||
await visibleTestId(page, "workspace-new-tab-menu-files").first().click();
|
|
||||||
}
|
|
||||||
await selectWorkspaceTab(filesTab);
|
|
||||||
await expect(visibleTestId(page, "file-explorer-tree-scroll").first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,13 +129,9 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
|
|||||||
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tabRow = await ensureExplorerPane(page);
|
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
|
||||||
const pullRequestTab = tabRow.getByTestId("workspace-tab-pull_request");
|
await trigger.click();
|
||||||
if ((await pullRequestTab.count()) === 0) {
|
await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
|
||||||
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
|
|
||||||
await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
|
|
||||||
}
|
|
||||||
await selectWorkspaceTab(pullRequestTab);
|
|
||||||
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,14 +139,14 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
|||||||
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
|
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(visibleTestId(page, "workspace-new-tab-menu-trigger").first()).toBeVisible({
|
await expect(visibleTestId(page, "workspace-new-tab-button").first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the `+` menu in the tab row and pick "New agent". */
|
/** Open the pane-local `+` menu and pick Agent. */
|
||||||
export async function createAgentTabFromMenu(page: Page): Promise<void> {
|
export async function createAgentTabFromMenu(page: Page): Promise<void> {
|
||||||
const trigger = visibleTestId(page, "workspace-new-tab-menu-trigger").first();
|
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
|
||||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||||
await trigger.click();
|
await trigger.click();
|
||||||
const item = visibleTestId(page, "workspace-new-tab-menu-agent").first();
|
const item = visibleTestId(page, "workspace-new-tab-menu-agent").first();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.5.0-beta.3",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
||||||
"profile:composer-typing": "node ./scripts/profile-composer-typing.mjs",
|
"profile:composer-typing": "node ./scripts/profile-composer-typing.mjs",
|
||||||
"profile:explorer-toggle": "node ./scripts/profile-explorer-toggle.mjs",
|
"profile:explorer-toggle": "node ./scripts/profile-explorer-toggle.mjs",
|
||||||
|
"profile:side-pane": "playwright test --config playwright.profile.config.ts side-pane-performance.spec.ts",
|
||||||
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
|
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
|
||||||
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
||||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { expect, type Locator, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
|
export interface ProfileTarget {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderSummary {
|
||||||
|
id: string;
|
||||||
|
renders: number;
|
||||||
|
mounts: number;
|
||||||
|
updates: number;
|
||||||
|
actualDurationMs: number;
|
||||||
|
maximumActualDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InteractionMeasurement {
|
||||||
|
name: string;
|
||||||
|
latencyMs: number;
|
||||||
|
commits: number;
|
||||||
|
domMutations: number;
|
||||||
|
addedElements: number;
|
||||||
|
removedElements: number;
|
||||||
|
addedTestIds: string[];
|
||||||
|
removedTestIds: string[];
|
||||||
|
trackedNodes: Array<{
|
||||||
|
selector: string;
|
||||||
|
existedBefore: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
sameNode: boolean;
|
||||||
|
}>;
|
||||||
|
renders: RenderSummary[];
|
||||||
|
reasons: Record<string, Record<string, number>>;
|
||||||
|
heapAfterGcBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowserMeasurementState {
|
||||||
|
startedAt: number;
|
||||||
|
selectors: string[];
|
||||||
|
originals: Array<Element | null>;
|
||||||
|
observer?: MutationObserver;
|
||||||
|
domMutations: number;
|
||||||
|
addedElements: number;
|
||||||
|
removedElements: number;
|
||||||
|
addedTestIds: Set<string>;
|
||||||
|
removedTestIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__PASEO_SIDE_PANE_MEASUREMENT__?: BrowserMeasurementState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCliJson<T>(output: string): T {
|
||||||
|
const arrayStart = output.indexOf("[\n");
|
||||||
|
const objectStart = output.indexOf("{\n");
|
||||||
|
const start = arrayStart >= 0 ? arrayStart : objectStart;
|
||||||
|
if (start < 0) throw new Error(`Could not find JSON in CLI output: ${output}`);
|
||||||
|
return JSON.parse(output.slice(start)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProfileTarget(): ProfileTarget {
|
||||||
|
const repoRoot = resolve(process.cwd(), "../..");
|
||||||
|
const serverId = (
|
||||||
|
process.env.PASEO_PROFILE_SERVER_ID ??
|
||||||
|
readFileSync(resolve(repoRoot, ".dev/paseo-home/server-id"), "utf8")
|
||||||
|
).trim();
|
||||||
|
const requestedName = process.env.PASEO_PROFILE_WORKSPACE_NAME?.trim() || "Paseo";
|
||||||
|
const requestedWorkspaceId = process.env.PASEO_PROFILE_WORKSPACE_ID?.trim();
|
||||||
|
if (requestedWorkspaceId) return { serverId, workspaceId: requestedWorkspaceId };
|
||||||
|
|
||||||
|
const output = execFileSync("npm", ["run", "cli", "--", "workspace", "ls", "--json"], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, FORCE_COLOR: "0" },
|
||||||
|
});
|
||||||
|
const workspaces =
|
||||||
|
parseCliJson<Array<{ workspaceId: string; name: string; cwd: string }>>(output);
|
||||||
|
const workspace = workspaces.find(
|
||||||
|
(candidate) => candidate.name === requestedName && candidate.cwd === repoRoot,
|
||||||
|
);
|
||||||
|
if (!workspace) {
|
||||||
|
throw new Error(`No Paseo workspace named ${requestedName} points at ${repoRoot}`);
|
||||||
|
}
|
||||||
|
return { serverId, workspaceId: workspace.workspaceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openProfileWorkspace(page: Page, target: ProfileTarget): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const preserveRenderProfile = (original: History["pushState"]) =>
|
||||||
|
function preserveProfileParam(
|
||||||
|
this: History,
|
||||||
|
state: unknown,
|
||||||
|
unused: string,
|
||||||
|
url?: string | URL | null,
|
||||||
|
) {
|
||||||
|
if (url == null) return Reflect.apply(original, this, [state, unused, url]);
|
||||||
|
const nextUrl = new URL(String(url), location.href);
|
||||||
|
nextUrl.searchParams.set("renderProfile", "1");
|
||||||
|
return Reflect.apply(original, this, [
|
||||||
|
state,
|
||||||
|
unused,
|
||||||
|
`${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
history.pushState = preserveRenderProfile(history.pushState);
|
||||||
|
history.replaceState = preserveRenderProfile(history.replaceState);
|
||||||
|
});
|
||||||
|
await page.goto("/?renderProfile=1", { waitUntil: "domcontentloaded" });
|
||||||
|
const workspaceRow = page.getByTestId(
|
||||||
|
`sidebar-workspace-row-${target.serverId}:${target.workspaceId}`,
|
||||||
|
);
|
||||||
|
await expect(workspaceRow).toBeVisible({ timeout: 60_000 });
|
||||||
|
await workspaceRow.click();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/workspace/${target.workspaceId}\\?renderProfile=1$`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enableChangesOpenInSidePane(page: Page): Promise<void> {
|
||||||
|
await page.evaluate((settingsKey) => {
|
||||||
|
const persisted = JSON.parse(localStorage.getItem(settingsKey) ?? "{}") as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const currentPreferences =
|
||||||
|
typeof persisted.openInSidePane === "object" && persisted.openInSidePane !== null
|
||||||
|
? (persisted.openInSidePane as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
localStorage.setItem(
|
||||||
|
settingsKey,
|
||||||
|
JSON.stringify({
|
||||||
|
...persisted,
|
||||||
|
openInSidePane: { ...currentPreferences, explorerChanges: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, APP_SETTINGS_KEY);
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openChangesExplorer(page: Page): Promise<void> {
|
||||||
|
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||||
|
if ((await explorer.count()) === 0) {
|
||||||
|
await page.keyboard.press("Meta+e");
|
||||||
|
}
|
||||||
|
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||||
|
const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first();
|
||||||
|
await expect(changesTab).toBeVisible();
|
||||||
|
if ((await changesTab.getAttribute("aria-selected")) !== "true") {
|
||||||
|
await changesTab.click();
|
||||||
|
}
|
||||||
|
await expect(page.getByTestId("changes-tree-panel")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(changedFileRows(page).nth(1)).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changedFileRows(page: Page): Locator {
|
||||||
|
return page
|
||||||
|
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||||
|
.filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectChangedFile(page: Page, index: number): Promise<void> {
|
||||||
|
const row = changedFileRows(page).nth(index);
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click();
|
||||||
|
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkingDiffVisible(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("git-diff-canvas").filter({ visible: true })).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeWorkingDiffPane(page: Page): Promise<void> {
|
||||||
|
const tab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true }).first();
|
||||||
|
await expect(tab).toBeVisible();
|
||||||
|
await tab.hover();
|
||||||
|
await page
|
||||||
|
.locator('[data-testid^="workspace-working-diff-close-"]')
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function measureHeapAfterGc(page: Page): Promise<number> {
|
||||||
|
await page.requestGC();
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const memory = performance as Performance & {
|
||||||
|
memory?: { usedJSHeapSize: number };
|
||||||
|
};
|
||||||
|
return memory.memory?.usedJSHeapSize ?? 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForReactIdle(page: Page, quietMs = 250): Promise<void> {
|
||||||
|
let previousCount = -1;
|
||||||
|
let unchangedSince = Date.now();
|
||||||
|
const deadline = Date.now() + 15_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const count = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE__?.length ?? 0);
|
||||||
|
if (count !== previousCount) {
|
||||||
|
previousCount = count;
|
||||||
|
unchangedSince = Date.now();
|
||||||
|
} else if (Date.now() - unchangedSince >= quietMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(25);
|
||||||
|
}
|
||||||
|
throw new Error("React profiler did not become idle");
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeRenders(
|
||||||
|
samples: Array<{
|
||||||
|
id: string;
|
||||||
|
phase: "mount" | "update" | "nested-update";
|
||||||
|
actualDuration: number;
|
||||||
|
}>,
|
||||||
|
): RenderSummary[] {
|
||||||
|
const summaries = new Map<string, RenderSummary>();
|
||||||
|
for (const sample of samples) {
|
||||||
|
const summary = summaries.get(sample.id) ?? {
|
||||||
|
id: sample.id,
|
||||||
|
renders: 0,
|
||||||
|
mounts: 0,
|
||||||
|
updates: 0,
|
||||||
|
actualDurationMs: 0,
|
||||||
|
maximumActualDurationMs: 0,
|
||||||
|
};
|
||||||
|
summary.renders += 1;
|
||||||
|
summary.mounts += sample.phase === "mount" ? 1 : 0;
|
||||||
|
summary.updates += sample.phase === "mount" ? 0 : 1;
|
||||||
|
summary.actualDurationMs += sample.actualDuration;
|
||||||
|
summary.maximumActualDurationMs = Math.max(
|
||||||
|
summary.maximumActualDurationMs,
|
||||||
|
sample.actualDuration,
|
||||||
|
);
|
||||||
|
summaries.set(sample.id, summary);
|
||||||
|
}
|
||||||
|
for (const summary of summaries.values()) {
|
||||||
|
summary.actualDurationMs = Math.round(summary.actualDurationMs * 100) / 100;
|
||||||
|
summary.maximumActualDurationMs = Math.round(summary.maximumActualDurationMs * 100) / 100;
|
||||||
|
}
|
||||||
|
return [...summaries.values()].sort(
|
||||||
|
(left, right) => right.actualDurationMs - left.actualDurationMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function beginMeasurement(page: Page, selectors: string[]): Promise<void> {
|
||||||
|
await waitForReactIdle(page);
|
||||||
|
await page.evaluate((trackedSelectors) => {
|
||||||
|
globalThis.__PASEO_RESET_RENDER_PROFILE__?.();
|
||||||
|
const collectTestIds = (node: Node, output: Set<string>) => {
|
||||||
|
if (!(node instanceof Element)) return;
|
||||||
|
const ownTestId = node.getAttribute("data-testid");
|
||||||
|
if (ownTestId) output.add(ownTestId);
|
||||||
|
for (const element of node.querySelectorAll("[data-testid]")) {
|
||||||
|
const testId = element.getAttribute("data-testid");
|
||||||
|
if (testId) output.add(testId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const state: BrowserMeasurementState = {
|
||||||
|
startedAt: performance.now(),
|
||||||
|
selectors: trackedSelectors,
|
||||||
|
originals: trackedSelectors.map((selector) => document.querySelector(selector)),
|
||||||
|
domMutations: 0,
|
||||||
|
addedElements: 0,
|
||||||
|
removedElements: 0,
|
||||||
|
addedTestIds: new Set(),
|
||||||
|
removedTestIds: new Set(),
|
||||||
|
};
|
||||||
|
state.observer = new MutationObserver((records) => {
|
||||||
|
state.domMutations += records.length;
|
||||||
|
for (const record of records) {
|
||||||
|
for (const node of record.addedNodes) {
|
||||||
|
state.addedElements +=
|
||||||
|
node instanceof Element ? 1 + node.querySelectorAll("*").length : 0;
|
||||||
|
collectTestIds(node, state.addedTestIds);
|
||||||
|
}
|
||||||
|
for (const node of record.removedNodes) {
|
||||||
|
state.removedElements +=
|
||||||
|
node instanceof Element ? 1 + node.querySelectorAll("*").length : 0;
|
||||||
|
collectTestIds(node, state.removedTestIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
state.observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
window.__PASEO_SIDE_PANE_MEASUREMENT__ = state;
|
||||||
|
}, selectors);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishMeasurement(page: Page, name: string): Promise<InteractionMeasurement> {
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolveFrame) => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame()));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitForReactIdle(page);
|
||||||
|
const raw = await page.evaluate((measurementName) => {
|
||||||
|
const state = window.__PASEO_SIDE_PANE_MEASUREMENT__;
|
||||||
|
if (!state) throw new Error("Side-pane performance measurement was not started");
|
||||||
|
state.observer?.disconnect();
|
||||||
|
const samples = globalThis.__PASEO_RENDER_PROFILE__ ?? [];
|
||||||
|
return {
|
||||||
|
name: measurementName,
|
||||||
|
latencyMs: Math.round((performance.now() - state.startedAt) * 100) / 100,
|
||||||
|
commits: new Set(samples.map((sample) => sample.commitTime)).size,
|
||||||
|
domMutations: state.domMutations,
|
||||||
|
addedElements: state.addedElements,
|
||||||
|
removedElements: state.removedElements,
|
||||||
|
addedTestIds: [...state.addedTestIds].sort(),
|
||||||
|
removedTestIds: [...state.removedTestIds].sort(),
|
||||||
|
trackedNodes: state.selectors.map((selector, index) => {
|
||||||
|
const original = state.originals[index] ?? null;
|
||||||
|
const current = document.querySelector(selector);
|
||||||
|
return {
|
||||||
|
selector,
|
||||||
|
existedBefore: original !== null,
|
||||||
|
connected: original?.isConnected ?? false,
|
||||||
|
sameNode: original !== null && original === current,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
samples,
|
||||||
|
reasons: globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {},
|
||||||
|
};
|
||||||
|
}, name);
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
renders: summarizeRenders(raw.samples),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function measureInteraction(
|
||||||
|
page: Page,
|
||||||
|
input: {
|
||||||
|
name: string;
|
||||||
|
trackedSelectors: string[];
|
||||||
|
interact: () => Promise<void>;
|
||||||
|
expectComplete: () => Promise<void>;
|
||||||
|
},
|
||||||
|
): Promise<InteractionMeasurement> {
|
||||||
|
await beginMeasurement(page, input.trackedSelectors);
|
||||||
|
await input.interact();
|
||||||
|
await input.expectComplete();
|
||||||
|
return finishMeasurement(page, input.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectTrackedNodePreserved(
|
||||||
|
measurement: InteractionMeasurement,
|
||||||
|
selector: string,
|
||||||
|
): void {
|
||||||
|
const tracked = measurement.trackedNodes.find((candidate) => candidate.selector === selector);
|
||||||
|
expect.soft(tracked).toEqual({
|
||||||
|
selector,
|
||||||
|
existedBefore: true,
|
||||||
|
connected: true,
|
||||||
|
sameNode: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectProfilerDidNotMount(
|
||||||
|
measurement: InteractionMeasurement,
|
||||||
|
idPrefix: string,
|
||||||
|
): void {
|
||||||
|
const summaries = measurement.renders.filter((summary) => summary.id.startsWith(idPrefix));
|
||||||
|
expect.soft(summaries.length).toBeGreaterThan(0);
|
||||||
|
expect.soft(summaries.every((summary) => summary.mounts === 0)).toBe(true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
changedFileRows,
|
||||||
|
closeWorkingDiffPane,
|
||||||
|
enableChangesOpenInSidePane,
|
||||||
|
expectProfilerDidNotMount,
|
||||||
|
expectTrackedNodePreserved,
|
||||||
|
expectWorkingDiffVisible,
|
||||||
|
measureInteraction,
|
||||||
|
measureHeapAfterGc,
|
||||||
|
openChangesExplorer,
|
||||||
|
openProfileWorkspace,
|
||||||
|
resolveProfileTarget,
|
||||||
|
selectChangedFile,
|
||||||
|
type InteractionMeasurement,
|
||||||
|
} from "./helpers/side-pane-performance";
|
||||||
|
|
||||||
|
const MAIN_PANE = '[data-testid="workspace-pane-main"]';
|
||||||
|
const WORKING_DIFF = '[data-testid="working-diff-panel"]';
|
||||||
|
const DIFF_CANVAS = '[data-testid="git-diff-canvas-root"]';
|
||||||
|
const DIFF_TAB = '[data-testid="workspace-tab-working_diff"]';
|
||||||
|
|
||||||
|
test("side-pane diff transitions preserve mounted work and report their real React cost", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
const target = resolveProfileTarget();
|
||||||
|
const reports: InteractionMeasurement[] = [];
|
||||||
|
|
||||||
|
await test.step("open the real main workspace with Changes routed beside it", async () => {
|
||||||
|
await openProfileWorkspace(page, target);
|
||||||
|
await enableChangesOpenInSidePane(page);
|
||||||
|
await openChangesExplorer(page);
|
||||||
|
expect(await changedFileRows(page).count()).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("creating the side pane keeps the main pane mounted", async () => {
|
||||||
|
const report = await measureInteraction(page, {
|
||||||
|
name: "create-side-pane",
|
||||||
|
trackedSelectors: [MAIN_PANE],
|
||||||
|
interact: () => selectChangedFile(page, 0),
|
||||||
|
expectComplete: () => expectWorkingDiffVisible(page),
|
||||||
|
});
|
||||||
|
reports.push(report);
|
||||||
|
await testInfo.attach(report.name, {
|
||||||
|
body: JSON.stringify(report, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
expectTrackedNodePreserved(report, MAIN_PANE);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("changing the selected diff file updates the existing diff in place", async () => {
|
||||||
|
const report = await measureInteraction(page, {
|
||||||
|
name: "change-diff-focus",
|
||||||
|
trackedSelectors: [MAIN_PANE, WORKING_DIFF, DIFF_CANVAS, DIFF_TAB],
|
||||||
|
interact: () => selectChangedFile(page, 1),
|
||||||
|
expectComplete: () => expectWorkingDiffVisible(page),
|
||||||
|
});
|
||||||
|
reports.push(report);
|
||||||
|
await testInfo.attach(report.name, {
|
||||||
|
body: JSON.stringify(report, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
expectTrackedNodePreserved(report, MAIN_PANE);
|
||||||
|
expectTrackedNodePreserved(report, WORKING_DIFF);
|
||||||
|
expectTrackedNodePreserved(report, DIFF_CANVAS);
|
||||||
|
expectTrackedNodePreserved(report, DIFF_TAB);
|
||||||
|
expectProfilerDidNotMount(report, "WorkingDiffPanel:");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("repeated cold reopenings measure retained workspace work", async () => {
|
||||||
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
await closeWorkingDiffPane(page);
|
||||||
|
const report = await measureInteraction(page, {
|
||||||
|
name: `reopen-side-pane-${index + 1}`,
|
||||||
|
trackedSelectors: [MAIN_PANE],
|
||||||
|
interact: () => selectChangedFile(page, index % 2),
|
||||||
|
expectComplete: () => expectWorkingDiffVisible(page),
|
||||||
|
});
|
||||||
|
report.heapAfterGcBytes = await measureHeapAfterGc(page);
|
||||||
|
reports.push(report);
|
||||||
|
await testInfo.attach(report.name, {
|
||||||
|
body: JSON.stringify(report, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
expectTrackedNodePreserved(report, MAIN_PANE);
|
||||||
|
expect(
|
||||||
|
report.renders.some(
|
||||||
|
(summary) => summary.id.startsWith("WorkingDiffPanel:") && summary.mounts > 0,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const artifact = {
|
||||||
|
appUrl: testInfo.project.use.baseURL,
|
||||||
|
target,
|
||||||
|
reports,
|
||||||
|
};
|
||||||
|
await testInfo.attach("side-pane-performance", {
|
||||||
|
body: JSON.stringify(artifact, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[perf] Side pane: ${JSON.stringify(
|
||||||
|
reports.map((report) => ({
|
||||||
|
name: report.name,
|
||||||
|
latencyMs: report.latencyMs,
|
||||||
|
commits: report.commits,
|
||||||
|
domMutations: report.domMutations,
|
||||||
|
addedElements: report.addedElements,
|
||||||
|
removedElements: report.removedElements,
|
||||||
|
heapAfterGcBytes: report.heapAfterGcBytes,
|
||||||
|
trackedNodes: report.trackedNodes,
|
||||||
|
renders: report.renders.slice(0, 8),
|
||||||
|
})),
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -6,6 +6,12 @@ const baseURL =
|
|||||||
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||||
const relayDeploymentSpec = "**/relay-deployment-reconnect.real.spec.ts";
|
const relayDeploymentSpec = "**/relay-deployment-reconnect.real.spec.ts";
|
||||||
|
|
||||||
|
function videoMode(): "on" | "on-first-retry" | "retain-on-failure" {
|
||||||
|
if (process.env.E2E_RECORD_VIDEO === "1") return "on";
|
||||||
|
if (process.env.CI) return "on-first-retry";
|
||||||
|
return "retain-on-failure";
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e/browser",
|
testDir: "./e2e/browser",
|
||||||
globalSetup: "./e2e/support/global-setup.ts",
|
globalSetup: "./e2e/support/global-setup.ts",
|
||||||
@@ -21,9 +27,9 @@ export default defineConfig({
|
|||||||
reporter: [["list"]],
|
reporter: [["list"]],
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
trace: "retain-on-failure",
|
trace: process.env.CI ? "on-first-retry" : "retain-on-failure",
|
||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
video: process.env.E2E_RECORD_VIDEO === "1" ? "on" : "retain-on-failure",
|
video: videoMode(),
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./perf",
|
||||||
|
timeout: 120_000,
|
||||||
|
expect: { timeout: 30_000 },
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 0,
|
||||||
|
reporter: [["list"]],
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
baseURL: process.env.PASEO_PROFILE_APP_URL ?? "http://127.0.0.1:8081",
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { withUnistyles } from "react-native-unistyles";
|
import { withUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Bot,
|
|
||||||
Boxes,
|
Boxes,
|
||||||
Brain,
|
Brain,
|
||||||
Bug,
|
Bug,
|
||||||
@@ -27,6 +26,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Shield,
|
Shield,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Star,
|
||||||
Terminal,
|
Terminal,
|
||||||
TestTube,
|
TestTube,
|
||||||
Wrench,
|
Wrench,
|
||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
} from "./profile-appearance";
|
} from "./profile-appearance";
|
||||||
|
|
||||||
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
|
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
|
||||||
const ThemedDefaultIcon = withUnistyles(Bot);
|
const ThemedDefaultIcon = withUnistyles(Star);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `withUnistyles` has to wrap each icon once at module scope, so the registry
|
* `withUnistyles` has to wrap each icon once at module scope, so the registry
|
||||||
|
|||||||
@@ -174,4 +174,151 @@ describe("useChatOutline", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(onJumpError).toHaveBeenCalledOnce());
|
await waitFor(() => expect(onJumpError).toHaveBeenCalledOnce());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reveals an already-loaded older prompt before scrolling to it", async () => {
|
||||||
|
runtime.listAgentTimelinePrompts.mockResolvedValue({
|
||||||
|
epoch: "epoch-1",
|
||||||
|
prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }],
|
||||||
|
});
|
||||||
|
const scrollToMessage = vi.fn();
|
||||||
|
const revealLoadedItem = vi.fn(() => true);
|
||||||
|
const viewport: StreamViewportHandle = {
|
||||||
|
scrollToBottom: vi.fn(),
|
||||||
|
prepareForViewportChange: vi.fn(),
|
||||||
|
scrollToMessage,
|
||||||
|
};
|
||||||
|
const viewportRef = { current: viewport };
|
||||||
|
const tail = [
|
||||||
|
{
|
||||||
|
id: "older-prompt",
|
||||||
|
kind: "user_message" as const,
|
||||||
|
text: "prompt",
|
||||||
|
timestamp: new Date(1),
|
||||||
|
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ visibleItemIds }) =>
|
||||||
|
useChatOutline({
|
||||||
|
agentId: "agent-1",
|
||||||
|
serverId: "server-1",
|
||||||
|
timelineEpoch: "epoch-1",
|
||||||
|
tail,
|
||||||
|
head: [],
|
||||||
|
enabled: true,
|
||||||
|
viewportRef,
|
||||||
|
onJumpError: vi.fn(),
|
||||||
|
visibleItemIds,
|
||||||
|
revealLoadedItem,
|
||||||
|
}),
|
||||||
|
{ initialProps: { visibleItemIds: new Set<string>() } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
|
||||||
|
await act(async () => result.current.jumpToPrompt(1));
|
||||||
|
expect(revealLoadedItem).toHaveBeenCalledWith("older-prompt");
|
||||||
|
expect(scrollToMessage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rerender({ visibleItemIds: new Set(["older-prompt"]) });
|
||||||
|
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledWith("older-prompt"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals a fetched prompt that lands outside the mounted history window", async () => {
|
||||||
|
runtime.listAgentTimelinePrompts.mockResolvedValue({
|
||||||
|
epoch: "epoch-1",
|
||||||
|
prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }],
|
||||||
|
});
|
||||||
|
const fetch = deferred<void>();
|
||||||
|
runtime.fetchAgentTimeline.mockReturnValue(fetch.promise);
|
||||||
|
const scrollToMessage = vi.fn();
|
||||||
|
const revealLoadedItem = vi.fn(() => true);
|
||||||
|
const viewportRef = {
|
||||||
|
current: {
|
||||||
|
scrollToBottom: vi.fn(),
|
||||||
|
prepareForViewportChange: vi.fn(),
|
||||||
|
scrollToMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const fetchedPrompt = {
|
||||||
|
id: "fetched-prompt",
|
||||||
|
kind: "user_message" as const,
|
||||||
|
text: "prompt",
|
||||||
|
timestamp: new Date(1),
|
||||||
|
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||||
|
};
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ tail, visibleItemIds }) =>
|
||||||
|
useChatOutline({
|
||||||
|
agentId: "agent-1",
|
||||||
|
serverId: "server-1",
|
||||||
|
timelineEpoch: "epoch-1",
|
||||||
|
tail,
|
||||||
|
head: [],
|
||||||
|
enabled: true,
|
||||||
|
viewportRef,
|
||||||
|
onJumpError: vi.fn(),
|
||||||
|
visibleItemIds,
|
||||||
|
revealLoadedItem,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
initialProps: {
|
||||||
|
tail: [] as (typeof fetchedPrompt)[],
|
||||||
|
visibleItemIds: new Set<string>(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
|
||||||
|
act(() => result.current.jumpToPrompt(1));
|
||||||
|
await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce());
|
||||||
|
|
||||||
|
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set<string>() });
|
||||||
|
await waitFor(() => expect(revealLoadedItem).toHaveBeenCalledWith(fetchedPrompt.id));
|
||||||
|
expect(scrollToMessage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set([fetchedPrompt.id]) });
|
||||||
|
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledOnce());
|
||||||
|
expect(scrollToMessage).toHaveBeenCalledWith(fetchedPrompt.id);
|
||||||
|
await act(async () => fetch.resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("jumps directly to a loaded prompt in the live head", async () => {
|
||||||
|
runtime.listAgentTimelinePrompts.mockResolvedValue({
|
||||||
|
epoch: "epoch-1",
|
||||||
|
prompts: [{ seq: 2, timestamp: new Date(2).toISOString(), preview: "prompt" }],
|
||||||
|
});
|
||||||
|
const scrollToMessage = vi.fn();
|
||||||
|
const viewport: StreamViewportHandle = {
|
||||||
|
scrollToBottom: vi.fn(),
|
||||||
|
prepareForViewportChange: vi.fn(),
|
||||||
|
scrollToMessage,
|
||||||
|
};
|
||||||
|
const livePrompt = {
|
||||||
|
id: "live-prompt",
|
||||||
|
kind: "user_message" as const,
|
||||||
|
text: "prompt",
|
||||||
|
timestamp: new Date(2),
|
||||||
|
timelineCursor: { epoch: "epoch-1", seq: 2 },
|
||||||
|
};
|
||||||
|
const revealLoadedItem = vi.fn();
|
||||||
|
revealLoadedItem.mockReturnValue(false);
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useChatOutline({
|
||||||
|
agentId: "agent-1",
|
||||||
|
serverId: "server-1",
|
||||||
|
timelineEpoch: "epoch-1",
|
||||||
|
tail: [],
|
||||||
|
head: [livePrompt],
|
||||||
|
enabled: true,
|
||||||
|
viewportRef: { current: viewport },
|
||||||
|
onJumpError: vi.fn(),
|
||||||
|
visibleItemIds: new Set([livePrompt.id]),
|
||||||
|
revealLoadedItem,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
|
||||||
|
await act(async () => result.current.jumpToPrompt(2));
|
||||||
|
expect(scrollToMessage).toHaveBeenCalledWith("live-prompt");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface UseChatOutlineInput {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||||
onJumpError: () => void;
|
onJumpError: () => void;
|
||||||
|
visibleItemIds?: ReadonlySet<string>;
|
||||||
|
revealLoadedItem?: (itemId: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatOutline {
|
export interface ChatOutline {
|
||||||
@@ -51,6 +53,8 @@ export function useChatOutline({
|
|||||||
enabled,
|
enabled,
|
||||||
viewportRef,
|
viewportRef,
|
||||||
onJumpError,
|
onJumpError,
|
||||||
|
visibleItemIds,
|
||||||
|
revealLoadedItem,
|
||||||
}: UseChatOutlineInput): ChatOutline {
|
}: UseChatOutlineInput): ChatOutline {
|
||||||
const [index, setIndex] = useState<AgentTimelinePromptIndexPayload | null>(null);
|
const [index, setIndex] = useState<AgentTimelinePromptIndexPayload | null>(null);
|
||||||
const [pendingJump, setPendingJump] = useState<PendingPromptJump | null>(null);
|
const [pendingJump, setPendingJump] = useState<PendingPromptJump | null>(null);
|
||||||
@@ -135,7 +139,12 @@ export function useChatOutline({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingJump === null) return;
|
if (pendingJump === null) return;
|
||||||
const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq);
|
const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq);
|
||||||
if (target && !pendingJump.hasScrolled) {
|
if (target) {
|
||||||
|
if (pendingJump.hasScrolled) return;
|
||||||
|
if (visibleItemIds?.has(target.id) === false) {
|
||||||
|
revealLoadedItem?.(target.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
viewportRef.current?.scrollToMessage?.(target.id);
|
viewportRef.current?.scrollToMessage?.(target.id);
|
||||||
setPendingJump((current) => {
|
setPendingJump((current) => {
|
||||||
if (current?.requestId !== pendingJump.requestId) return current;
|
if (current?.requestId !== pendingJump.requestId) return current;
|
||||||
@@ -144,7 +153,7 @@ export function useChatOutline({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pendingJump.fetchSettled) setPendingJump(null);
|
if (pendingJump.fetchSettled) setPendingJump(null);
|
||||||
}, [loadedItems, pendingJump, viewportRef]);
|
}, [loadedItems, pendingJump, revealLoadedItem, viewportRef, visibleItemIds]);
|
||||||
|
|
||||||
const jumpToPrompt = useCallback(
|
const jumpToPrompt = useCallback(
|
||||||
(seq: number) => {
|
(seq: number) => {
|
||||||
@@ -152,6 +161,11 @@ export function useChatOutline({
|
|||||||
setPendingJump(null);
|
setPendingJump(null);
|
||||||
const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq);
|
const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq);
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
|
if (revealLoadedItem?.(loaded.id)) {
|
||||||
|
const requestId = nextJumpRequestIdRef.current;
|
||||||
|
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
viewportRef.current?.scrollToMessage?.(loaded.id);
|
viewportRef.current?.scrollToMessage?.(loaded.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,7 +185,7 @@ export function useChatOutline({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[agentId, index, loadedItems, onJumpError, serverId, viewportRef],
|
[agentId, index, loadedItems, onJumpError, revealLoadedItem, serverId, viewportRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { prompts, activePrompt, jumpToPrompt, reportReadingPosition };
|
return { prompts, activePrompt, jumpToPrompt, reportReadingPosition };
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { TurnTiming } from "@/timeline/turn-time";
|
||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import { layoutStream } from "./layout";
|
||||||
|
import type { StreamStrategy } from "./strategy";
|
||||||
|
|
||||||
|
function strategyFor(platform: "web" | "native"): StreamStrategy {
|
||||||
|
const inverted = platform === "native";
|
||||||
|
const neighborStep = inverted ? 1 : -1;
|
||||||
|
const getHistoryBoundaryIndex = (items: StreamItem[]) => {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return inverted ? 0 : items.length - 1;
|
||||||
|
};
|
||||||
|
const getLiveHeadBoundaryIndex = (items: StreamItem[]) => {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return inverted ? items.length - 1 : 0;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
orderTail: (items) => (inverted ? [...items].toReversed() : items),
|
||||||
|
orderHead: (items) => (inverted ? [...items].toReversed() : items),
|
||||||
|
getNeighborIndex: (index, relation) =>
|
||||||
|
relation === "above" ? index + neighborStep : index - neighborStep,
|
||||||
|
getNeighborItem: (items, index, relation) => {
|
||||||
|
const neighborIndex = relation === "above" ? index + neighborStep : index - neighborStep;
|
||||||
|
return items[neighborIndex];
|
||||||
|
},
|
||||||
|
getHistoryLiveBoundaryIndex: getHistoryBoundaryIndex,
|
||||||
|
getLiveHeadHistoryBoundaryIndex: getLiveHeadBoundaryIndex,
|
||||||
|
getLatestItemIndex: getHistoryBoundaryIndex,
|
||||||
|
getFrameChildOrder: () => (inverted ? "footer-then-content" : "content-then-footer"),
|
||||||
|
} as StreamStrategy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function userMessage(id: string): Extract<StreamItem, { kind: "user_message" }> {
|
||||||
|
return {
|
||||||
|
kind: "user_message",
|
||||||
|
id,
|
||||||
|
text: id,
|
||||||
|
timestamp: new Date("2026-08-22T10:00:00.000Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantMessage(id: string): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||||
|
return {
|
||||||
|
kind: "assistant_message",
|
||||||
|
id,
|
||||||
|
text: id,
|
||||||
|
timestamp: new Date("2026-08-22T10:00:01.000Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantBlock(
|
||||||
|
id: string,
|
||||||
|
blockIndex: number,
|
||||||
|
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||||
|
return {
|
||||||
|
...assistantMessage(id),
|
||||||
|
blockGroupId: "response",
|
||||||
|
blockIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingFor(assistantId: string): Map<string, TurnTiming> {
|
||||||
|
return new Map([
|
||||||
|
[
|
||||||
|
assistantId,
|
||||||
|
{
|
||||||
|
startedAt: new Date("2026-08-22T10:00:00.000Z"),
|
||||||
|
completedAt: new Date("2026-08-22T10:00:02.000Z"),
|
||||||
|
durationMs: 2_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean }) {
|
||||||
|
const strategy = strategyFor(input.platform);
|
||||||
|
const assistant = assistantMessage("assistant");
|
||||||
|
return {
|
||||||
|
assistant,
|
||||||
|
layout: layoutStream({
|
||||||
|
strategy,
|
||||||
|
isTurnActive: input.isTurnActive,
|
||||||
|
history: strategy.orderTail([userMessage("user"), assistant]),
|
||||||
|
liveHead: [],
|
||||||
|
timingByAssistantId: timingFor(assistant.id),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("turn footer spacing", () => {
|
||||||
|
it.each(["web", "native"] as const)(
|
||||||
|
"compacts the last assistant against the completed footer on %s",
|
||||||
|
(platform) => {
|
||||||
|
const { assistant, layout } = layoutFor({ platform, isTurnActive: false });
|
||||||
|
const assistantLayout = layout.history.find((item) => item.item.id === assistant.id);
|
||||||
|
|
||||||
|
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
|
||||||
|
expect(assistantLayout?.assistantSpacing).toBe("compactBottom");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["web", "native"] as const)(
|
||||||
|
"keeps the same compact edge while the footer is running on %s",
|
||||||
|
(platform) => {
|
||||||
|
const { assistant, layout } = layoutFor({ platform, isTurnActive: true });
|
||||||
|
const assistantLayout = layout.history.find((item) => item.item.id === assistant.id);
|
||||||
|
|
||||||
|
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||||
|
expect(assistantLayout?.assistantSpacing).toBe("compactBottom");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["web", false],
|
||||||
|
["web", true],
|
||||||
|
["native", false],
|
||||||
|
["native", true],
|
||||||
|
] as const)(
|
||||||
|
"compacts both edges of the final split paragraph on %s when active=%s",
|
||||||
|
(platform, isTurnActive) => {
|
||||||
|
const strategy = strategyFor(platform);
|
||||||
|
const first = assistantBlock("first", 0);
|
||||||
|
const last = assistantBlock("last", 1);
|
||||||
|
const layout = layoutStream({
|
||||||
|
strategy,
|
||||||
|
isTurnActive,
|
||||||
|
history: strategy.orderTail([userMessage("user"), first]),
|
||||||
|
liveHead: strategy.orderHead([last]),
|
||||||
|
timingByAssistantId: timingFor(last.id),
|
||||||
|
});
|
||||||
|
const firstLayout = layout.history.find((item) => item.item.id === first.id);
|
||||||
|
const lastLayout = layout.liveHead.find((item) => item.item.id === last.id);
|
||||||
|
|
||||||
|
expect(firstLayout?.assistantSpacing).toBe("compactBottom");
|
||||||
|
expect(lastLayout?.assistantSpacing).toBe("compactBoth");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -22,6 +22,22 @@ const visibleHistoryStart: HistoryStartPaginationInput = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("history start pagination", () => {
|
describe("history start pagination", () => {
|
||||||
|
it("treats locally revealed history as progress before remote pagination", () => {
|
||||||
|
const first = evaluateHistoryStartPagination(
|
||||||
|
createArmedHistoryStartPaginationState(),
|
||||||
|
visibleHistoryStart,
|
||||||
|
);
|
||||||
|
const localReveal = evaluateHistoryStartPagination(first.state, {
|
||||||
|
...visibleHistoryStart,
|
||||||
|
progressKey: "epoch-1:20:local-60",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first.shouldLoad).toBe(true);
|
||||||
|
expect(localReveal.state).toEqual({
|
||||||
|
status: "settling",
|
||||||
|
loadedProgressKey: "epoch-1:20:local-60",
|
||||||
|
});
|
||||||
|
});
|
||||||
it("waits for user scroll intent before loading", () => {
|
it("waits for user scroll intent before loading", () => {
|
||||||
const dormant = createHistoryStartPaginationState();
|
const dormant = createHistoryStartPaginationState();
|
||||||
const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart);
|
const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user