mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 13:52:39 +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
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Build server stack
|
||||
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
|
||||
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
|
||||
|
||||
## 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))
|
||||
- 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))
|
||||
|
||||
## 0.5.0-beta.2 - 2026-08-19
|
||||
- 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))
|
||||
- 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))
|
||||
|
||||
### 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 `npm install @getpaseo/cli@beta` failing with a 404 because the plugin SDK it depends on was never published
|
||||
- Renamed the plugin SDK package to `@getpaseo/plugin`, with `@paseo/plugin` imports still resolving for existing plugins
|
||||
- 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
|
||||
- Fixed OpenCode turns failing when the first event-stream connection stalls during startup ([#3814](https://github.com/getpaseo/paseo/pull/3814))
|
||||
- Fixed OpenCode prompts starting before the provider reports a ready connection ([#3821](https://github.com/getpaseo/paseo/pull/3821))
|
||||
|
||||
## 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
|
||||
|
||||
- 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 workspace labels to organize the sidebar ([#3510](https://github.com/getpaseo/paseo/pull/3510) by [@cleiter](https://github.com/cleiter))
|
||||
- Added steering for Codex and Claude — send into a running turn instead of interrupting it ([#3394](https://github.com/getpaseo/paseo/pull/3394))
|
||||
- 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))
|
||||
- Changed Cmd/Ctrl+T to open a menu so you can choose what the new tab opens
|
||||
- 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 plugin-contributed application themes to Settings → Appearance ([#3602](https://github.com/getpaseo/paseo/pull/3602) by [@ragokan](https://github.com/ragokan))
|
||||
- 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))
|
||||
- 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))
|
||||
- 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 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 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 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 checks summary to the pull request pane, grouped by status with failures first ([#3483](https://github.com/getpaseo/paseo/pull/3483))
|
||||
- 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))
|
||||
- Offline hosts keep showing their projects and workspaces while they reconnect ([#3259](https://github.com/getpaseo/paseo/pull/3259))
|
||||
- Large diffs stay 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))
|
||||
- Typing in the composer no longer lags on web and desktop ([#3450](https://github.com/getpaseo/paseo/pull/3450))
|
||||
- Moved the subagents and tasks trackers into pills above the composer ([#3482](https://github.com/getpaseo/paseo/pull/3482))
|
||||
- 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))
|
||||
- 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))
|
||||
- Kept composer typing within the frame budget on web and desktop ([#3450](https://github.com/getpaseo/paseo/pull/3450))
|
||||
- Kept large diffs responsive while expanding, scrolling, and commenting ([#3422](https://github.com/getpaseo/paseo/pull/3422))
|
||||
- Kept large read-only source previews responsive with bounded rendering ([#3665](https://github.com/getpaseo/paseo/pull/3665))
|
||||
- Removed complete-transcript disk rewrites from long-running agent timelines ([#3647](https://github.com/getpaseo/paseo/pull/3647))
|
||||
- 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))
|
||||
- Archive finished now archives every finished subagent in the track ([#3368](https://github.com/getpaseo/paseo/pull/3368))
|
||||
- The text size setting now scales the whole interface
|
||||
- Opened summarized tool-call groups in live sheets on compact layouts ([#3619](https://github.com/getpaseo/paseo/pull/3619))
|
||||
- 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 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 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 its event stream dropped ([#3395](https://github.com/getpaseo/paseo/pull/3395))
|
||||
- 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 OpenCode turns failing when their 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 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 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 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 workspace pull request status being lost on reload
|
||||
- Fixed hidden terminal tabs stopping output in the focused workspace
|
||||
- Fixed mobile composers retaining sent text after submission ([#3564](https://github.com/getpaseo/paseo/pull/3564))
|
||||
- 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 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 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
|
||||
|
||||
|
||||
@@ -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/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/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/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 |
|
||||
|
||||
+13
-2
@@ -35,7 +35,16 @@ be in flight.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
(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
|
||||
`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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -402,7 +402,7 @@ Providers that can accept native tool definitions should set `supportsNativePase
|
||||
|
||||
```
|
||||
$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/workspaces.json # Workspace registry
|
||||
├── 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`.
|
||||
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
+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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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 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
|
||||
|
||||
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.
|
||||
- **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.
|
||||
- **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).
|
||||
- **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`.
|
||||
@@ -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".
|
||||
- **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`).
|
||||
- **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.
|
||||
- **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).
|
||||
|
||||
+30
-3
@@ -1,7 +1,7 @@
|
||||
# Local plugins
|
||||
|
||||
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;
|
||||
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
|
||||
`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.
|
||||
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.
|
||||
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
|
||||
drops the optional presentation fields.
|
||||
|
||||
See `plugin-examples/local-plugin` for a native surface and `plugin-examples/linear` for a complete
|
||||
attachment-source example.
|
||||
## Contribute a theme
|
||||
|
||||
`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.
|
||||
|
||||
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()`.
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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`.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
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:
|
||||
|
||||
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
||||
@@ -12,7 +15,7 @@ The invariants are:
|
||||
> through backward pagination.
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
- **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`).
|
||||
- **`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.
|
||||
- **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`.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-FhcGrCB3+Nsr/RCEQClXwTZW+jyRptNpNjIzAmnb3pI=
|
||||
sha256-gZ6egDKIDiDi9jmDlPamM+or2Tthwr17DbJ+P66t4RE=
|
||||
|
||||
Generated
+27
-27
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.6.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -36140,7 +36140,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37192,12 +37192,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.5.0-beta.3",
|
||||
"@getpaseo/protocol": "0.5.0-beta.3",
|
||||
"@getpaseo/server": "0.5.0-beta.3",
|
||||
"@getpaseo/client": "0.6.0",
|
||||
"@getpaseo/protocol": "0.6.0",
|
||||
"@getpaseo/server": "0.6.0",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37444,10 +37444,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.5.0-beta.3",
|
||||
"@getpaseo/relay": "0.5.0-beta.3",
|
||||
"@getpaseo/protocol": "0.6.0",
|
||||
"@getpaseo/relay": "0.6.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37458,7 +37458,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.6.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37701,7 +37701,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38597,7 +38597,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38626,12 +38626,12 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.5.0-beta.3",
|
||||
"@getpaseo/client": "0.6.0",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38640,14 +38640,14 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.5.0-beta.3",
|
||||
"@getpaseo/client": "0.6.0",
|
||||
"react": "19.1.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38660,7 +38660,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38878,19 +38878,19 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@getpaseo/client": "0.5.0-beta.3",
|
||||
"@getpaseo/highlight": "0.5.0-beta.3",
|
||||
"@getpaseo/plugin": "0.5.0-beta.3",
|
||||
"@getpaseo/protocol": "0.5.0-beta.3",
|
||||
"@getpaseo/relay": "0.5.0-beta.3",
|
||||
"@getpaseo/client": "0.6.0",
|
||||
"@getpaseo/highlight": "0.6.0",
|
||||
"@getpaseo/plugin": "0.6.0",
|
||||
"@getpaseo/protocol": "0.6.0",
|
||||
"@getpaseo/relay": "0.6.0",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
@@ -39940,7 +39940,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
const ACP_PROVIDER = {
|
||||
id: "hermes",
|
||||
name: "Hermes",
|
||||
id: "minimax-code",
|
||||
name: "MiniMax Code",
|
||||
};
|
||||
|
||||
interface ProviderCatalogDaemonClient {
|
||||
@@ -22,7 +22,7 @@ interface ProviderCatalogDaemonClient {
|
||||
}
|
||||
|
||||
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>({
|
||||
clientIdPrefix: "provider-catalog-e2e",
|
||||
});
|
||||
|
||||
@@ -66,54 +66,21 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
|
||||
test.describe("Add Project command-center flow", () => {
|
||||
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 openAddProjectFlow(page);
|
||||
|
||||
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(addProjectFlow(page).getByRole("textbox")).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 }) => {
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
@@ -273,14 +240,6 @@ test.describe("Add Project command-center flow", () => {
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -442,21 +442,40 @@ function formatFrames(frames: TurnFrame[], centers: number | number[]): string {
|
||||
.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 {
|
||||
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(
|
||||
(frame, index) => index > first && !hasPaintedLayout(frame.userRow),
|
||||
);
|
||||
const reappeared = frames.findIndex(
|
||||
(frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow),
|
||||
);
|
||||
const shifted = frames.findIndex(
|
||||
(frame, index) =>
|
||||
const contentShifted = frames.findIndex((frame, index) => {
|
||||
const contentTop = submittedRowContentTop(frame);
|
||||
return (
|
||||
index > first &&
|
||||
hasPaintedLayout(frame.userRow) &&
|
||||
baseline !== undefined &&
|
||||
Math.abs((frame.userRow.rect?.top ?? baseline) - baseline) > 1,
|
||||
baselineContentTop !== undefined &&
|
||||
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(
|
||||
(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`,
|
||||
]
|
||||
: []),
|
||||
...(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"] : []),
|
||||
];
|
||||
let failure = Math.max(0, first);
|
||||
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;
|
||||
expect(
|
||||
violations,
|
||||
@@ -726,7 +747,7 @@ async function recordDelayedRunningTransition(
|
||||
gate.holdNextAgentStreamEvent("turn_started");
|
||||
gate.holdNextAgentUpdate(agent.agentId, "running");
|
||||
gate.holdNextServerMessage("send_agent_message_response");
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", true);
|
||||
gate.setAgentStreamSuppressed(true);
|
||||
await recordTurnFrames(page, prompt);
|
||||
await installActivityContinuityOracle(page, prompt);
|
||||
await submitMessage(page, prompt);
|
||||
@@ -779,13 +800,13 @@ async function recordDelayedRunningTransition(
|
||||
const checkpoints = await readActivityContinuityOracle(page);
|
||||
expectActivityContinuity(checkpoints);
|
||||
expect(elapsedBeforeAuthoritative).toBe(0);
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", false);
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
return { frames, checkpoints };
|
||||
} finally {
|
||||
if (!turnReleased) gate.releaseHeldAgentStreamEvent("turn_started");
|
||||
if (!snapshotReleased) gate.releaseHeldAgentUpdate(agent.agentId, "running");
|
||||
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);
|
||||
|
||||
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 expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
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();
|
||||
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);
|
||||
expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page));
|
||||
} finally {
|
||||
|
||||
@@ -1,30 +1,9 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
||||
import {
|
||||
cancelAgent,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
} from "../support/helpers/composer";
|
||||
import { cancelAgent, expectComposerVisible, submitMessage } from "../support/helpers/composer";
|
||||
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> {
|
||||
await userMessage.getByText(prompt, { exact: true }).hover();
|
||||
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("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.setTimeout(90_000);
|
||||
const prompt = "Delay synthetic user message by 2000ms.";
|
||||
|
||||
@@ -315,10 +315,11 @@ async function configureInterruptInSettings(page: Page): Promise<void> {
|
||||
|
||||
async function selectSendBehaviorInSettings(
|
||||
page: Page,
|
||||
buttonName: string,
|
||||
behaviorLabel: string,
|
||||
stored: string,
|
||||
): 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
|
||||
.poll(async () => {
|
||||
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.configure({ timeout: 240_000 });
|
||||
|
||||
@@ -207,6 +219,9 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
|
||||
await openModelPicker(page);
|
||||
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.");
|
||||
|
||||
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
||||
|
||||
@@ -91,14 +91,16 @@ test.describe("Agent timeline pagination", () => {
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectTimelinePromptCentered(page, agent.newestPrompt);
|
||||
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
||||
await expectTimelinePromptNotMounted(page, agent.initialTailOldestPrompt);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await expectTimelinePromptVisible(page, agent.initialTailOldestPrompt);
|
||||
await history.expectRequestedPages(1);
|
||||
history.releasePage(1);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt);
|
||||
await history.expectRequestedPages(2);
|
||||
} finally {
|
||||
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(() => {
|
||||
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();
|
||||
await expect(sectionTitle).toHaveCSS("font-size", "18px");
|
||||
|
||||
const fontSizeInput = page.getByLabel("Base font size");
|
||||
await expect(fontSizeInput).toHaveValue("21");
|
||||
await fontSizeInput.fill("12");
|
||||
await fontSizeInput.press("Tab");
|
||||
const interfaceSizeInput = page.getByLabel("Interface font size");
|
||||
const contentSizeInput = page.getByLabel("Content font size");
|
||||
await expect(interfaceSizeInput).toHaveValue("21");
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
|
||||
repoPrefix: "bottom-sheet-reopen-",
|
||||
title: "Bottom sheet reopen e2e",
|
||||
initialPrompt: "Prepare a bottom sheet reopen test agent.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
|
||||
@@ -22,16 +23,6 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
|
||||
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) {
|
||||
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("tab switcher can open, close, reopen, and close again", async ({ page }) => {
|
||||
await withMobileMockAgent(page, 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,
|
||||
test("sheets reopen and model search returns to configuration", async ({ page }) => {
|
||||
const session = await openMockAgentAtMobileBreakpoint(page);
|
||||
try {
|
||||
await test.step("tab switcher opens, closes, and reopens", async () => {
|
||||
await openAndCloseTabSwitcherTwice(page);
|
||||
});
|
||||
|
||||
await model.click();
|
||||
await test.step("model selector opens, closes, and reopens", async () => {
|
||||
await openAndCloseModelSelectorTwice(page);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
await test.step("model search returns to configuration", 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 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.
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -30,11 +30,7 @@ const cleanupTasks: CleanupTask[] = [];
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
function changesTree(page: Page) {
|
||||
return page.getByTestId("changes-tree-rail-tree");
|
||||
}
|
||||
|
||||
function changesContent(page: Page) {
|
||||
return page.getByTestId("changes-tree-rail-content");
|
||||
return page.getByTestId("changes-file-tree").filter({ visible: true });
|
||||
}
|
||||
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -266,7 +319,7 @@ test("changes file actions open below the right-click without a reserved kebab",
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
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 deletedFileName.dblclick();
|
||||
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")))
|
||||
.toBe(AFTER);
|
||||
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-duplicate").click();
|
||||
await expect
|
||||
@@ -392,8 +444,6 @@ test("changes tree aligns every file status after its diff stat", async ({ page
|
||||
});
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
|
||||
const tree = changesTree(page);
|
||||
const modifiedRow = tree.getByTestId("diff-tree-file-0");
|
||||
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 openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
const tree = changesTree(page);
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).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 openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
const tree = changesTree(page);
|
||||
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
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 });
|
||||
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).toContainText("zz-deleted.ts");
|
||||
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 expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
|
||||
|
||||
// Collapse-all and the layout switch are overflow-menu items, not toolbar icons.
|
||||
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(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
|
||||
await page.getByTestId("changes-toggle-collapse-all").click();
|
||||
await diffOptions.click();
|
||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toContainText("Expand all files");
|
||||
await page.getByTestId("changes-toggle-collapse-all").click();
|
||||
const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all");
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files");
|
||||
await collapseFiles.click();
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files");
|
||||
await collapseFiles.click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
const layoutItem = page.getByTestId("changes-toggle-layout");
|
||||
await diffOptions.click();
|
||||
await expect(layoutItem).toContainText("Side-by-side diff");
|
||||
// Leading icon only: the layout is still unified, so no trailing check.
|
||||
await expect(layoutItem.locator("svg")).toHaveCount(1);
|
||||
await layoutItem.click();
|
||||
await diffOptions.click();
|
||||
await expect(layoutItem.locator("svg")).toHaveCount(2);
|
||||
const layout = visiblePanel.getByTestId("changes-toggle-layout");
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff");
|
||||
await layout.click();
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff");
|
||||
|
||||
await expect(page.getByText("Hide whitespace", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Wrap long lines", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("changes-refresh")).toHaveText("Refresh");
|
||||
await page.getByText("Wrap long lines", { exact: true }).click();
|
||||
await diffOptions.click();
|
||||
await expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace");
|
||||
await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace");
|
||||
const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines");
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines");
|
||||
await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Refresh",
|
||||
);
|
||||
await wrapLines.click();
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines");
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
test("desktop Changes toggles a navigation tree beside the expanded diff document", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await page.setViewportSize({ width: 480, height: 900 });
|
||||
|
||||
await expectFlatFileList(page);
|
||||
await expect(page.getByTestId("changes-layout-unified")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
|
||||
// The tree toggle is the one icon action that stays outside the overflow menu.
|
||||
await expect(page.getByTestId("changes-toggle-tree")).toBeVisible();
|
||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-toggle-layout")).toHaveCount(0);
|
||||
const compactChangesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||
if (!(await compactChangesTab.isVisible())) {
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
}
|
||||
await expect(compactChangesTab).toBeVisible();
|
||||
await compactChangesTab.click();
|
||||
const compactExplorer = page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
await expect(compactExplorer.getByTestId("changes-header")).toBeVisible();
|
||||
|
||||
await page.getByTestId("changes-options-menu").click();
|
||||
await expect(page.getByTestId("changes-options-menu-content")).toBeVisible();
|
||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toContainText("Collapse all files");
|
||||
await expect(page.getByTestId("changes-toggle-layout")).toContainText("Side-by-side diff");
|
||||
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Hide whitespace");
|
||||
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Wrap long lines");
|
||||
await expect(page.getByTestId("changes-refresh")).toHaveText("Refresh");
|
||||
await page.getByTestId("changes-toggle-whitespace").click();
|
||||
await page.getByTestId("changes-options-menu").click();
|
||||
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Show whitespace");
|
||||
const actions = compactExplorer.getByTestId("changes-actions-menu-trigger");
|
||||
const options = compactExplorer.getByRole("button", { name: "Diff options" });
|
||||
const [actionsBox, optionsBox, glyphBox] = await Promise.all([
|
||||
actions.boundingBox(),
|
||||
options.boundingBox(),
|
||||
options.locator("svg").boundingBox(),
|
||||
]);
|
||||
if (!actionsBox || !optionsBox || !glyphBox) {
|
||||
throw new Error("Compact Changes toolbar geometry could not be measured");
|
||||
}
|
||||
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 scrollToLowerUnwrappedDiffRows(page);
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
const tree = changesTree(page);
|
||||
const content = changesContent(page);
|
||||
await expect(tree.getByTestId("diff-folder-src")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS(
|
||||
"user-select",
|
||||
"none",
|
||||
);
|
||||
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);
|
||||
await options.click();
|
||||
const wrapLines = page.getByText("Wrap long lines", { exact: true });
|
||||
await expect(wrapLines).toBeVisible();
|
||||
await wrapLines.click();
|
||||
await options.click();
|
||||
await expect(
|
||||
page.getByTestId("changes-options-menu-content").getByTestId("changes-toggle-wrap-lines"),
|
||||
).toContainText("Scroll long lines");
|
||||
});
|
||||
|
||||
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 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
|
||||
.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.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())));
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await setOpenChangesPresentation(page, { layout: "split", wrapLines: false });
|
||||
await startReviewOnFirstChangedLine(page, "right");
|
||||
const [editor, body] = await Promise.all([
|
||||
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");
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||
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.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);
|
||||
});
|
||||
|
||||
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");
|
||||
await useUnwrappedDiffLines(page);
|
||||
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 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 ({
|
||||
@@ -1004,6 +1031,7 @@ test("canvas diff copies only the selected split side", async ({ context, page }
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
await setOpenChangesPresentation(page, { layout: "split", wrapLines: false });
|
||||
const before = await readSelectionPaintSamples(page, "right");
|
||||
await dragAddedTextRange(page, {
|
||||
lines,
|
||||
@@ -1027,6 +1055,7 @@ test("canvas diff copies exact wrapped fragments", async ({ context, page }) =>
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await configureDiffPresentation(page, { layout: "unified", wrapLines: true });
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
await setOpenChangesPresentation(page, { layout: "unified", wrapLines: true });
|
||||
await dragAddedTextRange(page, {
|
||||
lines: [content],
|
||||
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> {
|
||||
await page.addInitScript(() => {
|
||||
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> {
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
await expect
|
||||
@@ -1265,6 +1309,28 @@ async function createWorkspaceWithMountedTabDiff(
|
||||
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> {
|
||||
const repo = await createTempGitRepo("changes-canvas-selection-", {
|
||||
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.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await openChangesInVisibleExplorer(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> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(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);
|
||||
}
|
||||
|
||||
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||
await openChangesPanel(page);
|
||||
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||
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> {
|
||||
@@ -1353,7 +1441,8 @@ async function startReviewOnFirstChangedLine(
|
||||
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
||||
const lineHeight = Math.round(fontSize * 1.5);
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
disableChatOutlineFromAppearance,
|
||||
expectActiveChatOutlinePrompt,
|
||||
expectActiveChatOutlinePromptMovedFrom,
|
||||
expectChatOutlinePreview,
|
||||
expectChatOutlinePrompts,
|
||||
expectChatOutlinePromptToRemainBare,
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
openAgentTimeline,
|
||||
scrollThroughOlderHistoryPages,
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
scrollTimelineToOldestLoadedEdge,
|
||||
seedLongMockAgentTimeline,
|
||||
type LongTimelineAgent,
|
||||
} 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 }) => {
|
||||
await focusChatOutlinePrompt(page, 1);
|
||||
await expectChatOutlinePreview(page, agent.prompts[0]);
|
||||
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
||||
|
||||
await pressEnterOnFocusedPrompt(page);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.oldestPrompt);
|
||||
@@ -129,11 +130,12 @@ test.describe("desktop chat outline", () => {
|
||||
await expectOneActiveChatOutlinePrompt(page);
|
||||
|
||||
await clickChatOutlineRowEdge(page, 4);
|
||||
await expectTimelinePromptVisible(page, agent.prompts[3]);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]);
|
||||
await expectActiveChatOutlinePrompt(page, 4);
|
||||
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
await expectActiveChatOutlinePrompt(page, 1);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectActiveChatOutlinePromptMovedFrom(page, 4);
|
||||
});
|
||||
|
||||
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { closeCommandCenter, openCommandCenter } from "../support/helpers/comman
|
||||
import {
|
||||
applyCommandCenterAgentControls,
|
||||
chooseCommandCenterAgentControl,
|
||||
openCommandCenterForAgent,
|
||||
expectCommandCenterAgentControlSelected,
|
||||
expectFocusedAgentControls,
|
||||
expectWorkspaceAgentConfiguration,
|
||||
@@ -12,7 +13,9 @@ import {
|
||||
} from "../support/helpers/command-center-agent-controls";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { buildSchedulesRoute } from "@/utils/host-routes";
|
||||
|
||||
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 }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
|
||||
await seedMockDraftPreferences(page);
|
||||
|
||||
@@ -1,53 +1,14 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { addOfflineHostAndReload } from "../support/helpers/hosts";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
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.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 }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
|
||||
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Locator } from "@playwright/test";
|
||||
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";
|
||||
|
||||
@@ -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" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await openChangesPanel(page);
|
||||
await openChangesTreePanel(page);
|
||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||
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 workspace.navigateTo();
|
||||
|
||||
await openChangesPanel(page);
|
||||
await openChangesTreePanel(page);
|
||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||
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 });
|
||||
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
||||
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(panel.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
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-",
|
||||
title: "Composer bottom anchor",
|
||||
initialPrompt: "Produce enough content to make the chat scrollable.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -77,7 +77,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
||||
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 expectSingleCanvasSurface(page);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
|
||||
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("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 waitForWorkspaceTabsVisible(page);
|
||||
|
||||
await openChangesPanel(page);
|
||||
const focusedPane = page
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
|
||||
.first();
|
||||
const primaryPane = page
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ hasNot: page.getByTestId("workspace-tab-working_diff") })
|
||||
.first();
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
const focusedPaneChild = page
|
||||
.getByTestId("split-group-child")
|
||||
.filter({ has: page.getByTestId("workspace-new-tab-panel") });
|
||||
const originalPaneChild = page
|
||||
.getByTestId("split-group-child")
|
||||
.filter({ hasNot: page.getByTestId("workspace-new-tab-panel") });
|
||||
await expect(focusedPaneChild).toBeVisible();
|
||||
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 expect(focusedPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(1, {
|
||||
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 {
|
||||
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
|
||||
// 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
|
||||
// 2) splitting a tab out of a pane leaves that pane empty, and the app must stay
|
||||
// interactive in the [agent | empty main | explorer] state (the reporter hit an
|
||||
// app-wide lockup here with no way to close the empty pane).
|
||||
// 2) splitting a tab out of a pane leaves a New tab behind, and the app must stay
|
||||
// interactive in the [agent | New | explorer] state.
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
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 {
|
||||
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 });
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
return visible(page, "workspace-tabs-row").filter({
|
||||
has: page.getByTestId("workspace-tab-working_diff"),
|
||||
});
|
||||
return visible(page, "explorer-sidebar-tab-rail");
|
||||
}
|
||||
|
||||
function mainTabRow(page: Page): Locator {
|
||||
return visible(page, "workspace-tabs-row").filter({
|
||||
hasNot: page.getByTestId("workspace-tab-working_diff"),
|
||||
});
|
||||
return visible(page, "workspace-tabs-row");
|
||||
}
|
||||
|
||||
async function focusExplorerPane(page: Page): Promise<void> {
|
||||
await ensureExplorerPane(page);
|
||||
const changesTab = visible(page, "workspace-tab-working_diff").first();
|
||||
await changesTab.click({ position: { x: 12, y: 13 } });
|
||||
await expect(changesTab).toHaveAttribute("aria-selected", "true");
|
||||
async function selectExplorerChanges(page: Page): Promise<void> {
|
||||
await openChangesTreePanel(page);
|
||||
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
async function emptyPaneBox(page: Page) {
|
||||
const paneChild = page
|
||||
function newTabPaneChild(page: Page): Locator {
|
||||
return page
|
||||
.getByTestId("split-group-child")
|
||||
.filter({ hasText: "No tabs in this pane." })
|
||||
.filter({ has: page.getByTestId("workspace-new-tab-panel") })
|
||||
.first();
|
||||
}
|
||||
|
||||
async function emptyPaneBox(page: Page) {
|
||||
const paneChild = newTabPaneChild(page);
|
||||
await expect(paneChild).toBeVisible({ timeout: 15_000 });
|
||||
const box = await paneChild.boundingBox();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ async function dragChipTo(
|
||||
}
|
||||
|
||||
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,
|
||||
}, testInfo) => {
|
||||
const consoleErrors: string[] = [];
|
||||
@@ -113,8 +113,8 @@ test.describe("explorer pane tab placement", () => {
|
||||
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step("focus the explorer pane", async () => {
|
||||
await focusExplorerPane(page);
|
||||
await test.step("select Changes in Explorer", async () => {
|
||||
await selectExplorerChanges(page);
|
||||
});
|
||||
|
||||
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(
|
||||
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
||||
).toHaveCount(0);
|
||||
// The background open must not steal the explorer pane's focus.
|
||||
await expect(visible(page, "workspace-tab-working_diff").first()).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
// The background open must not change the selected Explorer view.
|
||||
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
|
||||
});
|
||||
|
||||
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(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
// The split must have taken: agent pane + empty main + explorer = 3 tab rows.
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
|
||||
await expect(page.getByText("No tabs in this pane.")).toBeVisible();
|
||||
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
|
||||
await expect(explorerTabRow(page)).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
});
|
||||
|
||||
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 expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
|
||||
|
||||
// Clicking inside the empty pane (focuses it) must not lock anything.
|
||||
const emptyPane = page
|
||||
.getByTestId("split-group-child")
|
||||
.filter({ hasText: "No tabs in this pane." })
|
||||
.first();
|
||||
await emptyPane.click({ timeout: 5_000 });
|
||||
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
|
||||
// above the vertically centred launcher, which would open a tab instead.
|
||||
const emptyPane = newTabPaneChild(page);
|
||||
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
|
||||
|
||||
// The empty pane's + menu must open and dismiss cleanly.
|
||||
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.
|
||||
// A second drag must work: center-drop the agent tab back into the New pane.
|
||||
const target = await emptyPaneBox(page);
|
||||
await dragChipTo(page, chip, {
|
||||
x: target.x + target.width / 2,
|
||||
@@ -206,8 +197,10 @@ test.describe("explorer pane tab placement", () => {
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
// The agent chip must have moved into the previously empty pane and stay clickable.
|
||||
await expect(page.getByText("No tabs in this pane.")).toHaveCount(0, { timeout: 10_000 });
|
||||
// The agent chip must have replaced New in that pane and stay clickable.
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await agentTabChip(page, agentId)
|
||||
.first()
|
||||
.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"));
|
||||
}
|
||||
|
||||
/** Only the docked sidebar ever rendered `explorer-header` on a wide layout. */
|
||||
function dockedSidebar(page: Page): Locator {
|
||||
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"),
|
||||
});
|
||||
function explorerSidebar(page: Page): Locator {
|
||||
return visible(page, "workspace-explorer-sidebar");
|
||||
}
|
||||
|
||||
/** 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 waitForWorkspaceTabsVisible(page);
|
||||
|
||||
await test.step("no docked sidebar, on load or after toggling", async () => {
|
||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||
|
||||
await test.step("the retired flag does not reveal Explorer", async () => {
|
||||
await expect(explorerSidebar(page)).toHaveCount(0);
|
||||
const toggle = visible(page, "workspace-explorer-toggle").first();
|
||||
await toggle.click();
|
||||
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(
|
||||
explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"),
|
||||
).toBeVisible();
|
||||
|
||||
await toggle.click();
|
||||
await expect(explorerTabRow(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
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 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 test.step("the shortcut collapses both", async () => {
|
||||
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 test.step("the shortcut restores both", async () => {
|
||||
await page.keyboard.press(`${modifier}+.`);
|
||||
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 {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
const RED_PIXEL = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
@@ -90,6 +92,87 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
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 }) => {
|
||||
const target = "target.ts:42";
|
||||
const session = await seedAgentWithFileLink({
|
||||
@@ -117,7 +200,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
|
||||
const sourceEditor = editor(page);
|
||||
await sourceEditor.click();
|
||||
await sourceEditor.press("Control+Home");
|
||||
await sourceEditor.press("ControlOrMeta+Home");
|
||||
await expect(page.getByLabel(/^Line 1, column \d+$/)).toBeVisible();
|
||||
|
||||
await page
|
||||
@@ -137,6 +220,12 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
});
|
||||
|
||||
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 session = await seedAgentWithFileLink({
|
||||
target,
|
||||
@@ -241,13 +330,13 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
const initialModeBox = await modeControl.boundingBox();
|
||||
expect(initialModeBox).not.toBeNull();
|
||||
const initialModeX = initialModeBox!.x;
|
||||
await content.press("Control+End");
|
||||
await content.press("ControlOrMeta+End");
|
||||
await expect(page.getByLabel(/Line 12, column \d+/)).toBeVisible();
|
||||
const movedModeBox = await modeControl.boundingBox();
|
||||
expect(movedModeBox).not.toBeNull();
|
||||
expect(movedModeBox!.x).toBe(initialModeX);
|
||||
|
||||
await content.press("Control+a");
|
||||
await content.press("ControlOrMeta+a");
|
||||
const selection = editorHost.locator(".cm-selectionBackground").first();
|
||||
await expect(selection).toBeVisible();
|
||||
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 })
|
||||
.first()
|
||||
.click({ button: "right" });
|
||||
await page
|
||||
.getByTestId("workspace-tab-context-file_draft.ts-close")
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
|
||||
expect(closePrompt).toContain("Closing it will discard the draft.");
|
||||
|
||||
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 type { Locator } from "@playwright/test";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
assertNewChatTileVisible,
|
||||
assertNewTabMenuTriggerVisible,
|
||||
assertSingleNewTabButton,
|
||||
openNewTabMenuWithShortcut,
|
||||
pressDirectNewTabShortcut,
|
||||
clickNewChat,
|
||||
clickNewTerminal,
|
||||
countTabsOfKind,
|
||||
waitForTabBar,
|
||||
pressNewTabShortcut,
|
||||
pressDirectNewTabShortcut,
|
||||
getTabTestIds,
|
||||
waitForTabWithTitle,
|
||||
measureTileTransition,
|
||||
@@ -45,6 +46,16 @@ const EMPTY_PROMPT_PROFILE: TerminalProfile = {
|
||||
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 () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
|
||||
const created = await workspace.client.createWorkspace({
|
||||
@@ -66,14 +77,12 @@ test.afterAll(async () => {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
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) {
|
||||
throw new Error("Secondary workspace was not created");
|
||||
}
|
||||
|
||||
const serverId = getServerId();
|
||||
const newTabMenuItem = page.getByRole("menuitem", { name: /^Agent/ });
|
||||
|
||||
const switchWorkspaceRow = async (workspaceId: string) => {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
@@ -92,11 +101,12 @@ test.describe("Tab creation", () => {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await switchWorkspaceRow(sequence[i % sequence.length]);
|
||||
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,
|
||||
});
|
||||
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);
|
||||
const workspaceUrl = page.url();
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
const newTabCountBefore = await countTabsOfKind(page, "new_tab");
|
||||
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible();
|
||||
|
||||
await page.keyboard.press(`${modifier}+t`);
|
||||
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
|
||||
|
||||
await page.goto(workspaceUrl);
|
||||
await assertNewTabMenuTriggerVisible(page);
|
||||
const countBefore = await countTabsOfKind(page, "draft");
|
||||
await pressDirectNewTabShortcut(page, "a");
|
||||
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore + 1);
|
||||
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore);
|
||||
});
|
||||
|
||||
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);
|
||||
const countBefore = await countTabsOfKind(page, "draft");
|
||||
|
||||
await openNewTabMenuWithShortcut(page);
|
||||
|
||||
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);
|
||||
|
||||
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 expect
|
||||
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
|
||||
.toBe(countBefore + 1);
|
||||
const countAfterFirst = await countTabsOfKind(page, "draft");
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabs).toHaveCount(countBefore + 1);
|
||||
const firstIds = await tabTestIds(newTabs);
|
||||
|
||||
await pressDirectNewTabShortcut(page, "a");
|
||||
await expect
|
||||
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
|
||||
.toBe(countAfterFirst + 1);
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabs).toHaveCount(countBefore + 2);
|
||||
const ids = await tabTestIds(newTabs);
|
||||
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 openNewTabMenuWithShortcut(page);
|
||||
|
||||
const agent = page.getByRole("menuitem", { name: /^Agent/ });
|
||||
const terminal = page.getByRole("menuitem", { name: /^Terminal/ });
|
||||
const changes = page.getByRole("menuitem", { name: /Changes/ });
|
||||
const files = page.getByRole("menuitem", { name: /Files/ });
|
||||
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
|
||||
const agent = panel.getByRole("button", { name: /^Agent/ });
|
||||
const terminal = panel.getByRole("button", { name: /^Terminal/ });
|
||||
const diff = panel.getByRole("button", { name: /Diff/ });
|
||||
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
|
||||
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
|
||||
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
|
||||
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*C`));
|
||||
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
|
||||
await expect(diff).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
|
||||
await expect(agent).toBeFocused();
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(terminal).toBeFocused();
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(agent).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("button", { name: "New tab" })).toBeFocused();
|
||||
await expect(page.getByRole("button", { name: "New tab" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
|
||||
await pressDirectNewTabShortcut(page, "e");
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
await expect(
|
||||
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 }) => {
|
||||
@@ -205,8 +234,12 @@ test.describe("Tab creation", () => {
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
|
||||
await page.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name }).click();
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).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");
|
||||
} 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 }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertSingleNewTabButton(page);
|
||||
@@ -226,15 +273,13 @@ test.describe("Tab creation", () => {
|
||||
|
||||
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
|
||||
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
|
||||
const newTabButtonInScroll = await scrollArea
|
||||
.getByTestId("workspace-new-tab-menu-trigger")
|
||||
.count();
|
||||
const newTabButtonInScroll = await scrollArea.getByTestId("workspace-new-tab-button").count();
|
||||
const scrollShades = await tabRow
|
||||
.locator('[data-testid^="workspace-tabs-scroll-shade-"]')
|
||||
.count();
|
||||
|
||||
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("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
test("New agent tab transition has no blank intermediate tab state", async ({
|
||||
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
|
||||
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page));
|
||||
// Sample the single New → Agent replacement, not the separate action that
|
||||
// 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
|
||||
for (const snapshot of snapshots) {
|
||||
expect(snapshot.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// Tab count should never spike excessively (no duplicate flash from add-then-remove).
|
||||
// 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.
|
||||
// Replacement is atomic: the set of identities changes once, while its size stays fixed.
|
||||
const counts = snapshots.map((snapshot) => snapshot.length);
|
||||
const maxCount = Math.max(...counts);
|
||||
const initialCount = counts[0] ?? 0;
|
||||
|
||||
expect(maxCount).toBeLessThanOrEqual(initialCount + 2);
|
||||
expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot))).size).toBeLessThanOrEqual(
|
||||
2,
|
||||
);
|
||||
expect(counts.every((count) => count === initialCount)).toBe(true);
|
||||
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
|
||||
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 openNewWorkspace();
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await fillTerminalPrompt(page, "remember me");
|
||||
await submitTerminalLaunch(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 selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await fillTerminalPrompt(page, "second run");
|
||||
await submitTerminalLaunch(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-",
|
||||
title: "Rewind e2e",
|
||||
initialPrompt: firstPrompt,
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -70,73 +70,55 @@ test.describe("History search", () => {
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test("typing narrows history to matching sessions and clearing restores them", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("searches, ranks, highlights, and clears session history", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
// Seeded newest-first, and at rest history is chronological.
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
|
||||
await test.step("narrows history and restores chronological grouping", async () => {
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await search(page, `${NONCE} billing`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
await search(page, `${NONCE} 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
|
||||
// the list no longer has.
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, { timeout: 30_000 });
|
||||
await page.getByTestId("sessions-search-clear").click();
|
||||
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
|
||||
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 expect(page.getByTestId("sessions-search-input")).toHaveValue("");
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
|
||||
});
|
||||
await test.step("ranks whole-word matches above substrings", async () => {
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
|
||||
});
|
||||
|
||||
test("ranks a whole-word hit above one buried inside a word", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
await test.step("highlights exact and typo-resolved matches", async () => {
|
||||
await search(page, `${NONCE} billing`);
|
||||
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
|
||||
// stronger match leads even though both sessions are equally recent.
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
|
||||
});
|
||||
await search(page, `${NONCE} bulling`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("marks the characters each result matched on", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
await test.step("distinguishes no matches from empty history", async () => {
|
||||
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 });
|
||||
|
||||
// The mark is a nested Text run, so the matched slice is its own element.
|
||||
await search(page, `${NONCE} billing`);
|
||||
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]);
|
||||
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 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", () => {
|
||||
|
||||
@@ -70,9 +70,28 @@ test.describe("Settings toggle tab regression", () => {
|
||||
await pressSettingsToggleShortcut(page);
|
||||
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 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 pressSettingsToggleShortcut(page);
|
||||
|
||||
@@ -41,46 +41,37 @@ async function expectExternalPage(
|
||||
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 expect(page.getByTestId("sidebar-help")).toBeVisible();
|
||||
|
||||
await openHelpMenu(page);
|
||||
const triggerBox = await page.getByTestId("sidebar-help").evaluate((element) => {
|
||||
const { y, height } = element.getBoundingClientRect();
|
||||
return { y, height };
|
||||
await test.step("opens diagnostics and keyboard shortcuts", async () => {
|
||||
await openHelpMenu(page);
|
||||
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");
|
||||
});
|
||||
const menuBox = await page.getByTestId("sidebar-help-menu").evaluate((element) => {
|
||||
const { y, height } = element.getBoundingClientRect();
|
||||
return { y, height };
|
||||
|
||||
await test.step("opens support and release pages", async () => {
|
||||
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 }) => {
|
||||
|
||||
@@ -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 { gotoAppShell } from "../support/helpers/app";
|
||||
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 { getServerId } from "../support/helpers/server-id";
|
||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||
@@ -142,6 +146,42 @@ async function installPinRpcGate(
|
||||
}
|
||||
|
||||
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 }) => {
|
||||
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 }) => {
|
||||
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 }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false });
|
||||
|
||||
@@ -319,7 +306,7 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await expect(closedIcon).toBeVisible();
|
||||
const closedBounds = await closedIcon.boundingBox();
|
||||
expect(closedBounds).not.toBeNull();
|
||||
expect(closedBounds?.x).toBeCloseTo(12, 0);
|
||||
expect(closedBounds?.x).toBeCloseTo(9, 0);
|
||||
expect(closedBounds?.y).toBe(openBounds?.y);
|
||||
});
|
||||
|
||||
@@ -342,16 +329,19 @@ test.describe("Half-screen desktop layout", () => {
|
||||
|
||||
await openFilesPanel(page);
|
||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
||||
await expect(explorerToggle).toHaveAccessibleName("Close explorer");
|
||||
await expect(
|
||||
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("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 expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(explorerToggle).toHaveAccessibleName("Open explorer");
|
||||
await expect(
|
||||
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -81,7 +81,7 @@ async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness
|
||||
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.
|
||||
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
|
||||
// main pane, so an unscoped testid locator matches both; scope to the
|
||||
// visible one.
|
||||
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
|
||||
await page.getByTestId("workspace-new-tab-menu-terminal").click();
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
await page
|
||||
.getByTestId("workspace-new-tab-menu-terminal")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
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 expect(
|
||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }),
|
||||
page.getByTestId("workspace-new-tab-button").filter({ visible: true }),
|
||||
).toBeVisible({
|
||||
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);
|
||||
await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 });
|
||||
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,
|
||||
});
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -266,7 +266,7 @@ test.describe("Workspace labels", () => {
|
||||
|
||||
// Clear restores the complete sidebar without leaving the filter page.
|
||||
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(labelledRow).toBeVisible();
|
||||
await expect(unlabelledRow).toBeVisible();
|
||||
|
||||
@@ -11,35 +11,37 @@ test.describe("Workspace lifecycle", () => {
|
||||
// One retry is enough for the daemon to stabilize.
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test.describe("Main checkout", () => {
|
||||
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" });
|
||||
await workspace.navigateTo();
|
||||
test("main checkout creates an agent chat and terminal in the project root", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
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);
|
||||
});
|
||||
|
||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" });
|
||||
await workspace.navigateTo();
|
||||
await test.step("creates a terminal in the project root", async () => {
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, workspace.repoPath);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Worktree workspace", () => {
|
||||
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" });
|
||||
await workspace.navigateTo();
|
||||
test("worktree creates an agent chat and terminal in its directory", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
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);
|
||||
});
|
||||
|
||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" });
|
||||
await workspace.navigateTo();
|
||||
await test.step("creates a terminal in the worktree", async () => {
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, workspace.repoPath);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execSync, spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
submitNewWorkspaceEmpty,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||
import { killProcessTree, spawnTsx } from "../support/helpers/spawn-node";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
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> {
|
||||
const startedAt = Date.now();
|
||||
let lastConnectionError: unknown = null;
|
||||
while (Date.now() - startedAt < 20_000) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`Restart test daemon exited before listening (exit ${child.exitCode}).`);
|
||||
while (Date.now() - startedAt < 90_000) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw new Error(
|
||||
`Restart test daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
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: {
|
||||
paseoHome: string;
|
||||
origin: string;
|
||||
@@ -251,8 +238,7 @@ async function startRestartDaemon(input: {
|
||||
}
|
||||
|
||||
const serverDir = path.resolve(__dirname, "../../../server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
const child = spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], {
|
||||
cwd: serverDir,
|
||||
env: withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
@@ -276,7 +262,7 @@ async function startRestartDaemon(input: {
|
||||
try {
|
||||
await waitForServer(port, child);
|
||||
} catch (error) {
|
||||
await stopProcess(child);
|
||||
await killProcessTree(child);
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||
{ cause: error },
|
||||
@@ -285,7 +271,7 @@ async function startRestartDaemon(input: {
|
||||
|
||||
return {
|
||||
port,
|
||||
close: () => stopProcess(child),
|
||||
close: () => killProcessTree(child),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -370,22 +356,6 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
|
||||
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(
|
||||
page: Page,
|
||||
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 waitForSidebarHydration(page);
|
||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceA,
|
||||
indicator: "running",
|
||||
});
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceB,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
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("opening the first split pane keeps the existing agent composer mounted", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("workspace navigation keeps the existing agent composer mounted", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -65,53 +77,35 @@ test.describe("Workspace pane mounting", () => {
|
||||
.first()
|
||||
.elementHandle();
|
||||
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 expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
|
||||
2,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
|
||||
expect(originalStillConnected).toBe(true);
|
||||
} finally {
|
||||
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 runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
|
||||
emptyPanesBeforeSplit + 1,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
|
||||
1,
|
||||
);
|
||||
await expectNodeConnected(originalComposer!);
|
||||
});
|
||||
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
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();
|
||||
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 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);
|
||||
const settingsShortcut = await getSettingsShortcut(page);
|
||||
await page.keyboard.press(settingsShortcut);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await expect(renameInput).not.toBeVisible();
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expectSameRenderedNode(originalComposer!, composer);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -138,18 +132,17 @@ test.describe("Workspace pane mounting", () => {
|
||||
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||
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.waitForURL(
|
||||
(url) => url.pathname === buildHostWorkspaceRoute(serverId, workspace.workspaceId),
|
||||
);
|
||||
await waitForWorkspaceRoute(page, workspaceRoute);
|
||||
await expectSameRenderedNode(originalComposer, composer);
|
||||
} finally {
|
||||
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);
|
||||
const serverId = getServerId();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "terminal-pane-retention-" });
|
||||
@@ -168,39 +161,20 @@ test.describe("Workspace pane mounting", () => {
|
||||
const terminalSurface = terminalSurfaceLocator(page);
|
||||
const originalTerminal = await captureRenderedNode(terminalSurface);
|
||||
|
||||
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
|
||||
await expectComposerVisible(page);
|
||||
expect(await originalTerminal.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await test.step("switching tabs preserves the terminal", async () => {
|
||||
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
|
||||
await expectComposerVisible(page);
|
||||
await expectNodeConnected(originalTerminal);
|
||||
|
||||
await clickFirstTerminalTab(page);
|
||||
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 clickFirstTerminalTab(page);
|
||||
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
||||
});
|
||||
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewTerminal(page);
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
const terminalSurface = terminalSurfaceLocator(page);
|
||||
const originalTerminal = await captureRenderedNode(terminalSurface);
|
||||
|
||||
await openSettings(page);
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
||||
await test.step("opening Settings preserves the terminal", async () => {
|
||||
await openSettings(page);
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
||||
});
|
||||
} finally {
|
||||
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 {
|
||||
closeSetupTab,
|
||||
waitForWorkspaceTabsVisible,
|
||||
expectFailedSetupTabSeededInMainPane,
|
||||
expectSetupTabNotSeeded,
|
||||
expectNoTerminalTabs,
|
||||
clickFirstTerminalTab,
|
||||
expectFirstTerminalTabContains,
|
||||
@@ -19,6 +22,7 @@ import {
|
||||
expectSetupPanel,
|
||||
openHomeWithProject,
|
||||
navigateToWorkspaceViaSidebar,
|
||||
returnHomeFromWorkspace,
|
||||
openWorkspaceScriptsMenu,
|
||||
startWorkspaceScriptFromMenu,
|
||||
closeWorkspaceScriptsMenu,
|
||||
@@ -39,7 +43,7 @@ interface WorkspaceScriptStarter {
|
||||
}
|
||||
|
||||
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 repo = await createTempGitRepo("setup-open-", {
|
||||
paseoConfig: {
|
||||
@@ -60,7 +64,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
|
||||
await expectSetupPanel(page);
|
||||
await expectSetupTabNotSeeded(page, workspace.id);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
@@ -99,6 +103,8 @@ test.describe("Workspace setup streaming", () => {
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
|
||||
await expectSetupTabNotSeeded(page, workspace.id);
|
||||
await expectSetupPanel(page);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
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 repo = await createTempGitRepo("setup-success-", {
|
||||
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 repo = await createTempGitRepo("setup-failure-", {
|
||||
paseoConfig: {
|
||||
@@ -173,7 +179,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
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("setup failed");
|
||||
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 {
|
||||
await client.close();
|
||||
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 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 repo = await createTempGitRepo("setup-scripts-", {
|
||||
paseoConfig: {
|
||||
|
||||
@@ -70,9 +70,9 @@ test.describe("Workspace terminal tab rename", () => {
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
const copyTerminalId = page.getByTestId(
|
||||
`workspace-tab-context-terminal_${terminalId}-copy-terminal-id`,
|
||||
);
|
||||
const copyTerminalId = page.getByRole("menuitem", {
|
||||
name: /^Copy terminal id/,
|
||||
});
|
||||
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
|
||||
await copyTerminalId.click();
|
||||
|
||||
@@ -99,10 +99,7 @@ test.describe("Workspace terminal tab rename", () => {
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
|
||||
const renameItem = page.getByRole("menuitem", { name: "Rename", exact: true });
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
|
||||
@@ -39,28 +39,6 @@ test.describe("Workspace archive with worktree backing", () => {
|
||||
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 }) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
|
||||
@@ -32,16 +32,8 @@ const metroTest = base.extend({
|
||||
},
|
||||
});
|
||||
|
||||
const test = metroTest.extend<
|
||||
{
|
||||
paseoE2ESetup: void;
|
||||
projectOwnership: void;
|
||||
outdatedDaemon: OutdatedDaemon;
|
||||
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
||||
relayConfigOutdatedDaemon: OutdatedDaemon;
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
},
|
||||
const daemonTest = metroTest.extend<
|
||||
{ projectOwnership: void },
|
||||
{ e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
|
||||
>({
|
||||
e2eForkProviders: [[], { scope: "worker", option: true }],
|
||||
@@ -94,6 +86,16 @@ const test = metroTest.extend<
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
});
|
||||
|
||||
const test = daemonTest.extend<{
|
||||
paseoE2ESetup: void;
|
||||
outdatedDaemon: OutdatedDaemon;
|
||||
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
||||
relayConfigOutdatedDaemon: OutdatedDaemon;
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
}>({
|
||||
paseoE2ESetup: [
|
||||
async ({ page }, provide, testInfo) => {
|
||||
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 { once } from "node:events";
|
||||
import { killProcessTree } from "./helpers/spawn-node";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
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 {
|
||||
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,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -216,11 +199,11 @@ export default async function globalSetup() {
|
||||
console.log(`[e2e] Metro warmed on port ${metroPort}`);
|
||||
|
||||
return async () => {
|
||||
await stopProcess(metroProcess);
|
||||
await killProcessTree(metroProcess);
|
||||
console.log("[e2e] Metro stopped");
|
||||
};
|
||||
} catch (error) {
|
||||
await stopProcess(metroProcess);
|
||||
await killProcessTree(metroProcess);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promi
|
||||
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
|
||||
}
|
||||
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);
|
||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||
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)) {
|
||||
if (field === "notes") {
|
||||
await expect(
|
||||
modal.getByRole("textbox", { name: "Notes for agents", exact: true }),
|
||||
).toHaveValue(value);
|
||||
await expect(modal.getByRole("textbox", { name: "When to use", exact: true })).toHaveValue(
|
||||
value,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await expect(
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
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
|
||||
// accessible name carries the current branch ("Current branch: <name>. Press 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
|
||||
// checkout directory from the opaque workspace id. Scoping to the changes header
|
||||
// keeps the matcher unambiguous even when the header is shared with diff actions.
|
||||
// checkout directory from the opaque workspace id. Scoping to the repository
|
||||
// header keeps the matcher aligned with the row that owns repository identity.
|
||||
function branchSwitcherTrigger(page: Page, branchName: string) {
|
||||
return page
|
||||
.getByTestId("changes-header")
|
||||
.getByTestId("changes-repository-header")
|
||||
.getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) })
|
||||
.filter({ visible: true })
|
||||
.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> {
|
||||
await openWorkspaceChangesPanel(page);
|
||||
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await openChangesTreePanel(page);
|
||||
await expect(
|
||||
page.getByTestId("changes-repository-header").filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
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(
|
||||
page: Page,
|
||||
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 { openCommandCenter } from "./command-center";
|
||||
import { openAgentRoute, type MockAgentWorkspace } from "./mock-agent";
|
||||
|
||||
export interface CommandCenterAgentControlChoice {
|
||||
query: 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: {
|
||||
page: Page;
|
||||
query: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fork, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import path from "node:path";
|
||||
import { killProcessTree } from "./spawn-node";
|
||||
|
||||
export interface OutdatedDaemon {
|
||||
endpoint: string;
|
||||
@@ -55,14 +55,10 @@ export async function startOutdatedDaemon(options?: {
|
||||
endpoint: ready.endpoint,
|
||||
label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host",
|
||||
serverId: ready.serverId,
|
||||
async close() {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
await once(child, "exit");
|
||||
},
|
||||
close: () => killProcessTree(child),
|
||||
};
|
||||
} catch (error) {
|
||||
child.kill("SIGTERM");
|
||||
await killProcessTree(child);
|
||||
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 { tmpdir } from "node:os";
|
||||
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-"));
|
||||
let realGhPath = "";
|
||||
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 {
|
||||
// The local GitHub fixture remains usable without a system gh binary.
|
||||
}
|
||||
|
||||
const fakeEditorSource = `#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -41,6 +49,9 @@ if (recordPath) {
|
||||
const editorPath = path.join(binDir, editorCommand);
|
||||
await writeFile(editorPath, fakeEditorSource);
|
||||
await chmod(editorPath, 0o755);
|
||||
if (process.platform === "win32") {
|
||||
await writeFile(`${editorPath}.cmd`, `@node "%~dp0${editorCommand}" %*\r\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const fakeGhPath = path.join(binDir, "gh");
|
||||
@@ -106,6 +117,9 @@ process.exit(result.status ?? 1);
|
||||
`;
|
||||
await writeFile(fakeGhPath, fakeGhSource);
|
||||
await chmod(fakeGhPath, 0o755);
|
||||
if (process.platform === "win32") {
|
||||
await writeFile(`${fakeGhPath}.cmd`, '@node "%~dp0gh" %*\r\n');
|
||||
}
|
||||
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,
|
||||
execSync,
|
||||
type ChildProcess,
|
||||
type SpawnOptions,
|
||||
} from "node:child_process";
|
||||
import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
import { killProcessTree, spawnTsx } from "./spawn-node";
|
||||
|
||||
export interface IsolatedHostDaemon {
|
||||
serverId: string;
|
||||
@@ -48,12 +42,14 @@ async function getAvailablePort(): Promise<number> {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`Isolated host daemon exited before listening (exit ${child.exitCode})`);
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw new Error(
|
||||
`Isolated host daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)})`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
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(
|
||||
serverId: string,
|
||||
options: IsolatedHostDaemonOptions = {},
|
||||
@@ -114,11 +97,17 @@ export async function startIsolatedHostDaemon(
|
||||
path.join(publishedPackageRoot, "package.json"),
|
||||
`${JSON.stringify({ private: true })}\n`,
|
||||
);
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
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(
|
||||
npmCommand,
|
||||
process.execPath,
|
||||
[
|
||||
npmCli,
|
||||
"install",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
@@ -128,6 +117,9 @@ export async function startIsolatedHostDaemon(
|
||||
{ cwd: publishedPackageRoot, stdio: "ignore" },
|
||||
);
|
||||
} catch (error) {
|
||||
if (!options.preserveHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
await rm(publishedPackageRoot, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
@@ -155,7 +147,6 @@ export async function startIsolatedHostDaemon(
|
||||
const serverDir = publishedPackageRoot
|
||||
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
|
||||
: path.resolve(__dirname, "../../../../server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const spawnDaemon = async (): Promise<ChildProcess> => {
|
||||
const spawnOptions: SpawnOptions = {
|
||||
cwd: serverDir,
|
||||
@@ -175,7 +166,7 @@ export async function startIsolatedHostDaemon(
|
||||
};
|
||||
const child = publishedPackageRoot
|
||||
? 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 = "";
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
@@ -187,7 +178,7 @@ export async function startIsolatedHostDaemon(
|
||||
await waitForServer(port, child);
|
||||
return child;
|
||||
} catch (error) {
|
||||
await stopProcess(child);
|
||||
await killProcessTree(child);
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||
{ cause: error },
|
||||
@@ -216,13 +207,13 @@ export async function startIsolatedHostDaemon(
|
||||
getPid: () => child.pid,
|
||||
restart: async () => {
|
||||
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
||||
await stopProcess(child);
|
||||
await killProcessTree(child);
|
||||
child = await spawnDaemon();
|
||||
},
|
||||
close: async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
await stopProcess(child);
|
||||
await killProcessTree(child);
|
||||
if (!options.preserveHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||
|
||||
// ─── 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> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
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> {
|
||||
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> {
|
||||
@@ -83,30 +83,27 @@ export async function pressDirectNewTabShortcut(page: Page, key: string): Promis
|
||||
/** Assert the inline plus button is visible in the tab bar. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
|
||||
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
|
||||
).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> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
|
||||
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// ─── 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> {
|
||||
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> {
|
||||
const trigger = page
|
||||
.getByTestId("workspace-new-tab-menu-trigger")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
const trigger = page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first();
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
const item = page
|
||||
@@ -137,7 +134,7 @@ export async function waitForTabWithTitle(
|
||||
|
||||
/** Assert the inline plus button is visible in the tab bar. */
|
||||
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();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
@@ -178,7 +175,7 @@ export async function sampleTabsDuringTransition(
|
||||
function sample() {
|
||||
const tabs = Array.from(
|
||||
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);
|
||||
scope.__paseoTabTrackFrames?.push(
|
||||
@@ -191,7 +188,10 @@ export async function sampleTabsDuringTransition(
|
||||
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);
|
||||
await action();
|
||||
await page.waitForTimeout(durationMs + 100);
|
||||
@@ -224,7 +224,7 @@ export async function expectTabTitleFits(
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { existsSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { killProcessTree } from "./spawn-node";
|
||||
|
||||
export interface LocalElixirRelay {
|
||||
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(
|
||||
port: number,
|
||||
child: ChildProcess,
|
||||
@@ -48,9 +35,9 @@ async function waitUntilReady(
|
||||
const deadline = Date.now() + 60_000;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
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 {
|
||||
@@ -70,6 +57,12 @@ async function waitUntilReady(
|
||||
}
|
||||
|
||||
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 =
|
||||
process.env.PASEO_RELAY_CHECKOUT ?? path.resolve(__dirname, "../../../../../..", "paseo-relay");
|
||||
if (!existsSync(path.join(relayRoot, "mix.exs"))) {
|
||||
@@ -107,14 +100,14 @@ export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
|
||||
try {
|
||||
await waitUntilReady(port, child, () => output.join("\n"));
|
||||
} catch (error) {
|
||||
await stopProcess(child);
|
||||
await killProcessTree(child);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
if (!child) return;
|
||||
await stopProcess(child);
|
||||
await killProcessTree(child);
|
||||
child = null;
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
|
||||
export interface MockAgentOptions {
|
||||
repoPrefix: string;
|
||||
title: string;
|
||||
repo?: Parameters<typeof seedWorkspace>[0]["repo"];
|
||||
port?: number;
|
||||
initialPrompt?: string;
|
||||
model?: string;
|
||||
@@ -29,7 +30,11 @@ export interface MockAgentOptions {
|
||||
export async function seedMockAgentWorkspace(
|
||||
options: MockAgentOptions,
|
||||
): 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 {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
@@ -37,7 +42,7 @@ export async function seedMockAgentWorkspace(
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: options.title,
|
||||
modeId: options.modeId ?? "load-test",
|
||||
model: options.model ?? "ten-second-stream",
|
||||
model: options.model ?? "e2e-fast-stream",
|
||||
initialPrompt: options.initialPrompt,
|
||||
featureValues: options.featureValues,
|
||||
});
|
||||
|
||||
@@ -105,7 +105,6 @@ export async function declineRelay(page: Page): Promise<void> {
|
||||
export async function enableRelayAndExpectOffer(page: Page): Promise<void> {
|
||||
const enableButton = page.getByRole("button", { name: "Enable relay", exact: true });
|
||||
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("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
|
||||
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> {
|
||||
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
|
||||
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: "Enable relay", exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface SeedWorkspaceDescriptor {
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
workspaceDirectory: string;
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,27 @@ export async function openSidebarDisplayPage(page: Page, branchTestID: string):
|
||||
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> {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-grouping");
|
||||
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> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.hover();
|
||||
for (let step = 0; step < 60; step += 1) {
|
||||
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);
|
||||
await returnTimelineToSettledHistoryStart(page, () => page.mouse.wheel(0, -1_000));
|
||||
}
|
||||
|
||||
export async function userNavigatesTimelineToHistoryStartWithKeyboard(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.focus();
|
||||
await page.keyboard.press("Home");
|
||||
await expect
|
||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
||||
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
|
||||
await returnTimelineToSettledHistoryStart(page, () => page.keyboard.press("Home"));
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
@@ -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> {
|
||||
await page.getByTestId("workspace-scripts-button").click();
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
}
|
||||
|
||||
function explorerPaneTabRow(page: Page) {
|
||||
return visibleTestId(page, "workspace-tabs-row")
|
||||
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
|
||||
.first();
|
||||
function explorerSidebar(page: Page) {
|
||||
return visibleTestId(page, "workspace-explorer-sidebar").first();
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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();
|
||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||
const tabRow = explorerPaneTabRow(page);
|
||||
if ((await tabRow.count()) === 0) {
|
||||
const explorer = explorerSidebar(page);
|
||||
if ((await explorer.count()) === 0) {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(tabRow).toBeVisible({ timeout: 30_000 });
|
||||
return tabRow;
|
||||
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||
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> {
|
||||
const tabRow = await ensureExplorerPane(page);
|
||||
await selectWorkspaceTab(tabRow.getByTestId("workspace-tab-working_diff"));
|
||||
await openChangesTreePanel(page);
|
||||
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({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openFilesPanel(page: Page): Promise<void> {
|
||||
const tabRow = await ensureExplorerPane(page);
|
||||
const filesTab = tabRow.getByTestId("workspace-tab-files");
|
||||
if ((await filesTab.count()) === 0) {
|
||||
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,
|
||||
await openExplorerView(page, {
|
||||
tabTestId: "explorer-sidebar-tab-files",
|
||||
contentTestId: "file-explorer-tree-scroll",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,13 +129,9 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
|
||||
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||
return;
|
||||
}
|
||||
const tabRow = await ensureExplorerPane(page);
|
||||
const pullRequestTab = tabRow.getByTestId("workspace-tab-pull_request");
|
||||
if ((await pullRequestTab.count()) === 0) {
|
||||
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
|
||||
await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
|
||||
}
|
||||
await selectWorkspaceTab(pullRequestTab);
|
||||
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
|
||||
await trigger.click();
|
||||
await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
|
||||
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({
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
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 trigger.click();
|
||||
const item = visibleTestId(page, "workspace-new-tab-menu-agent").first();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -30,6 +30,7 @@
|
||||
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
||||
"profile:composer-typing": "node ./scripts/profile-composer-typing.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-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
||||
"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"}`;
|
||||
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({
|
||||
testDir: "./e2e/browser",
|
||||
globalSetup: "./e2e/support/global-setup.ts",
|
||||
@@ -21,9 +27,9 @@ export default defineConfig({
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "retain-on-failure",
|
||||
trace: process.env.CI ? "on-first-retry" : "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: process.env.E2E_RECORD_VIDEO === "1" ? "on" : "retain-on-failure",
|
||||
video: videoMode(),
|
||||
},
|
||||
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 {
|
||||
BookOpen,
|
||||
Bot,
|
||||
Boxes,
|
||||
Brain,
|
||||
Bug,
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
Server,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Star,
|
||||
Terminal,
|
||||
TestTube,
|
||||
Wrench,
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
} from "./profile-appearance";
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -174,4 +174,151 @@ describe("useChatOutline", () => {
|
||||
|
||||
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;
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
onJumpError: () => void;
|
||||
visibleItemIds?: ReadonlySet<string>;
|
||||
revealLoadedItem?: (itemId: string) => boolean;
|
||||
}
|
||||
|
||||
export interface ChatOutline {
|
||||
@@ -51,6 +53,8 @@ export function useChatOutline({
|
||||
enabled,
|
||||
viewportRef,
|
||||
onJumpError,
|
||||
visibleItemIds,
|
||||
revealLoadedItem,
|
||||
}: UseChatOutlineInput): ChatOutline {
|
||||
const [index, setIndex] = useState<AgentTimelinePromptIndexPayload | null>(null);
|
||||
const [pendingJump, setPendingJump] = useState<PendingPromptJump | null>(null);
|
||||
@@ -135,7 +139,12 @@ export function useChatOutline({
|
||||
useEffect(() => {
|
||||
if (pendingJump === null) return;
|
||||
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);
|
||||
setPendingJump((current) => {
|
||||
if (current?.requestId !== pendingJump.requestId) return current;
|
||||
@@ -144,7 +153,7 @@ export function useChatOutline({
|
||||
return;
|
||||
}
|
||||
if (pendingJump.fetchSettled) setPendingJump(null);
|
||||
}, [loadedItems, pendingJump, viewportRef]);
|
||||
}, [loadedItems, pendingJump, revealLoadedItem, viewportRef, visibleItemIds]);
|
||||
|
||||
const jumpToPrompt = useCallback(
|
||||
(seq: number) => {
|
||||
@@ -152,6 +161,11 @@ export function useChatOutline({
|
||||
setPendingJump(null);
|
||||
const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq);
|
||||
if (loaded) {
|
||||
if (revealLoadedItem?.(loaded.id)) {
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
|
||||
return;
|
||||
}
|
||||
viewportRef.current?.scrollToMessage?.(loaded.id);
|
||||
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 };
|
||||
|
||||
@@ -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", () => {
|
||||
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", () => {
|
||||
const dormant = createHistoryStartPaginationState();
|
||||
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