mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+86
-76
@@ -1,98 +1,108 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.5.0-beta.4 - 2026-08-21
|
## 0.6.0 - 2026-08-25
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- 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 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.2 - 2026-08-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed workspaces crashing on Android tablets and foldables in non-compact layouts ([#3760](https://github.com/getpaseo/paseo/pull/3760) by [@jeid64](https://github.com/jeid64))
|
||||||
|
- Fixed app settings resetting when builds sharing a profile recognize different settings ([#3787](https://github.com/getpaseo/paseo/pull/3787))
|
||||||
|
|
||||||
|
## 0.5.1 - 2026-08-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed multiline composers stopping resizing after workspace and agent transitions on iOS and Android ([#3740](https://github.com/getpaseo/paseo/pull/3740))
|
||||||
|
|
||||||
|
## 0.5.0 - 2026-08-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added 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 plugin-contributed application themes to Settings → Appearance ([#3602](https://github.com/getpaseo/paseo/pull/3602) by [@ragokan](https://github.com/ragokan))
|
||||||
- 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 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 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 project filtering to the sidebar display menu ([#3563](https://github.com/getpaseo/paseo/pull/3563) by [@cleiter](https://github.com/cleiter))
|
||||||
- Added active-turn steering for OpenCode ([#3580](https://github.com/getpaseo/paseo/pull/3580) by [@mcowger](https://github.com/mcowger))
|
- Added optional branch and project names on workspace rows ([#3445](https://github.com/getpaseo/paseo/pull/3445))
|
||||||
- Added separate Content text sizing for chat, the composer, Markdown, and review prose ([#3637](https://github.com/getpaseo/paseo/pull/3637))
|
- Added drag-and-drop reordering for pinned workspaces ([#3341](https://github.com/getpaseo/paseo/pull/3341))
|
||||||
- Added live sheets for summarized tool-call groups on compact layouts ([#3619](https://github.com/getpaseo/paseo/pull/3619))
|
- 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
|
||||||
|
|
||||||
- Changed the Side panel into a user-directed surface with independent tabs and configurable supporting-tab routing ([#3605](https://github.com/getpaseo/paseo/pull/3605))
|
- 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))
|
||||||
- Changed Default send in Settings to a dropdown menu ([#3644](https://github.com/getpaseo/paseo/pull/3644))
|
- 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))
|
||||||
- Reduced Android workspace-switch stalls after using several long chats ([#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))
|
||||||
|
- 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 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))
|
- Corrected Russian UI translations and terminology ([#3586](https://github.com/getpaseo/paseo/pull/3586) by [@timz](https://github.com/timz))
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed modified tab indicators overlapping truncated tab titles
|
- 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 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 OpenCode turns failing when their event stream dropped ([#3395](https://github.com/getpaseo/paseo/pull/3395))
|
||||||
- Fixed Changes opening an empty comparison after checkout state changes ([#3636](https://github.com/getpaseo/paseo/pull/3636))
|
- 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 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 active Codex agents failing to open when their native thread was archived outside Paseo ([#3334](https://github.com/getpaseo/paseo/pull/3334))
|
|
||||||
- Fixed OpenCode turns failing when plugin-heavy startup takes more than ten seconds ([#3578](https://github.com/getpaseo/paseo/pull/3578) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
|
|
||||||
- Fixed worktree creation rejecting Git-valid branch names containing uppercase letters, underscores, or dots ([#3591](https://github.com/getpaseo/paseo/pull/3591))
|
|
||||||
- Fixed Android Studio showing a generic terminal icon in the desktop open-target menu ([#3614](https://github.com/getpaseo/paseo/pull/3614) by [@caikovsky](https://github.com/caikovsky))
|
|
||||||
|
|
||||||
## 0.5.0-beta.3 - 2026-08-20
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Added Android Studio to Open in editor ([#3531](https://github.com/getpaseo/paseo/pull/3531) by [@caikovsky](https://github.com/caikovsky))
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
## 0.5.0-beta.1 - 2026-08-18
|
|
||||||
|
|
||||||
### 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 `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 skill management to Host → Agents, including remote hosts ([#3451](https://github.com/getpaseo/paseo/pull/3451))
|
|
||||||
- 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))
|
|
||||||
|
|
||||||
### 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))
|
|
||||||
- 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
|
|
||||||
|
|
||||||
### 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 an unrelated merged pull request archiving a workspace ([#3425](https://github.com/getpaseo/paseo/pull/3425))
|
- Fixed an unrelated merged pull request archiving a workspace ([#3425](https://github.com/getpaseo/paseo/pull/3425))
|
||||||
- Fixed Annotate element and Screenshot element doing nothing on loaded desktop browser pages ([#3187](https://github.com/getpaseo/paseo/pull/3187) by [@dgk-dev](https://github.com/dgk-dev))
|
- Fixed Annotate element and Screenshot element doing nothing on loaded desktop browser pages ([#3187](https://github.com/getpaseo/paseo/pull/3187) by [@dgk-dev](https://github.com/dgk-dev))
|
||||||
- Fixed repeated copy and fork footers after heartbeat runs ([#3484](https://github.com/getpaseo/paseo/pull/3484))
|
- Fixed repeated copy and fork footers after heartbeat runs ([#3484](https://github.com/getpaseo/paseo/pull/3484))
|
||||||
- Fixed Cursor plan usage on hosts signed in only through `cursor-agent` ([#3486](https://github.com/getpaseo/paseo/pull/3486) by [@Lite-G](https://github.com/Lite-G))
|
- Fixed Cursor plan usage on hosts signed in only through `cursor-agent` ([#3486](https://github.com/getpaseo/paseo/pull/3486) by [@Lite-G](https://github.com/Lite-G))
|
||||||
- Fixed resumed subagents splitting into separate rows
|
- Fixed mobile composers retaining sent text after submission ([#3564](https://github.com/getpaseo/paseo/pull/3564))
|
||||||
- Fixed a detected pull request revealing the explorer pane and stealing focus, which then made new agents, terminals, and browser tabs open inside the explorer pane instead of the main one
|
- Fixed multiline composers retaining stale heights across workspace and draft lifecycles ([#3681](https://github.com/getpaseo/paseo/pull/3681), [#3740](https://github.com/getpaseo/paseo/pull/3740))
|
||||||
- Fixed workspace pull request status being lost on reload
|
- Fixed pull request checkout when the target repository is configured as `upstream` ([#2997](https://github.com/getpaseo/paseo/pull/2997) by [@mcowger](https://github.com/mcowger))
|
||||||
- Fixed hidden terminal tabs stopping output in the focused workspace
|
- Fixed composer steers remaining unread while Claude or Codex waited on a permission ([#3585](https://github.com/getpaseo/paseo/pull/3585) by [@cleiter](https://github.com/cleiter))
|
||||||
|
- Fixed Changes opening an empty comparison after checkout state changes ([#3636](https://github.com/getpaseo/paseo/pull/3636))
|
||||||
|
- Fixed Pi chats ending in an error state during a successful automatic retry ([#3639](https://github.com/getpaseo/paseo/pull/3639))
|
||||||
|
- Fixed Claude usage appearing unavailable when macOS Keychain contains multiple credential items ([#3597](https://github.com/getpaseo/paseo/pull/3597) by [@t-benoit](https://github.com/t-benoit))
|
||||||
|
- Fixed provider usage checks overwriting Claude or Codex credential files ([#3442](https://github.com/getpaseo/paseo/pull/3442) by [@danberindei](https://github.com/danberindei))
|
||||||
|
- Fixed active Codex agents failing to open when their native thread was archived outside Paseo ([#3334](https://github.com/getpaseo/paseo/pull/3334))
|
||||||
|
- Fixed agents remaining impossible to stop after the provider had already settled their turn ([#3742](https://github.com/getpaseo/paseo/pull/3742))
|
||||||
|
- Fixed worktree creation rejecting Git-valid branch names containing uppercase letters, underscores, or dots ([#3591](https://github.com/getpaseo/paseo/pull/3591))
|
||||||
|
- Fixed project icons showing stale images in New Workspace ([#3600](https://github.com/getpaseo/paseo/pull/3600))
|
||||||
|
- Fixed malformed SVG project icons crashing the iOS app ([#3711](https://github.com/getpaseo/paseo/pull/3711) by [@omercnet](https://github.com/omercnet))
|
||||||
|
- Fixed pinning a project's only workspace hiding its New workspace action ([#3722](https://github.com/getpaseo/paseo/pull/3722) by [@yugui923](https://github.com/yugui923))
|
||||||
|
- Fixed workspace layout changes bypassing timeline retry backoff ([#3736](https://github.com/getpaseo/paseo/pull/3736))
|
||||||
|
- Fixed fresh installs resolving provider SDK versions that were not validated with the release ([#3678](https://github.com/getpaseo/paseo/pull/3678))
|
||||||
|
|
||||||
## 0.4.0 - 2026-08-13
|
## 0.4.0 - 2026-08-13
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
|||||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||||
| [docs/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference |
|
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and routing preferences |
|
||||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||||
|
|||||||
+10
-1
@@ -35,7 +35,16 @@ be in flight.
|
|||||||
|
|
||||||
### Cancellation
|
### Cancellation
|
||||||
|
|
||||||
Cancellation changes lifecycle state only after the provider acknowledges the interrupt or emits a terminal turn event. If the interrupt is rejected or times out, the agent remains `running` with its active foreground turn intact. Follow-up actions such as replacement, reload, rewind, and Stop must report that failure instead of accepting work they cannot perform. Synthesizing a local cancellation without provider acknowledgment creates a split-brain session: Paseo accepts a new prompt while the provider still owns the previous foreground turn.
|
Provider interruption is idempotent at the `AgentSession` boundary. It resolves when the prior
|
||||||
|
foreground turn can no longer run, including when the provider reports that it is already idle. It
|
||||||
|
rejects only when the provider may still own the turn. Provider adapters translate native errors
|
||||||
|
into that contract; lifecycle callers do not interpret provider-specific errors.
|
||||||
|
|
||||||
|
After an acknowledged interrupt, the manager settles the captured run even when no terminal event
|
||||||
|
arrives or the run was still waiting for its provider turn id. The captured run token prevents an
|
||||||
|
older cancellation from settling a newer turn. If interruption is rejected or times out, the agent
|
||||||
|
keeps its active foreground turn and replacement, reload, rewind, and Stop report the failure.
|
||||||
|
Accepting new work after an ambiguous interruption would create a split-brain session.
|
||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
|
|||||||
|
|
||||||
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
|
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
|
||||||
|
|
||||||
|
Header and toolbar controls use `interactionHighlight` for hovered, pressed, open, and selected
|
||||||
|
backgrounds. It is a translucent semantic fill so the same control works over the main surface and
|
||||||
|
the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control also fades its content.
|
||||||
|
|
||||||
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
||||||
|
|
||||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||||
@@ -155,6 +159,8 @@ On a narrow desktop route, app navigation yields to the rendered content topolog
|
|||||||
|
|
||||||
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
|
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
|
||||||
|
|
||||||
|
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
|
||||||
|
|
||||||
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+23
-2
@@ -242,13 +242,13 @@ focused invalidation capture; React Native's generated stacks make that mode hig
|
|||||||
Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
|
Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
|
||||||
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
|
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
|
||||||
|
|
||||||
For the desktop Side panel toggle, run the app against the root checkout's daemon and use:
|
For the desktop Explorer sidebar toggle, run the app against the root checkout's daemon and use:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run profile:explorer-toggle --workspace=@getpaseo/app
|
npm run profile:explorer-toggle --workspace=@getpaseo/app
|
||||||
```
|
```
|
||||||
|
|
||||||
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Side panel pane,
|
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Explorer pane,
|
||||||
records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports
|
records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports
|
||||||
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
|
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
|
||||||
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
|
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
|
||||||
@@ -278,6 +278,17 @@ That report includes input and composer height changes plus React work grouped i
|
|||||||
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
|
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
|
||||||
boundaries are nested. A printable key after an empty newline should not change either height.
|
boundaries are nested. A printable key after an empty newline should not change either height.
|
||||||
|
|
||||||
|
### Preview Windows and Linux window controls on macOS
|
||||||
|
|
||||||
|
Desktop development can replace native macOS traffic lights with Paseo's custom controls:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PASEO_DESKTOP_WINDOW_CONTROLS=windows npm run dev:desktop
|
||||||
|
PASEO_DESKTOP_WINDOW_CONTROLS=linux npm run dev:desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
The override is rejected in packaged builds. Restart the desktop process when changing it.
|
||||||
|
|
||||||
### Desktop macOS compositor watchdog
|
### Desktop macOS compositor watchdog
|
||||||
|
|
||||||
macOS display sleep can leave Chromium's GPU-process display link — the vsync
|
macOS display sleep can leave Chromium's GPU-process display link — the vsync
|
||||||
@@ -502,6 +513,16 @@ For tighter loops, you can rebuild a single workspace:
|
|||||||
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
|
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
|
||||||
- Changed app build dependencies: `npm run build:app-deps`.
|
- Changed app build dependencies: `npm run build:app-deps`.
|
||||||
|
|
||||||
|
## Dependency patches
|
||||||
|
|
||||||
|
`patches/*.patch` are applied by `scripts/postinstall-patches.mjs` on every install. A patch only
|
||||||
|
runs when its package is actually present, so add the package to that script's `patchedPackages`
|
||||||
|
list when you introduce a new patch — otherwise the file sits in `patches/` and never applies.
|
||||||
|
Regenerate a patch with `npx patch-package <package>` after editing `node_modules/<package>`, and
|
||||||
|
patch every build the consumers use: Metro resolves the `react-native` field of a package
|
||||||
|
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
|
||||||
|
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
|
||||||
|
|
||||||
## ACP provider catalog versions
|
## ACP provider catalog versions
|
||||||
|
|
||||||
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
|
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Explorer sidebar and side pane
|
||||||
|
|
||||||
|
The Explorer sidebar and the side pane share panel implementations, but they have different shell
|
||||||
|
contracts.
|
||||||
|
|
||||||
|
| Surface | Purpose | Lifecycle |
|
||||||
|
| ---------------- | ---------------------------- | ------------------------------------------ |
|
||||||
|
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
|
||||||
|
| Side pane | Ordinary workspace content | Created and closed like any workspace pane |
|
||||||
|
|
||||||
|
## Panel host contract
|
||||||
|
|
||||||
|
Every desktop panel registers its supported `PaneHost` values and presentation. Launchers derive
|
||||||
|
fixed-target labels and icons from that registration, filter by host, and never substitute one
|
||||||
|
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
|
||||||
|
a compatible pane.
|
||||||
|
|
||||||
|
Files and Changes are the Explorer defaults and its singleton navigation views. Other compatible
|
||||||
|
tabs, including agents, terminals, files, and diffs, can move between Explorer and main panes.
|
||||||
|
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
|
||||||
|
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
|
||||||
|
|
||||||
|
## Explorer sidebar
|
||||||
|
|
||||||
|
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
|
||||||
|
On desktop, the shell is rendered outside the workspace split canvas so it divides the full
|
||||||
|
workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
|
||||||
|
never read or modify that width.
|
||||||
|
|
||||||
|
The persisted layout still contains the Explorer pane so tabs survive reloads. The renderer removes
|
||||||
|
that pane from the workspace split tree and docks it separately. Persisted identifiers retain the
|
||||||
|
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
|
||||||
|
|
||||||
|
The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and
|
||||||
|
toggles the singleton Files and Changes views. Individual tab menus close instances or move
|
||||||
|
compatible tabs to main. Explorer tabs can be reordered, but the dock cannot be split. Selecting
|
||||||
|
an Explorer tab does not change workspace focus.
|
||||||
|
|
||||||
|
Cmd+E selects Changes for Git workspaces and Files otherwise. Compact layouts use the combined
|
||||||
|
full-screen Explorer overlay and close it after a file opens. Wide native layouts without pane
|
||||||
|
splits use the same combined content in a resizable inline dock; opening a file leaves that dock
|
||||||
|
visible. Both presentations keep their selection in the panel store and reuse the layout store's
|
||||||
|
per-workspace Explorer width. They do not create a second Explorer lifecycle.
|
||||||
|
|
||||||
|
## Side pane
|
||||||
|
|
||||||
|
`packages/app/src/workspace-tabs/open-beside.ts` owns content opened beside the user's work. The
|
||||||
|
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
|
||||||
|
right split around the workspace root; later side opens reuse it.
|
||||||
|
|
||||||
|
Closing the pane or moving away its final tab removes it normally and clears the remembered id. A
|
||||||
|
later side open creates a new pane. There is no hidden side-pane lifecycle.
|
||||||
|
|
||||||
|
Placement intent still controls existing tabs:
|
||||||
|
|
||||||
|
| Mode | New target | Existing target |
|
||||||
|
| --------- | --------------------------- | --------------------------------- |
|
||||||
|
| `pane` | opens in the requested pane | moves to the requested pane |
|
||||||
|
| `prefer` | opens in the requested pane | stays where the user placed it |
|
||||||
|
| `focused` | opens in the focused pane | focuses it where it already lives |
|
||||||
|
| `ambient` | opens in a compatible pane | focuses it where it already lives |
|
||||||
|
|
||||||
|
Explicit **Open to Side** uses `pane`. Implicit opens use `prefer`, so a preference affects only a
|
||||||
|
new target and never yanks an existing tab out of a user-selected pane.
|
||||||
|
|
||||||
|
## Routing preferences
|
||||||
|
|
||||||
|
Desktop **Settings → Layout → Open in side pane** has independent switches for Explorer Files,
|
||||||
|
Explorer Changes, chat files, diff files, subagents, pull requests, and Changes links. All switches
|
||||||
|
default off. Mobile ignores them.
|
||||||
|
|
||||||
|
Panels request an implicit open through the narrow `openPreferredTarget(target, source)` pane
|
||||||
|
contract. Entry points outside panels use `openPreferredWorkspaceTarget`. Do not branch on a
|
||||||
|
specific shell inside a panel.
|
||||||
+2
-1
@@ -19,7 +19,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
|||||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||||
- **Side panel** — The companion surface beside the user's work, holding Changes, Files, the pull request, and tabs an agent opens alongside its output. UI: "Side panel". Code: `packages/app/src/workspace-tabs/side-panel.ts`; the persisted pane id and layout key keep the pre-rename `explorer` spelling. See [side-panel.md](side-panel.md). Forbidden: "Explorer" as the name of the surface — **File Explorer** is only the Files view inside it.
|
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||||
|
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||||
|
|||||||
@@ -165,6 +165,10 @@ RPC. Snapshot DTOs are deeply readonly and frozen at runtime so plugin code cann
|
|||||||
app state or a memoized selection. Panels use one persisted
|
app state or a memoized selection. Panels use one persisted
|
||||||
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
|
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
|
||||||
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
|
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
|
||||||
|
Panels declare `locations: ["workspace", "explorer"]` to opt into Explorer hosting; omission means
|
||||||
|
workspace only. Location controls hosting, not context. An agent panel target keeps its `agentId`
|
||||||
|
when moved between hosts. Explorer configuration can create workspace-context panels and remove
|
||||||
|
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
|
||||||
|
|
||||||
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
|
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
|
||||||
They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the
|
They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `
|
|||||||
|
|
||||||
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
|
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
|
||||||
|
|
||||||
|
### Usage fetchers are read-only on credentials
|
||||||
|
|
||||||
|
A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ACP Provider Checklist
|
## ACP Provider Checklist
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
# Side panel
|
|
||||||
|
|
||||||
The companion surface beside the user's work: Changes, Files, the pull request, and
|
|
||||||
anything an agent asks to show alongside its output.
|
|
||||||
|
|
||||||
Everything that opens, reveals, hides, or asks about it goes through
|
|
||||||
`packages/app/src/workspace-tabs/side-panel.ts`. Nothing else branches on which
|
|
||||||
surface the current layout uses.
|
|
||||||
|
|
||||||
## Three renderings, two facts
|
|
||||||
|
|
||||||
| Layout | Rendering |
|
|
||||||
| ---------------------- | ----------------------------------------------- |
|
|
||||||
| compact | slide-over overlay, owned by the panel store |
|
|
||||||
| non-compact + splits | dedicated right pane, owned by the layout store |
|
|
||||||
| non-compact, no splits | ordinary tabs in the focused pane |
|
|
||||||
|
|
||||||
The third case is the one that bites. A native tablet renders one pane at a time and
|
|
||||||
the tab row only lists the focused pane's tabs, so anything routed into a separate
|
|
||||||
side panel pane there disappears with no way back.
|
|
||||||
|
|
||||||
## Placement
|
|
||||||
|
|
||||||
Where a tab lands is an intent the caller declares, not something the store infers
|
|
||||||
from a `paneId` that happens to be in scope. `WorkspaceTabPlacement`
|
|
||||||
(`packages/app/src/stores/workspace-layout-actions.ts`) has four modes, and the
|
|
||||||
difference between them only shows up when the tab is already open somewhere:
|
|
||||||
|
|
||||||
| Mode | New tab | Tab already open |
|
|
||||||
| --------- | ----------------- | ---------------------------- |
|
|
||||||
| `pane` | opens in the pane | **moves** to the pane |
|
|
||||||
| `prefer` | opens in the pane | stays where the user left it |
|
|
||||||
| `focused` | focused pane | focuses it in place |
|
|
||||||
| `ambient` | focused pane | focuses it in place |
|
|
||||||
|
|
||||||
`pane` is a pane-local affordance — that pane's `+` menu, its empty-state launcher.
|
|
||||||
The user is placing the tab and expects it to arrive.
|
|
||||||
|
|
||||||
`prefer` is an implicit supporting open — an agent's file link or a failed workspace
|
|
||||||
setup. It has an opinion about new tabs only. Yanking a tab out of the pane the user
|
|
||||||
deliberately moved it to is the failure this mode exists to prevent.
|
|
||||||
|
|
||||||
`ambient` differs from `focused` in one rule: reconciliation opens tabs with nobody
|
|
||||||
behind the click, and the side panel can hold focus from an earlier reveal, so an
|
|
||||||
ambient open of something the user works _in_ (an agent, a terminal, a browser) is
|
|
||||||
pushed out of the side panel. A `focused` open honours the focused pane whatever it
|
|
||||||
is, because the user is standing in it.
|
|
||||||
|
|
||||||
Deduplication lives in `openTabInLayoutFocused`, so it obeys the placement intent for
|
|
||||||
every caller. Do not re-place a tab after opening it.
|
|
||||||
|
|
||||||
## Lifecycle
|
|
||||||
|
|
||||||
The side panel lives in the ordinary split tree, so it is a pane like any other until
|
|
||||||
it empties or the user dismisses it:
|
|
||||||
|
|
||||||
- **Close pane** on the side panel **hides** it; its tabs are waiting on the next reveal.
|
|
||||||
- **Close pane** on any other pane **removes** it.
|
|
||||||
- Closing the Side panel's final tab hides the pane. Revealing it returns the empty launcher.
|
|
||||||
This is close-only: claiming or dragging that tab to another pane leaves the empty Side panel
|
|
||||||
visible.
|
|
||||||
- Nothing takes the last visible pane away. `closePane` will not remove it, and
|
|
||||||
neither `closePane` nor `hideSidePanel` will hide it — including the header toggle
|
|
||||||
reaching `hideSidePanel` after every other pane is gone. The gate is one check in
|
|
||||||
`setPaneHiddenInLayout`; do not add a second one upstream.
|
|
||||||
|
|
||||||
`closePane` in the layout store owns that distinction. React renders the button and
|
|
||||||
does not decide what it means.
|
|
||||||
|
|
||||||
Dragging a pane's last tab into another pane collapses the source pane — except the
|
|
||||||
side panel, which the user summons and expects to find again, split size and all.
|
|
||||||
|
|
||||||
## What the side panel never does
|
|
||||||
|
|
||||||
It does not seed itself. Revealing it opens an empty pane with the launcher; the user
|
|
||||||
picks what goes in. Detecting a pull request makes the launcher's card available and
|
|
||||||
opens nothing. A failed workspace setup adds a background tab to the main pane.
|
|
||||||
Running and successful setup never seed tabs.
|
|
||||||
|
|
||||||
## Routing preference
|
|
||||||
|
|
||||||
**Settings → General → Open supporting tabs in the Side panel**, on by default,
|
|
||||||
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
|
|
||||||
supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions
|
|
||||||
— the header toggle, a pane's `+` menu, the empty-state launcher — are never subject
|
|
||||||
to it.
|
|
||||||
|
|
||||||
## The name
|
|
||||||
|
|
||||||
The product surface is the **Side panel**. **File Explorer** is still the name of the
|
|
||||||
Files view inside it. Persisted identifiers keep their pre-rename spelling: the pane
|
|
||||||
id is the literal string `"explorer"` (`SIDE_PANEL_PANE_ID`) and the persisted layout
|
|
||||||
key is `explorerPaneIdByWorkspace`. The persisted state schema is strict, so renaming
|
|
||||||
either would fail every saved blob and wipe the layout it describes.
|
|
||||||
@@ -189,6 +189,9 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
|||||||
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
|
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
|
||||||
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
|
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
|
||||||
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
|
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
|
||||||
|
- The app Playwright harness boots on Windows as well as POSIX. Spawn Node entrypoints through `process.execPath`, not `npx` or `node_modules/.bin` shims: Node refuses to spawn `.cmd` or `.bat` without `shell: true`, and shell mode concatenates argv without escaping and sends kill signals to `cmd.exe` instead of the real child.
|
||||||
|
- Teardown kills the process tree, because a Windows signal reaches only the direct child and leaves forked workers holding the listening port.
|
||||||
|
- The `asdf`-backed local Elixir relay stays POSIX-only, so the `relay-deployment` Playwright project is unavailable on Windows.
|
||||||
|
|
||||||
## Pull-request test routing
|
## Pull-request test routing
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ A failed catch-up or subscription reconcile retries on its own, doubling from 1s
|
|||||||
A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active
|
A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active
|
||||||
writer, say — into a request and a log line every second on an idle app. The delay resets on success,
|
writer, say — into a request and a log line every second on an idle app. The delay resets on success,
|
||||||
reconnect, delivery-mode change, and visibility change, so recovery is still immediate once the
|
reconnect, delivery-mode change, and visibility change, so recovery is still immediate once the
|
||||||
condition clears.
|
condition clears. Republishing the same visibility set is a no-op and never bypasses backoff.
|
||||||
|
|
||||||
Background retries are silent. The retry the user presses in the sync-error callout is a fallible
|
Background retries are silent. The retry the user presses in the sync-error callout is a fallible
|
||||||
user action and owns its pending state: `retrying` is a status the sync model publishes, not a React
|
user action and owns its pending state: `retrying` is a status the sync model publishes, not a React
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-TNwqDZ9gJP613iYmDzVfK5vfye5NBloKN5Jt9t7yhx8=
|
sha256-TRZej2L43C3go4NWe496Dqs/4A+0GivCRtGzt3pX2dw=
|
||||||
|
|||||||
Generated
+27
-27
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.5.0-beta.4",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.5.0-beta.4",
|
"version": "0.6.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -36140,7 +36140,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.5.0-beta.4",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
@@ -37192,12 +37192,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.5.0-beta.4",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.5.0-beta.4",
|
"@getpaseo/client": "0.6.0",
|
||||||
"@getpaseo/protocol": "0.5.0-beta.4",
|
"@getpaseo/protocol": "0.6.0",
|
||||||
"@getpaseo/server": "0.5.0-beta.4",
|
"@getpaseo/server": "0.6.0",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -37444,10 +37444,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.5.0-beta.4",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.5.0-beta.4",
|
"@getpaseo/protocol": "0.6.0",
|
||||||
"@getpaseo/relay": "0.5.0-beta.4",
|
"@getpaseo/relay": "0.6.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -37458,7 +37458,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.5.0-beta.4",
|
"version": "0.6.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -37701,7 +37701,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.5.0-beta.4",
|
"version": "0.6.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -38597,7 +38597,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.5.0-beta.4",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -38626,12 +38626,12 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.5.0-beta.4",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.5.0-beta.4",
|
"@getpaseo/client": "0.6.0",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -38640,14 +38640,14 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.5.0-beta.4",
|
"@getpaseo/client": "0.6.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.5.0-beta.4",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -38660,7 +38660,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.5.0-beta.4",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -38878,19 +38878,19 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.5.0-beta.4",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@babel/parser": "^7.29.8",
|
"@babel/parser": "^7.29.8",
|
||||||
"@getpaseo/client": "0.5.0-beta.4",
|
"@getpaseo/client": "0.6.0",
|
||||||
"@getpaseo/highlight": "0.5.0-beta.4",
|
"@getpaseo/highlight": "0.6.0",
|
||||||
"@getpaseo/plugin": "0.5.0-beta.4",
|
"@getpaseo/plugin": "0.6.0",
|
||||||
"@getpaseo/protocol": "0.5.0-beta.4",
|
"@getpaseo/protocol": "0.6.0",
|
||||||
"@getpaseo/relay": "0.5.0-beta.4",
|
"@getpaseo/relay": "0.6.0",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
"@xterm/headless": "^6.0.0",
|
"@xterm/headless": "^6.0.0",
|
||||||
"ai": "5.0.78",
|
"ai": "5.0.78",
|
||||||
@@ -39940,7 +39940,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.5.0-beta.4",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.5.0-beta.4",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -442,21 +442,40 @@ function formatFrames(frames: TurnFrame[], centers: number | number[]): string {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submittedRowContentTop(frame: TurnFrame | undefined): number | undefined {
|
||||||
|
const viewportTop = frame?.userRow.rect?.top;
|
||||||
|
const scrollTop = frame?.scroll.scrollTop;
|
||||||
|
return viewportTop == null || scrollTop == null ? undefined : viewportTop + scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
function expectRowContinuity(frames: TurnFrame[]): void {
|
function expectRowContinuity(frames: TurnFrame[]): void {
|
||||||
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
|
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
|
||||||
const baseline = frames[first]?.userRow.rect?.top;
|
const baselineFrame = frames[first];
|
||||||
|
const baselineContentTop = submittedRowContentTop(baselineFrame);
|
||||||
const disappeared = frames.findIndex(
|
const disappeared = frames.findIndex(
|
||||||
(frame, index) => index > first && !hasPaintedLayout(frame.userRow),
|
(frame, index) => index > first && !hasPaintedLayout(frame.userRow),
|
||||||
);
|
);
|
||||||
const reappeared = frames.findIndex(
|
const reappeared = frames.findIndex(
|
||||||
(frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow),
|
(frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow),
|
||||||
);
|
);
|
||||||
const shifted = frames.findIndex(
|
const contentShifted = frames.findIndex((frame, index) => {
|
||||||
(frame, index) =>
|
const contentTop = submittedRowContentTop(frame);
|
||||||
|
return (
|
||||||
index > first &&
|
index > first &&
|
||||||
hasPaintedLayout(frame.userRow) &&
|
hasPaintedLayout(frame.userRow) &&
|
||||||
baseline !== undefined &&
|
baselineContentTop !== undefined &&
|
||||||
Math.abs((frame.userRow.rect?.top ?? baseline) - baseline) > 1,
|
contentTop !== undefined &&
|
||||||
|
Math.abs(contentTop - baselineContentTop) > 1
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const viewportChanged = frames.findIndex(
|
||||||
|
(frame, index) =>
|
||||||
|
index > first &&
|
||||||
|
[
|
||||||
|
geometryChanged(frame.scroll.rect?.top, baselineFrame?.scroll.rect?.top),
|
||||||
|
geometryChanged(frame.scroll.rect?.height, baselineFrame?.scroll.rect?.height),
|
||||||
|
geometryChanged(frame.scroll.clientHeight, baselineFrame?.scroll.clientHeight),
|
||||||
|
].some(Boolean),
|
||||||
);
|
);
|
||||||
const attachmentMissing = frames.findIndex(
|
const attachmentMissing = frames.findIndex(
|
||||||
(frame, index) => index >= first && !hasPaintedLayout(frame.attachment),
|
(frame, index) => index >= first && !hasPaintedLayout(frame.attachment),
|
||||||
@@ -468,16 +487,18 @@ function expectRowContinuity(frames: TurnFrame[]): void {
|
|||||||
`submitted row disappeared for ${Math.max(0, reappeared - disappeared)} recorded frames (${Math.max(0, (frames[reappeared]?.at ?? frames.at(-1)?.at ?? 0) - frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`,
|
`submitted row disappeared for ${Math.max(0, reappeared - disappeared)} recorded frames (${Math.max(0, (frames[reappeared]?.at ?? frames.at(-1)?.at ?? 0) - frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(shifted >= 0
|
...(contentShifted >= 0
|
||||||
? [
|
? [
|
||||||
`submitted row moved from ${baseline?.toFixed(1)} to ${frames[shifted].userRow.rect?.top.toFixed(1)}`,
|
`submitted row moved in scroll content from ${baselineContentTop?.toFixed(1)} to ${submittedRowContentTop(frames[contentShifted])?.toFixed(1)}`,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(viewportChanged >= 0 ? ["scroll viewport geometry changed"] : []),
|
||||||
...(attachmentMissing >= 0 ? ["submitted image attachment left the painted layout"] : []),
|
...(attachmentMissing >= 0 ? ["submitted image attachment left the painted layout"] : []),
|
||||||
];
|
];
|
||||||
let failure = Math.max(0, first);
|
let failure = Math.max(0, first);
|
||||||
if (attachmentMissing >= 0) failure = attachmentMissing;
|
if (attachmentMissing >= 0) failure = attachmentMissing;
|
||||||
if (shifted >= 0) failure = shifted;
|
if (viewportChanged >= 0) failure = viewportChanged;
|
||||||
|
if (contentShifted >= 0) failure = contentShifted;
|
||||||
if (disappeared >= 0) failure = disappeared;
|
if (disappeared >= 0) failure = disappeared;
|
||||||
expect(
|
expect(
|
||||||
violations,
|
violations,
|
||||||
|
|||||||
@@ -30,11 +30,7 @@ const cleanupTasks: CleanupTask[] = [];
|
|||||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
function changesTree(page: Page) {
|
function changesTree(page: Page) {
|
||||||
return page.getByTestId("changes-tree-rail-tree");
|
return page.getByTestId("changes-file-tree").filter({ visible: true });
|
||||||
}
|
|
||||||
|
|
||||||
function changesContent(page: Page) {
|
|
||||||
return page.getByTestId("changes-tree-rail-content");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readFileIfPresent(filePath: string): Promise<string | null> {
|
async function readFileIfPresent(filePath: string): Promise<string | null> {
|
||||||
@@ -264,7 +260,8 @@ test("Changes opens the populated committed comparison for a clean checkout", as
|
|||||||
await openWorkspaceChangesSurface(page, workspace);
|
await openWorkspaceChangesSurface(page, workspace);
|
||||||
|
|
||||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
await expect(panel.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
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();
|
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -272,19 +269,19 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn
|
|||||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||||
const mode = panel.getByTestId("changes-diff-status-trigger");
|
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||||
await expect(mode).toContainText("Uncommitted");
|
await expect(mode).toContainText("Uncommitted");
|
||||||
|
|
||||||
await mode.click();
|
await mode.click();
|
||||||
await page.getByTestId("changes-diff-mode-committed").click();
|
await page.getByTestId("changes-diff-mode-committed").click();
|
||||||
await expect(mode).toContainText("Committed");
|
await expect(mode).toContainText("Committed");
|
||||||
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toBeVisible();
|
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toBeVisible();
|
||||||
|
|
||||||
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
|
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
|
||||||
execFileSync("git", ["commit", "-m", "Commit working changes"], { cwd: workspace.repoPath });
|
execFileSync("git", ["commit", "-m", "Commit working changes"], { cwd: workspace.repoPath });
|
||||||
await expect(mode).toContainText("Committed");
|
await expect(mode).toContainText("Committed");
|
||||||
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, {
|
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -297,7 +294,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
|||||||
await openWorkspaceChangesSurface(page, workspace);
|
await openWorkspaceChangesSurface(page, workspace);
|
||||||
|
|
||||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
const mode = panel.getByTestId("changes-diff-status-trigger");
|
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||||
|
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||||
await mode.click();
|
await mode.click();
|
||||||
await page.getByTestId("changes-diff-mode-committed").click();
|
await page.getByTestId("changes-diff-mode-committed").click();
|
||||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||||
@@ -305,8 +303,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
|||||||
await mode.click();
|
await mode.click();
|
||||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||||
|
|
||||||
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
||||||
const seeCommitted = panel.getByRole("button", { name: "See committed changes" });
|
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
|
||||||
await expect(seeCommitted).toBeVisible();
|
await expect(seeCommitted).toBeVisible();
|
||||||
await seeCommitted.click();
|
await seeCommitted.click();
|
||||||
await expect(mode).toContainText("Committed");
|
await expect(mode).toContainText("Committed");
|
||||||
@@ -321,7 +319,7 @@ test("changes file actions open below the right-click without a reserved kebab",
|
|||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||||
const deletedFileName = page.getByText("zz-deleted.ts", { exact: true });
|
const deletedFileName = page.getByTestId("diff-file-1-name");
|
||||||
await expect(deletedFileName).toHaveCSS("user-select", "none");
|
await expect(deletedFileName).toHaveCSS("user-select", "none");
|
||||||
await deletedFileName.dblclick();
|
await deletedFileName.dblclick();
|
||||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||||
@@ -432,7 +430,6 @@ test("changes context menus duplicate files and folders", async ({ page }) => {
|
|||||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
|
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
|
||||||
.toBe(AFTER);
|
.toBe(AFTER);
|
||||||
|
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
|
||||||
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||||
await page.getByTestId("diff-folder-src-duplicate").click();
|
await page.getByTestId("diff-folder-src-duplicate").click();
|
||||||
await expect
|
await expect
|
||||||
@@ -447,8 +444,6 @@ test("changes tree aligns every file status after its diff stat", async ({ page
|
|||||||
});
|
});
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
|
||||||
|
|
||||||
const tree = changesTree(page);
|
const tree = changesTree(page);
|
||||||
const modifiedRow = tree.getByTestId("diff-tree-file-0");
|
const modifiedRow = tree.getByTestId("diff-tree-file-0");
|
||||||
const deletedRow = tree.getByTestId("diff-tree-file-1");
|
const deletedRow = tree.getByTestId("diff-tree-file-1");
|
||||||
@@ -471,7 +466,6 @@ test("changes context menu recursively collapses descendant folders", async ({ p
|
|||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
|
||||||
const tree = changesTree(page);
|
const tree = changesTree(page);
|
||||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||||
@@ -516,7 +510,6 @@ test("changes context menus expose folder revert and restore a file after confir
|
|||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
|
||||||
const tree = changesTree(page);
|
const tree = changesTree(page);
|
||||||
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||||
const folderRevert = page.getByTestId("diff-folder-src-revert");
|
const folderRevert = page.getByTestId("diff-folder-src-revert");
|
||||||
@@ -649,6 +642,11 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
|||||||
|
|
||||||
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
await expect(visiblePanel).toBeVisible();
|
await expect(visiblePanel).toBeVisible();
|
||||||
|
await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0);
|
||||||
|
await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0);
|
||||||
|
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
|
||||||
|
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
|
||||||
|
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
||||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||||
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||||
@@ -658,34 +656,30 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
|||||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
|
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
|
||||||
|
|
||||||
// Collapse-all and the layout switch are overflow-menu items, not toolbar icons.
|
await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0);
|
||||||
const diffOptions = visiblePanel.getByRole("button", { name: "Diff options" });
|
|
||||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
|
|
||||||
await diffOptions.click();
|
|
||||||
// A workspace tab has nowhere to open itself, so the menu withholds that item.
|
|
||||||
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
|
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
|
||||||
await page.getByTestId("changes-toggle-collapse-all").click();
|
const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all");
|
||||||
await diffOptions.click();
|
await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files");
|
||||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toContainText("Expand all files");
|
await collapseFiles.click();
|
||||||
await page.getByTestId("changes-toggle-collapse-all").click();
|
await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files");
|
||||||
|
await collapseFiles.click();
|
||||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||||
|
|
||||||
const layoutItem = page.getByTestId("changes-toggle-layout");
|
const layout = visiblePanel.getByTestId("changes-toggle-layout");
|
||||||
await diffOptions.click();
|
await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff");
|
||||||
await expect(layoutItem).toContainText("Side-by-side diff");
|
await layout.click();
|
||||||
// Leading icon only: the layout is still unified, so no trailing check.
|
await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff");
|
||||||
await expect(layoutItem.locator("svg")).toHaveCount(1);
|
|
||||||
await layoutItem.click();
|
|
||||||
await diffOptions.click();
|
|
||||||
await expect(layoutItem.locator("svg")).toHaveCount(2);
|
|
||||||
|
|
||||||
await expect(page.getByText("Hide whitespace", { exact: true })).toBeVisible();
|
const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace");
|
||||||
await expect(page.getByText("Wrap long lines", { exact: true })).toBeVisible();
|
await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace");
|
||||||
await expect(page.getByTestId("changes-refresh")).toHaveText("Refresh");
|
const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines");
|
||||||
await page.getByText("Wrap long lines", { exact: true }).click();
|
await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines");
|
||||||
await diffOptions.click();
|
await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute(
|
||||||
await expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible();
|
"aria-label",
|
||||||
await page.keyboard.press("Escape");
|
"Refresh",
|
||||||
|
);
|
||||||
|
await wrapLines.click();
|
||||||
|
await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines");
|
||||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||||
|
|
||||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||||
@@ -701,168 +695,53 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
|||||||
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
|
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("desktop Changes toggles a navigation tree beside the expanded diff document", async ({
|
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
|
||||||
await useUnwrappedDiffLines(page);
|
|
||||||
await openWorkspaceChanges(page, workspace);
|
|
||||||
|
|
||||||
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();
|
|
||||||
const [changesToolbarBox, treeToggleBox, optionsBox, treeGlyphBox, optionsGlyphBox] =
|
|
||||||
await Promise.all([
|
|
||||||
page.getByTestId("changes-header").boundingBox(),
|
|
||||||
page.getByTestId("changes-toggle-tree").boundingBox(),
|
|
||||||
page.getByRole("button", { name: "Diff options" }).boundingBox(),
|
|
||||||
page.getByTestId("changes-toggle-tree").locator("svg").boundingBox(),
|
|
||||||
page.getByRole("button", { name: "Diff options" }).locator("svg").boundingBox(),
|
|
||||||
]);
|
|
||||||
if (!changesToolbarBox || !treeToggleBox || !optionsBox || !treeGlyphBox || !optionsGlyphBox) {
|
|
||||||
throw new Error("Changes toolbar geometry could not be measured");
|
|
||||||
}
|
|
||||||
expect(changesToolbarBox.height).toBe(36);
|
|
||||||
expect(treeToggleBox.width).toBe(24);
|
|
||||||
expect(treeToggleBox.height).toBe(24);
|
|
||||||
expect(optionsBox.width).toBe(24);
|
|
||||||
expect(optionsBox.height).toBe(24);
|
|
||||||
expect(treeGlyphBox.width).toBe(14);
|
|
||||||
expect(treeGlyphBox.height).toBe(14);
|
|
||||||
expect(optionsGlyphBox.width).toBe(14);
|
|
||||||
expect(optionsGlyphBox.height).toBe(14);
|
|
||||||
const diffOptions = page.getByRole("button", { name: "Diff options" });
|
|
||||||
const diffOptionsRestBackground = await diffOptions.evaluate(
|
|
||||||
(element) => getComputedStyle(element).backgroundColor,
|
|
||||||
);
|
|
||||||
await diffOptions.hover();
|
|
||||||
const diffOptionsHoverBackground = await diffOptions.evaluate(
|
|
||||||
(element) => getComputedStyle(element).backgroundColor,
|
|
||||||
);
|
|
||||||
expect(diffOptionsHoverBackground).not.toBe(diffOptionsRestBackground);
|
|
||||||
await page.getByTestId("changes-toggle-tree").hover();
|
|
||||||
expect(
|
|
||||||
await page
|
|
||||||
.getByTestId("changes-toggle-tree")
|
|
||||||
.evaluate((element) => getComputedStyle(element).backgroundColor),
|
|
||||||
).toBe(diffOptionsHoverBackground);
|
|
||||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("changes-toggle-layout")).toHaveCount(0);
|
|
||||||
|
|
||||||
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");
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
|
|
||||||
await scrollToLowerUnwrappedDiffRows(page);
|
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
|
||||||
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "true");
|
|
||||||
expect(
|
|
||||||
await page
|
|
||||||
.getByTestId("changes-toggle-tree")
|
|
||||||
.evaluate((element) => getComputedStyle(element).backgroundColor),
|
|
||||||
).toBe(diffOptionsHoverBackground);
|
|
||||||
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 expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "false");
|
|
||||||
await expectFlatFileList(page);
|
|
||||||
const screenshot = testInfo.outputPath("changes-toolbar.png");
|
|
||||||
await page.screenshot({ path: screenshot });
|
|
||||||
await testInfo.attach("changes-toolbar", { path: screenshot, contentType: "image/png" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("compact Changes keeps its options control touch-sized", async ({ page }) => {
|
|
||||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
await page.setViewportSize({ width: 480, height: 900 });
|
await page.setViewportSize({ width: 480, height: 900 });
|
||||||
|
|
||||||
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
|
const compactChangesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||||
const [optionsBox, glyphBox] = await Promise.all([
|
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();
|
||||||
|
|
||||||
|
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.boundingBox(),
|
||||||
options.locator("svg").boundingBox(),
|
options.locator("svg").boundingBox(),
|
||||||
]);
|
]);
|
||||||
if (!optionsBox || !glyphBox) {
|
if (!actionsBox || !optionsBox || !glyphBox) {
|
||||||
throw new Error("Compact Changes options geometry could not be measured");
|
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.width).toBe(32);
|
||||||
expect(optionsBox.height).toBe(32);
|
expect(optionsBox.height).toBe(32);
|
||||||
expect(glyphBox.width).toBe(18);
|
expect(glyphBox.width).toBe(18);
|
||||||
expect(glyphBox.height).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 options.click();
|
await options.click();
|
||||||
const wrapLines = page.getByText("Wrap long lines", { exact: true });
|
const wrapLines = page.getByText("Wrap long lines", { exact: true });
|
||||||
await expect(wrapLines).toBeVisible();
|
await expect(wrapLines).toBeVisible();
|
||||||
await wrapLines.click();
|
await wrapLines.click();
|
||||||
await options.click();
|
await options.click();
|
||||||
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Scroll long lines");
|
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 }) => {
|
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
|
||||||
@@ -872,7 +751,9 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
|
|||||||
|
|
||||||
const canvas = page.getByTestId("git-diff-canvas");
|
const canvas = page.getByTestId("git-diff-canvas");
|
||||||
const root = page.getByTestId("git-diff-canvas-root");
|
const root = page.getByTestId("git-diff-canvas-root");
|
||||||
const handle = page.getByTestId("workspace-split-resize-handle").getByRole("separator");
|
const handle = page
|
||||||
|
.getByTestId("workspace-explorer-sidebar-resize-handle")
|
||||||
|
.getByRole("separator");
|
||||||
await expect(handle).toBeVisible();
|
await expect(handle).toBeVisible();
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -893,11 +774,11 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
if (!handleBounds) throw new Error("Workspace split resize handle has no bounds");
|
if (!handleBounds) throw new Error("Explorer sidebar resize handle has no bounds");
|
||||||
|
|
||||||
await page.mouse.move(handleBounds.x + handleBounds.width / 2, handleBounds.y + 120);
|
await page.mouse.move(handleBounds.x + handleBounds.width / 2, handleBounds.y + 120);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.mouse.move(handleBounds.x - 120, handleBounds.y + 120);
|
await page.mouse.move(handleBounds.x + 120, handleBounds.y + 120);
|
||||||
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
|
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
|
||||||
|
|
||||||
const duringDrag = await Promise.all([
|
const duringDrag = await Promise.all([
|
||||||
@@ -996,6 +877,23 @@ test("canvas diff creates, edits, and deletes an inline review without DOM code
|
|||||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
|
||||||
|
const focusedBackground = await changesTab.evaluate(
|
||||||
|
(element) => getComputedStyle(element).backgroundColor,
|
||||||
|
);
|
||||||
|
|
||||||
|
await startReviewOnFirstChangedLine(page);
|
||||||
|
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||||
|
await expect
|
||||||
|
.poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||||
|
.toBe(focusedBackground);
|
||||||
|
});
|
||||||
|
|
||||||
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -1046,6 +944,7 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
|
|||||||
if (!bounds) throw new Error("Diff overlay scrollbar thumb has no bounds");
|
if (!bounds) throw new Error("Diff overlay scrollbar thumb has no bounds");
|
||||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
|
await expect(grab).toHaveCSS("cursor", "grabbing");
|
||||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 180);
|
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 180);
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
|
|
||||||
@@ -1095,7 +994,7 @@ test("canvas diff copies a dragged character selection without opening a review"
|
|||||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the first click after a canvas selection only dismisses the selection", async ({ page }) => {
|
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
|
||||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openSelectionWorkspaceChanges(page, workspace);
|
await openSelectionWorkspaceChanges(page, workspace);
|
||||||
@@ -1105,7 +1004,7 @@ test("the first click after a canvas selection only dismisses the selection", as
|
|||||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||||
|
|
||||||
await clickFirstChangedLine(page);
|
await clickFirstChangedLine(page);
|
||||||
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("canvas diff replaces a selection with forward and backward drags", async ({
|
test("canvas diff replaces a selection with forward and backward drags", async ({
|
||||||
@@ -1285,24 +1184,20 @@ async function setOpenChangesPresentation(
|
|||||||
page: Page,
|
page: Page,
|
||||||
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
|
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
|
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
|
const layoutItem = diffPanel.getByTestId("changes-toggle-layout");
|
||||||
await options.click();
|
const currentLayout =
|
||||||
const layoutItem = page.getByTestId("changes-toggle-layout");
|
(await layoutItem.getAttribute("aria-label")) === "Switch to unified diff"
|
||||||
const currentLayout = (await layoutItem.locator("svg").count()) === 2 ? "split" : "unified";
|
? "split"
|
||||||
|
: "unified";
|
||||||
if (currentLayout !== requestedPresentation.layout) {
|
if (currentLayout !== requestedPresentation.layout) {
|
||||||
await layoutItem.click();
|
await layoutItem.click();
|
||||||
} else {
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await options.click();
|
const wrapItem = diffPanel.getByTestId("changes-toggle-wrap-lines");
|
||||||
const wrapItem = page.getByTestId("changes-toggle-wrap-lines");
|
const currentWrapLines = (await wrapItem.getAttribute("aria-label")) === "Scroll long lines";
|
||||||
const currentWrapLines = (await wrapItem.locator("svg").count()) === 2;
|
|
||||||
if (currentWrapLines !== requestedPresentation.wrapLines) {
|
if (currentWrapLines !== requestedPresentation.wrapLines) {
|
||||||
await wrapItem.click();
|
await wrapItem.click();
|
||||||
} else {
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1337,12 +1232,6 @@ async function releaseBrowserFontLoads(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectFlatFileList(page: Page): Promise<void> {
|
|
||||||
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
|
|
||||||
await expect(page.getByTestId("diff-file-0")).toContainText("src");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
||||||
const canvas = page.getByTestId("git-diff-canvas");
|
const canvas = page.getByTestId("git-diff-canvas");
|
||||||
await expect
|
await expect
|
||||||
@@ -1463,6 +1352,7 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
|
|||||||
await page.setViewportSize({ width: 1400, height: 900 });
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||||
await openChangesInVisibleExplorer(page);
|
await openChangesInVisibleExplorer(page);
|
||||||
await expectExpandedMountedTabDiff(page);
|
await expectExpandedMountedTabDiff(page);
|
||||||
}
|
}
|
||||||
@@ -1479,13 +1369,27 @@ async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspa
|
|||||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await openChangesPanel(page);
|
await openChangesPanel(page);
|
||||||
await expect(page.getByText("selection.ts")).toBeVisible({ timeout: 30_000 });
|
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
|
await expect(diffPanel.getByTestId("diff-file-0-name")).toHaveText("selection.ts", {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
await expectExpandedMountedTabDiff(page);
|
await expectExpandedMountedTabDiff(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||||
await openChangesPanel(page);
|
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||||
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
|
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||||
|
const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first();
|
||||||
|
await changesTab.click();
|
||||||
|
const changedFile = explorer
|
||||||
|
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first();
|
||||||
|
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||||
|
await changedFile.click();
|
||||||
|
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||||
@@ -1537,7 +1441,8 @@ async function startReviewOnFirstChangedLine(
|
|||||||
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
||||||
const lineHeight = Math.round(fontSize * 1.5);
|
const lineHeight = Math.round(fontSize * 1.5);
|
||||||
const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x;
|
const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x;
|
||||||
await page.mouse.click(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
|
await page.mouse.move(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
|
||||||
|
await page.getByRole("button", { name: "Add review comment" }).click();
|
||||||
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { expect, test } from "../support/fixtures";
|
||||||
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
|
const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`;
|
||||||
|
|
||||||
|
test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-explorer-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
|
await page.keyboard.press(CHANGES_SHORTCUT);
|
||||||
|
|
||||||
|
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||||
|
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { Locator } from "@playwright/test";
|
import type { Locator } from "@playwright/test";
|
||||||
import { test, expect } from "../support/fixtures";
|
import { test, expect } from "../support/fixtures";
|
||||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
const COMMIT_SUBJECT = "Show commit timestamps";
|
const COMMIT_SUBJECT = "Show commit timestamps";
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ test("commit history explains when the workspace has no commits ahead of its bas
|
|||||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
|
||||||
await workspace.navigateTo();
|
await workspace.navigateTo();
|
||||||
|
|
||||||
await openChangesPanel(page);
|
await openChangesTreePanel(page);
|
||||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||||
await commitsSection.click();
|
await commitsSection.click();
|
||||||
@@ -35,7 +35,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
|||||||
await page.setViewportSize({ width: 1400, height: 900 });
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
await workspace.navigateTo();
|
await workspace.navigateTo();
|
||||||
|
|
||||||
await openChangesPanel(page);
|
await openChangesTreePanel(page);
|
||||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||||
await commitsSection.click();
|
await commitsSection.click();
|
||||||
@@ -60,8 +60,8 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
|||||||
throw new Error("Commit-diff toolbar geometry could not be measured");
|
throw new Error("Commit-diff toolbar geometry could not be measured");
|
||||||
}
|
}
|
||||||
expect(commitToolbarBox.height).toBe(36);
|
expect(commitToolbarBox.height).toBe(36);
|
||||||
expect(layoutToggleBox.width).toBe(24);
|
expect(layoutToggleBox.width).toBe(20);
|
||||||
expect(layoutToggleBox.height).toBe(24);
|
expect(layoutToggleBox.height).toBe(20);
|
||||||
expect(layoutToggleGlyphBox.width).toBe(14);
|
expect(layoutToggleGlyphBox.width).toBe(14);
|
||||||
expect(layoutToggleGlyphBox.height).toBe(14);
|
expect(layoutToggleGlyphBox.height).toBe(14);
|
||||||
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { expect, test } from "../support/fixtures";
|
import { expect, test } from "../support/fixtures";
|
||||||
|
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||||
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
|
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
test.describe("Direct terminal shortcut pane placement", () => {
|
test.describe("Direct terminal shortcut pane placement", () => {
|
||||||
test("opens a terminal in the focused pane", async ({ page }) => {
|
test("opens a terminal in the focused pane", async ({ page }) => {
|
||||||
@@ -11,21 +12,31 @@ test.describe("Direct terminal shortcut pane placement", () => {
|
|||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
await openChangesPanel(page);
|
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||||
const focusedPane = page
|
const focusedPaneChild = page
|
||||||
.getByTestId("workspace-tabs-row")
|
.getByTestId("split-group-child")
|
||||||
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
|
.filter({ has: page.getByTestId("workspace-new-tab-panel") });
|
||||||
.first();
|
const originalPaneChild = page
|
||||||
const primaryPane = page
|
.getByTestId("split-group-child")
|
||||||
.getByTestId("workspace-tabs-row")
|
.filter({ hasNot: page.getByTestId("workspace-new-tab-panel") });
|
||||||
.filter({ hasNot: page.getByTestId("workspace-tab-working_diff") })
|
await expect(focusedPaneChild).toBeVisible();
|
||||||
.first();
|
const focusedPaneId = await focusedPaneChild
|
||||||
|
.locator('[data-testid^="workspace-pane-"]')
|
||||||
|
.getAttribute("data-testid");
|
||||||
|
const originalPaneId = await originalPaneChild
|
||||||
|
.locator('[data-testid^="workspace-pane-"]')
|
||||||
|
.getAttribute("data-testid");
|
||||||
|
expect(focusedPaneId).not.toBeNull();
|
||||||
|
expect(originalPaneId).not.toBeNull();
|
||||||
|
const focusedPane = page.getByTestId(focusedPaneId!);
|
||||||
|
const originalPane = page.getByTestId(originalPaneId!);
|
||||||
|
|
||||||
await pressDirectNewTabShortcut(page, "t");
|
await pressDirectNewTabShortcut(page, "t");
|
||||||
|
|
||||||
await expect(focusedPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(1, {
|
await expect(focusedPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(1, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(primaryPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
await expect(originalPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { expect, type Locator, type Page } from "@playwright/test";
|
||||||
|
import { test } from "../support/fixtures";
|
||||||
|
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||||
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
|
function visibleNewTabPanel(page: Page): Locator {
|
||||||
|
return page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectLauncherSelection(launcher: Locator, name: string): Promise<void> {
|
||||||
|
await expect(launcher.getByRole("button", { name, exact: true })).toBeFocused();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openExplorerWithKeyboard(page: Page): Promise<Locator> {
|
||||||
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
|
await page.keyboard.press(`${modifier}+E`);
|
||||||
|
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||||
|
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||||
|
return explorer;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("New tab keyboard launcher", () => {
|
||||||
|
test("Cmd+E reveals the repository Changes tree", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "explorer-keyboard-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
|
const explorer = await openExplorerWithKeyboard(page);
|
||||||
|
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
|
||||||
|
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a new split pane focuses its launcher and supports arrow navigation", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-split-keyboard-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||||
|
|
||||||
|
const launcher = visibleNewTabPanel(page);
|
||||||
|
await expect(launcher).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expectLauncherSelection(launcher, "Agent");
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
await expectLauncherSelection(launcher, "Terminal");
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
await expectLauncherSelection(launcher, "Diff");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("workspace-tab-working_diff").filter({ visible: true }),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,14 +2,16 @@
|
|||||||
// 1) with the explorer pane focused, a newly appearing agent must auto-open into the
|
// 1) with the explorer pane focused, a newly appearing agent must auto-open into the
|
||||||
// MAIN pane in the background — the explorer pane is a background surface and must
|
// MAIN pane in the background — the explorer pane is a background surface and must
|
||||||
// never swallow entity tabs or lose the user's focus, and
|
// never swallow entity tabs or lose the user's focus, and
|
||||||
// 2) splitting a tab out of a pane leaves that pane empty, and the app must stay
|
// 2) splitting a tab out of a pane leaves a New tab behind, and the app must stay
|
||||||
// interactive in the [agent | empty main | explorer] state (the reporter hit an
|
// interactive in the [agent | New | explorer] state.
|
||||||
// app-wide lockup here with no way to close the empty pane).
|
|
||||||
import { expect, type Locator, type Page } from "@playwright/test";
|
import { expect, type Locator, type Page } from "@playwright/test";
|
||||||
import { test } from "../support/fixtures";
|
import { test } from "../support/fixtures";
|
||||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
import {
|
||||||
|
openChangesTreePanel,
|
||||||
|
waitForWorkspaceTabsVisible,
|
||||||
|
} from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
function visible(page: Page, testId: string): Locator {
|
function visible(page: Page, testId: string): Locator {
|
||||||
return page.getByTestId(testId).filter({ visible: true });
|
return page.getByTestId(testId).filter({ visible: true });
|
||||||
@@ -23,23 +25,18 @@ function draftTabChip(page: Page): Locator {
|
|||||||
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
|
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The explorer pane is the only tab row carrying the Changes tab. */
|
/** The Explorer has its own fixed tab rail, separate from workspace pane rows. */
|
||||||
function explorerTabRow(page: Page): Locator {
|
function explorerTabRow(page: Page): Locator {
|
||||||
return visible(page, "workspace-tabs-row").filter({
|
return visible(page, "explorer-sidebar-tab-rail");
|
||||||
has: page.getByTestId("workspace-tab-working_diff"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mainTabRow(page: Page): Locator {
|
function mainTabRow(page: Page): Locator {
|
||||||
return visible(page, "workspace-tabs-row").filter({
|
return visible(page, "workspace-tabs-row");
|
||||||
hasNot: page.getByTestId("workspace-tab-working_diff"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function focusExplorerPane(page: Page): Promise<void> {
|
async function selectExplorerChanges(page: Page): Promise<void> {
|
||||||
await openChangesPanel(page);
|
await openChangesTreePanel(page);
|
||||||
const changesTab = visible(page, "workspace-tab-working_diff").first();
|
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
|
||||||
await expect(changesTab).toHaveAttribute("aria-selected", "true");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeSeededDraftInMainPane(page: Page): Promise<void> {
|
async function closeSeededDraftInMainPane(page: Page): Promise<void> {
|
||||||
@@ -53,19 +50,19 @@ async function closeSeededDraftInMainPane(page: Page): Promise<void> {
|
|||||||
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
|
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyPaneChild(page: Page): Locator {
|
function newTabPaneChild(page: Page): Locator {
|
||||||
return page
|
return page
|
||||||
.getByTestId("split-group-child")
|
.getByTestId("split-group-child")
|
||||||
.filter({ has: page.getByTestId("workspace-pane-empty-state") })
|
.filter({ has: page.getByTestId("workspace-new-tab-panel") })
|
||||||
.first();
|
.first();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function emptyPaneBox(page: Page) {
|
async function emptyPaneBox(page: Page) {
|
||||||
const paneChild = emptyPaneChild(page);
|
const paneChild = newTabPaneChild(page);
|
||||||
await expect(paneChild).toBeVisible({ timeout: 15_000 });
|
await expect(paneChild).toBeVisible({ timeout: 15_000 });
|
||||||
const box = await paneChild.boundingBox();
|
const box = await paneChild.boundingBox();
|
||||||
expect(box).not.toBeNull();
|
expect(box).not.toBeNull();
|
||||||
if (!box) throw new Error("Empty pane has no bounding box");
|
if (!box) throw new Error("New tab pane has no bounding box");
|
||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +94,7 @@ async function dragChipTo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("explorer pane tab placement", () => {
|
test.describe("explorer pane tab placement", () => {
|
||||||
test("agents open in the main pane and empty-pane splits keep the app usable", async ({
|
test("agents open in the main pane and New-tab splits keep the app usable", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
const consoleErrors: string[] = [];
|
const consoleErrors: string[] = [];
|
||||||
@@ -116,8 +113,8 @@ test.describe("explorer pane tab placement", () => {
|
|||||||
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
|
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("focus the explorer pane", async () => {
|
await test.step("select Changes in Explorer", async () => {
|
||||||
await focusExplorerPane(page);
|
await selectExplorerChanges(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
|
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
|
||||||
@@ -141,11 +138,8 @@ test.describe("explorer pane tab placement", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
// The background open must not steal the explorer pane's focus.
|
// The background open must not change the selected Explorer view.
|
||||||
await expect(visible(page, "workspace-tab-working_diff").first()).toHaveAttribute(
|
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
|
||||||
"aria-selected",
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("close the draft; the agent is the main pane's only tab", async () => {
|
await test.step("close the draft; the agent is the main pane's only tab", async () => {
|
||||||
@@ -171,9 +165,10 @@ test.describe("explorer pane tab placement", () => {
|
|||||||
body: await page.screenshot(),
|
body: await page.screenshot(),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
// The split must have taken: agent pane + empty main + explorer = 3 tab rows.
|
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
|
||||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
|
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
|
||||||
await expect(page.getByTestId("workspace-pane-empty-state")).toBeVisible();
|
await expect(explorerTabRow(page)).toHaveCount(1);
|
||||||
|
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("app must stay interactive after the split", async () => {
|
await test.step("app must stay interactive after the split", async () => {
|
||||||
@@ -186,17 +181,12 @@ test.describe("explorer pane tab placement", () => {
|
|||||||
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
|
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
|
||||||
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
|
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
|
||||||
|
|
||||||
// Clicking inside the empty pane (focuses it) must not lock anything. Aim
|
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
|
||||||
// above the vertically centred launcher, which would open a tab instead.
|
// above the vertically centred launcher, which would open a tab instead.
|
||||||
const emptyPane = emptyPaneChild(page);
|
const emptyPane = newTabPaneChild(page);
|
||||||
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
|
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
|
||||||
|
|
||||||
// The empty pane's + menu must open and dismiss cleanly.
|
// A second drag must work: center-drop the agent tab back into the New pane.
|
||||||
const emptyPaneTrigger = emptyPane.getByTestId("workspace-new-tab-menu-trigger").first();
|
|
||||||
await emptyPaneTrigger.click({ timeout: 5_000 });
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
|
|
||||||
// A second drag must work: center-drop the agent tab back into the empty pane.
|
|
||||||
const target = await emptyPaneBox(page);
|
const target = await emptyPaneBox(page);
|
||||||
await dragChipTo(page, chip, {
|
await dragChipTo(page, chip, {
|
||||||
x: target.x + target.width / 2,
|
x: target.x + target.width / 2,
|
||||||
@@ -207,8 +197,8 @@ test.describe("explorer pane tab placement", () => {
|
|||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
|
|
||||||
// The agent chip must have moved into the previously empty pane and stay clickable.
|
// The agent chip must have replaced New in that pane and stay clickable.
|
||||||
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(0, {
|
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
await agentTabChip(page, agentId)
|
await agentTabChip(page, agentId)
|
||||||
|
|||||||
@@ -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,14 +19,8 @@ function readPersistedPanelState(page: Page): Promise<string | null> {
|
|||||||
return page.evaluate(() => localStorage.getItem("panel-state"));
|
return page.evaluate(() => localStorage.getItem("panel-state"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only the docked sidebar ever rendered `explorer-header` on a wide layout. */
|
function explorerSidebar(page: Page): Locator {
|
||||||
function dockedSidebar(page: Page): Locator {
|
return visible(page, "workspace-explorer-sidebar");
|
||||||
return visible(page, "explorer-header");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The desktop companion surface is a hideable side panel. */
|
|
||||||
function sidePanel(page: Page): Locator {
|
|
||||||
return visible(page, "workspace-side-panel");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
|
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
|
||||||
@@ -65,18 +59,18 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
|||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
await test.step("no docked sidebar, on load or after toggling", async () => {
|
await test.step("the retired flag does not reveal Explorer", async () => {
|
||||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
await expect(explorerSidebar(page)).toHaveCount(0);
|
||||||
|
|
||||||
const toggle = visible(page, "workspace-explorer-toggle").first();
|
const toggle = visible(page, "workspace-explorer-toggle").first();
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||||
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
|
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
await expect(
|
||||||
|
explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
|
||||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("the retired keys are migrated out of storage", async () => {
|
await test.step("the retired keys are migrated out of storage", async () => {
|
||||||
@@ -107,22 +101,22 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
|||||||
|
|
||||||
await test.step("open the explorer so both sides are showing", async () => {
|
await test.step("open the explorer so both sides are showing", async () => {
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||||
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
|
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||||
await expect(agentList).toHaveCount(1);
|
await expect(agentList).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("the shortcut collapses both", async () => {
|
await test.step("the shortcut collapses both", async () => {
|
||||||
await page.keyboard.press(`${modifier}+.`);
|
await page.keyboard.press(`${modifier}+.`);
|
||||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
|
||||||
await expect(agentList).toHaveCount(0);
|
await expect(agentList).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("the shortcut restores both", async () => {
|
await test.step("the shortcut restores both", async () => {
|
||||||
await page.keyboard.press(`${modifier}+.`);
|
await page.keyboard.press(`${modifier}+.`);
|
||||||
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
|
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
|
||||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||||
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
|
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
|
||||||
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
const RED_PIXEL = Buffer.from(
|
const RED_PIXEL = Buffer.from(
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -90,6 +92,87 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("CodeMirror workspace file editing", () => {
|
test.describe("CodeMirror workspace file editing", () => {
|
||||||
|
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "file-source-lockfile-",
|
||||||
|
title: "Large source",
|
||||||
|
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||||
|
});
|
||||||
|
const lockfile = `${'{"packages":['}${Array.from({ length: 42_000 }, (_, index) => `{"name":"package-${index}","version":"1.0.0"}`).join(",")}]}`;
|
||||||
|
await writeFile(path.join(session.cwd, "package-lock.json"), lockfile, "utf8");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await openWorkspaceFile(page, "package-lock.json");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
await expect(editor(page)).toContainText('"package-0"');
|
||||||
|
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the app interactive around a plain 11 MB source", async ({ page }) => {
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "file-source-plain-",
|
||||||
|
title: "Plain large source",
|
||||||
|
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
path.join(session.cwd, "plain.txt"),
|
||||||
|
"plain source\n".repeat(1_050_000),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await openWorkspaceFile(page, "plain.txt");
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
await expect(editor(page)).toContainText("plain source");
|
||||||
|
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
|
||||||
|
|
||||||
|
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
|
||||||
|
await expect(page.getByTestId("message-input-root")).toBeVisible();
|
||||||
|
await page.getByTestId("workspace-tab-file_plain.txt").first().click();
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses a file above the display budget and keeps its tab recoverable", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "file-source-unsupported-",
|
||||||
|
title: "Unsupported large source",
|
||||||
|
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
path.join(session.cwd, "too-large.txt"),
|
||||||
|
Buffer.alloc(51 * 1024 * 1024),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await openWorkspaceFile(page, "too-large.txt");
|
||||||
|
await expect(page.getByTestId("file-source-too-large")).toContainText(
|
||||||
|
"This file is too large to display",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
|
||||||
|
await expect(page.getByTestId("message-input-root")).toBeVisible();
|
||||||
|
await page.getByTestId("workspace-tab-file_too-large.txt").first().click();
|
||||||
|
await expect(page.getByTestId("file-source-too-large")).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
||||||
const target = "target.ts:42";
|
const target = "target.ts:42";
|
||||||
const session = await seedAgentWithFileLink({
|
const session = await seedAgentWithFileLink({
|
||||||
@@ -137,6 +220,12 @@ test.describe("CodeMirror workspace file editing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
|
test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
|
||||||
|
await page.addInitScript((settingsKey) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
settingsKey,
|
||||||
|
JSON.stringify({ openInSidePane: { explorerFiles: true } }),
|
||||||
|
);
|
||||||
|
}, APP_SETTINGS_KEY);
|
||||||
const target = "target.ts:42";
|
const target = "target.ts:42";
|
||||||
const session = await seedAgentWithFileLink({
|
const session = await seedAgentWithFileLink({
|
||||||
target,
|
target,
|
||||||
@@ -413,10 +502,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
|||||||
.filter({ visible: true })
|
.filter({ visible: true })
|
||||||
.first()
|
.first()
|
||||||
.click({ button: "right" });
|
.click({ button: "right" });
|
||||||
await page
|
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
|
||||||
.getByTestId("workspace-tab-context-file_draft.ts-close")
|
|
||||||
.filter({ visible: true })
|
|
||||||
.click();
|
|
||||||
expect(closePrompt).toContain("Closing it will discard the draft.");
|
expect(closePrompt).toContain("Closing it will discard the draft.");
|
||||||
|
|
||||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ async function assertHeldFileTransition(
|
|||||||
finalText: string,
|
finalText: string,
|
||||||
gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
|
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) => {
|
await page.evaluate((tabTestId) => {
|
||||||
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
|
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
|
||||||
const sample = () => {
|
const sample = () => {
|
||||||
@@ -69,6 +75,19 @@ async function assertHeldFileTransition(
|
|||||||
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
|
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
|
||||||
await expect(page.getByText("export const sourceA = true;")).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(
|
const heldTimeline = await page.evaluate(
|
||||||
() =>
|
() =>
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
// The file panel's tree rail had no way to close: the changes panel's toolbar
|
|
||||||
// carries a folder-tree toggle, the file toolbar carried nothing, and the rail
|
|
||||||
// was permanent on desktop.
|
|
||||||
import { expect } from "@playwright/test";
|
|
||||||
import { test } from "../support/fixtures";
|
|
||||||
import { 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-tree-rail-toggle-",
|
|
||||||
repo: { files: [{ path: "docs/guide.md", content: "# Guide\n" }] },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await workspace?.cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("File panel tree rail", () => {
|
|
||||||
test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => {
|
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
|
||||||
await openFileExplorer(page);
|
|
||||||
await openFileFromExplorer(page, "docs");
|
|
||||||
await openFileFromExplorer(page, "guide.md");
|
|
||||||
|
|
||||||
const rail = page.getByTestId("file-tree-rail-tree").filter({ visible: true });
|
|
||||||
const toggle = page.getByTestId("file-toggle-tree").filter({ visible: true }).first();
|
|
||||||
const editorToolbar = page.getByTestId("file-panel-bar").filter({ visible: true });
|
|
||||||
const filesToolbar = page.getByTestId("files-pane-header").filter({ visible: true });
|
|
||||||
const refresh = page.getByRole("button", { name: "Refresh files" }).filter({ visible: true });
|
|
||||||
const hiddenFiles = page
|
|
||||||
.getByRole("button", { name: "Hide hidden files" })
|
|
||||||
.filter({ visible: true });
|
|
||||||
const shoot = async (name: string) => {
|
|
||||||
const path = testInfo.outputPath(`${name}.png`);
|
|
||||||
await page.screenshot({ path });
|
|
||||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
|
||||||
};
|
|
||||||
await expect(rail).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(toggle).toHaveAttribute("aria-selected", "true");
|
|
||||||
const [
|
|
||||||
editorToolbarBox,
|
|
||||||
filesToolbarBox,
|
|
||||||
treeToggleBox,
|
|
||||||
refreshBox,
|
|
||||||
treeGlyphBox,
|
|
||||||
refreshGlyphBox,
|
|
||||||
] = await Promise.all([
|
|
||||||
editorToolbar.boundingBox(),
|
|
||||||
filesToolbar.boundingBox(),
|
|
||||||
toggle.boundingBox(),
|
|
||||||
refresh.boundingBox(),
|
|
||||||
toggle.locator("svg").boundingBox(),
|
|
||||||
refresh.locator("svg").boundingBox(),
|
|
||||||
]);
|
|
||||||
if (
|
|
||||||
!editorToolbarBox ||
|
|
||||||
!filesToolbarBox ||
|
|
||||||
!treeToggleBox ||
|
|
||||||
!refreshBox ||
|
|
||||||
!treeGlyphBox ||
|
|
||||||
!refreshGlyphBox
|
|
||||||
) {
|
|
||||||
throw new Error("Pane-content toolbar geometry could not be measured");
|
|
||||||
}
|
|
||||||
expect(editorToolbarBox.height).toBe(36);
|
|
||||||
expect(filesToolbarBox.height).toBe(36);
|
|
||||||
expect(editorToolbarBox.y + editorToolbarBox.height).toBe(
|
|
||||||
filesToolbarBox.y + filesToolbarBox.height,
|
|
||||||
);
|
|
||||||
expect(treeToggleBox.width).toBe(24);
|
|
||||||
expect(treeToggleBox.height).toBe(24);
|
|
||||||
expect(refreshBox.width).toBe(24);
|
|
||||||
expect(refreshBox.height).toBe(24);
|
|
||||||
expect(treeGlyphBox.width).toBe(14);
|
|
||||||
expect(treeGlyphBox.height).toBe(14);
|
|
||||||
expect(refreshGlyphBox.width).toBe(14);
|
|
||||||
expect(refreshGlyphBox.height).toBe(14);
|
|
||||||
|
|
||||||
const restBackground = await refresh.evaluate(
|
|
||||||
(element) => getComputedStyle(element).backgroundColor,
|
|
||||||
);
|
|
||||||
const treeToggleSelectedBackground = await toggle.evaluate(
|
|
||||||
(element) => getComputedStyle(element).backgroundColor,
|
|
||||||
);
|
|
||||||
await refresh.hover();
|
|
||||||
const refreshHoverBackground = await refresh.evaluate(
|
|
||||||
(element) => getComputedStyle(element).backgroundColor,
|
|
||||||
);
|
|
||||||
expect(refreshHoverBackground).not.toBe(restBackground);
|
|
||||||
expect(refreshHoverBackground).toBe(treeToggleSelectedBackground);
|
|
||||||
await hiddenFiles.click();
|
|
||||||
const hiddenFilesSelected = page
|
|
||||||
.getByRole("button", { name: "Show hidden files" })
|
|
||||||
.filter({ visible: true });
|
|
||||||
expect(
|
|
||||||
await hiddenFilesSelected.evaluate((element) => getComputedStyle(element).backgroundColor),
|
|
||||||
).toBe(refreshHoverBackground);
|
|
||||||
await shoot("tree-open");
|
|
||||||
|
|
||||||
await toggle.click();
|
|
||||||
await expect(toggle).toHaveAttribute("aria-selected", "false");
|
|
||||||
await expect(rail).toHaveCount(0);
|
|
||||||
await shoot("tree-closed");
|
|
||||||
|
|
||||||
await toggle.click();
|
|
||||||
await expect(toggle).toHaveAttribute("aria-selected", "true");
|
|
||||||
await expect(rail).toBeVisible();
|
|
||||||
await shoot("tree-reopened");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { test, expect } from "../support/fixtures";
|
|
||||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
|
||||||
import {
|
|
||||||
expectExplorerNodeRetained,
|
|
||||||
hideExplorerSplit,
|
|
||||||
measureExplorerSplit,
|
|
||||||
openExplorerSplit,
|
|
||||||
resizeExplorerSplit,
|
|
||||||
stampExplorerNode,
|
|
||||||
} from "../support/helpers/split-pane";
|
|
||||||
import {
|
|
||||||
createAgentTabFromMenu,
|
|
||||||
waitForWorkspaceTabsVisible,
|
|
||||||
} from "../support/helpers/workspace-tabs";
|
|
||||||
|
|
||||||
test.describe("Hidden split pane retention", () => {
|
|
||||||
test("releases all layout space without unmounting and restores its dragged width", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "hidden-split-pane-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await createAgentTabFromMenu(page);
|
|
||||||
await openExplorerSplit(page);
|
|
||||||
|
|
||||||
// Drag to a non-default width first: restoring the default would prove nothing.
|
|
||||||
await resizeExplorerSplit(page, 137);
|
|
||||||
await stampExplorerNode(page);
|
|
||||||
const open = await measureExplorerSplit(page);
|
|
||||||
expect(open.explorerWidth).toBeGreaterThan(250);
|
|
||||||
expect(open.separatorCount).toBe(1);
|
|
||||||
|
|
||||||
await hideExplorerSplit(page);
|
|
||||||
const hidden = await measureExplorerSplit(page);
|
|
||||||
expect(hidden.explorerWidth).toBe(0);
|
|
||||||
// The sibling must absorb the whole row, not just collapse the hidden pane.
|
|
||||||
expect(hidden.visibleWidth).toBeCloseTo(hidden.containerWidth, 5);
|
|
||||||
expect(hidden.occupiedWidth).toBeCloseTo(hidden.containerWidth, 5);
|
|
||||||
expect(hidden.separatorCount).toBe(0);
|
|
||||||
// Same DOM node, so a teardown-and-rebuild cannot pass as retention.
|
|
||||||
await expectExplorerNodeRetained(page);
|
|
||||||
|
|
||||||
await openExplorerSplit(page);
|
|
||||||
await expectExplorerNodeRetained(page);
|
|
||||||
const reopened = await measureExplorerSplit(page);
|
|
||||||
expect(reopened.explorerWidth).toBeCloseTo(open.explorerWidth, 5);
|
|
||||||
expect(reopened.separatorCount).toBe(1);
|
|
||||||
|
|
||||||
await testInfo.attach("split-pane-geometry", {
|
|
||||||
body: JSON.stringify({ open, hidden, reopened }, null, 2),
|
|
||||||
contentType: "application/json",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { test, expect } from "../support/fixtures";
|
import { test, expect } from "../support/fixtures";
|
||||||
|
import type { Locator } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
gotoWorkspace,
|
gotoWorkspace,
|
||||||
assertNewChatTileVisible,
|
assertNewChatTileVisible,
|
||||||
assertNewTabMenuTriggerVisible,
|
assertNewTabMenuTriggerVisible,
|
||||||
assertSingleNewTabButton,
|
assertSingleNewTabButton,
|
||||||
openNewTabMenuWithShortcut,
|
openNewTabMenuWithShortcut,
|
||||||
pressDirectNewTabShortcut,
|
|
||||||
clickNewChat,
|
clickNewChat,
|
||||||
clickNewTerminal,
|
clickNewTerminal,
|
||||||
countTabsOfKind,
|
countTabsOfKind,
|
||||||
waitForTabBar,
|
waitForTabBar,
|
||||||
pressNewTabShortcut,
|
pressNewTabShortcut,
|
||||||
|
pressDirectNewTabShortcut,
|
||||||
getTabTestIds,
|
getTabTestIds,
|
||||||
waitForTabWithTitle,
|
waitForTabWithTitle,
|
||||||
measureTileTransition,
|
measureTileTransition,
|
||||||
@@ -45,6 +46,16 @@ const EMPTY_PROMPT_PROFILE: TerminalProfile = {
|
|||||||
args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"],
|
args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function tabTestIds(tabs: Locator): Promise<(string | null)[]> {
|
||||||
|
return tabs.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => element.getAttribute("data-testid")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabIdentityKey(snapshot: Array<{ id: string }>): string {
|
||||||
|
return JSON.stringify(snapshot.map(({ id }) => id));
|
||||||
|
}
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
|
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
|
||||||
const created = await workspace.client.createWorkspace({
|
const created = await workspace.client.createWorkspace({
|
||||||
@@ -66,14 +77,12 @@ test.afterAll(async () => {
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
test.describe("Tab creation", () => {
|
test.describe("Tab creation", () => {
|
||||||
test("Cmd+T keeps opening the New tab menu after workspace switches", async ({ page }) => {
|
test("Cmd+T keeps creating a New tab after workspace switches", async ({ page }) => {
|
||||||
if (!secondWorkspaceId) {
|
if (!secondWorkspaceId) {
|
||||||
throw new Error("Secondary workspace was not created");
|
throw new Error("Secondary workspace was not created");
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
const newTabMenuItem = page.getByRole("menuitem", { name: /^Agent/ });
|
|
||||||
|
|
||||||
const switchWorkspaceRow = async (workspaceId: string) => {
|
const switchWorkspaceRow = async (workspaceId: string) => {
|
||||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
|
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
@@ -92,11 +101,12 @@ test.describe("Tab creation", () => {
|
|||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
await switchWorkspaceRow(sequence[i % sequence.length]);
|
await switchWorkspaceRow(sequence[i % sequence.length]);
|
||||||
await pressNewTabShortcut(page);
|
await pressNewTabShortcut(page);
|
||||||
await expect(newTabMenuItem, `New tab menu did not open after switch ${i}`).toBeVisible({
|
await expect(
|
||||||
|
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||||
|
`New tab did not open after switch ${i}`,
|
||||||
|
).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
});
|
});
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await expect(newTabMenuItem).toBeHidden({ timeout: 5_000 });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,72 +114,91 @@ test.describe("Tab creation", () => {
|
|||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
const workspaceUrl = page.url();
|
const workspaceUrl = page.url();
|
||||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
|
const newTabCountBefore = await countTabsOfKind(page, "new_tab");
|
||||||
|
|
||||||
await page.keyboard.press(`${modifier}+Comma`);
|
await page.keyboard.press(`${modifier}+Comma`);
|
||||||
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible();
|
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible();
|
||||||
|
|
||||||
await page.keyboard.press(`${modifier}+t`);
|
await page.keyboard.press(`${modifier}+t`);
|
||||||
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.goto(workspaceUrl);
|
await page.goto(workspaceUrl);
|
||||||
await assertNewTabMenuTriggerVisible(page);
|
await assertNewTabMenuTriggerVisible(page);
|
||||||
const countBefore = await countTabsOfKind(page, "draft");
|
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore);
|
||||||
await pressDirectNewTabShortcut(page, "a");
|
|
||||||
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore + 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Cmd+T opens the New tab menu without creating an agent", async ({ page }) => {
|
test("Cmd+T creates a New tab without creating an agent", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
const countBefore = await countTabsOfKind(page, "draft");
|
const countBefore = await countTabsOfKind(page, "draft");
|
||||||
|
|
||||||
await openNewTabMenuWithShortcut(page);
|
await openNewTabMenuWithShortcut(page);
|
||||||
|
|
||||||
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore);
|
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore);
|
||||||
await expect(page.getByRole("button", { name: "New tab", expanded: true })).toBeVisible();
|
await expect(
|
||||||
|
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("opening two new tabs creates two draft tabs", async ({ page }) => {
|
test("clicking + opens its menu without creating a New tab", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
const countBefore = await countTabsOfKind(page, "new_tab");
|
||||||
|
|
||||||
|
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("workspace-new-tab-menu").filter({ visible: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(countBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opening two New tabs creates two independent tab identities", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
const countBefore = await countTabsOfKind(page, "draft");
|
const newTabs = page
|
||||||
|
.locator('[data-testid^="workspace-tab-tab_"]')
|
||||||
|
.filter({ hasText: "New tab" });
|
||||||
|
const countBefore = await newTabs.count();
|
||||||
|
|
||||||
await pressDirectNewTabShortcut(page, "a");
|
await pressNewTabShortcut(page);
|
||||||
await expect
|
await expect(newTabs).toHaveCount(countBefore + 1);
|
||||||
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
|
const firstIds = await tabTestIds(newTabs);
|
||||||
.toBe(countBefore + 1);
|
|
||||||
const countAfterFirst = await countTabsOfKind(page, "draft");
|
|
||||||
|
|
||||||
await pressDirectNewTabShortcut(page, "a");
|
await pressNewTabShortcut(page);
|
||||||
await expect
|
await expect(newTabs).toHaveCount(countBefore + 2);
|
||||||
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
|
const ids = await tabTestIds(newTabs);
|
||||||
.toBe(countAfterFirst + 1);
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
expect(ids.filter((id) => !firstIds.includes(id))).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("New tab menu exposes shortcuts and supports arrow navigation", async ({ page }) => {
|
test("New tab exposes shortcuts and supports arrow navigation after refocus", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await openNewTabMenuWithShortcut(page);
|
await openNewTabMenuWithShortcut(page);
|
||||||
|
|
||||||
const agent = page.getByRole("menuitem", { name: /^Agent/ });
|
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
|
||||||
const terminal = page.getByRole("menuitem", { name: /^Terminal/ });
|
const agent = panel.getByRole("button", { name: /^Agent/ });
|
||||||
const changes = page.getByRole("menuitem", { name: /Changes/ });
|
const terminal = panel.getByRole("button", { name: /^Terminal/ });
|
||||||
const files = page.getByRole("menuitem", { name: /Files/ });
|
const diff = panel.getByRole("button", { name: /Diff/ });
|
||||||
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
|
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
|
||||||
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
|
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
|
||||||
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
|
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
|
||||||
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*C`));
|
await expect(diff).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
|
||||||
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
|
|
||||||
await expect(agent).toBeFocused();
|
await expect(agent).toBeFocused();
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown");
|
await page.keyboard.press("ArrowDown");
|
||||||
await expect(terminal).toBeFocused();
|
await expect(terminal).toBeFocused();
|
||||||
await page.keyboard.press("ArrowUp");
|
await page.keyboard.press("ArrowUp");
|
||||||
await expect(agent).toBeFocused();
|
await expect(agent).toBeFocused();
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await expect(page.getByRole("button", { name: "New tab" })).toBeFocused();
|
await pressDirectNewTabShortcut(page, "e");
|
||||||
await expect(page.getByRole("button", { name: "New tab" })).toHaveAttribute(
|
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||||
"aria-expanded",
|
await expect(
|
||||||
"false",
|
page.getByTestId("file-explorer-tree-scroll").filter({ visible: true }),
|
||||||
);
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator("body").click({ position: { x: 1, y: 1 } });
|
||||||
|
await panel.click({ position: { x: 20, y: 20 } });
|
||||||
|
await expect(agent).toBeFocused();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
||||||
@@ -205,8 +234,12 @@ test.describe("Tab creation", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
|
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||||
await page.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name }).click();
|
await page
|
||||||
|
.getByTestId("workspace-new-tab-menu")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name })
|
||||||
|
.click();
|
||||||
|
|
||||||
await expectTerminalOutputContains(page, "prompt-args: 0");
|
await expectTerminalOutputContains(page, "prompt-args: 0");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -214,6 +247,20 @@ test.describe("Tab creation", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("terminal profiles are grouped with a settings action", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||||
|
|
||||||
|
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||||
|
await expect(menu.getByText("Terminal profiles", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
const editProfiles = menu.getByTestId("workspace-new-tab-menu-edit-terminal-profiles");
|
||||||
|
await expect(editProfiles).toHaveAccessibleName("Edit profiles");
|
||||||
|
|
||||||
|
await editProfiles.click();
|
||||||
|
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
|
||||||
|
});
|
||||||
|
|
||||||
test("tab bar shows action buttons per pane", async ({ page }) => {
|
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await assertSingleNewTabButton(page);
|
await assertSingleNewTabButton(page);
|
||||||
@@ -226,15 +273,13 @@ test.describe("Tab creation", () => {
|
|||||||
|
|
||||||
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
|
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
|
||||||
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
|
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
|
||||||
const newTabButtonInScroll = await scrollArea
|
const newTabButtonInScroll = await scrollArea.getByTestId("workspace-new-tab-button").count();
|
||||||
.getByTestId("workspace-new-tab-menu-trigger")
|
|
||||||
.count();
|
|
||||||
const scrollShades = await tabRow
|
const scrollShades = await tabRow
|
||||||
.locator('[data-testid^="workspace-tabs-scroll-shade-"]')
|
.locator('[data-testid^="workspace-tabs-scroll-shade-"]')
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true);
|
expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true);
|
||||||
await expect(tabRow.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible();
|
await expect(tabRow.getByTestId("workspace-new-tab-button")).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -333,28 +378,34 @@ test.describe("Terminal title propagation", () => {
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
test.describe("Tab transitions (no flash)", () => {
|
test.describe("Tab transitions (no flash)", () => {
|
||||||
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
|
test("New agent tab transition has no blank intermediate tab state", async ({
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
|
const isolatedWorkspace = await withWorkspace({ prefix: "launcher-no-flash-" });
|
||||||
|
await isolatedWorkspace.navigateTo();
|
||||||
|
await pressNewTabShortcut(page);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// Sample tabs at high frequency across the transition
|
// Sample the single New → Agent replacement, not the separate action that
|
||||||
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page));
|
// creates the New tab in the first place.
|
||||||
|
const snapshots = await sampleTabsDuringTransition(page, async () => {
|
||||||
|
await page.getByTestId("workspace-new-tab-agent").filter({ visible: true }).first().click();
|
||||||
|
});
|
||||||
|
|
||||||
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
||||||
for (const snapshot of snapshots) {
|
for (const snapshot of snapshots) {
|
||||||
expect(snapshot.length).toBeGreaterThanOrEqual(1);
|
expect(snapshot.length).toBeGreaterThanOrEqual(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab count should never spike excessively (no duplicate flash from add-then-remove).
|
// Replacement is atomic: the set of identities changes once, while its size stays fixed.
|
||||||
// When running in-suite, previous tests may have created tabs on the shared workspace,
|
|
||||||
// so we allow +2 tolerance for accumulated state and React render batching.
|
|
||||||
const counts = snapshots.map((snapshot) => snapshot.length);
|
const counts = snapshots.map((snapshot) => snapshot.length);
|
||||||
const maxCount = Math.max(...counts);
|
|
||||||
const initialCount = counts[0] ?? 0;
|
const initialCount = counts[0] ?? 0;
|
||||||
|
|
||||||
expect(maxCount).toBeLessThanOrEqual(initialCount + 2);
|
expect(counts.every((count) => count === initialCount)).toBe(true);
|
||||||
expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot))).size).toBeLessThanOrEqual(
|
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
|
||||||
2,
|
|
||||||
);
|
|
||||||
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
|
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { expect } from "@playwright/test";
|
||||||
|
import { test } from "../support/fixtures";
|
||||||
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
|
||||||
|
test("a workspace with an existing agent never presents New during startup", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "new-tab-startup-agent-" });
|
||||||
|
const agent = await workspace.client.createAgent({
|
||||||
|
provider: "mock",
|
||||||
|
cwd: workspace.repoPath,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
title: "Existing startup agent",
|
||||||
|
modeId: "load-test",
|
||||||
|
model: "ten-second-stream",
|
||||||
|
initialPrompt: "ready",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
Reflect.set(globalThis, "__paseoSawVisibleNewTab", false);
|
||||||
|
function recordVisibleNewTab() {
|
||||||
|
const panel = document.querySelector('[data-testid="workspace-new-tab-panel"]');
|
||||||
|
if (panel?.getClientRects().length) {
|
||||||
|
Reflect.set(globalThis, "__paseoSawVisibleNewTab", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
|
new MutationObserver(recordVisibleNewTab).observe(document.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
recordVisibleNewTab();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
|
||||||
|
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(await page.evaluate(() => Reflect.get(globalThis, "__paseoSawVisibleNewTab"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
import { expect, type Locator, type Page, type TestInfo } from "@playwright/test";
|
|
||||||
import { test } from "../support/fixtures";
|
|
||||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
|
||||||
import { ensureSidePanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
|
||||||
|
|
||||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
|
||||||
const path = testInfo.outputPath(`${name}.png`);
|
|
||||||
await page.screenshot({ path });
|
|
||||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function sidePanel(page: Page): Locator {
|
|
||||||
return page.getByTestId("workspace-side-panel").filter({ visible: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function mainPane(page: Page): Locator {
|
|
||||||
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyState(scope: Locator): Locator {
|
|
||||||
return scope.getByTestId("workspace-pane-empty-state");
|
|
||||||
}
|
|
||||||
|
|
||||||
function sidePanelToggle(page: Page): Locator {
|
|
||||||
return page.getByTestId("workspace-explorer-toggle").first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePaneButton(page: Page): Locator {
|
|
||||||
return page.getByRole("button", { name: "Close pane" });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drags the pane's only tab into the left edge of its own pane, splitting it out. */
|
|
||||||
async function splitTabOutOfMainPane(page: Page, chip: Locator): Promise<void> {
|
|
||||||
const paneBox = await mainPane(page).boundingBox();
|
|
||||||
const chipBox = await chip.boundingBox();
|
|
||||||
expect(paneBox).not.toBeNull();
|
|
||||||
expect(chipBox).not.toBeNull();
|
|
||||||
if (!paneBox || !chipBox) throw new Error("Unable to measure the main pane");
|
|
||||||
|
|
||||||
await page.mouse.move(chipBox.x + chipBox.width / 2, chipBox.y + chipBox.height / 2);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(chipBox.x + chipBox.width / 2 + 12, chipBox.y + chipBox.height / 2 + 4);
|
|
||||||
await page.mouse.move(paneBox.x + paneBox.width * 0.06, paneBox.y + paneBox.height * 0.6, {
|
|
||||||
steps: 20,
|
|
||||||
});
|
|
||||||
await page.mouse.up();
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Side panel", () => {
|
|
||||||
test("reveals empty, launches a view, and hides back out of the way", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-reveal-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await expect(sidePanel(page)).toHaveCount(0);
|
|
||||||
|
|
||||||
await test.step("revealing the Side panel seeds nothing", async () => {
|
|
||||||
await sidePanelToggle(page).click();
|
|
||||||
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(emptyState(sidePanel(page))).toBeVisible();
|
|
||||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
|
||||||
await capture(page, testInfo, "01-side-panel-reveals-empty");
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("Changes opens in the Side panel that offered it", async () => {
|
|
||||||
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
|
||||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await capture(page, testInfo, "02-changes-opens-in-side-panel");
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("a loaded pane offers no one-tap close", async () => {
|
|
||||||
await expect(closePaneButton(page)).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("closing its final tab hides the Side panel, and it reopens empty", async () => {
|
|
||||||
const changesTab = sidePanel(page).getByTestId("workspace-tab-working_diff");
|
|
||||||
await changesTab.hover();
|
|
||||||
await page
|
|
||||||
.locator('[data-testid^="workspace-working-diff-close-"]')
|
|
||||||
.filter({ visible: true })
|
|
||||||
.click();
|
|
||||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
|
||||||
await capture(page, testInfo, "03-side-panel-hidden-after-final-tab-close");
|
|
||||||
|
|
||||||
await sidePanelToggle(page).click();
|
|
||||||
await expect(sidePanel(page)).toBeVisible({ timeout: 15_000 });
|
|
||||||
await expect(emptyState(sidePanel(page))).toBeVisible();
|
|
||||||
await capture(page, testInfo, "04-side-panel-revealed-empty");
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an emptied ordinary pane is removed rather than hidden", async ({ page }, testInfo) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-close-main-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await ensureSidePanel(page);
|
|
||||||
|
|
||||||
// Dragging the pane's only tab into its own left edge splits that tab into a
|
|
||||||
// new pane and leaves the one it came from empty.
|
|
||||||
const draftTab = page
|
|
||||||
.locator('[data-testid^="workspace-tab-draft_"]')
|
|
||||||
.filter({ visible: true })
|
|
||||||
.first();
|
|
||||||
await expect(draftTab).toBeVisible({ timeout: 30_000 });
|
|
||||||
await splitTabOutOfMainPane(page, draftTab);
|
|
||||||
const draftPane = page
|
|
||||||
.locator('[data-testid^="workspace-pane-pane_"]')
|
|
||||||
.filter({ visible: true })
|
|
||||||
.first();
|
|
||||||
await expect(draftPane).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
const emptyMain = emptyState(mainPane(page));
|
|
||||||
await expect(emptyMain).toBeVisible({ timeout: 15_000 });
|
|
||||||
await capture(page, testInfo, "06-empty-main-pane-launcher");
|
|
||||||
|
|
||||||
await emptyMain.getByRole("button", { name: "Close pane" }).click();
|
|
||||||
|
|
||||||
// Gone for good, unlike the Side panel beside it, which is still only hidden away.
|
|
||||||
await expect(mainPane(page)).toHaveCount(0, { timeout: 30_000 });
|
|
||||||
await expect(sidePanel(page)).toBeVisible();
|
|
||||||
await expect(draftPane.getByTestId("workspace-pane-empty-state")).toHaveCount(0);
|
|
||||||
await capture(page, testInfo, "07-empty-main-pane-removed");
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the last pane on screen offers no Close pane", async ({ page }, testInfo) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-final-pane-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
|
|
||||||
// Park a tab in the Side panel, then put the panel away. The workspace still
|
|
||||||
// holds that tab, so emptying Main does not look like an empty workspace and
|
|
||||||
// nothing reseeds a draft into it.
|
|
||||||
await ensureSidePanel(page);
|
|
||||||
await emptyState(sidePanel(page)).getByRole("button", { name: "Files" }).click();
|
|
||||||
await expect(sidePanel(page).getByTestId("workspace-tab-files")).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await sidePanelToggle(page).click();
|
|
||||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
|
||||||
|
|
||||||
const draftTab = page
|
|
||||||
.locator('[data-testid^="workspace-tab-draft_"]')
|
|
||||||
.filter({ visible: true })
|
|
||||||
.first();
|
|
||||||
await draftTab.hover();
|
|
||||||
await page
|
|
||||||
.locator('[data-testid^="workspace-draft-close-"]')
|
|
||||||
.filter({ visible: true })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Main is now empty and the only pane the user can see. Taking it away would
|
|
||||||
// leave nowhere to look, so the launcher appears without a Close pane.
|
|
||||||
const lastPane = emptyState(mainPane(page));
|
|
||||||
await expect(lastPane).toBeVisible({ timeout: 15_000 });
|
|
||||||
await expect(lastPane.getByRole("button", { name: "Files" })).toBeVisible();
|
|
||||||
await expect(lastPane.getByRole("button", { name: "Close pane" })).toHaveCount(0);
|
|
||||||
await expect(closePaneButton(page)).toHaveCount(0);
|
|
||||||
await capture(page, testInfo, "08-final-visible-pane-has-no-close");
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a pane's own menu creates an independent view when one is open elsewhere", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-claim-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await ensureSidePanel(page);
|
|
||||||
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
|
||||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await capture(page, testInfo, "08-changes-in-side-panel");
|
|
||||||
|
|
||||||
await mainPane(page).getByTestId("workspace-new-tab-menu-trigger").click();
|
|
||||||
await page
|
|
||||||
.getByTestId("workspace-new-tab-menu-changes")
|
|
||||||
.filter({ visible: true })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
|
||||||
timeout: 15_000,
|
|
||||||
});
|
|
||||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
|
||||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
|
||||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(2);
|
|
||||||
await capture(page, testInfo, "09-independent-changes-in-both-panes");
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -29,10 +29,10 @@ test("sidebar and workspace pane borders highlight on hover", async ({ page, wit
|
|||||||
await expectBorderHighlight(page, "left-sidebar-resize-handle", sidebarHandle, "1px");
|
await expectBorderHighlight(page, "left-sidebar-resize-handle", sidebarHandle, "1px");
|
||||||
|
|
||||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||||
const splitHandle = page.getByTestId("workspace-split-resize-handle");
|
const splitHandle = page.getByTestId("workspace-explorer-sidebar-resize-handle");
|
||||||
await expectBorderHighlight(
|
await expectBorderHighlight(
|
||||||
page,
|
page,
|
||||||
"workspace-split-resize-handle",
|
"workspace-explorer-sidebar-resize-handle",
|
||||||
splitHandle.getByRole("separator"),
|
splitHandle.getByRole("separator"),
|
||||||
"3px",
|
"3px",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { test, expect, type Page } from "../support/fixtures";
|
import { test, expect, type Page } from "../support/fixtures";
|
||||||
import { gotoAppShell } from "../support/helpers/app";
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||||
|
import {
|
||||||
|
expectNewWorkspaceProjectSelected,
|
||||||
|
openNewWorkspaceComposer,
|
||||||
|
} from "../support/helpers/new-workspace";
|
||||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||||
import { getServerId } from "../support/helpers/server-id";
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||||
@@ -142,6 +146,25 @@ async function installPinRpcGate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Pin workspace shortcut", () => {
|
test.describe("Pin workspace shortcut", () => {
|
||||||
|
test("keeps project creation reachable after pinning its only workspace", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({
|
||||||
|
repoPrefix: "pin-project-creation-",
|
||||||
|
title: "Pinned workspace",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openWorkspace(page, workspace.workspaceId);
|
||||||
|
await page.keyboard.press(PIN_SHORTCUT);
|
||||||
|
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
await openNewWorkspaceComposer(page, workspace);
|
||||||
|
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("pins the active workspace while its project section is collapsed", async ({ page }) => {
|
test("pins the active workspace while its project section is collapsed", async ({ page }) => {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-collapsed-" });
|
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-collapsed-" });
|
||||||
|
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ test.describe("Half-screen desktop layout", () => {
|
|||||||
await expect(closedIcon).toBeVisible();
|
await expect(closedIcon).toBeVisible();
|
||||||
const closedBounds = await closedIcon.boundingBox();
|
const closedBounds = await closedIcon.boundingBox();
|
||||||
expect(closedBounds).not.toBeNull();
|
expect(closedBounds).not.toBeNull();
|
||||||
expect(closedBounds?.x).toBeCloseTo(12, 0);
|
expect(closedBounds?.x).toBeCloseTo(9, 0);
|
||||||
expect(closedBounds?.y).toBe(openBounds?.y);
|
expect(closedBounds?.y).toBe(openBounds?.y);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -342,16 +342,19 @@ test.describe("Half-screen desktop layout", () => {
|
|||||||
|
|
||||||
await openFilesPanel(page);
|
await openFilesPanel(page);
|
||||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
await expect(
|
||||||
await expect(explorerToggle).toHaveAccessibleName("Close side panel");
|
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
|
||||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
await expect(page.getByTestId("explorer-sidebar-tab-rail")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(1);
|
||||||
|
|
||||||
await explorerToggle.click();
|
await explorerToggle.click();
|
||||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
|
await expect(
|
||||||
0,
|
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
|
||||||
);
|
).toHaveCount(0);
|
||||||
await expect(explorerToggle).toHaveAccessibleName("Open side panel");
|
await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar");
|
||||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness
|
|||||||
beforeXterm.rows,
|
beforeXterm.rows,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Split the pane downward. This focuses the new empty pane, so the terminal
|
// Split the pane downward. This focuses the new tab pane, so the terminal
|
||||||
// pane is unfocused at the exact moment its container shrinks.
|
// pane is unfocused at the exact moment its container shrinks.
|
||||||
await runWorkspaceActionFromCommandCenter(page, "Split pane down");
|
await runWorkspaceActionFromCommandCenter(page, "Split pane down");
|
||||||
|
|
||||||
|
|||||||
@@ -59,8 +59,12 @@ async function createTerminalViaMenu(page: Page): Promise<void> {
|
|||||||
// Workspaces always render a hidden explorer companion pane alongside the
|
// Workspaces always render a hidden explorer companion pane alongside the
|
||||||
// main pane, so an unscoped testid locator matches both; scope to the
|
// main pane, so an unscoped testid locator matches both; scope to the
|
||||||
// visible one.
|
// visible one.
|
||||||
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
|
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||||
await page.getByTestId("workspace-new-tab-menu-terminal").click();
|
await page
|
||||||
|
.getByTestId("workspace-new-tab-menu-terminal")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listTerminalIds(harness: TerminalE2EHarness): Promise<string[]> {
|
async function listTerminalIds(harness: TerminalE2EHarness): Promise<string[]> {
|
||||||
@@ -128,7 +132,7 @@ test.describe("terminal PTY size claim under lost window focus", () => {
|
|||||||
|
|
||||||
await page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId));
|
await page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId));
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }),
|
page.getByTestId("workspace-new-tab-button").filter({ visible: true }),
|
||||||
).toBeVisible({
|
).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ test.describe("Workspace agent title handoff", () => {
|
|||||||
.toBe(promptTitle);
|
.toBe(promptTitle);
|
||||||
await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 });
|
await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 });
|
||||||
await agentTab.click({ button: "right" });
|
await agentTab.click({ button: "right" });
|
||||||
await expect(page.getByTestId(`workspace-tab-context-agent_${agentId}-rename`)).toBeVisible({
|
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible({
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { execSync, spawn, type ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
import { once } from "node:events";
|
|
||||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -23,6 +22,7 @@ import {
|
|||||||
submitNewWorkspaceEmpty,
|
submitNewWorkspaceEmpty,
|
||||||
} from "../support/helpers/new-workspace";
|
} from "../support/helpers/new-workspace";
|
||||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||||
|
import { killProcessTree, spawnTsx } from "../support/helpers/spawn-node";
|
||||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||||
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
|
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
@@ -195,9 +195,11 @@ async function getAvailablePort(): Promise<number> {
|
|||||||
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let lastConnectionError: unknown = null;
|
let lastConnectionError: unknown = null;
|
||||||
while (Date.now() - startedAt < 20_000) {
|
while (Date.now() - startedAt < 90_000) {
|
||||||
if (child.exitCode !== null) {
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
throw new Error(`Restart test daemon exited before listening (exit ${child.exitCode}).`);
|
throw new Error(
|
||||||
|
`Restart test daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@@ -226,21 +228,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopProcess(child: ChildProcess): Promise<void> {
|
|
||||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
if (child.exitCode === null && child.signalCode === null) {
|
|
||||||
child.kill("SIGKILL");
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
try {
|
|
||||||
await once(child, "exit");
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startRestartDaemon(input: {
|
async function startRestartDaemon(input: {
|
||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
origin: string;
|
origin: string;
|
||||||
@@ -251,8 +238,7 @@ async function startRestartDaemon(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serverDir = path.resolve(__dirname, "../../../server");
|
const serverDir = path.resolve(__dirname, "../../../server");
|
||||||
const tsxBin = execSync("which tsx").toString().trim();
|
const child = spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], {
|
||||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
|
||||||
cwd: serverDir,
|
cwd: serverDir,
|
||||||
env: withDisabledE2ESpeechEnv({
|
env: withDisabledE2ESpeechEnv({
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -276,7 +262,7 @@ async function startRestartDaemon(input: {
|
|||||||
try {
|
try {
|
||||||
await waitForServer(port, child);
|
await waitForServer(port, child);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||||
{ cause: error },
|
{ cause: error },
|
||||||
@@ -285,7 +271,7 @@ async function startRestartDaemon(input: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
port,
|
port,
|
||||||
close: () => stopProcess(child),
|
close: () => killProcessTree(child),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,22 +356,6 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
|
|||||||
return decodeWorkspaceIdFromPathSegment(match[1]);
|
return decodeWorkspaceIdFromPathSegment(match[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectWorkspaceRowHasOnlyIndicator(
|
|
||||||
page: Page,
|
|
||||||
input: { serverId: string; workspaceId: string; indicator: string },
|
|
||||||
) {
|
|
||||||
const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
for (const indicator of ["attention", "done", "failed", "loading", "needs_input", "running"]) {
|
|
||||||
const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`);
|
|
||||||
if (indicator === input.indicator) {
|
|
||||||
await expect(locator).toBeVisible({ timeout: 30_000 });
|
|
||||||
} else {
|
|
||||||
await expect(locator).toHaveCount(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectWorkspaceRowDoesNotShowIndicator(
|
async function expectWorkspaceRowDoesNotShowIndicator(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { serverId: string; workspaceId: string; indicator: string },
|
input: { serverId: string; workspaceId: string; indicator: string },
|
||||||
@@ -458,11 +428,6 @@ test.describe("Workspace model restart regressions", () => {
|
|||||||
|
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
|
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
|
||||||
serverId,
|
|
||||||
workspaceId: seeded.workspaceA,
|
|
||||||
indicator: "running",
|
|
||||||
});
|
|
||||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: seeded.workspaceB,
|
workspaceId: seeded.workspaceB,
|
||||||
|
|||||||
@@ -65,10 +65,10 @@ test.describe("Workspace pane mounting", () => {
|
|||||||
.first()
|
.first()
|
||||||
.elementHandle();
|
.elementHandle();
|
||||||
expect(originalComposer).not.toBeNull();
|
expect(originalComposer).not.toBeNull();
|
||||||
const emptyPanesBeforeSplit = await page.getByTestId("workspace-pane-empty-state").count();
|
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
|
||||||
|
|
||||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||||
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(
|
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
|
||||||
emptyPanesBeforeSplit + 1,
|
emptyPanesBeforeSplit + 1,
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ test.describe("Workspace terminal tab rename", () => {
|
|||||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
await tab.click({ button: "right" });
|
await tab.click({ button: "right" });
|
||||||
const copyTerminalId = page.getByTestId(
|
const copyTerminalId = page.getByRole("menuitem", {
|
||||||
`workspace-tab-context-terminal_${terminalId}-copy-terminal-id`,
|
name: /^Copy terminal id/,
|
||||||
);
|
});
|
||||||
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
|
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
|
||||||
await copyTerminalId.click();
|
await copyTerminalId.click();
|
||||||
|
|
||||||
@@ -99,10 +99,7 @@ test.describe("Workspace terminal tab rename", () => {
|
|||||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
await tab.click({ button: "right" });
|
await tab.click({ button: "right" });
|
||||||
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
|
const renameItem = page.getByRole("menuitem", { name: "Rename", exact: true });
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
|
|
||||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||||
await renameItem.click();
|
await renameItem.click();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import { once } from "node:events";
|
import { killProcessTree } from "./helpers/spawn-node";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
@@ -148,28 +148,11 @@ export async function warmMetro(port: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
|
||||||
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
try {
|
|
||||||
await Promise.race([
|
|
||||||
once(child, "exit"),
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
||||||
resolve();
|
|
||||||
}, 5_000);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
if (timeout) clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
|
function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
|
||||||
const appDir = path.resolve(__dirname, "../..");
|
const appDir = path.resolve(__dirname, "../..");
|
||||||
const child = spawn("npx", ["expo", "start", "--web", "--port", String(port)], {
|
const expoCli = require.resolve("expo/bin/cli");
|
||||||
|
// Spawns Node directly to bypass Windows .cmd shim execution restrictions without shell: true.
|
||||||
|
const child = spawn(process.execPath, [expoCli, "start", "--web", "--port", String(port)], {
|
||||||
cwd: appDir,
|
cwd: appDir,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -216,11 +199,11 @@ export default async function globalSetup() {
|
|||||||
console.log(`[e2e] Metro warmed on port ${metroPort}`);
|
console.log(`[e2e] Metro warmed on port ${metroPort}`);
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
await stopProcess(metroProcess);
|
await killProcessTree(metroProcess);
|
||||||
console.log("[e2e] Metro stopped");
|
console.log("[e2e] Metro stopped");
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await stopProcess(metroProcess);
|
await killProcessTree(metroProcess);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promi
|
|||||||
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
|
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
|
||||||
}
|
}
|
||||||
if (draft.notes !== undefined) {
|
if (draft.notes !== undefined) {
|
||||||
await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes);
|
await modal.getByRole("textbox", { name: "When to use", exact: true }).fill(draft.notes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,11 +329,18 @@ export async function expectAgentProfileForm(
|
|||||||
const modal = editModal(page);
|
const modal = editModal(page);
|
||||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
|
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
|
||||||
|
const notesHint = modal.getByTestId("agent-profile-notes-field-hint");
|
||||||
|
await expect(notesHint).toHaveText(
|
||||||
|
"Helps agents choose this profile when starting another agent.",
|
||||||
|
);
|
||||||
|
expect(await notesHint.evaluate((element) => getComputedStyle(element).webkitLineClamp)).not.toBe(
|
||||||
|
"1",
|
||||||
|
);
|
||||||
for (const [field, value] of Object.entries(expected)) {
|
for (const [field, value] of Object.entries(expected)) {
|
||||||
if (field === "notes") {
|
if (field === "notes") {
|
||||||
await expect(
|
await expect(modal.getByRole("textbox", { name: "When to use", exact: true })).toHaveValue(
|
||||||
modal.getByRole("textbox", { name: "Notes for agents", exact: true }),
|
value,
|
||||||
).toHaveValue(value);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { escapeRegex } from "./regex";
|
import { escapeRegex } from "./regex";
|
||||||
import { openChangesPanel as openWorkspaceChangesPanel } from "./workspace-tabs";
|
import { openChangesTreePanel } from "./workspace-tabs";
|
||||||
|
|
||||||
// The branch switcher lives in the Changes tab, not in the workspace header. It renders as a button whose
|
// The branch switcher lives in the Changes tab, not in the workspace header. It renders as a button whose
|
||||||
// accessible name carries the current branch ("Current branch: <name>. Press to
|
// accessible name carries the current branch ("Current branch: <name>. Press to
|
||||||
// switch branch."). Matching on the accessible name keeps these helpers tied to
|
// switch branch."). Matching on the accessible name keeps these helpers tied to
|
||||||
// what a screen reader user hears, and it proves the panel resolved a real
|
// what a screen reader user hears, and it proves the panel resolved a real
|
||||||
// checkout directory from the opaque workspace id. Scoping to the changes header
|
// checkout directory from the opaque workspace id. Scoping to the repository
|
||||||
// keeps the matcher unambiguous even when the header is shared with diff actions.
|
// header keeps the matcher aligned with the row that owns repository identity.
|
||||||
function branchSwitcherTrigger(page: Page, branchName: string) {
|
function branchSwitcherTrigger(page: Page, branchName: string) {
|
||||||
return page
|
return page
|
||||||
.getByTestId("changes-header")
|
.getByTestId("changes-repository-header")
|
||||||
.getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) })
|
.getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) })
|
||||||
.filter({ visible: true })
|
.filter({ visible: true })
|
||||||
.first();
|
.first();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opens the fixed pane and lands on the Changes tab, where the switcher and diff live.
|
// Opens the fixed Explorer Changes view, where the switcher lives.
|
||||||
export async function openChangesPanel(page: Page): Promise<void> {
|
export async function openChangesPanel(page: Page): Promise<void> {
|
||||||
await openWorkspaceChangesPanel(page);
|
await openChangesTreePanel(page);
|
||||||
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
|
await expect(
|
||||||
timeout: 30_000,
|
page.getByTestId("changes-repository-header").filter({ visible: true }).first(),
|
||||||
});
|
).toBeVisible({ timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectWorkspaceBranch(page: Page, branchName: string): Promise<void> {
|
export async function expectWorkspaceBranch(page: Page, branchName: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { fork, type ChildProcess } from "node:child_process";
|
import { fork, type ChildProcess } from "node:child_process";
|
||||||
import { once } from "node:events";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { killProcessTree } from "./spawn-node";
|
||||||
|
|
||||||
export interface OutdatedDaemon {
|
export interface OutdatedDaemon {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
@@ -55,14 +55,10 @@ export async function startOutdatedDaemon(options?: {
|
|||||||
endpoint: ready.endpoint,
|
endpoint: ready.endpoint,
|
||||||
label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host",
|
label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host",
|
||||||
serverId: ready.serverId,
|
serverId: ready.serverId,
|
||||||
async close() {
|
close: () => killProcessTree(child),
|
||||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
await once(child, "exit");
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
child.kill("SIGTERM");
|
await killProcessTree(child);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -19,11 +19,19 @@ async function createFakeEditorBin(): Promise<string> {
|
|||||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
||||||
let realGhPath = "";
|
let realGhPath = "";
|
||||||
try {
|
try {
|
||||||
realGhPath = execSync("which gh").toString().trim();
|
const locator = process.platform === "win32" ? "where.exe" : "which";
|
||||||
|
const candidates = execFileSync(locator, ["gh"], { encoding: "utf8" })
|
||||||
|
.split(/\r?\n/u)
|
||||||
|
.map((candidate) => candidate.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
realGhPath =
|
||||||
|
candidates.find(
|
||||||
|
(candidate) =>
|
||||||
|
process.platform !== "win32" || !/\.(?:cmd|bat)$/iu.test(path.extname(candidate)),
|
||||||
|
) ?? "";
|
||||||
} catch {
|
} catch {
|
||||||
// The local GitHub fixture remains usable without a system gh binary.
|
// The local GitHub fixture remains usable without a system gh binary.
|
||||||
}
|
}
|
||||||
|
|
||||||
const fakeEditorSource = `#!/usr/bin/env node
|
const fakeEditorSource = `#!/usr/bin/env node
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
@@ -41,6 +49,9 @@ if (recordPath) {
|
|||||||
const editorPath = path.join(binDir, editorCommand);
|
const editorPath = path.join(binDir, editorCommand);
|
||||||
await writeFile(editorPath, fakeEditorSource);
|
await writeFile(editorPath, fakeEditorSource);
|
||||||
await chmod(editorPath, 0o755);
|
await chmod(editorPath, 0o755);
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
await writeFile(`${editorPath}.cmd`, `@node "%~dp0${editorCommand}" %*\r\n`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fakeGhPath = path.join(binDir, "gh");
|
const fakeGhPath = path.join(binDir, "gh");
|
||||||
@@ -106,6 +117,9 @@ process.exit(result.status ?? 1);
|
|||||||
`;
|
`;
|
||||||
await writeFile(fakeGhPath, fakeGhSource);
|
await writeFile(fakeGhPath, fakeGhSource);
|
||||||
await chmod(fakeGhPath, 0o755);
|
await chmod(fakeGhPath, 0o755);
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
await writeFile(`${fakeGhPath}.cmd`, '@node "%~dp0gh" %*\r\n');
|
||||||
|
}
|
||||||
return binDir;
|
return binDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import { once } from "node:events";
|
import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||||
import {
|
|
||||||
spawn,
|
|
||||||
execFileSync,
|
|
||||||
execSync,
|
|
||||||
type ChildProcess,
|
|
||||||
type SpawnOptions,
|
|
||||||
} from "node:child_process";
|
|
||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||||
|
import { killProcessTree, spawnTsx } from "./spawn-node";
|
||||||
|
|
||||||
export interface IsolatedHostDaemon {
|
export interface IsolatedHostDaemon {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -48,12 +42,14 @@ async function getAvailablePort(): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||||
const deadline = Date.now() + 20_000;
|
const deadline = Date.now() + 90_000;
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
if (child.exitCode !== null) {
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
throw new Error(`Isolated host daemon exited before listening (exit ${child.exitCode})`);
|
throw new Error(
|
||||||
|
`Isolated host daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@@ -81,19 +77,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopProcess(child: ChildProcess): Promise<void> {
|
|
||||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
||||||
}, 5_000);
|
|
||||||
try {
|
|
||||||
await once(child, "exit");
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startIsolatedHostDaemon(
|
export async function startIsolatedHostDaemon(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
options: IsolatedHostDaemonOptions = {},
|
options: IsolatedHostDaemonOptions = {},
|
||||||
@@ -114,11 +97,17 @@ export async function startIsolatedHostDaemon(
|
|||||||
path.join(publishedPackageRoot, "package.json"),
|
path.join(publishedPackageRoot, "package.json"),
|
||||||
`${JSON.stringify({ private: true })}\n`,
|
`${JSON.stringify({ private: true })}\n`,
|
||||||
);
|
);
|
||||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
||||||
try {
|
try {
|
||||||
|
const npmCli = process.env.npm_execpath;
|
||||||
|
if (!npmCli || path.basename(npmCli).toLowerCase() !== "npm-cli.js") {
|
||||||
|
throw new Error(
|
||||||
|
"Published-version E2E requires npm_execpath from npm. Start it through `npm run test:e2e`.",
|
||||||
|
);
|
||||||
|
}
|
||||||
execFileSync(
|
execFileSync(
|
||||||
npmCommand,
|
process.execPath,
|
||||||
[
|
[
|
||||||
|
npmCli,
|
||||||
"install",
|
"install",
|
||||||
"--no-audit",
|
"--no-audit",
|
||||||
"--no-fund",
|
"--no-fund",
|
||||||
@@ -128,6 +117,9 @@ export async function startIsolatedHostDaemon(
|
|||||||
{ cwd: publishedPackageRoot, stdio: "ignore" },
|
{ cwd: publishedPackageRoot, stdio: "ignore" },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!options.preserveHome) {
|
||||||
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
await rm(publishedPackageRoot, { recursive: true, force: true });
|
await rm(publishedPackageRoot, { recursive: true, force: true });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -155,7 +147,6 @@ export async function startIsolatedHostDaemon(
|
|||||||
const serverDir = publishedPackageRoot
|
const serverDir = publishedPackageRoot
|
||||||
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
|
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
|
||||||
: path.resolve(__dirname, "../../../../server");
|
: path.resolve(__dirname, "../../../../server");
|
||||||
const tsxBin = execSync("which tsx").toString().trim();
|
|
||||||
const spawnDaemon = async (): Promise<ChildProcess> => {
|
const spawnDaemon = async (): Promise<ChildProcess> => {
|
||||||
const spawnOptions: SpawnOptions = {
|
const spawnOptions: SpawnOptions = {
|
||||||
cwd: serverDir,
|
cwd: serverDir,
|
||||||
@@ -175,7 +166,7 @@ export async function startIsolatedHostDaemon(
|
|||||||
};
|
};
|
||||||
const child = publishedPackageRoot
|
const child = publishedPackageRoot
|
||||||
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
|
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
|
||||||
: spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], spawnOptions);
|
: spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
|
||||||
|
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
child.stderr?.on("data", (chunk: Buffer) => {
|
child.stderr?.on("data", (chunk: Buffer) => {
|
||||||
@@ -187,7 +178,7 @@ export async function startIsolatedHostDaemon(
|
|||||||
await waitForServer(port, child);
|
await waitForServer(port, child);
|
||||||
return child;
|
return child;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||||
{ cause: error },
|
{ cause: error },
|
||||||
@@ -216,13 +207,13 @@ export async function startIsolatedHostDaemon(
|
|||||||
getPid: () => child.pid,
|
getPid: () => child.pid,
|
||||||
restart: async () => {
|
restart: async () => {
|
||||||
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
child = await spawnDaemon();
|
child = await spawnDaemon();
|
||||||
},
|
},
|
||||||
close: async () => {
|
close: async () => {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
closed = true;
|
closed = true;
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
if (!options.preserveHome) {
|
if (!options.preserveHome) {
|
||||||
await rm(paseoHome, { recursive: true, force: true });
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
|||||||
|
|
||||||
// ─── Tab actions ───────────────────────────────────────────────────────────
|
// ─── Tab actions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open the focused pane's New tab menu. */
|
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to create a New tab in the focused pane. */
|
||||||
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
await page.keyboard.press(`${modifier}+t`);
|
await page.keyboard.press(`${modifier}+t`);
|
||||||
@@ -70,7 +70,7 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
|
|||||||
|
|
||||||
export async function openNewTabMenuWithShortcut(page: Page): Promise<void> {
|
export async function openNewTabMenuWithShortcut(page: Page): Promise<void> {
|
||||||
await pressNewTabShortcut(page);
|
await pressNewTabShortcut(page);
|
||||||
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toBeVisible();
|
await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pressDirectNewTabShortcut(page: Page, key: string): Promise<void> {
|
export async function pressDirectNewTabShortcut(page: Page, key: string): Promise<void> {
|
||||||
@@ -83,30 +83,27 @@ export async function pressDirectNewTabShortcut(page: Page, key: string): Promis
|
|||||||
/** Assert the inline plus button is visible in the tab bar. */
|
/** Assert the inline plus button is visible in the tab bar. */
|
||||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
|
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Assert the new-tab dropdown trigger is visible in the tab bar. */
|
/** Assert the New tab button is visible in the tab bar. */
|
||||||
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
|
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
|
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tab creation actions ─────────────────────────────────────────────────
|
// ─── Tab creation actions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Open the new-tab menu and click "New agent" to create a draft/chat tab. */
|
/** Choose Agent from the pane-local `+` menu. */
|
||||||
export async function clickNewChat(page: Page): Promise<void> {
|
export async function clickNewChat(page: Page): Promise<void> {
|
||||||
await createAgentTabFromMenu(page);
|
await createAgentTabFromMenu(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the new-tab menu and click "New terminal". */
|
/** Choose Terminal from the pane-local `+` menu. */
|
||||||
export async function clickNewTerminal(page: Page): Promise<void> {
|
export async function clickNewTerminal(page: Page): Promise<void> {
|
||||||
const trigger = page
|
const trigger = page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first();
|
||||||
.getByTestId("workspace-new-tab-menu-trigger")
|
|
||||||
.filter({ visible: true })
|
|
||||||
.first();
|
|
||||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||||
await trigger.click();
|
await trigger.click();
|
||||||
const item = page
|
const item = page
|
||||||
@@ -137,7 +134,7 @@ export async function waitForTabWithTitle(
|
|||||||
|
|
||||||
/** Assert the inline plus button is visible in the tab bar. */
|
/** Assert the inline plus button is visible in the tab bar. */
|
||||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||||
const buttons = page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true });
|
const buttons = page.getByTestId("workspace-new-tab-button").filter({ visible: true });
|
||||||
const count = await buttons.count();
|
const count = await buttons.count();
|
||||||
expect(count).toBeGreaterThanOrEqual(1);
|
expect(count).toBeGreaterThanOrEqual(1);
|
||||||
}
|
}
|
||||||
@@ -224,7 +221,7 @@ export async function expectTabTitleFits(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function terminalSurfaceLocator(page: Page) {
|
export function terminalSurfaceLocator(page: Page) {
|
||||||
return page.locator('[data-testid="terminal-surface"]').first();
|
return page.locator('[data-testid="terminal-surface"]').filter({ visible: true }).first();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
|
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import { once } from "node:events";
|
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { killProcessTree } from "./spawn-node";
|
||||||
|
|
||||||
export interface LocalElixirRelay {
|
export interface LocalElixirRelay {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
@@ -27,19 +27,6 @@ async function availablePort(): Promise<number> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopProcess(child: ChildProcess): Promise<void> {
|
|
||||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
const force = setTimeout(() => {
|
|
||||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
||||||
}, 5_000);
|
|
||||||
try {
|
|
||||||
await once(child, "exit");
|
|
||||||
} finally {
|
|
||||||
clearTimeout(force);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitUntilReady(
|
async function waitUntilReady(
|
||||||
port: number,
|
port: number,
|
||||||
child: ChildProcess,
|
child: ChildProcess,
|
||||||
@@ -48,9 +35,9 @@ async function waitUntilReady(
|
|||||||
const deadline = Date.now() + 60_000;
|
const deadline = Date.now() + 60_000;
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
if (child.exitCode !== null) {
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Elixir relay exited before becoming ready (exit ${child.exitCode}).\n${recentOutput()}`,
|
`Elixir relay exited before becoming ready (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).\n${recentOutput()}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -70,6 +57,12 @@ async function waitUntilReady(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
|
export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
throw new Error(
|
||||||
|
"The local Elixir relay requires asdf, which is not available on Windows; the relay-deployment Playwright project is POSIX-only.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const relayRoot =
|
const relayRoot =
|
||||||
process.env.PASEO_RELAY_CHECKOUT ?? path.resolve(__dirname, "../../../../../..", "paseo-relay");
|
process.env.PASEO_RELAY_CHECKOUT ?? path.resolve(__dirname, "../../../../../..", "paseo-relay");
|
||||||
if (!existsSync(path.join(relayRoot, "mix.exs"))) {
|
if (!existsSync(path.join(relayRoot, "mix.exs"))) {
|
||||||
@@ -107,14 +100,14 @@ export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
|
|||||||
try {
|
try {
|
||||||
await waitUntilReady(port, child, () => output.join("\n"));
|
await waitUntilReady(port, child, () => output.join("\n"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stop = async () => {
|
const stop = async () => {
|
||||||
if (!child) return;
|
if (!child) return;
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
child = null;
|
child = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
|
|||||||
export interface MockAgentOptions {
|
export interface MockAgentOptions {
|
||||||
repoPrefix: string;
|
repoPrefix: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
repo?: Parameters<typeof seedWorkspace>[0]["repo"];
|
||||||
port?: number;
|
port?: number;
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
@@ -29,7 +30,11 @@ export interface MockAgentOptions {
|
|||||||
export async function seedMockAgentWorkspace(
|
export async function seedMockAgentWorkspace(
|
||||||
options: MockAgentOptions,
|
options: MockAgentOptions,
|
||||||
): Promise<MockAgentWorkspace> {
|
): Promise<MockAgentWorkspace> {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: options.port });
|
const workspace = await seedWorkspace({
|
||||||
|
repoPrefix: options.repoPrefix,
|
||||||
|
repo: options.repo,
|
||||||
|
port: options.port,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const agent = await workspace.client.createAgent({
|
const agent = await workspace.client.createAgent({
|
||||||
provider: "mock",
|
provider: "mock",
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ export async function enableRelayAndExpectOffer(page: Page): Promise<void> {
|
|||||||
export async function expectPairingOffer(page: Page): Promise<void> {
|
export async function expectPairingOffer(page: Page): Promise<void> {
|
||||||
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
|
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
|
||||||
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
|
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("alert").filter({
|
||||||
|
hasText: "Treat this pairing link like a password. Anyone with it can access this daemon.",
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface SeedWorkspaceDescriptor {
|
|||||||
projectDisplayName: string;
|
projectDisplayName: string;
|
||||||
projectRootPath: string;
|
projectRootPath: string;
|
||||||
workspaceDirectory: string;
|
workspaceDirectory: string;
|
||||||
|
diffStat: { additions: number; deletions: number } | null;
|
||||||
labels?: string[];
|
labels?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 side panel" }).click();
|
|
||||||
await expect(splitSeparator(page)).toHaveCount(1, { timeout: 30_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hideExplorerSplit(page: Page): Promise<void> {
|
|
||||||
await page.getByRole("button", { name: "Close side panel" }).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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -42,8 +42,8 @@ export async function expectFailedSetupTabSeededInMainPane(
|
|||||||
await expect(page.getByTestId(tabId).filter({ visible: true }).first()).toBeVisible({
|
await expect(page.getByTestId(tabId).filter({ visible: true }).first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
const panel = await ensureSidePanel(page);
|
const explorer = await ensureExplorerSidebar(page);
|
||||||
await expect(panel.getByTestId(tabId)).toHaveCount(0);
|
await expect(explorer.getByTestId(tabId)).toHaveCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeSetupTab(page: Page, workspaceId: string): Promise<void> {
|
export async function closeSetupTab(page: Page, workspaceId: string): Promise<void> {
|
||||||
@@ -57,8 +57,8 @@ function visibleTestId(page: Page, testId: string) {
|
|||||||
return page.getByTestId(testId).filter({ visible: true });
|
return page.getByTestId(testId).filter({ visible: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function sidePanel(page: Page) {
|
function explorerSidebar(page: Page) {
|
||||||
return visibleTestId(page, "workspace-side-panel").first();
|
return visibleTestId(page, "workspace-explorer-sidebar").first();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
||||||
@@ -70,50 +70,54 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
|||||||
await expect(tab).toHaveAttribute("aria-selected", "true");
|
await expect(tab).toHaveAttribute("aria-selected", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reveal the Side panel. It opens empty; the caller decides what goes in it. */
|
/** Reveal the Explorer sidebar without changing its selected view. */
|
||||||
export async function ensureSidePanel(page: Page): Promise<Locator> {
|
export async function ensureExplorerSidebar(page: Page): Promise<Locator> {
|
||||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||||
const panel = sidePanel(page);
|
const explorer = explorerSidebar(page);
|
||||||
if ((await panel.count()) === 0) {
|
if ((await explorer.count()) === 0) {
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
}
|
}
|
||||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||||
return panel;
|
return explorer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Reveals the Explorer sidebar and selects one of its fixed navigation views. */
|
||||||
* Reveals the Side panel and brings one of its views up in it. Goes through the
|
async function openExplorerView(
|
||||||
* pane's own `+` menu, which is there whether the pane is empty or already loaded.
|
|
||||||
*/
|
|
||||||
async function openSidePanelView(
|
|
||||||
page: Page,
|
page: Page,
|
||||||
view: { tabTestId: string; menuTestId: string; contentTestId: string; timeout?: number },
|
view: { tabTestId: string; contentTestId: string; timeout?: number },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const panel = await ensureSidePanel(page);
|
const explorer = await ensureExplorerSidebar(page);
|
||||||
const tab = panel.getByTestId(view.tabTestId);
|
const tab = explorer.getByTestId(view.tabTestId);
|
||||||
if ((await tab.count()) === 0) {
|
await tab.click();
|
||||||
await panel.getByTestId("workspace-new-tab-menu-trigger").click();
|
|
||||||
await visibleTestId(page, view.menuTestId).first().click();
|
|
||||||
}
|
|
||||||
await selectWorkspaceTab(tab);
|
|
||||||
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
|
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
|
||||||
timeout: view.timeout ?? 30_000,
|
timeout: view.timeout ?? 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function openChangesTreePanel(page: Page): Promise<void> {
|
||||||
|
await openExplorerView(page, {
|
||||||
|
tabTestId: "explorer-sidebar-tab-changes_tree",
|
||||||
|
contentTestId: "changes-tree-panel",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function openChangesPanel(page: Page): Promise<void> {
|
export async function openChangesPanel(page: Page): Promise<void> {
|
||||||
await openSidePanelView(page, {
|
await openChangesTreePanel(page);
|
||||||
tabTestId: "workspace-tab-working_diff",
|
const changedFile = page
|
||||||
menuTestId: "workspace-new-tab-menu-changes",
|
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||||
contentTestId: "working-diff-panel",
|
.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> {
|
export async function openFilesPanel(page: Page): Promise<void> {
|
||||||
await openSidePanelView(page, {
|
await openExplorerView(page, {
|
||||||
tabTestId: "workspace-tab-files",
|
tabTestId: "explorer-sidebar-tab-files",
|
||||||
menuTestId: "workspace-new-tab-menu-files",
|
|
||||||
contentTestId: "file-explorer-tree-scroll",
|
contentTestId: "file-explorer-tree-scroll",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -125,26 +129,24 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
|
|||||||
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await openSidePanelView(page, {
|
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
|
||||||
tabTestId: "workspace-tab-pull_request",
|
await trigger.click();
|
||||||
menuTestId: "workspace-new-tab-menu-pull-request",
|
await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
|
||||||
contentTestId: "pr-pane",
|
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||||
timeout: 15_000,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||||
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
|
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(visibleTestId(page, "workspace-new-tab-menu-trigger").first()).toBeVisible({
|
await expect(visibleTestId(page, "workspace-new-tab-button").first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the `+` menu in the tab row and pick "New agent". */
|
/** Open the pane-local `+` menu and pick Agent. */
|
||||||
export async function createAgentTabFromMenu(page: Page): Promise<void> {
|
export async function createAgentTabFromMenu(page: Page): Promise<void> {
|
||||||
const trigger = visibleTestId(page, "workspace-new-tab-menu-trigger").first();
|
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
|
||||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||||
await trigger.click();
|
await trigger.click();
|
||||||
const item = visibleTestId(page, "workspace-new-tab-menu-agent").first();
|
const item = visibleTestId(page, "workspace-new-tab-menu-agent").first();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.5.0-beta.4",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
||||||
"profile:composer-typing": "node ./scripts/profile-composer-typing.mjs",
|
"profile:composer-typing": "node ./scripts/profile-composer-typing.mjs",
|
||||||
"profile:explorer-toggle": "node ./scripts/profile-explorer-toggle.mjs",
|
"profile:explorer-toggle": "node ./scripts/profile-explorer-toggle.mjs",
|
||||||
|
"profile:side-pane": "playwright test --config playwright.profile.config.ts side-pane-performance.spec.ts",
|
||||||
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
|
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
|
||||||
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
||||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { expect, type Locator, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
|
export interface ProfileTarget {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderSummary {
|
||||||
|
id: string;
|
||||||
|
renders: number;
|
||||||
|
mounts: number;
|
||||||
|
updates: number;
|
||||||
|
actualDurationMs: number;
|
||||||
|
maximumActualDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InteractionMeasurement {
|
||||||
|
name: string;
|
||||||
|
latencyMs: number;
|
||||||
|
commits: number;
|
||||||
|
domMutations: number;
|
||||||
|
addedElements: number;
|
||||||
|
removedElements: number;
|
||||||
|
addedTestIds: string[];
|
||||||
|
removedTestIds: string[];
|
||||||
|
trackedNodes: Array<{
|
||||||
|
selector: string;
|
||||||
|
existedBefore: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
sameNode: boolean;
|
||||||
|
}>;
|
||||||
|
renders: RenderSummary[];
|
||||||
|
reasons: Record<string, Record<string, number>>;
|
||||||
|
heapAfterGcBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowserMeasurementState {
|
||||||
|
startedAt: number;
|
||||||
|
selectors: string[];
|
||||||
|
originals: Array<Element | null>;
|
||||||
|
observer?: MutationObserver;
|
||||||
|
domMutations: number;
|
||||||
|
addedElements: number;
|
||||||
|
removedElements: number;
|
||||||
|
addedTestIds: Set<string>;
|
||||||
|
removedTestIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__PASEO_SIDE_PANE_MEASUREMENT__?: BrowserMeasurementState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCliJson<T>(output: string): T {
|
||||||
|
const arrayStart = output.indexOf("[\n");
|
||||||
|
const objectStart = output.indexOf("{\n");
|
||||||
|
const start = arrayStart >= 0 ? arrayStart : objectStart;
|
||||||
|
if (start < 0) throw new Error(`Could not find JSON in CLI output: ${output}`);
|
||||||
|
return JSON.parse(output.slice(start)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProfileTarget(): ProfileTarget {
|
||||||
|
const repoRoot = resolve(process.cwd(), "../..");
|
||||||
|
const serverId = (
|
||||||
|
process.env.PASEO_PROFILE_SERVER_ID ??
|
||||||
|
readFileSync(resolve(repoRoot, ".dev/paseo-home/server-id"), "utf8")
|
||||||
|
).trim();
|
||||||
|
const requestedName = process.env.PASEO_PROFILE_WORKSPACE_NAME?.trim() || "Paseo";
|
||||||
|
const requestedWorkspaceId = process.env.PASEO_PROFILE_WORKSPACE_ID?.trim();
|
||||||
|
if (requestedWorkspaceId) return { serverId, workspaceId: requestedWorkspaceId };
|
||||||
|
|
||||||
|
const output = execFileSync("npm", ["run", "cli", "--", "workspace", "ls", "--json"], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, FORCE_COLOR: "0" },
|
||||||
|
});
|
||||||
|
const workspaces =
|
||||||
|
parseCliJson<Array<{ workspaceId: string; name: string; cwd: string }>>(output);
|
||||||
|
const workspace = workspaces.find(
|
||||||
|
(candidate) => candidate.name === requestedName && candidate.cwd === repoRoot,
|
||||||
|
);
|
||||||
|
if (!workspace) {
|
||||||
|
throw new Error(`No Paseo workspace named ${requestedName} points at ${repoRoot}`);
|
||||||
|
}
|
||||||
|
return { serverId, workspaceId: workspace.workspaceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openProfileWorkspace(page: Page, target: ProfileTarget): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const preserveRenderProfile = (original: History["pushState"]) =>
|
||||||
|
function preserveProfileParam(
|
||||||
|
this: History,
|
||||||
|
state: unknown,
|
||||||
|
unused: string,
|
||||||
|
url?: string | URL | null,
|
||||||
|
) {
|
||||||
|
if (url == null) return Reflect.apply(original, this, [state, unused, url]);
|
||||||
|
const nextUrl = new URL(String(url), location.href);
|
||||||
|
nextUrl.searchParams.set("renderProfile", "1");
|
||||||
|
return Reflect.apply(original, this, [
|
||||||
|
state,
|
||||||
|
unused,
|
||||||
|
`${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
history.pushState = preserveRenderProfile(history.pushState);
|
||||||
|
history.replaceState = preserveRenderProfile(history.replaceState);
|
||||||
|
});
|
||||||
|
await page.goto("/?renderProfile=1", { waitUntil: "domcontentloaded" });
|
||||||
|
const workspaceRow = page.getByTestId(
|
||||||
|
`sidebar-workspace-row-${target.serverId}:${target.workspaceId}`,
|
||||||
|
);
|
||||||
|
await expect(workspaceRow).toBeVisible({ timeout: 60_000 });
|
||||||
|
await workspaceRow.click();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/workspace/${target.workspaceId}\\?renderProfile=1$`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enableChangesOpenInSidePane(page: Page): Promise<void> {
|
||||||
|
await page.evaluate((settingsKey) => {
|
||||||
|
const persisted = JSON.parse(localStorage.getItem(settingsKey) ?? "{}") as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const currentPreferences =
|
||||||
|
typeof persisted.openInSidePane === "object" && persisted.openInSidePane !== null
|
||||||
|
? (persisted.openInSidePane as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
localStorage.setItem(
|
||||||
|
settingsKey,
|
||||||
|
JSON.stringify({
|
||||||
|
...persisted,
|
||||||
|
openInSidePane: { ...currentPreferences, explorerChanges: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, APP_SETTINGS_KEY);
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openChangesExplorer(page: Page): Promise<void> {
|
||||||
|
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||||
|
if ((await explorer.count()) === 0) {
|
||||||
|
await page.keyboard.press("Meta+e");
|
||||||
|
}
|
||||||
|
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||||
|
const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first();
|
||||||
|
await expect(changesTab).toBeVisible();
|
||||||
|
if ((await changesTab.getAttribute("aria-selected")) !== "true") {
|
||||||
|
await changesTab.click();
|
||||||
|
}
|
||||||
|
await expect(page.getByTestId("changes-tree-panel")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(changedFileRows(page).nth(1)).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changedFileRows(page: Page): Locator {
|
||||||
|
return page
|
||||||
|
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||||
|
.filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectChangedFile(page: Page, index: number): Promise<void> {
|
||||||
|
const row = changedFileRows(page).nth(index);
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click();
|
||||||
|
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkingDiffVisible(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("git-diff-canvas").filter({ visible: true })).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeWorkingDiffPane(page: Page): Promise<void> {
|
||||||
|
const tab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true }).first();
|
||||||
|
await expect(tab).toBeVisible();
|
||||||
|
await tab.hover();
|
||||||
|
await page
|
||||||
|
.locator('[data-testid^="workspace-working-diff-close-"]')
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function measureHeapAfterGc(page: Page): Promise<number> {
|
||||||
|
await page.requestGC();
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const memory = performance as Performance & {
|
||||||
|
memory?: { usedJSHeapSize: number };
|
||||||
|
};
|
||||||
|
return memory.memory?.usedJSHeapSize ?? 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForReactIdle(page: Page, quietMs = 250): Promise<void> {
|
||||||
|
let previousCount = -1;
|
||||||
|
let unchangedSince = Date.now();
|
||||||
|
const deadline = Date.now() + 15_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const count = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE__?.length ?? 0);
|
||||||
|
if (count !== previousCount) {
|
||||||
|
previousCount = count;
|
||||||
|
unchangedSince = Date.now();
|
||||||
|
} else if (Date.now() - unchangedSince >= quietMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(25);
|
||||||
|
}
|
||||||
|
throw new Error("React profiler did not become idle");
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeRenders(
|
||||||
|
samples: Array<{
|
||||||
|
id: string;
|
||||||
|
phase: "mount" | "update" | "nested-update";
|
||||||
|
actualDuration: number;
|
||||||
|
}>,
|
||||||
|
): RenderSummary[] {
|
||||||
|
const summaries = new Map<string, RenderSummary>();
|
||||||
|
for (const sample of samples) {
|
||||||
|
const summary = summaries.get(sample.id) ?? {
|
||||||
|
id: sample.id,
|
||||||
|
renders: 0,
|
||||||
|
mounts: 0,
|
||||||
|
updates: 0,
|
||||||
|
actualDurationMs: 0,
|
||||||
|
maximumActualDurationMs: 0,
|
||||||
|
};
|
||||||
|
summary.renders += 1;
|
||||||
|
summary.mounts += sample.phase === "mount" ? 1 : 0;
|
||||||
|
summary.updates += sample.phase === "mount" ? 0 : 1;
|
||||||
|
summary.actualDurationMs += sample.actualDuration;
|
||||||
|
summary.maximumActualDurationMs = Math.max(
|
||||||
|
summary.maximumActualDurationMs,
|
||||||
|
sample.actualDuration,
|
||||||
|
);
|
||||||
|
summaries.set(sample.id, summary);
|
||||||
|
}
|
||||||
|
for (const summary of summaries.values()) {
|
||||||
|
summary.actualDurationMs = Math.round(summary.actualDurationMs * 100) / 100;
|
||||||
|
summary.maximumActualDurationMs = Math.round(summary.maximumActualDurationMs * 100) / 100;
|
||||||
|
}
|
||||||
|
return [...summaries.values()].sort(
|
||||||
|
(left, right) => right.actualDurationMs - left.actualDurationMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function beginMeasurement(page: Page, selectors: string[]): Promise<void> {
|
||||||
|
await waitForReactIdle(page);
|
||||||
|
await page.evaluate((trackedSelectors) => {
|
||||||
|
globalThis.__PASEO_RESET_RENDER_PROFILE__?.();
|
||||||
|
const collectTestIds = (node: Node, output: Set<string>) => {
|
||||||
|
if (!(node instanceof Element)) return;
|
||||||
|
const ownTestId = node.getAttribute("data-testid");
|
||||||
|
if (ownTestId) output.add(ownTestId);
|
||||||
|
for (const element of node.querySelectorAll("[data-testid]")) {
|
||||||
|
const testId = element.getAttribute("data-testid");
|
||||||
|
if (testId) output.add(testId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const state: BrowserMeasurementState = {
|
||||||
|
startedAt: performance.now(),
|
||||||
|
selectors: trackedSelectors,
|
||||||
|
originals: trackedSelectors.map((selector) => document.querySelector(selector)),
|
||||||
|
domMutations: 0,
|
||||||
|
addedElements: 0,
|
||||||
|
removedElements: 0,
|
||||||
|
addedTestIds: new Set(),
|
||||||
|
removedTestIds: new Set(),
|
||||||
|
};
|
||||||
|
state.observer = new MutationObserver((records) => {
|
||||||
|
state.domMutations += records.length;
|
||||||
|
for (const record of records) {
|
||||||
|
for (const node of record.addedNodes) {
|
||||||
|
state.addedElements +=
|
||||||
|
node instanceof Element ? 1 + node.querySelectorAll("*").length : 0;
|
||||||
|
collectTestIds(node, state.addedTestIds);
|
||||||
|
}
|
||||||
|
for (const node of record.removedNodes) {
|
||||||
|
state.removedElements +=
|
||||||
|
node instanceof Element ? 1 + node.querySelectorAll("*").length : 0;
|
||||||
|
collectTestIds(node, state.removedTestIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
state.observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
window.__PASEO_SIDE_PANE_MEASUREMENT__ = state;
|
||||||
|
}, selectors);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishMeasurement(page: Page, name: string): Promise<InteractionMeasurement> {
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolveFrame) => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame()));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitForReactIdle(page);
|
||||||
|
const raw = await page.evaluate((measurementName) => {
|
||||||
|
const state = window.__PASEO_SIDE_PANE_MEASUREMENT__;
|
||||||
|
if (!state) throw new Error("Side-pane performance measurement was not started");
|
||||||
|
state.observer?.disconnect();
|
||||||
|
const samples = globalThis.__PASEO_RENDER_PROFILE__ ?? [];
|
||||||
|
return {
|
||||||
|
name: measurementName,
|
||||||
|
latencyMs: Math.round((performance.now() - state.startedAt) * 100) / 100,
|
||||||
|
commits: new Set(samples.map((sample) => sample.commitTime)).size,
|
||||||
|
domMutations: state.domMutations,
|
||||||
|
addedElements: state.addedElements,
|
||||||
|
removedElements: state.removedElements,
|
||||||
|
addedTestIds: [...state.addedTestIds].sort(),
|
||||||
|
removedTestIds: [...state.removedTestIds].sort(),
|
||||||
|
trackedNodes: state.selectors.map((selector, index) => {
|
||||||
|
const original = state.originals[index] ?? null;
|
||||||
|
const current = document.querySelector(selector);
|
||||||
|
return {
|
||||||
|
selector,
|
||||||
|
existedBefore: original !== null,
|
||||||
|
connected: original?.isConnected ?? false,
|
||||||
|
sameNode: original !== null && original === current,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
samples,
|
||||||
|
reasons: globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {},
|
||||||
|
};
|
||||||
|
}, name);
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
renders: summarizeRenders(raw.samples),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function measureInteraction(
|
||||||
|
page: Page,
|
||||||
|
input: {
|
||||||
|
name: string;
|
||||||
|
trackedSelectors: string[];
|
||||||
|
interact: () => Promise<void>;
|
||||||
|
expectComplete: () => Promise<void>;
|
||||||
|
},
|
||||||
|
): Promise<InteractionMeasurement> {
|
||||||
|
await beginMeasurement(page, input.trackedSelectors);
|
||||||
|
await input.interact();
|
||||||
|
await input.expectComplete();
|
||||||
|
return finishMeasurement(page, input.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectTrackedNodePreserved(
|
||||||
|
measurement: InteractionMeasurement,
|
||||||
|
selector: string,
|
||||||
|
): void {
|
||||||
|
const tracked = measurement.trackedNodes.find((candidate) => candidate.selector === selector);
|
||||||
|
expect.soft(tracked).toEqual({
|
||||||
|
selector,
|
||||||
|
existedBefore: true,
|
||||||
|
connected: true,
|
||||||
|
sameNode: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectProfilerDidNotMount(
|
||||||
|
measurement: InteractionMeasurement,
|
||||||
|
idPrefix: string,
|
||||||
|
): void {
|
||||||
|
const summaries = measurement.renders.filter((summary) => summary.id.startsWith(idPrefix));
|
||||||
|
expect.soft(summaries.length).toBeGreaterThan(0);
|
||||||
|
expect.soft(summaries.every((summary) => summary.mounts === 0)).toBe(true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
changedFileRows,
|
||||||
|
closeWorkingDiffPane,
|
||||||
|
enableChangesOpenInSidePane,
|
||||||
|
expectProfilerDidNotMount,
|
||||||
|
expectTrackedNodePreserved,
|
||||||
|
expectWorkingDiffVisible,
|
||||||
|
measureInteraction,
|
||||||
|
measureHeapAfterGc,
|
||||||
|
openChangesExplorer,
|
||||||
|
openProfileWorkspace,
|
||||||
|
resolveProfileTarget,
|
||||||
|
selectChangedFile,
|
||||||
|
type InteractionMeasurement,
|
||||||
|
} from "./helpers/side-pane-performance";
|
||||||
|
|
||||||
|
const MAIN_PANE = '[data-testid="workspace-pane-main"]';
|
||||||
|
const WORKING_DIFF = '[data-testid="working-diff-panel"]';
|
||||||
|
const DIFF_CANVAS = '[data-testid="git-diff-canvas-root"]';
|
||||||
|
const DIFF_TAB = '[data-testid="workspace-tab-working_diff"]';
|
||||||
|
|
||||||
|
test("side-pane diff transitions preserve mounted work and report their real React cost", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
const target = resolveProfileTarget();
|
||||||
|
const reports: InteractionMeasurement[] = [];
|
||||||
|
|
||||||
|
await test.step("open the real main workspace with Changes routed beside it", async () => {
|
||||||
|
await openProfileWorkspace(page, target);
|
||||||
|
await enableChangesOpenInSidePane(page);
|
||||||
|
await openChangesExplorer(page);
|
||||||
|
expect(await changedFileRows(page).count()).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("creating the side pane keeps the main pane mounted", async () => {
|
||||||
|
const report = await measureInteraction(page, {
|
||||||
|
name: "create-side-pane",
|
||||||
|
trackedSelectors: [MAIN_PANE],
|
||||||
|
interact: () => selectChangedFile(page, 0),
|
||||||
|
expectComplete: () => expectWorkingDiffVisible(page),
|
||||||
|
});
|
||||||
|
reports.push(report);
|
||||||
|
await testInfo.attach(report.name, {
|
||||||
|
body: JSON.stringify(report, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
expectTrackedNodePreserved(report, MAIN_PANE);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("changing the selected diff file updates the existing diff in place", async () => {
|
||||||
|
const report = await measureInteraction(page, {
|
||||||
|
name: "change-diff-focus",
|
||||||
|
trackedSelectors: [MAIN_PANE, WORKING_DIFF, DIFF_CANVAS, DIFF_TAB],
|
||||||
|
interact: () => selectChangedFile(page, 1),
|
||||||
|
expectComplete: () => expectWorkingDiffVisible(page),
|
||||||
|
});
|
||||||
|
reports.push(report);
|
||||||
|
await testInfo.attach(report.name, {
|
||||||
|
body: JSON.stringify(report, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
expectTrackedNodePreserved(report, MAIN_PANE);
|
||||||
|
expectTrackedNodePreserved(report, WORKING_DIFF);
|
||||||
|
expectTrackedNodePreserved(report, DIFF_CANVAS);
|
||||||
|
expectTrackedNodePreserved(report, DIFF_TAB);
|
||||||
|
expectProfilerDidNotMount(report, "WorkingDiffPanel:");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("repeated cold reopenings measure retained workspace work", async () => {
|
||||||
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
await closeWorkingDiffPane(page);
|
||||||
|
const report = await measureInteraction(page, {
|
||||||
|
name: `reopen-side-pane-${index + 1}`,
|
||||||
|
trackedSelectors: [MAIN_PANE],
|
||||||
|
interact: () => selectChangedFile(page, index % 2),
|
||||||
|
expectComplete: () => expectWorkingDiffVisible(page),
|
||||||
|
});
|
||||||
|
report.heapAfterGcBytes = await measureHeapAfterGc(page);
|
||||||
|
reports.push(report);
|
||||||
|
await testInfo.attach(report.name, {
|
||||||
|
body: JSON.stringify(report, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
expectTrackedNodePreserved(report, MAIN_PANE);
|
||||||
|
expect(
|
||||||
|
report.renders.some(
|
||||||
|
(summary) => summary.id.startsWith("WorkingDiffPanel:") && summary.mounts > 0,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const artifact = {
|
||||||
|
appUrl: testInfo.project.use.baseURL,
|
||||||
|
target,
|
||||||
|
reports,
|
||||||
|
};
|
||||||
|
await testInfo.attach("side-pane-performance", {
|
||||||
|
body: JSON.stringify(artifact, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[perf] Side pane: ${JSON.stringify(
|
||||||
|
reports.map((report) => ({
|
||||||
|
name: report.name,
|
||||||
|
latencyMs: report.latencyMs,
|
||||||
|
commits: report.commits,
|
||||||
|
domMutations: report.domMutations,
|
||||||
|
addedElements: report.addedElements,
|
||||||
|
removedElements: report.removedElements,
|
||||||
|
heapAfterGcBytes: report.heapAfterGcBytes,
|
||||||
|
trackedNodes: report.trackedNodes,
|
||||||
|
renders: report.renders.slice(0, 8),
|
||||||
|
})),
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./perf",
|
||||||
|
timeout: 120_000,
|
||||||
|
expect: { timeout: 30_000 },
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 0,
|
||||||
|
reporter: [["list"]],
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
baseURL: process.env.PASEO_PROFILE_APP_URL ?? "http://127.0.0.1:8081",
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { withUnistyles } from "react-native-unistyles";
|
import { withUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Bot,
|
|
||||||
Boxes,
|
Boxes,
|
||||||
Brain,
|
Brain,
|
||||||
Bug,
|
Bug,
|
||||||
@@ -27,6 +26,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Shield,
|
Shield,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Star,
|
||||||
Terminal,
|
Terminal,
|
||||||
TestTube,
|
TestTube,
|
||||||
Wrench,
|
Wrench,
|
||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
} from "./profile-appearance";
|
} from "./profile-appearance";
|
||||||
|
|
||||||
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
|
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
|
||||||
const ThemedDefaultIcon = withUnistyles(Bot);
|
const ThemedDefaultIcon = withUnistyles(Star);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `withUnistyles` has to wrap each icon once at module scope, so the registry
|
* `withUnistyles` has to wrap each icon once at module scope, so the registry
|
||||||
|
|||||||
@@ -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");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -330,7 +330,7 @@ describe("layoutStream", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom");
|
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom");
|
||||||
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactTop");
|
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactBoth");
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["web", "android"] as const)(
|
it.each(["web", "android"] as const)(
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ interface LayoutSegmentInput {
|
|||||||
items: StreamItem[];
|
items: StreamItem[];
|
||||||
timingByAssistantId: Map<string, TurnTiming>;
|
timingByAssistantId: Map<string, TurnTiming>;
|
||||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||||
|
hasAuxiliaryFooter: boolean;
|
||||||
frameOrder: StreamFrameChildOrder;
|
frameOrder: StreamFrameChildOrder;
|
||||||
boundaryIndex: number | null;
|
boundaryIndex: number | null;
|
||||||
boundaryAboveItem: StreamItem | null;
|
boundaryAboveItem: StreamItem | null;
|
||||||
@@ -249,11 +250,6 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
|||||||
boundaryIndex: input.boundaryIndex,
|
boundaryIndex: input.boundaryIndex,
|
||||||
boundaryItem: input.boundaryBelowItem,
|
boundaryItem: input.boundaryBelowItem,
|
||||||
});
|
});
|
||||||
const assistantSpacing = getAssistantBlockSpacing({
|
|
||||||
item,
|
|
||||||
aboveItem,
|
|
||||||
belowItem,
|
|
||||||
});
|
|
||||||
const completedFooter = resolveCompletedFooter({
|
const completedFooter = resolveCompletedFooter({
|
||||||
strategy: input.strategy,
|
strategy: input.strategy,
|
||||||
items: input.items,
|
items: input.items,
|
||||||
@@ -265,6 +261,12 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
|||||||
boundaryAboveItems: input.boundaryAboveItems,
|
boundaryAboveItems: input.boundaryAboveItems,
|
||||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||||
});
|
});
|
||||||
|
const assistantSpacing = getAssistantBlockSpacing({
|
||||||
|
item,
|
||||||
|
aboveItem,
|
||||||
|
belowItem,
|
||||||
|
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
item,
|
item,
|
||||||
@@ -294,6 +296,7 @@ const historyLayoutCache = new WeakMap<StreamItem[], Map<string, StreamLayoutIte
|
|||||||
|
|
||||||
export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||||
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
|
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
|
||||||
|
const hasAuxiliaryFooter = input.isTurnActive || auxiliaryTurnFooter !== null;
|
||||||
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
|
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
|
||||||
const liveHeadBoundaryIndex = input.strategy.getLiveHeadHistoryBoundaryIndex(input.liveHead);
|
const liveHeadBoundaryIndex = input.strategy.getLiveHeadHistoryBoundaryIndex(input.liveHead);
|
||||||
const historyBoundaryItem =
|
const historyBoundaryItem =
|
||||||
@@ -313,6 +316,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
|||||||
liveHeadBoundaryItem?.kind ?? "null",
|
liveHeadBoundaryItem?.kind ?? "null",
|
||||||
liveHeadBoundaryItem?.turnId ?? "null",
|
liveHeadBoundaryItem?.turnId ?? "null",
|
||||||
auxiliaryTurnFooter?.itemId ?? "null",
|
auxiliaryTurnFooter?.itemId ?? "null",
|
||||||
|
hasAuxiliaryFooter ? "footer" : "no-footer",
|
||||||
].join(":");
|
].join(":");
|
||||||
let byKey = historyLayoutCache.get(input.history);
|
let byKey = historyLayoutCache.get(input.history);
|
||||||
if (!byKey) {
|
if (!byKey) {
|
||||||
@@ -328,6 +332,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
|||||||
items: input.history,
|
items: input.history,
|
||||||
timingByAssistantId: input.timingByAssistantId,
|
timingByAssistantId: input.timingByAssistantId,
|
||||||
auxiliaryTurnFooter,
|
auxiliaryTurnFooter,
|
||||||
|
hasAuxiliaryFooter,
|
||||||
frameOrder,
|
frameOrder,
|
||||||
boundaryIndex: historyBoundaryIndex,
|
boundaryIndex: historyBoundaryIndex,
|
||||||
boundaryAboveItem: null,
|
boundaryAboveItem: null,
|
||||||
@@ -347,6 +352,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
|||||||
items: input.liveHead,
|
items: input.liveHead,
|
||||||
timingByAssistantId: input.timingByAssistantId,
|
timingByAssistantId: input.timingByAssistantId,
|
||||||
auxiliaryTurnFooter,
|
auxiliaryTurnFooter,
|
||||||
|
hasAuxiliaryFooter,
|
||||||
frameOrder,
|
frameOrder,
|
||||||
boundaryIndex: liveHeadBoundaryIndex,
|
boundaryIndex: liveHeadBoundaryIndex,
|
||||||
boundaryAboveItem: historyBoundaryItem,
|
boundaryAboveItem: historyBoundaryItem,
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ describe("getAssistantBlockSpacing", () => {
|
|||||||
expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default");
|
expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("compacts the bottom edge when the turn footer follows the assistant block", () => {
|
||||||
|
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
expect(
|
||||||
|
getAssistantBlockSpacing({
|
||||||
|
item: a,
|
||||||
|
aboveItem: null,
|
||||||
|
belowItem: null,
|
||||||
|
hasFooterBelow: true,
|
||||||
|
}),
|
||||||
|
).toBe("compactBottom");
|
||||||
|
});
|
||||||
|
|
||||||
it("returns compactTop when the item above is in the same block group", () => {
|
it("returns compactTop when the item above is in the same block group", () => {
|
||||||
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
|
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
|
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ export function getAssistantBlockSpacing(params: {
|
|||||||
item: StreamItem;
|
item: StreamItem;
|
||||||
aboveItem: StreamItem | null | undefined;
|
aboveItem: StreamItem | null | undefined;
|
||||||
belowItem: StreamItem | null | undefined;
|
belowItem: StreamItem | null | undefined;
|
||||||
|
hasFooterBelow?: boolean;
|
||||||
}): "default" | "compactTop" | "compactBottom" | "compactBoth" {
|
}): "default" | "compactTop" | "compactBottom" | "compactBoth" {
|
||||||
if (params.item.kind !== "assistant_message") {
|
if (params.item.kind !== "assistant_message") {
|
||||||
return "default";
|
return "default";
|
||||||
}
|
}
|
||||||
const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem });
|
const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem });
|
||||||
const compactBottom = isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
|
const compactBottom =
|
||||||
|
params.hasFooterBelow ||
|
||||||
|
isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
|
||||||
if (compactTop && compactBottom) return "compactBoth";
|
if (compactTop && compactBottom) return "compactBoth";
|
||||||
if (compactTop) return "compactTop";
|
if (compactTop) return "compactTop";
|
||||||
if (compactBottom) return "compactBottom";
|
if (compactBottom) return "compactBottom";
|
||||||
@@ -44,6 +47,9 @@ export function getGapBetweenStreamItems(
|
|||||||
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
|
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
|
||||||
return SPACING[1];
|
return SPACING[1];
|
||||||
}
|
}
|
||||||
|
if (item.kind === "user_message" && belowItem.kind === "assistant_message") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
|
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
|||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||||
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
|
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
|
||||||
import { estimateStreamItemHeight } from "./web-virtualization";
|
import {
|
||||||
|
estimateStreamItemHeight,
|
||||||
|
shouldAdjustScrollForVirtualRowResize,
|
||||||
|
} from "./web-virtualization";
|
||||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||||
import { createStreamStrategy } from "./strategy";
|
import { createStreamStrategy } from "./strategy";
|
||||||
import {
|
import {
|
||||||
@@ -370,14 +373,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
overscan: 8,
|
overscan: 8,
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
|
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
|
||||||
if (historyStartPrependAnchorActiveRef.current) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const viewportHeight = instance.scrollRect?.height ?? 0;
|
const viewportHeight = instance.scrollRect?.height ?? 0;
|
||||||
const scrollOffset = instance.scrollOffset ?? 0;
|
const scrollOffset = instance.scrollOffset ?? 0;
|
||||||
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
|
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
|
||||||
return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
|
return shouldAdjustScrollForVirtualRowResize({
|
||||||
|
isHistoryStartPrependActive: historyStartPrependAnchorActiveRef.current,
|
||||||
|
rowStart: item.start,
|
||||||
|
scrollOffset,
|
||||||
|
remainingDistanceFromBottom: remainingDistance,
|
||||||
|
bottomThreshold: AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
return () => {
|
return () => {
|
||||||
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
|
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React, { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("react-native", () => ({
|
|
||||||
Platform: {
|
|
||||||
OS: "web",
|
|
||||||
select: (options: { web?: unknown; default?: unknown }) => options.web ?? options.default,
|
|
||||||
},
|
|
||||||
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => (
|
|
||||||
<div data-testid={testID}>{children}</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) =>
|
|
||||||
typeof factory === "function"
|
|
||||||
? (factory as (theme: Record<string, unknown>) => unknown)({
|
|
||||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 },
|
|
||||||
colors: {
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
palette: { amber: { 500: "#f0b429", 700: "#b7791f" } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: factory,
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ rt: { breakpoint: "md" } }),
|
|
||||||
withUnistyles: <T,>(component: T) => component,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/message", () => ({
|
|
||||||
AssistantTurnFooter: () => null,
|
|
||||||
LiveElapsed: () => <span data-testid="running-turn-timestamp" />,
|
|
||||||
STREAM_METADATA_FONT_SIZE: 11,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/assistant-fork-menu", () => ({
|
|
||||||
AssistantForkMenu: () => <button data-testid="running-turn-fork" type="button" />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/synced-loader", () => ({
|
|
||||||
SyncedLoader: () => <span data-testid="running-turn-loader" />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/retained-panel", () => ({
|
|
||||||
useRetainedPanelActive: () => true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { TurnFooter } from "./turn-footer";
|
|
||||||
|
|
||||||
const unusedRunningTurnStrategy = null as unknown as React.ComponentProps<
|
|
||||||
typeof TurnFooter
|
|
||||||
>["strategy"];
|
|
||||||
|
|
||||||
describe("TurnFooter", () => {
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLDivElement | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
||||||
value: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => root?.unmount());
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("places the running-turn fork between the loader and timestamp", () => {
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<TurnFooter
|
|
||||||
isRunning
|
|
||||||
inFlightTurnStartedAt={new Date("2026-08-01T10:00:00.000Z")}
|
|
||||||
host={null}
|
|
||||||
strategy={unusedRunningTurnStrategy}
|
|
||||||
supportsTimelineCursor
|
|
||||||
onForkInFlightTurn={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const footer = container.querySelector('[data-testid="turn-working-indicator"]');
|
|
||||||
const controls = Array.from(footer?.querySelectorAll("[data-testid]") ?? []).map((node) =>
|
|
||||||
node.getAttribute("data-testid"),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(controls).toEqual([
|
|
||||||
"running-turn-loader",
|
|
||||||
"running-turn-fork",
|
|
||||||
"running-turn-timestamp",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -23,7 +23,7 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
|
|||||||
|
|
||||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||||
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[6];
|
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[8];
|
||||||
|
|
||||||
export type TurnContentStrategy = StreamStrategy;
|
export type TurnContentStrategy = StreamStrategy;
|
||||||
export type AssistantTurnForkHandler = (input: {
|
export type AssistantTurnForkHandler = (input: {
|
||||||
@@ -220,7 +220,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
},
|
},
|
||||||
turnFooterRow: {
|
turnFooterRow: {
|
||||||
marginTop: theme.spacing[4],
|
marginTop: theme.spacing[2] + 5,
|
||||||
},
|
},
|
||||||
turnFooterSlot: {
|
turnFooterSlot: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||||
import { Check, ChevronDown, X } from "lucide-react-native";
|
import { Check, ChevronDown, X } from "lucide-react-native";
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import { openSidePanelView } from "@/workspace-tabs/side-panel";
|
import { openExplorerSidebarView } from "@/workspace-tabs/explorer-sidebar";
|
||||||
import {
|
import {
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
SpeakMessage,
|
SpeakMessage,
|
||||||
@@ -260,6 +260,8 @@ export interface AgentStreamViewProps {
|
|||||||
isAuthoritativeHistoryReady?: boolean;
|
isAuthoritativeHistoryReady?: boolean;
|
||||||
/** Tail space required by a transparent overlay rendered at the bottom edge. */
|
/** Tail space required by a transparent overlay rendered at the bottom edge. */
|
||||||
bottomOverlayTailClearance?: number;
|
bottomOverlayTailClearance?: number;
|
||||||
|
/** Bottom offset required for controls floating above that overlay. */
|
||||||
|
bottomOverlayControlClearance?: number;
|
||||||
toast?: ToastApi | null;
|
toast?: ToastApi | null;
|
||||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@@ -295,6 +297,10 @@ function useRetainedValue<T>(value: T, active: boolean): T {
|
|||||||
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
|
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
|
||||||
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
|
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
|
||||||
|
|
||||||
|
function resolveBottomOverlayControlOffset(clearance: number | undefined): number {
|
||||||
|
return Math.max(16, clearance ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
|
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
|
||||||
function AgentStreamView(
|
function AgentStreamView(
|
||||||
{
|
{
|
||||||
@@ -309,6 +315,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
routeBottomAnchorRequest = null,
|
routeBottomAnchorRequest = null,
|
||||||
isAuthoritativeHistoryReady = true,
|
isAuthoritativeHistoryReady = true,
|
||||||
bottomOverlayTailClearance = 0,
|
bottomOverlayTailClearance = 0,
|
||||||
|
bottomOverlayControlClearance,
|
||||||
toast,
|
toast,
|
||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
@@ -450,7 +457,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
setCurrentPath: false,
|
setCurrentPath: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
openSidePanelView({
|
openExplorerSidebarView({
|
||||||
isCompact: isMobile,
|
isCompact: isMobile,
|
||||||
workspaceKey: buildWorkspaceTabPersistenceKey({
|
workspaceKey: buildWorkspaceTabPersistenceKey({
|
||||||
serverId: resolvedServerId,
|
serverId: resolvedServerId,
|
||||||
@@ -467,7 +474,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleToolCallOpenFile = useStableEvent((filePath: string) => {
|
const handleToolCallOpenFile = useStableEvent((filePath: string) => {
|
||||||
handleInlinePathPress({ raw: filePath, path: filePath }, "side");
|
handleInlinePathPress({ raw: filePath, path: filePath }, "preferred");
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
|
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
|
||||||
@@ -934,6 +941,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
|
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
|
||||||
|
|
||||||
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
|
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
|
||||||
|
const scrollToBottomContainerStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
stylesheet.scrollToBottomContainer,
|
||||||
|
{ bottom: resolveBottomOverlayControlOffset(bottomOverlayControlClearance) },
|
||||||
|
],
|
||||||
|
[bottomOverlayControlClearance],
|
||||||
|
);
|
||||||
const listEmptyComponent = useMemo(
|
const listEmptyComponent = useMemo(
|
||||||
() =>
|
() =>
|
||||||
renderListEmptyComponent({
|
renderListEmptyComponent({
|
||||||
@@ -1065,7 +1079,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
onJumpToPrompt={chatOutline.jumpToPrompt}
|
onJumpToPrompt={chatOutline.jumpToPrompt}
|
||||||
/>
|
/>
|
||||||
{(!isNearBottom || isTimelineDetached) && (
|
{(!isNearBottom || isTimelineDetached) && (
|
||||||
<View style={stylesheet.scrollToBottomContainer} pointerEvents="box-none">
|
<View style={scrollToBottomContainerStyle} pointerEvents="box-none">
|
||||||
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
|
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={stylesheet.scrollToBottomButton}
|
style={stylesheet.scrollToBottomButton}
|
||||||
@@ -1574,7 +1588,6 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
scrollToBottomContainer: {
|
scrollToBottomContainer: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 16,
|
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
findMountedWindowStart,
|
findMountedWindowStart,
|
||||||
getWebMountedRecentStreamItems,
|
getWebMountedRecentStreamItems,
|
||||||
getWebPartialVirtualizationThreshold,
|
getWebPartialVirtualizationThreshold,
|
||||||
|
shouldAdjustScrollForVirtualRowResize,
|
||||||
splitWebVirtualizedHistory,
|
splitWebVirtualizedHistory,
|
||||||
type IndexedStreamItem,
|
type IndexedStreamItem,
|
||||||
} from "./web-virtualization";
|
} from "./web-virtualization";
|
||||||
@@ -168,6 +169,55 @@ describe("estimateStreamItemHeight", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("virtual row resize anchoring", () => {
|
||||||
|
it("does not move the viewport when a visible row expands while detached", () => {
|
||||||
|
expect(
|
||||||
|
shouldAdjustScrollForVirtualRowResize({
|
||||||
|
isHistoryStartPrependActive: false,
|
||||||
|
rowStart: 1200,
|
||||||
|
scrollOffset: 1000,
|
||||||
|
remainingDistanceFromBottom: 5000,
|
||||||
|
bottomThreshold: 64,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the reading position when a row above the viewport changes size", () => {
|
||||||
|
expect(
|
||||||
|
shouldAdjustScrollForVirtualRowResize({
|
||||||
|
isHistoryStartPrependActive: false,
|
||||||
|
rowStart: 800,
|
||||||
|
scrollOffset: 1000,
|
||||||
|
remainingDistanceFromBottom: 5000,
|
||||||
|
bottomThreshold: 64,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves history prepend and bottom following to their existing anchors", () => {
|
||||||
|
const baseInput = {
|
||||||
|
rowStart: 800,
|
||||||
|
scrollOffset: 1000,
|
||||||
|
remainingDistanceFromBottom: 5000,
|
||||||
|
bottomThreshold: 64,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldAdjustScrollForVirtualRowResize({
|
||||||
|
...baseInput,
|
||||||
|
isHistoryStartPrependActive: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldAdjustScrollForVirtualRowResize({
|
||||||
|
...baseInput,
|
||||||
|
isHistoryStartPrependActive: false,
|
||||||
|
remainingDistanceFromBottom: 64,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("web virtualization test overrides", () => {
|
describe("web virtualization test overrides", () => {
|
||||||
it("uses defaults unless explicit positive integer overrides are present", () => {
|
it("uses defaults unless explicit positive integer overrides are present", () => {
|
||||||
const globalWithOverrides = globalThis as typeof globalThis & {
|
const globalWithOverrides = globalThis as typeof globalThis & {
|
||||||
|
|||||||
@@ -10,6 +10,21 @@ export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
|||||||
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
|
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
|
||||||
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
|
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
|
||||||
|
|
||||||
|
export function shouldAdjustScrollForVirtualRowResize(input: {
|
||||||
|
isHistoryStartPrependActive: boolean;
|
||||||
|
rowStart: number;
|
||||||
|
scrollOffset: number;
|
||||||
|
remainingDistanceFromBottom: number;
|
||||||
|
bottomThreshold: number;
|
||||||
|
}): boolean {
|
||||||
|
if (input.isHistoryStartPrependActive) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
input.remainingDistanceFromBottom > input.bottomThreshold && input.rowStart < input.scrollOffset
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type BottomAnchorE2ETestGlobals = typeof globalThis & {
|
type BottomAnchorE2ETestGlobals = typeof globalThis & {
|
||||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
|
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"
|
|||||||
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
||||||
import { LeftSidebar } from "@/components/left-sidebar";
|
import { LeftSidebar } from "@/components/left-sidebar";
|
||||||
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
|
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
|
||||||
|
import { DesktopWindowControls } from "@/components/desktop/window-controls";
|
||||||
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||||
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
|
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
|
||||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||||
@@ -76,7 +77,7 @@ import { AgentNavigationListener } from "@/desktop/agent-navigation";
|
|||||||
import { LegacyAgentSkillsMigration } from "@/agent-skills/legacy-migration";
|
import { LegacyAgentSkillsMigration } from "@/agent-skills/legacy-migration";
|
||||||
import { legacyFavoriteProfileMigration } from "@/agent-profiles/migration";
|
import { legacyFavoriteProfileMigration } from "@/agent-profiles/migration";
|
||||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
import { updateDesktopWindowChrome } from "@/desktop/electron/window";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
|
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||||
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
|
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
|
||||||
@@ -86,6 +87,7 @@ import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-ac
|
|||||||
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
|
||||||
import { KeyboardShiftProvider } from "@/hooks/use-keyboard-shift-style";
|
import { KeyboardShiftProvider } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
||||||
import { useOpenProject } from "@/hooks/use-open-project";
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
@@ -473,6 +475,10 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
|||||||
}, [settings.theme, updateSettings]);
|
}, [settings.theme, updateSettings]);
|
||||||
|
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
|
const explorerSidebarPresentation = resolveExplorerSidebarPresentation({
|
||||||
|
isCompact: isCompactLayout,
|
||||||
|
});
|
||||||
|
const usesCompactExplorerHost = explorerSidebarPresentation !== "pane";
|
||||||
useCompactWebViewportZoomLock(isCompactLayout);
|
useCompactWebViewportZoomLock(isCompactLayout);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
|
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
|
||||||
@@ -545,8 +551,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
|||||||
{sidebarChrome}
|
{sidebarChrome}
|
||||||
</WindowChromeRegion>
|
</WindowChromeRegion>
|
||||||
) : null}
|
) : null}
|
||||||
{isCompactLayout ? (
|
{usesCompactExplorerHost ? (
|
||||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
<CompactExplorerSidebarHost
|
||||||
|
enabled={chromeEnabled}
|
||||||
|
presentation={explorerSidebarPresentation === "dock" ? "dock" : "overlay"}
|
||||||
|
>
|
||||||
<WindowChromeRegion corners={chromeEnabled ? "both" : appChromeLayout.contentCorners}>
|
<WindowChromeRegion corners={chromeEnabled ? "both" : appChromeLayout.contentCorners}>
|
||||||
<View style={flexStyle}>{children}</View>
|
<View style={flexStyle}>{children}</View>
|
||||||
</WindowChromeRegion>
|
</WindowChromeRegion>
|
||||||
@@ -574,6 +583,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
|||||||
</WindowChromeSafeArea>
|
</WindowChromeSafeArea>
|
||||||
</WindowChromeRegion>
|
</WindowChromeRegion>
|
||||||
) : null}
|
) : null}
|
||||||
|
<DesktopWindowControls />
|
||||||
<FloatingPanelPortalHost />
|
<FloatingPanelPortalHost />
|
||||||
{isCompactLayout ? sidebarChrome : null}
|
{isCompactLayout ? sidebarChrome : null}
|
||||||
<DownloadToast />
|
<DownloadToast />
|
||||||
@@ -665,18 +675,16 @@ function DesktopWindowControlsSync() {
|
|||||||
const { isLoading } = useAppSettings();
|
const { isLoading } = useAppSettings();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const surface0 = theme.colors.surface0;
|
const surface0 = theme.colors.surface0;
|
||||||
const foreground = theme.colors.foreground;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isNative) return;
|
if (isLoading || isNative) return;
|
||||||
void updateDesktopWindowControls({
|
void updateDesktopWindowChrome({
|
||||||
backgroundColor: surface0,
|
backgroundColor: surface0,
|
||||||
foregroundColor: foreground,
|
trafficLightOffsetY: -4,
|
||||||
trafficLightOffsetY: -5,
|
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
||||||
});
|
});
|
||||||
}, [isLoading, surface0, foreground]);
|
}, [isLoading, surface0]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -953,9 +961,18 @@ function RootProviders({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordUserActivity(): void {
|
||||||
|
getHostRuntimeStore().recordUserActivity();
|
||||||
|
}
|
||||||
|
|
||||||
function RootAppTree() {
|
function RootAppTree() {
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={flexStyle}>
|
<GestureHandlerRootView
|
||||||
|
style={flexStyle}
|
||||||
|
onTouchStart={recordUserActivity}
|
||||||
|
onTouchEnd={recordUserActivity}
|
||||||
|
onTouchCancel={recordUserActivity}
|
||||||
|
>
|
||||||
<View style={layoutStyles.surfaceFill}>
|
<View style={layoutStyles.surfaceFill}>
|
||||||
<RootProviders>
|
<RootProviders>
|
||||||
<RuntimeProviders>
|
<RuntimeProviders>
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ describe("useFileLink", () => {
|
|||||||
lineStart: undefined,
|
lineStart: undefined,
|
||||||
lineEnd: undefined,
|
lineEnd: undefined,
|
||||||
},
|
},
|
||||||
disposition: "side",
|
disposition: "preferred",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onPress = useStableEvent(() => {
|
const onPress = useStableEvent(() => {
|
||||||
open(stableSource, "side");
|
open(stableSource, "preferred");
|
||||||
});
|
});
|
||||||
|
|
||||||
const target = useMemo(() => {
|
const target = useMemo(() => {
|
||||||
|
|||||||
@@ -36,10 +36,40 @@ function source(gitActions: GitActions): {
|
|||||||
newBrowser: "New browser",
|
newBrowser: "New browser",
|
||||||
splitRight: "Split pane right",
|
splitRight: "Split pane right",
|
||||||
splitDown: "Split pane down",
|
splitDown: "Split pane down",
|
||||||
|
changes: "Changes",
|
||||||
|
files: "Files",
|
||||||
|
pullRequest: "Pull request",
|
||||||
|
openPanel: (name, placement) => `Open ${name} ${placement}`,
|
||||||
|
previousTab: "Previous tab",
|
||||||
|
nextTab: "Next tab",
|
||||||
|
closeCurrentTab: "Close current tab",
|
||||||
|
renameTab: "Rename",
|
||||||
|
reloadAgent: "Reload agent",
|
||||||
|
copyResumeCommand: "Copy resume command",
|
||||||
|
copyAgentId: "Copy agent id",
|
||||||
|
copyTerminalId: "Copy terminal id",
|
||||||
|
copyFilePath: "Copy file path",
|
||||||
|
closeTabsLeft: "Close tabs left",
|
||||||
|
closeTabsRight: "Close tabs right",
|
||||||
|
closeOtherTabs: "Close other tabs",
|
||||||
|
focusPaneLeft: "Focus pane left",
|
||||||
|
focusPaneRight: "Focus pane right",
|
||||||
|
focusPaneUp: "Focus pane up",
|
||||||
|
focusPaneDown: "Focus pane down",
|
||||||
|
moveTabLeft: "Move tab left",
|
||||||
|
moveTabRight: "Move tab right",
|
||||||
|
moveTabUp: "Move tab up",
|
||||||
|
moveTabDown: "Move tab down",
|
||||||
|
closePane: "Close pane",
|
||||||
|
toggleFocusMode: "Toggle focus mode",
|
||||||
|
toggleExplorerSidebar: "Toggle side panel",
|
||||||
},
|
},
|
||||||
icons: {},
|
icons: {},
|
||||||
shortcuts: {},
|
shortcuts: {},
|
||||||
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true },
|
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
|
||||||
|
activeTabKind: null,
|
||||||
|
activeTabIndex: -1,
|
||||||
|
activeTabCount: 0,
|
||||||
dispatch: (action) => dispatched.push(action),
|
dispatch: (action) => dispatched.push(action),
|
||||||
runGitAction: (action) => runGitActions.push(action),
|
runGitAction: (action) => runGitActions.push(action),
|
||||||
},
|
},
|
||||||
@@ -88,23 +118,43 @@ describe("workspace command center contributions", () => {
|
|||||||
|
|
||||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||||
|
|
||||||
expect(contributions.map(({ id, rank, visibility }) => ({ id, rank, visibility }))).toEqual([
|
expect(
|
||||||
{ id: "tab:new-agent", rank: 0, visibility: "always" },
|
contributions
|
||||||
{ id: "git:commit", rank: 1, visibility: "always" },
|
.filter((item) =>
|
||||||
{ id: "tab:new-terminal", rank: 2, visibility: "query" },
|
[
|
||||||
{ id: "tab:new-browser", rank: 3, visibility: "query" },
|
"tab:new-agent",
|
||||||
{ id: "pane:split-right", rank: 4, visibility: "query" },
|
"git:commit",
|
||||||
{ id: "pane:split-down", rank: 5, visibility: "query" },
|
"tab:new-terminal",
|
||||||
|
"tab:new-browser",
|
||||||
|
"pane:split-right",
|
||||||
|
"pane:split-down",
|
||||||
|
].includes(item.id),
|
||||||
|
)
|
||||||
|
.map(({ id, visibility }) => ({ id, visibility })),
|
||||||
|
).toEqual([
|
||||||
|
{ id: "tab:new-agent", visibility: "always" },
|
||||||
|
{ id: "git:commit", visibility: "always" },
|
||||||
|
{ id: "tab:new-terminal", visibility: "query" },
|
||||||
|
{ id: "tab:new-browser", visibility: "query" },
|
||||||
|
{ id: "pane:split-right", visibility: "query" },
|
||||||
|
{ id: "pane:split-down", visibility: "query" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits browser and split actions when their existing capabilities are unavailable", () => {
|
it("omits browser and split actions when their existing capabilities are unavailable", () => {
|
||||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||||
fixture.value.capabilities = { canSplitPanes: false, canOpenBrowserTabs: false };
|
fixture.value.capabilities = {
|
||||||
|
canSplitPanes: false,
|
||||||
|
canOpenBrowserTabs: false,
|
||||||
|
isGit: false,
|
||||||
|
};
|
||||||
|
|
||||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||||
|
|
||||||
expect(contributions.map((item) => item.id)).toEqual(["tab:new-agent", "tab:new-terminal"]);
|
expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
|
||||||
|
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
|
||||||
|
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(false);
|
||||||
|
expect(contributions.some((item) => item.id.startsWith("pane:"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dispatches every tab and pane command to the workspace scope", () => {
|
it("dispatches every tab and pane command to the workspace scope", () => {
|
||||||
@@ -113,13 +163,8 @@ describe("workspace command center contributions", () => {
|
|||||||
|
|
||||||
for (const contribution of contributions) contribution.run();
|
for (const contribution of contributions) contribution.run();
|
||||||
|
|
||||||
expect(fixture.dispatched).toEqual([
|
expect(fixture.dispatched.length).toBeGreaterThan(5);
|
||||||
{ id: "workspace.agent.new", scope: "workspace" },
|
expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
|
||||||
{ id: "workspace.terminal.new", scope: "workspace" },
|
|
||||||
{ id: "workspace.browser.new", scope: "workspace" },
|
|
||||||
{ id: "workspace.pane.split.right", scope: "workspace" },
|
|
||||||
{ id: "workspace.pane.split.down", scope: "workspace" },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps workspace creation commands available outside Git", () => {
|
it("keeps workspace creation commands available outside Git", () => {
|
||||||
@@ -127,13 +172,11 @@ describe("workspace command center contributions", () => {
|
|||||||
|
|
||||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||||
|
|
||||||
expect(contributions.map((item) => item.id)).toEqual([
|
expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
|
||||||
"tab:new-agent",
|
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
|
||||||
"tab:new-terminal",
|
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(true);
|
||||||
"tab:new-browser",
|
expect(contributions.some((item) => item.id === "pane:split-right")).toBe(true);
|
||||||
"pane:split-right",
|
expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
|
||||||
"pane:split-down",
|
|
||||||
]);
|
|
||||||
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
|
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { GitAction, GitActions } from "@/git/policy";
|
import type { GitAction, GitActions } from "@/git/policy";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
|
import type {
|
||||||
|
WorkspacePanelPlacement,
|
||||||
|
WorkspacePanelTarget,
|
||||||
|
} from "@/keyboard/keyboard-action-dispatcher";
|
||||||
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
|
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
|
||||||
|
|
||||||
@@ -10,6 +15,33 @@ export interface WorkspaceCommandCenterLabels {
|
|||||||
newBrowser: string;
|
newBrowser: string;
|
||||||
splitRight: string;
|
splitRight: string;
|
||||||
splitDown: string;
|
splitDown: string;
|
||||||
|
changes: string;
|
||||||
|
files: string;
|
||||||
|
pullRequest: string;
|
||||||
|
openPanel(name: string, placement: WorkspacePanelPlacement): string;
|
||||||
|
previousTab: string;
|
||||||
|
nextTab: string;
|
||||||
|
closeCurrentTab: string;
|
||||||
|
renameTab: string;
|
||||||
|
reloadAgent: string;
|
||||||
|
copyResumeCommand: string;
|
||||||
|
copyAgentId: string;
|
||||||
|
copyTerminalId: string;
|
||||||
|
copyFilePath: string;
|
||||||
|
closeTabsLeft: string;
|
||||||
|
closeTabsRight: string;
|
||||||
|
closeOtherTabs: string;
|
||||||
|
focusPaneLeft: string;
|
||||||
|
focusPaneRight: string;
|
||||||
|
focusPaneUp: string;
|
||||||
|
focusPaneDown: string;
|
||||||
|
moveTabLeft: string;
|
||||||
|
moveTabRight: string;
|
||||||
|
moveTabUp: string;
|
||||||
|
moveTabDown: string;
|
||||||
|
closePane: string;
|
||||||
|
toggleFocusMode: string;
|
||||||
|
toggleExplorerSidebar: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceCommandCenterIcons {
|
export interface WorkspaceCommandCenterIcons {
|
||||||
@@ -18,6 +50,19 @@ export interface WorkspaceCommandCenterIcons {
|
|||||||
newBrowser?: CommandCenterIcon;
|
newBrowser?: CommandCenterIcon;
|
||||||
splitRight?: CommandCenterIcon;
|
splitRight?: CommandCenterIcon;
|
||||||
splitDown?: CommandCenterIcon;
|
splitDown?: CommandCenterIcon;
|
||||||
|
changes?: CommandCenterIcon;
|
||||||
|
files?: CommandCenterIcon;
|
||||||
|
pullRequest?: CommandCenterIcon;
|
||||||
|
previousTab?: CommandCenterIcon;
|
||||||
|
nextTab?: CommandCenterIcon;
|
||||||
|
close?: CommandCenterIcon;
|
||||||
|
rename?: CommandCenterIcon;
|
||||||
|
reload?: CommandCenterIcon;
|
||||||
|
copy?: CommandCenterIcon;
|
||||||
|
focusPane?: CommandCenterIcon;
|
||||||
|
moveTab?: CommandCenterIcon;
|
||||||
|
focusMode?: CommandCenterIcon;
|
||||||
|
explorerSidebar?: CommandCenterIcon;
|
||||||
git?(action: GitAction): CommandCenterIcon | undefined;
|
git?(action: GitAction): CommandCenterIcon | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +72,12 @@ export interface WorkspaceCommandCenterShortcuts {
|
|||||||
splitRight?: ShortcutKey[][];
|
splitRight?: ShortcutKey[][];
|
||||||
splitDown?: ShortcutKey[][];
|
splitDown?: ShortcutKey[][];
|
||||||
archiveWorkspace?: ShortcutKey[][];
|
archiveWorkspace?: ShortcutKey[][];
|
||||||
|
previousTab?: ShortcutKey[][];
|
||||||
|
nextTab?: ShortcutKey[][];
|
||||||
|
closeCurrentTab?: ShortcutKey[][];
|
||||||
|
closePane?: ShortcutKey[][];
|
||||||
|
toggleFocusMode?: ShortcutKey[][];
|
||||||
|
toggleExplorerSidebar?: ShortcutKey[][];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceCommandCenterSource {
|
export interface WorkspaceCommandCenterSource {
|
||||||
@@ -37,7 +88,11 @@ export interface WorkspaceCommandCenterSource {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
canSplitPanes: boolean;
|
canSplitPanes: boolean;
|
||||||
canOpenBrowserTabs: boolean;
|
canOpenBrowserTabs: boolean;
|
||||||
|
isGit: boolean;
|
||||||
};
|
};
|
||||||
|
activeTabKind: WorkspaceTabTarget["kind"] | null;
|
||||||
|
activeTabIndex: number;
|
||||||
|
activeTabCount: number;
|
||||||
dispatch(action: KeyboardActionDefinition): void;
|
dispatch(action: KeyboardActionDefinition): void;
|
||||||
runGitAction(action: GitAction): void;
|
runGitAction(action: GitAction): void;
|
||||||
}
|
}
|
||||||
@@ -96,7 +151,315 @@ function buildWorkspaceAction(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWorkspaceCommandCenterContributions(
|
type QueryActionInput = Omit<Parameters<typeof buildWorkspaceAction>[0], "source" | "visibility">;
|
||||||
|
|
||||||
|
function buildQueryAction(
|
||||||
|
source: WorkspaceCommandCenterSource,
|
||||||
|
input: QueryActionInput,
|
||||||
|
): CommandCenterContribution {
|
||||||
|
return buildWorkspaceAction({ ...input, source, visibility: "query" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPanelContributions(
|
||||||
|
source: WorkspaceCommandCenterSource,
|
||||||
|
): CommandCenterContribution[] {
|
||||||
|
const contributions: CommandCenterContribution[] = [];
|
||||||
|
const panelTargets: Array<{
|
||||||
|
target: WorkspacePanelTarget;
|
||||||
|
name: string;
|
||||||
|
icon?: CommandCenterIcon;
|
||||||
|
}> = [
|
||||||
|
{ target: "changes", name: source.labels.changes, icon: source.icons.changes },
|
||||||
|
{ target: "files", name: source.labels.files, icon: source.icons.files },
|
||||||
|
{
|
||||||
|
target: "pull-request",
|
||||||
|
name: source.labels.pullRequest,
|
||||||
|
icon: source.icons.pullRequest,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const [index, panel] of panelTargets.entries()) {
|
||||||
|
if (panel.target !== "files" && !source.capabilities.isGit) continue;
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: `tab:open:${panel.target}`,
|
||||||
|
rank: 4 + index,
|
||||||
|
title: source.labels.openPanel(panel.name, "supporting"),
|
||||||
|
keywords: ["open", panel.target, "tab", "supporting"],
|
||||||
|
icon: panel.icon,
|
||||||
|
action: {
|
||||||
|
id: "workspace.tab.open",
|
||||||
|
scope: "workspace",
|
||||||
|
target: panel.target,
|
||||||
|
placement: "supporting",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!source.capabilities.canSplitPanes) continue;
|
||||||
|
if (panel.target !== "pull-request") continue;
|
||||||
|
const placements = ["side-pane", "focused-pane"] as const;
|
||||||
|
for (const [placementIndex, placement] of placements.entries()) {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: `tab:open:${panel.target}:${placement}`,
|
||||||
|
rank: 10 + index * 2 + placementIndex,
|
||||||
|
title: source.labels.openPanel(panel.name, placement),
|
||||||
|
keywords: ["open", panel.target, "tab", placement, "pane"],
|
||||||
|
icon: panel.icon,
|
||||||
|
action: {
|
||||||
|
id: "workspace.tab.open",
|
||||||
|
scope: "workspace",
|
||||||
|
target: panel.target,
|
||||||
|
placement,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return contributions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActiveTabContributions(
|
||||||
|
source: WorkspaceCommandCenterSource,
|
||||||
|
): CommandCenterContribution[] {
|
||||||
|
if (source.activeTabCount === 0) return [];
|
||||||
|
const contributions: CommandCenterContribution[] = [
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:previous",
|
||||||
|
rank: 30,
|
||||||
|
title: source.labels.previousTab,
|
||||||
|
keywords: ["tab", "previous", "switch", "navigate"],
|
||||||
|
icon: source.icons.previousTab,
|
||||||
|
shortcutKeys: source.shortcuts.previousTab,
|
||||||
|
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: -1 },
|
||||||
|
}),
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:next",
|
||||||
|
rank: 31,
|
||||||
|
title: source.labels.nextTab,
|
||||||
|
keywords: ["tab", "next", "switch", "navigate"],
|
||||||
|
icon: source.icons.nextTab,
|
||||||
|
shortcutKeys: source.shortcuts.nextTab,
|
||||||
|
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: 1 },
|
||||||
|
}),
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:close-current",
|
||||||
|
rank: 32,
|
||||||
|
title: source.labels.closeCurrentTab,
|
||||||
|
keywords: ["tab", "close", "current"],
|
||||||
|
icon: source.icons.close,
|
||||||
|
shortcutKeys: source.shortcuts.closeCurrentTab,
|
||||||
|
action: { id: "workspace.tab.close-current", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
if (source.activeTabKind === "agent" || source.activeTabKind === "terminal") {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:rename-current",
|
||||||
|
rank: 33,
|
||||||
|
title: source.labels.renameTab,
|
||||||
|
keywords: ["tab", "rename", "current"],
|
||||||
|
icon: source.icons.rename,
|
||||||
|
action: { id: "workspace.tab.rename-current", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:copy-id",
|
||||||
|
rank: 34,
|
||||||
|
title:
|
||||||
|
source.activeTabKind === "agent"
|
||||||
|
? source.labels.copyAgentId
|
||||||
|
: source.labels.copyTerminalId,
|
||||||
|
keywords: ["tab", "copy", "id"],
|
||||||
|
icon: source.icons.copy,
|
||||||
|
action: { id: "workspace.tab.copy-id", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (source.activeTabKind === "agent") {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:reload-current",
|
||||||
|
rank: 35,
|
||||||
|
title: source.labels.reloadAgent,
|
||||||
|
keywords: ["tab", "agent", "reload"],
|
||||||
|
icon: source.icons.reload,
|
||||||
|
action: { id: "workspace.tab.reload-current", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:copy-resume-command",
|
||||||
|
rank: 36,
|
||||||
|
title: source.labels.copyResumeCommand,
|
||||||
|
keywords: ["tab", "agent", "copy", "resume", "command"],
|
||||||
|
icon: source.icons.copy,
|
||||||
|
action: { id: "workspace.tab.copy-resume-command", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (source.activeTabKind === "file") {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:copy-file-path",
|
||||||
|
rank: 37,
|
||||||
|
title: source.labels.copyFilePath,
|
||||||
|
keywords: ["tab", "file", "copy", "path"],
|
||||||
|
icon: source.icons.copy,
|
||||||
|
action: { id: "workspace.tab.copy-file-path", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (source.activeTabIndex > 0) {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:close-left",
|
||||||
|
rank: 38,
|
||||||
|
title: source.labels.closeTabsLeft,
|
||||||
|
keywords: ["tab", "close", "left"],
|
||||||
|
icon: source.icons.close,
|
||||||
|
action: { id: "workspace.tab.close-left", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (source.activeTabIndex >= 0 && source.activeTabIndex < source.activeTabCount - 1) {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:close-right",
|
||||||
|
rank: 39,
|
||||||
|
title: source.labels.closeTabsRight,
|
||||||
|
keywords: ["tab", "close", "right"],
|
||||||
|
icon: source.icons.close,
|
||||||
|
action: { id: "workspace.tab.close-right", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (source.activeTabCount > 1) {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:close-others",
|
||||||
|
rank: 40,
|
||||||
|
title: source.labels.closeOtherTabs,
|
||||||
|
keywords: ["tab", "close", "others"],
|
||||||
|
icon: source.icons.close,
|
||||||
|
action: { id: "workspace.tab.close-others", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return contributions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCenterContribution[] {
|
||||||
|
const paneActions: Array<{
|
||||||
|
id: string;
|
||||||
|
rank: number;
|
||||||
|
title: string;
|
||||||
|
keywords: string[];
|
||||||
|
action: KeyboardActionDefinition;
|
||||||
|
icon?: CommandCenterIcon;
|
||||||
|
shortcutKeys?: ShortcutKey[][];
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: "pane:split-right",
|
||||||
|
rank: 50,
|
||||||
|
title: source.labels.splitRight,
|
||||||
|
keywords: ["split", "pane", "vertical"],
|
||||||
|
icon: source.icons.splitRight,
|
||||||
|
shortcutKeys: source.shortcuts.splitRight,
|
||||||
|
action: { id: "workspace.pane.split.right", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:split-down",
|
||||||
|
rank: 51,
|
||||||
|
title: source.labels.splitDown,
|
||||||
|
keywords: ["split", "pane", "horizontal"],
|
||||||
|
icon: source.icons.splitDown,
|
||||||
|
shortcutKeys: source.shortcuts.splitDown,
|
||||||
|
action: { id: "workspace.pane.split.down", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:focus-left",
|
||||||
|
rank: 52,
|
||||||
|
title: source.labels.focusPaneLeft,
|
||||||
|
keywords: ["pane", "focus", "left"],
|
||||||
|
icon: source.icons.focusPane,
|
||||||
|
action: { id: "workspace.pane.focus.left", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:focus-right",
|
||||||
|
rank: 53,
|
||||||
|
title: source.labels.focusPaneRight,
|
||||||
|
keywords: ["pane", "focus", "right"],
|
||||||
|
icon: source.icons.focusPane,
|
||||||
|
action: { id: "workspace.pane.focus.right", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:focus-up",
|
||||||
|
rank: 54,
|
||||||
|
title: source.labels.focusPaneUp,
|
||||||
|
keywords: ["pane", "focus", "up"],
|
||||||
|
icon: source.icons.focusPane,
|
||||||
|
action: { id: "workspace.pane.focus.up", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:focus-down",
|
||||||
|
rank: 55,
|
||||||
|
title: source.labels.focusPaneDown,
|
||||||
|
keywords: ["pane", "focus", "down"],
|
||||||
|
icon: source.icons.focusPane,
|
||||||
|
action: { id: "workspace.pane.focus.down", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:move-tab-left",
|
||||||
|
rank: 56,
|
||||||
|
title: source.labels.moveTabLeft,
|
||||||
|
keywords: ["pane", "tab", "move", "left"],
|
||||||
|
icon: source.icons.moveTab,
|
||||||
|
action: { id: "workspace.pane.move-tab.left", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:move-tab-right",
|
||||||
|
rank: 57,
|
||||||
|
title: source.labels.moveTabRight,
|
||||||
|
keywords: ["pane", "tab", "move", "right"],
|
||||||
|
icon: source.icons.moveTab,
|
||||||
|
action: { id: "workspace.pane.move-tab.right", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:move-tab-up",
|
||||||
|
rank: 58,
|
||||||
|
title: source.labels.moveTabUp,
|
||||||
|
keywords: ["pane", "tab", "move", "up"],
|
||||||
|
icon: source.icons.moveTab,
|
||||||
|
action: { id: "workspace.pane.move-tab.up", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:move-tab-down",
|
||||||
|
rank: 59,
|
||||||
|
title: source.labels.moveTabDown,
|
||||||
|
keywords: ["pane", "tab", "move", "down"],
|
||||||
|
icon: source.icons.moveTab,
|
||||||
|
action: { id: "workspace.pane.move-tab.down", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:close",
|
||||||
|
rank: 60,
|
||||||
|
title: source.labels.closePane,
|
||||||
|
keywords: ["pane", "close"],
|
||||||
|
icon: source.icons.close,
|
||||||
|
shortcutKeys: source.shortcuts.closePane,
|
||||||
|
action: { id: "workspace.pane.close", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:focus-mode-toggle",
|
||||||
|
rank: 62,
|
||||||
|
title: source.labels.toggleFocusMode,
|
||||||
|
keywords: ["pane", "focus", "mode", "toggle"],
|
||||||
|
icon: source.icons.focusMode,
|
||||||
|
shortcutKeys: source.shortcuts.toggleFocusMode,
|
||||||
|
action: { id: "workspace.focus.toggle", scope: "workspace" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return paneActions.map((action) => buildQueryAction(source, action));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCreationContributions(
|
||||||
source: WorkspaceCommandCenterSource,
|
source: WorkspaceCommandCenterSource,
|
||||||
): CommandCenterContribution[] {
|
): CommandCenterContribution[] {
|
||||||
const contributions: CommandCenterContribution[] = [
|
const contributions: CommandCenterContribution[] = [
|
||||||
@@ -115,8 +478,7 @@ export function buildWorkspaceCommandCenterContributions(
|
|||||||
const primary = source.gitActions.primary;
|
const primary = source.gitActions.primary;
|
||||||
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
|
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
|
||||||
contributions.push(
|
contributions.push(
|
||||||
buildWorkspaceAction({
|
buildQueryAction(source, {
|
||||||
source,
|
|
||||||
id: "tab:new-terminal",
|
id: "tab:new-terminal",
|
||||||
rank: 2,
|
rank: 2,
|
||||||
title: source.labels.newTerminal,
|
title: source.labels.newTerminal,
|
||||||
@@ -124,52 +486,45 @@ export function buildWorkspaceCommandCenterContributions(
|
|||||||
icon: source.icons.newTerminal,
|
icon: source.icons.newTerminal,
|
||||||
shortcutKeys: source.shortcuts.newTerminal,
|
shortcutKeys: source.shortcuts.newTerminal,
|
||||||
action: { id: "workspace.terminal.new", scope: "workspace" },
|
action: { id: "workspace.terminal.new", scope: "workspace" },
|
||||||
visibility: "query",
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (source.capabilities.canOpenBrowserTabs) {
|
if (source.capabilities.canOpenBrowserTabs) {
|
||||||
contributions.push(
|
contributions.push(
|
||||||
buildWorkspaceAction({
|
buildQueryAction(source, {
|
||||||
source,
|
|
||||||
id: "tab:new-browser",
|
id: "tab:new-browser",
|
||||||
rank: 3,
|
rank: 3,
|
||||||
title: source.labels.newBrowser,
|
title: source.labels.newBrowser,
|
||||||
keywords: ["browser", "web", "preview"],
|
keywords: ["browser", "web", "preview"],
|
||||||
icon: source.icons.newBrowser,
|
icon: source.icons.newBrowser,
|
||||||
action: { id: "workspace.browser.new", scope: "workspace" },
|
action: { id: "workspace.browser.new", scope: "workspace" },
|
||||||
visibility: "query",
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (source.capabilities.canSplitPanes) {
|
return contributions;
|
||||||
contributions.push(
|
}
|
||||||
buildWorkspaceAction({
|
|
||||||
source,
|
export function buildWorkspaceCommandCenterContributions(
|
||||||
id: "pane:split-right",
|
source: WorkspaceCommandCenterSource,
|
||||||
rank: 4,
|
): CommandCenterContribution[] {
|
||||||
title: source.labels.splitRight,
|
const contributions = [
|
||||||
keywords: ["split", "pane", "vertical"],
|
...buildCreationContributions(source),
|
||||||
icon: source.icons.splitRight,
|
...buildPanelContributions(source),
|
||||||
shortcutKeys: source.shortcuts.splitRight,
|
...buildActiveTabContributions(source),
|
||||||
action: { id: "workspace.pane.split.right", scope: "workspace" },
|
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
|
||||||
visibility: "query",
|
buildQueryAction(source, {
|
||||||
}),
|
id: "explorer-sidebar:toggle",
|
||||||
buildWorkspaceAction({
|
rank: 70,
|
||||||
source,
|
title: source.labels.toggleExplorerSidebar,
|
||||||
id: "pane:split-down",
|
keywords: ["explorer", "sidebar", "toggle", "show", "hide"],
|
||||||
rank: 5,
|
icon: source.icons.explorerSidebar,
|
||||||
title: source.labels.splitDown,
|
shortcutKeys: source.shortcuts.toggleExplorerSidebar,
|
||||||
keywords: ["split", "pane", "horizontal"],
|
action: { id: "sidebar.toggle.right", scope: "workspace" },
|
||||||
icon: source.icons.splitDown,
|
}),
|
||||||
shortcutKeys: source.shortcuts.splitDown,
|
];
|
||||||
action: { id: "workspace.pane.split.down", scope: "workspace" },
|
const primary = source.gitActions.primary;
|
||||||
visibility: "query",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const [index, action] of source.gitActions.secondary.entries()) {
|
for (const [index, action] of source.gitActions.secondary.entries()) {
|
||||||
if (action.id === primary?.id) continue;
|
if (action.id === primary?.id) continue;
|
||||||
contributions.push(buildGitContribution(source, action, 10 + index, "query"));
|
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
|
||||||
}
|
}
|
||||||
return contributions;
|
return contributions;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
|
|||||||
openWorkspaceFileFromExplorer({
|
openWorkspaceFileFromExplorer({
|
||||||
filePath: path,
|
filePath: path,
|
||||||
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||||
|
closeExplorerAfterOpen: true,
|
||||||
showMobileAgent: usePanelStore.getState().showMobileAgent,
|
showMobileAgent: usePanelStore.getState().showMobileAgent,
|
||||||
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
|
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
|
||||||
useWorkspaceLayoutStore.getState().openTab({
|
useWorkspaceLayoutStore.getState().openTab({
|
||||||
|
|||||||
@@ -1,18 +1,40 @@
|
|||||||
import { useMemo, type ReactElement } from "react";
|
import { useMemo, type ReactElement } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Columns2, Globe, Rows2, SquarePen, SquareTerminal } from "lucide-react-native";
|
import {
|
||||||
|
ArrowDownToLine,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Columns2,
|
||||||
|
Copy,
|
||||||
|
Files,
|
||||||
|
Focus,
|
||||||
|
GitCompareArrows,
|
||||||
|
GitPullRequest,
|
||||||
|
Globe,
|
||||||
|
Move,
|
||||||
|
PanelRight,
|
||||||
|
Pencil,
|
||||||
|
RotateCw,
|
||||||
|
Rows2,
|
||||||
|
SquarePen,
|
||||||
|
SquareTerminal,
|
||||||
|
X,
|
||||||
|
} from "lucide-react-native";
|
||||||
import { getIsElectron } from "@/constants/platform";
|
import { getIsElectron } from "@/constants/platform";
|
||||||
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||||
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
|
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
|
||||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||||
import {
|
import { type ShortcutOverrides } from "@/keyboard/keyboard-shortcuts";
|
||||||
resolveShortcutKeysForAction,
|
|
||||||
type ShortcutOverrides,
|
|
||||||
} from "@/keyboard/keyboard-shortcuts";
|
|
||||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||||
|
import {
|
||||||
|
collectAllTabs,
|
||||||
|
findPaneById,
|
||||||
|
useWorkspaceLayoutStore,
|
||||||
|
} from "@/stores/workspace-layout-store";
|
||||||
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import { getCommandCenterIcon } from "./icon";
|
import { getCommandCenterIcon } from "./icon";
|
||||||
@@ -22,6 +44,7 @@ import {
|
|||||||
buildWorkspaceCommandCenterContributions,
|
buildWorkspaceCommandCenterContributions,
|
||||||
type WorkspaceCommandCenterShortcuts,
|
type WorkspaceCommandCenterShortcuts,
|
||||||
} from "./workspace-contributions";
|
} from "./workspace-contributions";
|
||||||
|
import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts";
|
||||||
|
|
||||||
const WORKSPACE_COMMAND_CENTER_ICONS = {
|
const WORKSPACE_COMMAND_CENTER_ICONS = {
|
||||||
newAgent: getCommandCenterIcon(SquarePen),
|
newAgent: getCommandCenterIcon(SquarePen),
|
||||||
@@ -29,8 +52,27 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
|
|||||||
newBrowser: getCommandCenterIcon(Globe),
|
newBrowser: getCommandCenterIcon(Globe),
|
||||||
splitRight: getCommandCenterIcon(Columns2),
|
splitRight: getCommandCenterIcon(Columns2),
|
||||||
splitDown: getCommandCenterIcon(Rows2),
|
splitDown: getCommandCenterIcon(Rows2),
|
||||||
|
changes: getCommandCenterIcon(GitCompareArrows),
|
||||||
|
files: getCommandCenterIcon(Files),
|
||||||
|
pullRequest: getCommandCenterIcon(GitPullRequest),
|
||||||
|
previousTab: getCommandCenterIcon(ArrowLeft),
|
||||||
|
nextTab: getCommandCenterIcon(ArrowRight),
|
||||||
|
close: getCommandCenterIcon(X),
|
||||||
|
rename: getCommandCenterIcon(Pencil),
|
||||||
|
reload: getCommandCenterIcon(RotateCw),
|
||||||
|
copy: getCommandCenterIcon(Copy),
|
||||||
|
focusPane: getCommandCenterIcon(Focus),
|
||||||
|
moveTab: getCommandCenterIcon(Move),
|
||||||
|
focusMode: getCommandCenterIcon(ArrowDownToLine),
|
||||||
|
explorerSidebar: getCommandCenterIcon(PanelRight),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OPEN_PANEL_LABEL_KEYS = {
|
||||||
|
supporting: "shell.commandCenter.open",
|
||||||
|
"side-pane": "shell.commandCenter.openInSidePane",
|
||||||
|
"focused-pane": "shell.commandCenter.openInFocusedPane",
|
||||||
|
} as const;
|
||||||
|
|
||||||
function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined {
|
function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined {
|
||||||
if (!element) return undefined;
|
if (!element) return undefined;
|
||||||
function StaticIcon() {
|
function StaticIcon() {
|
||||||
@@ -41,17 +83,7 @@ function staticIcon(element: ReactElement | undefined): CommandCenterIcon | unde
|
|||||||
|
|
||||||
function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceCommandCenterShortcuts {
|
function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceCommandCenterShortcuts {
|
||||||
const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() };
|
const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() };
|
||||||
return {
|
return resolveWorkspaceCommandCenterShortcuts({ overrides, platform });
|
||||||
newAgent: resolveShortcutKeysForAction("workspace-tab-new", overrides, platform) ?? undefined,
|
|
||||||
newTerminal:
|
|
||||||
resolveShortcutKeysForAction("workspace-terminal-new", overrides, platform) ?? undefined,
|
|
||||||
splitRight:
|
|
||||||
resolveShortcutKeysForAction("workspace-pane-split-right", overrides, platform) ?? undefined,
|
|
||||||
splitDown:
|
|
||||||
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
|
|
||||||
archiveWorkspace:
|
|
||||||
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkspaceCommandCenterActions(): void {
|
export function useWorkspaceCommandCenterActions(): void {
|
||||||
@@ -60,10 +92,22 @@ export function useWorkspaceCommandCenterActions(): void {
|
|||||||
const selection = useActiveWorkspaceSelection();
|
const selection = useActiveWorkspaceSelection();
|
||||||
const serverId = selection?.serverId ?? null;
|
const serverId = selection?.serverId ?? null;
|
||||||
const workspaceId = selection?.workspaceId ?? null;
|
const workspaceId = selection?.workspaceId ?? null;
|
||||||
|
const workspaceKey =
|
||||||
|
serverId && workspaceId ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId }) : null;
|
||||||
|
const layout = useWorkspaceLayoutStore((state) =>
|
||||||
|
workspaceKey ? (state.layoutByWorkspace[workspaceKey] ?? null) : null,
|
||||||
|
);
|
||||||
|
const focusedPane = layout ? findPaneById(layout.root, layout.focusedPaneId) : null;
|
||||||
|
const focusedTabs = layout
|
||||||
|
? collectAllTabs(layout.root).filter((tab) => focusedPane?.tabIds.includes(tab.tabId))
|
||||||
|
: [];
|
||||||
|
const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId);
|
||||||
|
const activeTabKind =
|
||||||
|
activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null;
|
||||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const { overrides } = useKeyboardShortcutOverrides();
|
const { overrides } = useKeyboardShortcutOverrides();
|
||||||
const { gitActions } = useGitActions({
|
const { gitActions, isGit } = useGitActions({
|
||||||
serverId: serverId ?? "",
|
serverId: serverId ?? "",
|
||||||
cwd: cwd ?? "",
|
cwd: cwd ?? "",
|
||||||
icons: GIT_ACTION_ICONS,
|
icons: GIT_ACTION_ICONS,
|
||||||
@@ -81,6 +125,33 @@ export function useWorkspaceCommandCenterActions(): void {
|
|||||||
newBrowser: t("workspace.tabs.actions.newBrowser"),
|
newBrowser: t("workspace.tabs.actions.newBrowser"),
|
||||||
splitRight: t("workspace.tabs.actions.splitRight"),
|
splitRight: t("workspace.tabs.actions.splitRight"),
|
||||||
splitDown: t("workspace.tabs.actions.splitDown"),
|
splitDown: t("workspace.tabs.actions.splitDown"),
|
||||||
|
changes: t("workspace.tabs.actions.changes"),
|
||||||
|
files: t("workspace.tabs.actions.files"),
|
||||||
|
pullRequest: t("workspace.tabs.actions.pullRequest"),
|
||||||
|
openPanel: (name, placement) => t(OPEN_PANEL_LABEL_KEYS[placement], { name }),
|
||||||
|
previousTab: t("settings.shortcuts.help.previousTab"),
|
||||||
|
nextTab: t("settings.shortcuts.help.nextTab"),
|
||||||
|
closeCurrentTab: t("settings.shortcuts.help.closeCurrentTab"),
|
||||||
|
renameTab: t("workspace.tabs.menu.rename"),
|
||||||
|
reloadAgent: t("workspace.tabs.menu.reloadAgent"),
|
||||||
|
copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"),
|
||||||
|
copyAgentId: t("workspace.tabs.menu.copyAgentId"),
|
||||||
|
copyTerminalId: t("workspace.tabs.menu.copyTerminalId"),
|
||||||
|
copyFilePath: t("workspace.tabs.menu.copyFilePath"),
|
||||||
|
closeTabsLeft: t("workspace.tabs.menu.closeLeft"),
|
||||||
|
closeTabsRight: t("workspace.tabs.menu.closeRight"),
|
||||||
|
closeOtherTabs: t("workspace.tabs.menu.closeOthers"),
|
||||||
|
focusPaneLeft: t("settings.shortcuts.help.focusPaneLeft"),
|
||||||
|
focusPaneRight: t("settings.shortcuts.help.focusPaneRight"),
|
||||||
|
focusPaneUp: t("settings.shortcuts.help.focusPaneUp"),
|
||||||
|
focusPaneDown: t("settings.shortcuts.help.focusPaneDown"),
|
||||||
|
moveTabLeft: t("settings.shortcuts.help.moveTabLeft"),
|
||||||
|
moveTabRight: t("settings.shortcuts.help.moveTabRight"),
|
||||||
|
moveTabUp: t("settings.shortcuts.help.moveTabUp"),
|
||||||
|
moveTabDown: t("settings.shortcuts.help.moveTabDown"),
|
||||||
|
closePane: t("settings.shortcuts.help.closePane"),
|
||||||
|
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
|
||||||
|
toggleExplorerSidebar: t("workspace.tabs.explorerSidebar.toggle"),
|
||||||
},
|
},
|
||||||
icons: {
|
icons: {
|
||||||
...WORKSPACE_COMMAND_CENTER_ICONS,
|
...WORKSPACE_COMMAND_CENTER_ICONS,
|
||||||
@@ -90,14 +161,29 @@ export function useWorkspaceCommandCenterActions(): void {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
|
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
|
||||||
canOpenBrowserTabs: getIsElectron(),
|
canOpenBrowserTabs: getIsElectron(),
|
||||||
|
isGit,
|
||||||
},
|
},
|
||||||
|
activeTabKind,
|
||||||
|
activeTabIndex,
|
||||||
|
activeTabCount: focusedTabs.length,
|
||||||
dispatch: (action) => {
|
dispatch: (action) => {
|
||||||
clearCommandCenterFocusRestoreElement();
|
clearCommandCenterFocusRestoreElement();
|
||||||
keyboardActionDispatcher.dispatch(action);
|
keyboardActionDispatcher.dispatch(action);
|
||||||
},
|
},
|
||||||
runGitAction,
|
runGitAction,
|
||||||
}),
|
}),
|
||||||
[gitActions, isCompact, keyboardActionDispatcher, overrides, runGitAction, t],
|
[
|
||||||
|
activeTabIndex,
|
||||||
|
activeTabKind,
|
||||||
|
focusedTabs.length,
|
||||||
|
gitActions,
|
||||||
|
isCompact,
|
||||||
|
isGit,
|
||||||
|
keyboardActionDispatcher,
|
||||||
|
overrides,
|
||||||
|
runGitAction,
|
||||||
|
t,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useCommandCenterActions({
|
useCommandCenterActions({
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts";
|
||||||
|
|
||||||
|
describe("resolveWorkspaceCommandCenterShortcuts", () => {
|
||||||
|
it("assigns the New agent command its own shortcut", () => {
|
||||||
|
expect(
|
||||||
|
resolveWorkspaceCommandCenterShortcuts({
|
||||||
|
overrides: {},
|
||||||
|
platform: { isMac: true, isDesktop: true },
|
||||||
|
}).newAgent,
|
||||||
|
).toEqual([["mod", "shift", "A"]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
resolveShortcutKeysForAction,
|
||||||
|
type ShortcutOverrides,
|
||||||
|
} from "@/keyboard/keyboard-shortcuts";
|
||||||
|
import type { WorkspaceCommandCenterShortcuts } from "./workspace-contributions";
|
||||||
|
|
||||||
|
interface ResolveWorkspaceCommandCenterShortcutsInput {
|
||||||
|
overrides: ShortcutOverrides;
|
||||||
|
platform: { isMac: boolean; isDesktop: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWorkspaceCommandCenterShortcuts({
|
||||||
|
overrides,
|
||||||
|
platform,
|
||||||
|
}: ResolveWorkspaceCommandCenterShortcutsInput): WorkspaceCommandCenterShortcuts {
|
||||||
|
return {
|
||||||
|
newAgent:
|
||||||
|
resolveShortcutKeysForAction("workspace-tab-target-agent", overrides, platform) ?? undefined,
|
||||||
|
newTerminal:
|
||||||
|
resolveShortcutKeysForAction("workspace-terminal-new", overrides, platform) ?? undefined,
|
||||||
|
splitRight:
|
||||||
|
resolveShortcutKeysForAction("workspace-pane-split-right", overrides, platform) ?? undefined,
|
||||||
|
splitDown:
|
||||||
|
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
|
||||||
|
archiveWorkspace:
|
||||||
|
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
|
||||||
|
previousTab:
|
||||||
|
resolveShortcutKeysForAction("workspace-tab-prev", overrides, platform) ?? undefined,
|
||||||
|
nextTab: resolveShortcutKeysForAction("workspace-tab-next", overrides, platform) ?? undefined,
|
||||||
|
closeCurrentTab:
|
||||||
|
resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined,
|
||||||
|
closePane:
|
||||||
|
resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined,
|
||||||
|
toggleExplorerSidebar:
|
||||||
|
resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { ChevronDown, GitBranch } from "lucide-react-native";
|
import { GitBranch } from "lucide-react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
@@ -10,6 +10,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
||||||
|
import { ToolbarLabelSelectTrigger } from "@/components/ui/toolbar-label-trigger";
|
||||||
|
|
||||||
interface BranchSwitcherProps {
|
interface BranchSwitcherProps {
|
||||||
currentBranchName: string | null;
|
currentBranchName: string | null;
|
||||||
@@ -23,9 +24,7 @@ interface BranchSwitcherProps {
|
|||||||
const foregroundMutedIconColorMapping = (theme: Theme) => ({
|
const foregroundMutedIconColorMapping = (theme: Theme) => ({
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ThemedGitBranch = withUnistyles(GitBranch);
|
const ThemedGitBranch = withUnistyles(GitBranch);
|
||||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
|
||||||
|
|
||||||
export function BranchSwitcher({
|
export function BranchSwitcher({
|
||||||
currentBranchName,
|
currentBranchName,
|
||||||
@@ -56,14 +55,6 @@ export function BranchSwitcher({
|
|||||||
|
|
||||||
const handleOpen = useCallback(() => setIsOpen(true), [setIsOpen]);
|
const handleOpen = useCallback(() => setIsOpen(true), [setIsOpen]);
|
||||||
|
|
||||||
const triggerStyle = useCallback(
|
|
||||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
||||||
styles.trigger,
|
|
||||||
(Boolean(hovered) || pressed) && styles.triggerHovered,
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const branchLeadingSlot = useMemo(
|
const branchLeadingSlot = useMemo(
|
||||||
() => <ThemedGitBranch size={14} uniProps={foregroundMutedIconColorMapping} />,
|
() => <ThemedGitBranch size={14} uniProps={foregroundMutedIconColorMapping} />,
|
||||||
[],
|
[],
|
||||||
@@ -90,21 +81,16 @@ export function BranchSwitcher({
|
|||||||
<View ref={anchorRef} collapsable={false} style={styles.anchor}>
|
<View ref={anchorRef} collapsable={false} style={styles.anchor}>
|
||||||
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Pressable
|
<ToolbarLabelSelectTrigger
|
||||||
testID={testID}
|
testID={testID}
|
||||||
|
label={currentBranchName}
|
||||||
|
open={isOpen}
|
||||||
onPress={handleOpen}
|
onPress={handleOpen}
|
||||||
style={triggerStyle}
|
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={t("branchSwitcher.currentBranch", {
|
accessibilityLabel={t("branchSwitcher.currentBranch", {
|
||||||
branchName: currentBranchName,
|
branchName: currentBranchName,
|
||||||
})}
|
})}
|
||||||
>
|
/>
|
||||||
<ThemedGitBranch size={12} uniProps={foregroundMutedIconColorMapping} />
|
|
||||||
<Text style={styles.branchLabel} numberOfLines={1}>
|
|
||||||
{currentBranchName}
|
|
||||||
</Text>
|
|
||||||
<ThemedChevronDown size={12} uniProps={foregroundMutedIconColorMapping} />
|
|
||||||
</Pressable>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
<Text style={styles.tooltipText}>{t("branchSwitcher.triggerTooltip")}</Text>
|
<Text style={styles.tooltipText}>{t("branchSwitcher.triggerTooltip")}</Text>
|
||||||
@@ -136,25 +122,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
trigger: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
minWidth: 0,
|
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
paddingHorizontal: theme.spacing[1],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
flexShrink: 1,
|
|
||||||
},
|
|
||||||
triggerHovered: {
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
},
|
|
||||||
branchLabel: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
flexShrink: 1,
|
|
||||||
},
|
|
||||||
tooltipText: {
|
tooltipText: {
|
||||||
color: theme.colors.popoverForeground,
|
color: theme.colors.popoverForeground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef } from "react";
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View, type LayoutChangeEvent } from "react-native";
|
||||||
import { GestureDetector } from "react-native-gesture-handler";
|
import { GestureDetector } from "react-native-gesture-handler";
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||||
import { CompactExplorerSidebar } from "@/components/compact-explorer-sidebar";
|
import {
|
||||||
|
CompactExplorerSidebar,
|
||||||
|
NativeExplorerSidebarDock,
|
||||||
|
} from "@/components/compact-explorer-sidebar";
|
||||||
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
@@ -12,6 +15,7 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
|||||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
|
||||||
import {
|
import {
|
||||||
resolveCompactExplorerSidebarHostModel,
|
resolveCompactExplorerSidebarHostModel,
|
||||||
type CompactExplorerSidebarHostModel,
|
type CompactExplorerSidebarHostModel,
|
||||||
@@ -94,10 +98,16 @@ function useActiveCompactExplorerSidebarModel(
|
|||||||
interface CompactExplorerSidebarHostProps {
|
interface CompactExplorerSidebarHostProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
presentation: "overlay" | "dock";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) {
|
export function CompactExplorerSidebarHost({
|
||||||
|
children,
|
||||||
|
enabled,
|
||||||
|
presentation,
|
||||||
|
}: CompactExplorerSidebarHostProps) {
|
||||||
const model = useActiveCompactExplorerSidebarModel(enabled);
|
const model = useActiveCompactExplorerSidebarModel(enabled);
|
||||||
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer);
|
const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer);
|
||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
|
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
|
||||||
@@ -127,14 +137,54 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
|
|||||||
openWorkspaceFileFromExplorer({
|
openWorkspaceFileFromExplorer({
|
||||||
filePath,
|
filePath,
|
||||||
persistenceKey: model.persistenceKey,
|
persistenceKey: model.persistenceKey,
|
||||||
|
closeExplorerAfterOpen: presentation === "overlay",
|
||||||
showMobileAgent,
|
showMobileAgent,
|
||||||
openWorkspaceTabInFocusedPane,
|
openWorkspaceTabInFocusedPane,
|
||||||
focusWorkspaceTab,
|
focusWorkspaceTab,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, showMobileAgent],
|
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, presentation, showMobileAgent],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
|
const nextWidth = event.nativeEvent.layout.width;
|
||||||
|
setContainerWidth((current) => (current === nextWidth ? current : nextWidth));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const explorer =
|
||||||
|
enabled && model ? (
|
||||||
|
<DiffDocumentWorkspaceCacheProvider key={model.persistenceKey}>
|
||||||
|
{presentation === "dock" ? (
|
||||||
|
<NativeExplorerSidebarDock
|
||||||
|
serverId={model.serverId}
|
||||||
|
workspaceId={model.workspaceId}
|
||||||
|
workspaceRoot={model.workspaceRoot}
|
||||||
|
isGit={model.isGit}
|
||||||
|
persistenceKey={model.persistenceKey}
|
||||||
|
containerWidth={containerWidth}
|
||||||
|
onOpenFile={handleOpenFile}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CompactExplorerSidebar
|
||||||
|
serverId={model.serverId}
|
||||||
|
workspaceId={model.workspaceId}
|
||||||
|
workspaceRoot={model.workspaceRoot}
|
||||||
|
isGit={model.isGit}
|
||||||
|
onOpenFile={handleOpenFile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DiffDocumentWorkspaceCacheProvider>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (presentation === "dock") {
|
||||||
|
return (
|
||||||
|
<View style={styles.row} onLayout={handleContainerLayout}>
|
||||||
|
<View style={styles.fill}>{children}</View>
|
||||||
|
{explorer}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CompactExplorerOpenGestureSurface
|
<CompactExplorerOpenGestureSurface
|
||||||
@@ -143,15 +193,7 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</CompactExplorerOpenGestureSurface>
|
</CompactExplorerOpenGestureSurface>
|
||||||
{enabled && model ? (
|
{explorer}
|
||||||
<CompactExplorerSidebar
|
|
||||||
serverId={model.serverId}
|
|
||||||
workspaceId={model.workspaceId}
|
|
||||||
workspaceRoot={model.workspaceRoot}
|
|
||||||
isGit={model.isGit}
|
|
||||||
onOpenFile={handleOpenFile}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -160,4 +202,8 @@ const styles = {
|
|||||||
fill: {
|
fill: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
row: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { View, Text, Pressable } from "react-native";
|
import { View, Text, Pressable } from "react-native";
|
||||||
|
import { Gesture } from "react-native-gesture-handler";
|
||||||
|
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||||
|
import { scheduleOnRN } from "react-native-worklets";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { X } from "lucide-react-native";
|
import { X } from "lucide-react-native";
|
||||||
@@ -29,6 +32,13 @@ import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
|||||||
import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability";
|
import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability";
|
||||||
import { PullRequestContent } from "@/panels/pull-request";
|
import { PullRequestContent } from "@/panels/pull-request";
|
||||||
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
||||||
|
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||||
|
import {
|
||||||
|
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||||
|
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||||
|
} from "@/components/sidebar-resize-handle-layout";
|
||||||
|
import { resolveExplorerSidebarWidth } from "@/components/explorer-sidebar-layout";
|
||||||
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
|
|
||||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||||
|
|
||||||
@@ -139,6 +149,118 @@ export function CompactExplorerSidebar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NativeExplorerSidebarDockProps extends ExplorerSidebarProps {
|
||||||
|
persistenceKey: string;
|
||||||
|
containerWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NativeExplorerSidebarDock({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
workspaceRoot,
|
||||||
|
isGit,
|
||||||
|
onOpenFile,
|
||||||
|
persistenceKey,
|
||||||
|
containerWidth,
|
||||||
|
}: NativeExplorerSidebarDockProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||||
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
|
const storedWidth = useWorkspaceLayoutStore(
|
||||||
|
(state) => state.explorerSidebarWidthByWorkspace[persistenceKey],
|
||||||
|
);
|
||||||
|
const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar);
|
||||||
|
const visibleWidth = resolveExplorerSidebarWidth({
|
||||||
|
requestedWidth: storedWidth,
|
||||||
|
containerWidth,
|
||||||
|
});
|
||||||
|
const resizeWidth = useSharedValue(visibleWidth);
|
||||||
|
const startWidthRef = useRef(visibleWidth);
|
||||||
|
const [resizePressed, setResizePressed] = useState(false);
|
||||||
|
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||||
|
serverId,
|
||||||
|
workspaceRoot,
|
||||||
|
isGit,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resizeWidth.value = visibleWidth;
|
||||||
|
}, [resizeWidth, visibleWidth]);
|
||||||
|
|
||||||
|
const showResizeGrip = useCallback(() => setResizePressed(true), []);
|
||||||
|
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
|
||||||
|
const commitWidth = useCallback(
|
||||||
|
(width: number) => resizeExplorerSidebar(persistenceKey, width),
|
||||||
|
[persistenceKey, resizeExplorerSidebar],
|
||||||
|
);
|
||||||
|
const resizeGesture = useMemo(
|
||||||
|
() =>
|
||||||
|
Gesture.Pan()
|
||||||
|
.enabled(true)
|
||||||
|
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||||
|
.onBegin(() => scheduleOnRN(showResizeGrip))
|
||||||
|
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
|
||||||
|
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
|
||||||
|
.onStart((event) => {
|
||||||
|
startWidthRef.current = visibleWidth + event.translationX;
|
||||||
|
resizeWidth.value = visibleWidth;
|
||||||
|
})
|
||||||
|
.onUpdate((event) => {
|
||||||
|
resizeWidth.value = resolveExplorerSidebarWidth({
|
||||||
|
requestedWidth: startWidthRef.current - event.translationX,
|
||||||
|
containerWidth,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.onEnd(() => runOnJS(commitWidth)(resizeWidth.value))
|
||||||
|
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
|
||||||
|
[commitWidth, containerWidth, hideResizeGrip, resizeWidth, showResizeGrip, visibleWidth],
|
||||||
|
);
|
||||||
|
const animatedWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value }));
|
||||||
|
const dockStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
styles.nativeDock,
|
||||||
|
{
|
||||||
|
display: isOpen ? ("flex" as const) : ("none" as const),
|
||||||
|
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
|
||||||
|
backgroundColor: theme.colors.surfaceSidebar,
|
||||||
|
},
|
||||||
|
animatedWidthStyle,
|
||||||
|
],
|
||||||
|
[animatedWidthStyle, insets.top, isOpen, theme.colors.surfaceSidebar],
|
||||||
|
);
|
||||||
|
const dockContentStyle = useMemo(
|
||||||
|
() => [styles.nativeDockContent, { borderLeftColor: theme.colors.border }],
|
||||||
|
[theme.colors.border],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RetainedPanelActivity active={isOpen}>
|
||||||
|
<Animated.View style={dockStyle} testID="native-explorer-sidebar-dock">
|
||||||
|
<View style={dockContentStyle}>
|
||||||
|
<SidebarResizeHandle
|
||||||
|
edge="left"
|
||||||
|
gesture={resizeGesture}
|
||||||
|
pressed={resizePressed}
|
||||||
|
testID="native-explorer-sidebar-resize-handle"
|
||||||
|
/>
|
||||||
|
<ExplorerSidebarContent
|
||||||
|
activeTab={explorerTab}
|
||||||
|
onTabPress={handleTabPress}
|
||||||
|
onClose={showMobileAgent}
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
workspaceRoot={workspaceRoot}
|
||||||
|
isGit={isGit}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</RetainedPanelActivity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ExplorerTabButtonProps {
|
interface ExplorerTabButtonProps {
|
||||||
tab: ExplorerTab;
|
tab: ExplorerTab;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -230,7 +352,7 @@ function ExplorerSidebarContent({
|
|||||||
<ExplorerTabButton
|
<ExplorerTabButton
|
||||||
tab="changes"
|
tab="changes"
|
||||||
active={resolvedTab === "changes"}
|
active={resolvedTab === "changes"}
|
||||||
label={t("workspace.tabs.sidePanel.changes")}
|
label={t("workspace.tabs.explorerSidebar.changes")}
|
||||||
onTabPress={onTabPress}
|
onTabPress={onTabPress}
|
||||||
testID="explorer-tab-changes"
|
testID="explorer-tab-changes"
|
||||||
/>
|
/>
|
||||||
@@ -238,7 +360,7 @@ function ExplorerSidebarContent({
|
|||||||
<ExplorerTabButton
|
<ExplorerTabButton
|
||||||
tab="files"
|
tab="files"
|
||||||
active={resolvedTab === "files"}
|
active={resolvedTab === "files"}
|
||||||
label={t("workspace.tabs.sidePanel.files")}
|
label={t("workspace.tabs.explorerSidebar.files")}
|
||||||
onTabPress={onTabPress}
|
onTabPress={onTabPress}
|
||||||
testID="explorer-tab-files"
|
testID="explorer-tab-files"
|
||||||
/>
|
/>
|
||||||
@@ -268,7 +390,7 @@ function ExplorerSidebarContent({
|
|||||||
nativeID="explorer-close"
|
nativeID="explorer-close"
|
||||||
accessible
|
accessible
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={t("workspace.tabs.sidePanel.close")}
|
accessibilityLabel={t("workspace.tabs.explorerSidebar.close")}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
{({ hovered, pressed }) => (
|
{({ hovered, pressed }) => (
|
||||||
@@ -335,7 +457,6 @@ function ChangedFilesPane({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<ChangesSurface
|
<ChangesSurface
|
||||||
host="explorer"
|
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
cwd={workspaceRoot}
|
cwd={workspaceRoot}
|
||||||
@@ -370,6 +491,18 @@ function FilesPane({
|
|||||||
const PrTabContent = PullRequestContent;
|
const PrTabContent = PullRequestContent;
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
nativeDock: {
|
||||||
|
position: "relative",
|
||||||
|
height: "100%",
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
nativeDockContent: {
|
||||||
|
position: "relative",
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
borderLeftWidth: 1,
|
||||||
|
},
|
||||||
sidebarContent: {
|
sidebarContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
|
|||||||
@@ -17,15 +17,20 @@ import { isNative } from "@/constants/platform";
|
|||||||
* On macOS, Electron handles edge resize natively.
|
* On macOS, Electron handles edge resize natively.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export const titlebarDragSurfaceStyle: React.CSSProperties = {
|
||||||
|
cursor: "default",
|
||||||
|
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
|
||||||
|
WebkitAppRegion: "drag",
|
||||||
|
};
|
||||||
|
|
||||||
const DRAG_OVERLAY_STYLE: React.CSSProperties = {
|
const DRAG_OVERLAY_STYLE: React.CSSProperties = {
|
||||||
|
...titlebarDragSurfaceStyle,
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
display: "block",
|
display: "block",
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
|
|
||||||
WebkitAppRegion: "drag",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const TOP_RESIZER_STYLE: React.CSSProperties = {
|
const TOP_RESIZER_STYLE: React.CSSProperties = {
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import Svg, { Line, Rect } from "react-native-svg";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import {
|
||||||
|
closeDesktopWindow,
|
||||||
|
minimizeDesktopWindow,
|
||||||
|
toggleDesktopMaximize,
|
||||||
|
} from "@/desktop/electron/window";
|
||||||
|
import {
|
||||||
|
getDesktopWindowControlsPresentation,
|
||||||
|
type DesktopWindowControlsPresentation,
|
||||||
|
} from "@/desktop/window-chrome-presentation";
|
||||||
|
import { useCustomDesktopWindowControls } from "@/utils/desktop-window";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
|
type WindowControlKind = "minimize" | "maximize" | "restore" | "close";
|
||||||
|
|
||||||
|
function WindowControlGlyph({
|
||||||
|
kind,
|
||||||
|
color,
|
||||||
|
size,
|
||||||
|
strokeWidth,
|
||||||
|
}: {
|
||||||
|
kind: WindowControlKind;
|
||||||
|
color: string;
|
||||||
|
size: number;
|
||||||
|
strokeWidth: number;
|
||||||
|
}) {
|
||||||
|
if (kind === "minimize") {
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 16 16" style={styles.glyph}>
|
||||||
|
<Line x1={3} y1={8} x2={13} y2={8} stroke={color} strokeWidth={strokeWidth} />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (kind === "restore") {
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 16 16" style={styles.glyph}>
|
||||||
|
<Rect
|
||||||
|
x={5.5}
|
||||||
|
y={3.5}
|
||||||
|
width={7}
|
||||||
|
height={7}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
/>
|
||||||
|
<Line x1={3.5} y1={5.5} x2={3.5} y2={12.5} stroke={color} strokeWidth={strokeWidth} />
|
||||||
|
<Line x1={3} y1={12.5} x2={10.5} y2={12.5} stroke={color} strokeWidth={strokeWidth} />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (kind === "close") {
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 16 16" style={styles.glyph}>
|
||||||
|
<Line x1={4} y1={4} x2={12} y2={12} stroke={color} strokeWidth={strokeWidth} />
|
||||||
|
<Line x1={12} y1={4} x2={4} y2={12} stroke={color} strokeWidth={strokeWidth} />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 16 16" style={styles.glyph}>
|
||||||
|
<Rect
|
||||||
|
x={3.5}
|
||||||
|
y={3.5}
|
||||||
|
width={9}
|
||||||
|
height={9}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemedWindowControlGlyph = withUnistyles(WindowControlGlyph);
|
||||||
|
const mutedGlyphMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
const whiteGlyphMapping = () => ({ color: "#ffffff" });
|
||||||
|
|
||||||
|
function WindowControl({
|
||||||
|
kind,
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
presentation,
|
||||||
|
}: {
|
||||||
|
kind: WindowControlKind;
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
presentation: DesktopWindowControlsPresentation;
|
||||||
|
}) {
|
||||||
|
const glyph = useCallback(
|
||||||
|
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) => {
|
||||||
|
const highlighted = hovered || pressed;
|
||||||
|
const closeHighlighted = kind === "close" && highlighted;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.controlSurface,
|
||||||
|
presentation.hoverShape === "circle"
|
||||||
|
? styles.circularControlSurface
|
||||||
|
: styles.fullControlSurface,
|
||||||
|
highlighted ? styles.controlHovered : null,
|
||||||
|
closeHighlighted ? styles.closeHovered : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ThemedWindowControlGlyph
|
||||||
|
kind={kind}
|
||||||
|
size={presentation.glyphSize}
|
||||||
|
strokeWidth={presentation.glyphStrokeWidth}
|
||||||
|
uniProps={closeHighlighted ? whiteGlyphMapping : mutedGlyphMapping}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[kind, presentation],
|
||||||
|
);
|
||||||
|
const controlStyle = useMemo(
|
||||||
|
() => [styles.control, { width: presentation.controlWidth }],
|
||||||
|
[presentation.controlWidth],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={label}
|
||||||
|
onPress={onPress}
|
||||||
|
style={controlStyle}
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopWindowControls() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { visible, mode, isMaximized } = useCustomDesktopWindowControls();
|
||||||
|
const minimize = useCallback(() => void minimizeDesktopWindow(), []);
|
||||||
|
const toggleMaximize = useCallback(() => void toggleDesktopMaximize(), []);
|
||||||
|
const close = useCallback(() => void closeDesktopWindow(), []);
|
||||||
|
if (!visible || !mode) return null;
|
||||||
|
const presentation = getDesktopWindowControlsPresentation(mode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.controls, { height: presentation.controlHeight }]}
|
||||||
|
testID="desktop-window-controls"
|
||||||
|
>
|
||||||
|
<WindowControl
|
||||||
|
kind="minimize"
|
||||||
|
label={t("desktop.windowControls.minimize")}
|
||||||
|
onPress={minimize}
|
||||||
|
presentation={presentation}
|
||||||
|
/>
|
||||||
|
<WindowControl
|
||||||
|
kind={isMaximized ? "restore" : "maximize"}
|
||||||
|
label={t(
|
||||||
|
isMaximized ? "desktop.windowControls.restore" : "desktop.windowControls.maximize",
|
||||||
|
)}
|
||||||
|
onPress={toggleMaximize}
|
||||||
|
presentation={presentation}
|
||||||
|
/>
|
||||||
|
<WindowControl
|
||||||
|
kind="close"
|
||||||
|
label={t("desktop.windowControls.close")}
|
||||||
|
onPress={close}
|
||||||
|
presentation={presentation}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
controls: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 3000,
|
||||||
|
flexDirection: "row",
|
||||||
|
},
|
||||||
|
control: {
|
||||||
|
height: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
controlSurface: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
fullControlSurface: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
circularControlSurface: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
glyph: {
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
controlHovered: {
|
||||||
|
backgroundColor: theme.colors.interactionHighlight,
|
||||||
|
},
|
||||||
|
closeHovered: {
|
||||||
|
backgroundColor: "rgba(232, 17, 35, 0.9)",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
resolveExplorerSidebarDockSizes,
|
||||||
|
resolveExplorerSidebarWidth,
|
||||||
|
} from "@/components/explorer-sidebar-layout";
|
||||||
|
|
||||||
|
describe("Explorer sidebar layout", () => {
|
||||||
|
it("keeps the sidebar width fixed when the workspace body changes size", () => {
|
||||||
|
const narrow = resolveExplorerSidebarDockSizes({ requestedWidth: 320, containerWidth: 1200 });
|
||||||
|
const wide = resolveExplorerSidebarDockSizes({ requestedWidth: 320, containerWidth: 1520 });
|
||||||
|
|
||||||
|
expect(narrow[1] * 1200).toBeCloseTo(320);
|
||||||
|
expect(wide[1] * 1520).toBeCloseTo(320);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no fixed maximum while preserving room for the workspace body", () => {
|
||||||
|
expect(resolveExplorerSidebarWidth({ requestedWidth: 100, containerWidth: 1200 })).toBe(240);
|
||||||
|
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1600 })).toBe(900);
|
||||||
|
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(800);
|
||||||
|
expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(350);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 320;
|
||||||
|
const MIN_EXPLORER_SIDEBAR_WIDTH = 240;
|
||||||
|
const MIN_WORKSPACE_BODY_WIDTH = 400;
|
||||||
|
|
||||||
|
export function resolveExplorerSidebarWidth(input: {
|
||||||
|
requestedWidth?: number;
|
||||||
|
containerWidth: number;
|
||||||
|
}): number {
|
||||||
|
const requestedWidth = input.requestedWidth ?? DEFAULT_EXPLORER_SIDEBAR_WIDTH;
|
||||||
|
const maximumVisibleWidth =
|
||||||
|
input.containerWidth > 0
|
||||||
|
? Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, input.containerWidth - MIN_WORKSPACE_BODY_WIDTH)
|
||||||
|
: requestedWidth;
|
||||||
|
return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maximumVisibleWidth, requestedWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveExplorerSidebarDockSizes(input: {
|
||||||
|
requestedWidth?: number;
|
||||||
|
containerWidth: number;
|
||||||
|
}): number[] {
|
||||||
|
if (input.containerWidth <= 0) {
|
||||||
|
return [1, 0];
|
||||||
|
}
|
||||||
|
const width = resolveExplorerSidebarWidth(input);
|
||||||
|
const ratio = width / input.containerWidth;
|
||||||
|
return [1 - ratio, ratio];
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Fragment, useMemo, type ReactElement, type ReactNode } from "react";
|
import { Fragment, useMemo, type ReactElement } from "react";
|
||||||
import { withUnistyles } from "react-native-unistyles";
|
import { withUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
|
ArrowRightToLine,
|
||||||
Copy,
|
Copy,
|
||||||
CopyPlus,
|
CopyPlus,
|
||||||
Download,
|
Download,
|
||||||
@@ -25,8 +26,11 @@ import {
|
|||||||
|
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
|
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
|
||||||
|
type FileActionGroup = "create" | "open" | "reference" | "manage" | "destructive";
|
||||||
|
|
||||||
interface FileAction {
|
interface FileAction {
|
||||||
key: string;
|
key: string;
|
||||||
|
group: FileActionGroup;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
@@ -35,10 +39,19 @@ interface FileAction {
|
|||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalFileAction(
|
||||||
|
available: boolean,
|
||||||
|
onSelect: (() => void) | undefined,
|
||||||
|
action: Omit<FileAction, "onSelect">,
|
||||||
|
): FileAction | null {
|
||||||
|
return available && onSelect ? { ...action, onSelect } : null;
|
||||||
|
}
|
||||||
|
|
||||||
interface FileActionsContextMenuContentProps {
|
interface FileActionsContextMenuContentProps {
|
||||||
fileKind: "file" | "directory";
|
fileKind: "file" | "directory";
|
||||||
fileExists?: boolean;
|
fileExists?: boolean;
|
||||||
onOpenFile?: () => void;
|
onOpenFile?: () => void;
|
||||||
|
onOpenToSide?: () => void;
|
||||||
onCopyPath?: () => void;
|
onCopyPath?: () => void;
|
||||||
onCopyRelativePath?: () => void;
|
onCopyRelativePath?: () => void;
|
||||||
onReveal?: () => void;
|
onReveal?: () => void;
|
||||||
@@ -52,8 +65,6 @@ interface FileActionsContextMenuContentProps {
|
|||||||
onDuplicate?: () => void;
|
onDuplicate?: () => void;
|
||||||
onRevert?: () => void;
|
onRevert?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
/** Optional metadata block rendered above the actions (e.g. size/modified). */
|
|
||||||
header?: ReactNode;
|
|
||||||
testIDPrefix?: string;
|
testIDPrefix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +76,7 @@ export function FileActionsContextMenuContent({
|
|||||||
fileKind,
|
fileKind,
|
||||||
fileExists = true,
|
fileExists = true,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
|
onOpenToSide,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
onCopyRelativePath,
|
onCopyRelativePath,
|
||||||
onReveal,
|
onReveal,
|
||||||
@@ -78,7 +90,6 @@ export function FileActionsContextMenuContent({
|
|||||||
onDuplicate,
|
onDuplicate,
|
||||||
onRevert,
|
onRevert,
|
||||||
onDelete,
|
onDelete,
|
||||||
header,
|
|
||||||
testIDPrefix,
|
testIDPrefix,
|
||||||
}: FileActionsContextMenuContentProps): ReactElement | null {
|
}: FileActionsContextMenuContentProps): ReactElement | null {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -88,6 +99,7 @@ export function FileActionsContextMenuContent({
|
|||||||
onNewFile
|
onNewFile
|
||||||
? {
|
? {
|
||||||
key: "new-file",
|
key: "new-file",
|
||||||
|
group: "create",
|
||||||
label: t("workspace.fileActions.newFile"),
|
label: t("workspace.fileActions.newFile"),
|
||||||
icon: FilePlus,
|
icon: FilePlus,
|
||||||
onSelect: onNewFile,
|
onSelect: onNewFile,
|
||||||
@@ -96,6 +108,7 @@ export function FileActionsContextMenuContent({
|
|||||||
onNewFolder
|
onNewFolder
|
||||||
? {
|
? {
|
||||||
key: "new-folder",
|
key: "new-folder",
|
||||||
|
group: "create",
|
||||||
label: t("workspace.fileActions.newFolder"),
|
label: t("workspace.fileActions.newFolder"),
|
||||||
icon: FolderPlus,
|
icon: FolderPlus,
|
||||||
onSelect: onNewFolder,
|
onSelect: onNewFolder,
|
||||||
@@ -104,6 +117,7 @@ export function FileActionsContextMenuContent({
|
|||||||
onCollapseFolder
|
onCollapseFolder
|
||||||
? {
|
? {
|
||||||
key: "collapse-folder",
|
key: "collapse-folder",
|
||||||
|
group: "open",
|
||||||
label: t("workspace.fileActions.collapseFolder"),
|
label: t("workspace.fileActions.collapseFolder"),
|
||||||
icon: FolderMinus,
|
icon: FolderMinus,
|
||||||
onSelect: onCollapseFolder,
|
onSelect: onCollapseFolder,
|
||||||
@@ -112,30 +126,22 @@ export function FileActionsContextMenuContent({
|
|||||||
availableFile && onOpenFile
|
availableFile && onOpenFile
|
||||||
? {
|
? {
|
||||||
key: "open-file",
|
key: "open-file",
|
||||||
|
group: "open",
|
||||||
label: t("workspace.fileActions.openFile"),
|
label: t("workspace.fileActions.openFile"),
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
onSelect: onOpenFile,
|
onSelect: onOpenFile,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
onRename
|
optionalFileAction(availableFile, onOpenToSide, {
|
||||||
? {
|
key: "open-to-side",
|
||||||
key: "rename",
|
group: "open",
|
||||||
label: t("workspace.fileActions.rename"),
|
label: t("workspace.fileActions.openToSide"),
|
||||||
icon: Pencil,
|
icon: ArrowRightToLine,
|
||||||
onSelect: onRename,
|
}),
|
||||||
}
|
|
||||||
: null,
|
|
||||||
onDuplicate
|
|
||||||
? {
|
|
||||||
key: "duplicate",
|
|
||||||
label: t("workspace.fileActions.duplicate"),
|
|
||||||
icon: CopyPlus,
|
|
||||||
onSelect: onDuplicate,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
onCopyPath
|
onCopyPath
|
||||||
? {
|
? {
|
||||||
key: "copy-path",
|
key: "copy-path",
|
||||||
|
group: "reference",
|
||||||
label: t("workspace.fileActions.copyPath"),
|
label: t("workspace.fileActions.copyPath"),
|
||||||
icon: Copy,
|
icon: Copy,
|
||||||
onSelect: onCopyPath,
|
onSelect: onCopyPath,
|
||||||
@@ -144,6 +150,7 @@ export function FileActionsContextMenuContent({
|
|||||||
onCopyRelativePath
|
onCopyRelativePath
|
||||||
? {
|
? {
|
||||||
key: "copy-relative-path",
|
key: "copy-relative-path",
|
||||||
|
group: "reference",
|
||||||
label: t("workspace.fileActions.copyRelativePath"),
|
label: t("workspace.fileActions.copyRelativePath"),
|
||||||
icon: Copy,
|
icon: Copy,
|
||||||
onSelect: onCopyRelativePath,
|
onSelect: onCopyRelativePath,
|
||||||
@@ -152,6 +159,7 @@ export function FileActionsContextMenuContent({
|
|||||||
onReveal && revealTargetName
|
onReveal && revealTargetName
|
||||||
? {
|
? {
|
||||||
key: "reveal",
|
key: "reveal",
|
||||||
|
group: "reference",
|
||||||
label: t("workspace.fileActions.revealIn", { target: revealTargetName }),
|
label: t("workspace.fileActions.revealIn", { target: revealTargetName }),
|
||||||
icon: FolderOpen,
|
icon: FolderOpen,
|
||||||
onSelect: onReveal,
|
onSelect: onReveal,
|
||||||
@@ -160,6 +168,7 @@ export function FileActionsContextMenuContent({
|
|||||||
availableFile && onDownload
|
availableFile && onDownload
|
||||||
? {
|
? {
|
||||||
key: "download",
|
key: "download",
|
||||||
|
group: "reference",
|
||||||
label: t("workspace.fileActions.download"),
|
label: t("workspace.fileActions.download"),
|
||||||
icon: Download,
|
icon: Download,
|
||||||
onSelect: onDownload,
|
onSelect: onDownload,
|
||||||
@@ -168,14 +177,34 @@ export function FileActionsContextMenuContent({
|
|||||||
availableFile && onAddToChat
|
availableFile && onAddToChat
|
||||||
? {
|
? {
|
||||||
key: "add-to-chat",
|
key: "add-to-chat",
|
||||||
|
group: "reference",
|
||||||
label: t("workspace.fileActions.addToChat"),
|
label: t("workspace.fileActions.addToChat"),
|
||||||
icon: MessageSquarePlus,
|
icon: MessageSquarePlus,
|
||||||
onSelect: onAddToChat,
|
onSelect: onAddToChat,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
onRename
|
||||||
|
? {
|
||||||
|
key: "rename",
|
||||||
|
group: "manage",
|
||||||
|
label: t("workspace.fileActions.rename"),
|
||||||
|
icon: Pencil,
|
||||||
|
onSelect: onRename,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onDuplicate
|
||||||
|
? {
|
||||||
|
key: "duplicate",
|
||||||
|
group: "manage",
|
||||||
|
label: t("workspace.fileActions.duplicate"),
|
||||||
|
icon: CopyPlus,
|
||||||
|
onSelect: onDuplicate,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
onRevert
|
onRevert
|
||||||
? {
|
? {
|
||||||
key: "revert",
|
key: "revert",
|
||||||
|
group: "destructive",
|
||||||
label: t("workspace.fileActions.revert"),
|
label: t("workspace.fileActions.revert"),
|
||||||
icon: Undo2,
|
icon: Undo2,
|
||||||
onSelect: onRevert,
|
onSelect: onRevert,
|
||||||
@@ -185,6 +214,7 @@ export function FileActionsContextMenuContent({
|
|||||||
onDelete
|
onDelete
|
||||||
? {
|
? {
|
||||||
key: "delete",
|
key: "delete",
|
||||||
|
group: "destructive",
|
||||||
label: t("workspace.fileActions.delete"),
|
label: t("workspace.fileActions.delete"),
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
onSelect: onDelete,
|
onSelect: onDelete,
|
||||||
@@ -195,9 +225,7 @@ export function FileActionsContextMenuContent({
|
|||||||
const availableActions = specs.filter((action): action is FileAction => action !== null);
|
const availableActions = specs.filter((action): action is FileAction => action !== null);
|
||||||
return availableActions.map((action, index) =>
|
return availableActions.map((action, index) =>
|
||||||
Object.assign(action, {
|
Object.assign(action, {
|
||||||
separatorBefore: Boolean(
|
separatorBefore: index > 0 && action.group !== availableActions[index - 1]?.group,
|
||||||
action.destructive && index > 0 && !availableActions[index - 1].destructive,
|
|
||||||
),
|
|
||||||
testID: testIDPrefix ? `${testIDPrefix}-${action.key}` : undefined,
|
testID: testIDPrefix ? `${testIDPrefix}-${action.key}` : undefined,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -214,6 +242,7 @@ export function FileActionsContextMenuContent({
|
|||||||
onNewFile,
|
onNewFile,
|
||||||
onNewFolder,
|
onNewFolder,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
|
onOpenToSide,
|
||||||
onRename,
|
onRename,
|
||||||
onReveal,
|
onReveal,
|
||||||
onRevert,
|
onRevert,
|
||||||
@@ -231,12 +260,6 @@ export function FileActionsContextMenuContent({
|
|||||||
width={220}
|
width={220}
|
||||||
testID={testIDPrefix ? `${testIDPrefix}-context-menu` : undefined}
|
testID={testIDPrefix ? `${testIDPrefix}-context-menu` : undefined}
|
||||||
>
|
>
|
||||||
{header ? (
|
|
||||||
<>
|
|
||||||
{header}
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{actions.map((action) => (
|
{actions.map((action) => (
|
||||||
<Fragment key={action.key}>
|
<Fragment key={action.key}>
|
||||||
{action.separatorBefore ? <ContextMenuSeparator /> : null}
|
{action.separatorBefore ? <ContextMenuSeparator /> : null}
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|||||||
import {
|
import {
|
||||||
PaneContentToolbar,
|
PaneContentToolbar,
|
||||||
paneContentToolbarIconSize,
|
paneContentToolbarIconSize,
|
||||||
paneContentToolbarIconButtonStyle,
|
paneContentToolbarTrailingPadding,
|
||||||
|
ToolbarButton,
|
||||||
|
ToolbarControls,
|
||||||
} from "@/components/ui/pane-content-toolbar";
|
} from "@/components/ui/pane-content-toolbar";
|
||||||
import {
|
import {
|
||||||
useOverlayFlatListScrollbar,
|
useOverlayFlatListScrollbar,
|
||||||
@@ -61,7 +63,6 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
|||||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||||
import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store";
|
import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
|
||||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||||
import { isHiddenExplorerPath } from "@/file-explorer/visibility";
|
import { isHiddenExplorerPath } from "@/file-explorer/visibility";
|
||||||
import {
|
import {
|
||||||
@@ -100,16 +101,6 @@ function DirectoryChevronIcon({ loading, expanded }: { loading: boolean; expande
|
|||||||
return <TreeChevron expanded={expanded} />;
|
return <TreeChevron expanded={expanded} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize({ size }: { size: number }): string {
|
|
||||||
if (size < 1024) {
|
|
||||||
return `${size} B`;
|
|
||||||
}
|
|
||||||
if (size < 1024 * 1024) {
|
|
||||||
return `${(size / 1024).toFixed(1)} KB`;
|
|
||||||
}
|
|
||||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TreeRowItemProps {
|
interface TreeRowItemProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
@@ -126,6 +117,7 @@ interface TreeRowItemProps {
|
|||||||
revealTargetName?: string;
|
revealTargetName?: string;
|
||||||
onDownloadEntry: (entry: ExplorerEntry) => void;
|
onDownloadEntry: (entry: ExplorerEntry) => void;
|
||||||
onAddToChat?: (path: string) => void;
|
onAddToChat?: (path: string) => void;
|
||||||
|
onOpenFileToSide?: (path: string) => void;
|
||||||
onNewEntry?: (parentPath: string, kind: "file" | "directory") => void;
|
onNewEntry?: (parentPath: string, kind: "file" | "directory") => void;
|
||||||
onCollapseDirectory?: (path: string) => void;
|
onCollapseDirectory?: (path: string) => void;
|
||||||
onRenameEntry?: (entry: ExplorerEntry) => void;
|
onRenameEntry?: (entry: ExplorerEntry) => void;
|
||||||
@@ -141,13 +133,6 @@ function sortTriggerStyle({
|
|||||||
return [styles.sortTrigger, (Boolean(hovered) || pressed) && styles.sortTriggerHovered];
|
return [styles.sortTrigger, (Boolean(hovered) || pressed) && styles.sortTriggerHovered];
|
||||||
}
|
}
|
||||||
|
|
||||||
function iconButtonStyle(
|
|
||||||
isCompact: boolean,
|
|
||||||
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
|
|
||||||
) {
|
|
||||||
return paneContentToolbarIconButtonStyle({ hovered, pressed }, false, isCompact);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExplorerListRow =
|
type ExplorerListRow =
|
||||||
| { type: "entry"; row: ExplorerTreeRow }
|
| { type: "entry"; row: ExplorerTreeRow }
|
||||||
| { type: "draft"; parentPath: string; kind: "file" | "directory"; depth: number }
|
| { type: "draft"; parentPath: string; kind: "file" | "directory"; depth: number }
|
||||||
@@ -256,6 +241,7 @@ function TreeRowItem({
|
|||||||
revealTargetName,
|
revealTargetName,
|
||||||
onDownloadEntry,
|
onDownloadEntry,
|
||||||
onAddToChat,
|
onAddToChat,
|
||||||
|
onOpenFileToSide,
|
||||||
onNewEntry,
|
onNewEntry,
|
||||||
onCollapseDirectory,
|
onCollapseDirectory,
|
||||||
onRenameEntry,
|
onRenameEntry,
|
||||||
@@ -263,7 +249,6 @@ function TreeRowItem({
|
|||||||
onDeleteEntry,
|
onDeleteEntry,
|
||||||
testID,
|
testID,
|
||||||
}: TreeRowItemProps) {
|
}: TreeRowItemProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const showNameHover = useCallback(() => setIsHovered(true), []);
|
const showNameHover = useCallback(() => setIsHovered(true), []);
|
||||||
const hideNameHover = useCallback(() => setIsHovered(false), []);
|
const hideNameHover = useCallback(() => setIsHovered(false), []);
|
||||||
@@ -317,6 +302,10 @@ function TreeRowItem({
|
|||||||
onAddToChat?.(entry.path);
|
onAddToChat?.(entry.path);
|
||||||
}, [onAddToChat, entry.path]);
|
}, [onAddToChat, entry.path]);
|
||||||
|
|
||||||
|
const handleOpenToSide = useCallback(() => {
|
||||||
|
onOpenFileToSide?.(entry.path);
|
||||||
|
}, [entry.path, onOpenFileToSide]);
|
||||||
|
|
||||||
const handleNewFile = useCallback(() => {
|
const handleNewFile = useCallback(() => {
|
||||||
onNewEntry?.(entry.path, "file");
|
onNewEntry?.(entry.path, "file");
|
||||||
}, [onNewEntry, entry.path]);
|
}, [onNewEntry, entry.path]);
|
||||||
@@ -341,30 +330,6 @@ function TreeRowItem({
|
|||||||
onDeleteEntry?.(entry);
|
onDeleteEntry?.(entry);
|
||||||
}, [onDeleteEntry, entry]);
|
}, [onDeleteEntry, entry]);
|
||||||
|
|
||||||
const metaHeader = useMemo(
|
|
||||||
() => (
|
|
||||||
<View style={styles.contextMetaBlock}>
|
|
||||||
<View style={styles.contextMetaRow}>
|
|
||||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
|
||||||
{t("workspace.fileExplorer.context.size")}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
|
||||||
{formatFileSize({ size: entry.size })}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.contextMetaRow}>
|
|
||||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
|
||||||
{t("workspace.fileExplorer.context.modified")}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
|
||||||
{formatTimeAgo(new Date(entry.modifiedAt))}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
[entry.modifiedAt, entry.size, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger
|
<ContextMenuTrigger
|
||||||
@@ -407,13 +372,13 @@ function TreeRowItem({
|
|||||||
revealTargetName={revealTargetName}
|
revealTargetName={revealTargetName}
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
onAddToChat={onAddToChat ? handleAddToChat : undefined}
|
onAddToChat={onAddToChat ? handleAddToChat : undefined}
|
||||||
|
onOpenToSide={!isDirectory && onOpenFileToSide ? handleOpenToSide : undefined}
|
||||||
onNewFile={onNewEntry ? handleNewFile : undefined}
|
onNewFile={onNewEntry ? handleNewFile : undefined}
|
||||||
onNewFolder={onNewEntry ? handleNewFolder : undefined}
|
onNewFolder={onNewEntry ? handleNewFolder : undefined}
|
||||||
onCollapseFolder={isDirectory && isExpanded ? handleCollapseDirectory : undefined}
|
onCollapseFolder={isDirectory && isExpanded ? handleCollapseDirectory : undefined}
|
||||||
onRename={onRenameEntry ? handleRename : undefined}
|
onRename={onRenameEntry ? handleRename : undefined}
|
||||||
onDuplicate={onDuplicateEntry ? handleDuplicate : undefined}
|
onDuplicate={onDuplicateEntry ? handleDuplicate : undefined}
|
||||||
onDelete={onDeleteEntry ? handleDelete : undefined}
|
onDelete={onDeleteEntry ? handleDelete : undefined}
|
||||||
header={metaHeader}
|
|
||||||
testIDPrefix={testID}
|
testIDPrefix={testID}
|
||||||
/>
|
/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
@@ -425,6 +390,7 @@ interface FileExplorerPaneProps {
|
|||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
onOpenFile?: (filePath: string) => void;
|
onOpenFile?: (filePath: string) => void;
|
||||||
|
onOpenFileToSide?: (filePath: string) => void;
|
||||||
onAddToChat?: (path: string) => void;
|
onAddToChat?: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,6 +399,7 @@ export function FileExplorerPane({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
|
onOpenFileToSide,
|
||||||
onAddToChat,
|
onAddToChat,
|
||||||
}: FileExplorerPaneProps) {
|
}: FileExplorerPaneProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -996,6 +963,7 @@ export function FileExplorerPane({
|
|||||||
revealTargetName={fileManagerTarget?.label}
|
revealTargetName={fileManagerTarget?.label}
|
||||||
onDownloadEntry={handleDownloadEntry}
|
onDownloadEntry={handleDownloadEntry}
|
||||||
onAddToChat={onAddToChat}
|
onAddToChat={onAddToChat}
|
||||||
|
onOpenFileToSide={onOpenFileToSide}
|
||||||
onNewEntry={fsEntryOpsEnabled ? handleNewEntry : undefined}
|
onNewEntry={fsEntryOpsEnabled ? handleNewEntry : undefined}
|
||||||
onCollapseDirectory={handleCollapseDirectory}
|
onCollapseDirectory={handleCollapseDirectory}
|
||||||
onRenameEntry={fsEntryOpsEnabled ? handleRenameEntry : undefined}
|
onRenameEntry={fsEntryOpsEnabled ? handleRenameEntry : undefined}
|
||||||
@@ -1026,6 +994,7 @@ export function FileExplorerPane({
|
|||||||
fileManagerTarget,
|
fileManagerTarget,
|
||||||
selectedEntryPath,
|
selectedEntryPath,
|
||||||
onAddToChat,
|
onAddToChat,
|
||||||
|
onOpenFileToSide,
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
],
|
],
|
||||||
@@ -1049,11 +1018,6 @@ export function FileExplorerPane({
|
|||||||
});
|
});
|
||||||
}, [requestDirectoryListing]);
|
}, [requestDirectoryListing]);
|
||||||
|
|
||||||
const toolbarIconButtonStyle = useCallback(
|
|
||||||
(state: PressableStateCallbackType) => iconButtonStyle(isCompact, state),
|
|
||||||
[isCompact],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasWorkspaceScope) {
|
if (!hasWorkspaceScope) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
@@ -1087,7 +1051,6 @@ export function FileExplorerPane({
|
|||||||
handleBackFromError={handleBackFromError}
|
handleBackFromError={handleBackFromError}
|
||||||
handleRetry={handleRetry}
|
handleRetry={handleRetry}
|
||||||
sortTriggerStyle={sortTriggerStyle}
|
sortTriggerStyle={sortTriggerStyle}
|
||||||
iconButtonStyle={toolbarIconButtonStyle}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -1162,7 +1125,6 @@ interface FileExplorerPaneContentProps {
|
|||||||
handleBackFromError: () => void;
|
handleBackFromError: () => void;
|
||||||
handleRetry: () => void;
|
handleRetry: () => void;
|
||||||
sortTriggerStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
|
sortTriggerStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
|
||||||
iconButtonStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||||
@@ -1186,7 +1148,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
|||||||
handleBackFromError,
|
handleBackFromError,
|
||||||
handleRetry,
|
handleRetry,
|
||||||
sortTriggerStyle: sortTriggerStyleProp,
|
sortTriggerStyle: sortTriggerStyleProp,
|
||||||
iconButtonStyle: iconButtonStyleProp,
|
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
|
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
|
||||||
@@ -1204,17 +1165,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
|||||||
const emptyLabel = showHiddenFiles
|
const emptyLabel = showHiddenFiles
|
||||||
? t("workspace.fileExplorer.empty.noFiles")
|
? t("workspace.fileExplorer.empty.noFiles")
|
||||||
: t("workspace.fileExplorer.empty.noVisibleFiles");
|
: t("workspace.fileExplorer.empty.noVisibleFiles");
|
||||||
const hiddenFilesToggleStyle = useCallback(
|
|
||||||
(state: PressableStateCallbackType) => [
|
|
||||||
iconButtonStyleProp(state),
|
|
||||||
!showHiddenFiles && styles.iconButtonActive,
|
|
||||||
],
|
|
||||||
[showHiddenFiles, iconButtonStyleProp],
|
|
||||||
);
|
|
||||||
const hiddenFilesToggleAccessibilityState = useMemo(
|
|
||||||
() => ({ selected: !showHiddenFiles }),
|
|
||||||
[showHiddenFiles],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
@@ -1245,7 +1195,10 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||||
<PaneContentToolbar style={styles.paneHeader} testID="files-pane-header">
|
<PaneContentToolbar
|
||||||
|
style={[styles.paneHeader, { paddingRight: paneContentToolbarTrailingPadding(isCompact) }]}
|
||||||
|
testID="files-pane-header"
|
||||||
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleSortCycle}
|
onPress={handleSortCycle}
|
||||||
style={sortTriggerStyleProp}
|
style={sortTriggerStyleProp}
|
||||||
@@ -1256,86 +1209,82 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.headerActions}>
|
<ToolbarControls style={styles.headerActions}>
|
||||||
{onNewEntryAtRoot ? (
|
{onNewEntryAtRoot ? (
|
||||||
<>
|
<>
|
||||||
<Pressable
|
<ToolbarButton
|
||||||
onPress={handleNewFileAtRoot}
|
label={t("workspace.fileActions.newFile")}
|
||||||
|
compact={isCompact}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={iconButtonStyleProp}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={t("workspace.fileActions.newFile")}
|
|
||||||
testID="files-new-file"
|
testID="files-new-file"
|
||||||
|
onPress={handleNewFileAtRoot}
|
||||||
>
|
>
|
||||||
<FilePlus
|
<FilePlus
|
||||||
size={paneContentToolbarIconSize(isCompact)}
|
size={paneContentToolbarIconSize(isCompact)}
|
||||||
color={theme.colors.foregroundMuted}
|
color={theme.colors.foregroundExtraMuted}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</ToolbarButton>
|
||||||
<Pressable
|
<ToolbarButton
|
||||||
onPress={handleNewFolderAtRoot}
|
label={t("workspace.fileActions.newFolder")}
|
||||||
|
compact={isCompact}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={iconButtonStyleProp}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={t("workspace.fileActions.newFolder")}
|
|
||||||
testID="files-new-folder"
|
testID="files-new-folder"
|
||||||
|
onPress={handleNewFolderAtRoot}
|
||||||
>
|
>
|
||||||
<FolderPlus
|
<FolderPlus
|
||||||
size={paneContentToolbarIconSize(isCompact)}
|
size={paneContentToolbarIconSize(isCompact)}
|
||||||
color={theme.colors.foregroundMuted}
|
color={theme.colors.foregroundExtraMuted}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</ToolbarButton>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<Pressable
|
<ToolbarButton
|
||||||
onPress={handleToggleHiddenFiles}
|
label={hiddenFilesToggleAccessibilityLabel}
|
||||||
|
selected={!showHiddenFiles}
|
||||||
|
compact={isCompact}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={hiddenFilesToggleStyle}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
|
|
||||||
accessibilityState={hiddenFilesToggleAccessibilityState}
|
|
||||||
testID="files-hidden-toggle"
|
testID="files-hidden-toggle"
|
||||||
|
onPress={handleToggleHiddenFiles}
|
||||||
>
|
>
|
||||||
{showHiddenFiles ? (
|
{showHiddenFiles ? (
|
||||||
<Eye
|
<Eye
|
||||||
size={paneContentToolbarIconSize(isCompact)}
|
size={paneContentToolbarIconSize(isCompact)}
|
||||||
color={theme.colors.foregroundMuted}
|
color={theme.colors.foregroundExtraMuted}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EyeOff
|
<EyeOff
|
||||||
size={paneContentToolbarIconSize(isCompact)}
|
size={paneContentToolbarIconSize(isCompact)}
|
||||||
color={theme.colors.foregroundMuted}
|
color={theme.colors.foregroundExtraMuted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</ToolbarButton>
|
||||||
<Pressable
|
<ToolbarButton
|
||||||
onPress={handleRefresh}
|
label={
|
||||||
disabled={isRefreshFetching}
|
|
||||||
hitSlop={8}
|
|
||||||
style={iconButtonStyleProp}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={
|
|
||||||
isRefreshFetching
|
isRefreshFetching
|
||||||
? t("workspace.fileExplorer.actions.refreshing")
|
? t("workspace.fileExplorer.actions.refreshing")
|
||||||
: t("workspace.fileExplorer.actions.refresh")
|
: t("workspace.fileExplorer.actions.refresh")
|
||||||
}
|
}
|
||||||
|
compact={isCompact}
|
||||||
|
disabled={isRefreshFetching}
|
||||||
|
hitSlop={8}
|
||||||
testID="files-refresh"
|
testID="files-refresh"
|
||||||
|
onPress={handleRefresh}
|
||||||
>
|
>
|
||||||
<View style={styles.refreshIcon}>
|
<View style={styles.refreshIcon}>
|
||||||
{isRefreshFetching ? (
|
{isRefreshFetching ? (
|
||||||
<LoadingSpinner
|
<LoadingSpinner
|
||||||
size={paneContentToolbarIconSize(isCompact)}
|
size={paneContentToolbarIconSize(isCompact)}
|
||||||
color={theme.colors.foregroundMuted}
|
color={theme.colors.foregroundExtraMuted}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RotateCw
|
<RotateCw
|
||||||
size={paneContentToolbarIconSize(isCompact)}
|
size={paneContentToolbarIconSize(isCompact)}
|
||||||
color={theme.colors.foregroundMuted}
|
color={theme.colors.foregroundExtraMuted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</ToolbarButton>
|
||||||
</View>
|
</ToolbarControls>
|
||||||
</PaneContentToolbar>
|
</PaneContentToolbar>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<RootCreationContextTarget enabled={Boolean(onNewEntryAtRoot)}>
|
<RootCreationContextTarget enabled={Boolean(onNewEntryAtRoot)}>
|
||||||
@@ -1479,6 +1428,7 @@ function TreeRowDispatcher({
|
|||||||
revealTargetName,
|
revealTargetName,
|
||||||
onDownloadEntry,
|
onDownloadEntry,
|
||||||
onAddToChat,
|
onAddToChat,
|
||||||
|
onOpenFileToSide,
|
||||||
onNewEntry,
|
onNewEntry,
|
||||||
onCollapseDirectory,
|
onCollapseDirectory,
|
||||||
onRenameEntry,
|
onRenameEntry,
|
||||||
@@ -1500,6 +1450,7 @@ function TreeRowDispatcher({
|
|||||||
revealTargetName?: string;
|
revealTargetName?: string;
|
||||||
onDownloadEntry: (entry: ExplorerEntry) => void;
|
onDownloadEntry: (entry: ExplorerEntry) => void;
|
||||||
onAddToChat?: (path: string) => void;
|
onAddToChat?: (path: string) => void;
|
||||||
|
onOpenFileToSide?: (path: string) => void;
|
||||||
onNewEntry?: (parentPath: string, kind: "file" | "directory") => void;
|
onNewEntry?: (parentPath: string, kind: "file" | "directory") => void;
|
||||||
onCollapseDirectory?: (path: string) => void;
|
onCollapseDirectory?: (path: string) => void;
|
||||||
onRenameEntry?: (entry: ExplorerEntry) => void;
|
onRenameEntry?: (entry: ExplorerEntry) => void;
|
||||||
@@ -1530,6 +1481,7 @@ function TreeRowDispatcher({
|
|||||||
revealTargetName={revealTargetName}
|
revealTargetName={revealTargetName}
|
||||||
onDownloadEntry={onDownloadEntry}
|
onDownloadEntry={onDownloadEntry}
|
||||||
onAddToChat={onAddToChat}
|
onAddToChat={onAddToChat}
|
||||||
|
onOpenFileToSide={onOpenFileToSide}
|
||||||
onNewEntry={onNewEntry}
|
onNewEntry={onNewEntry}
|
||||||
onCollapseDirectory={onCollapseDirectory}
|
onCollapseDirectory={onCollapseDirectory}
|
||||||
onRenameEntry={onRenameEntry}
|
onRenameEntry={onRenameEntry}
|
||||||
@@ -1662,7 +1614,6 @@ function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string
|
|||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
|
||||||
},
|
},
|
||||||
desktopSplit: {
|
desktopSplit: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -1699,7 +1650,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
paddingRight: theme.spacing[3],
|
backgroundColor: theme.colors.surfaceSidebar,
|
||||||
},
|
},
|
||||||
sortTrigger: {
|
sortTrigger: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -1712,7 +1663,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.base,
|
borderRadius: theme.borderRadius.base,
|
||||||
},
|
},
|
||||||
sortTriggerHovered: {
|
sortTriggerHovered: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
},
|
},
|
||||||
sortTriggerText: {
|
sortTriggerText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
@@ -1721,7 +1672,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
headerActions: {
|
headerActions: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[1],
|
|
||||||
},
|
},
|
||||||
treeList: {
|
treeList: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -1808,38 +1758,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
draftPlaceholder: {
|
draftPlaceholder: {
|
||||||
color: theme.colors.foregroundExtraMuted,
|
color: theme.colors.foregroundExtraMuted,
|
||||||
},
|
},
|
||||||
contextMetaBlock: {
|
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
},
|
|
||||||
contextMetaRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
minHeight: 32,
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
},
|
|
||||||
contextMetaLabel: {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
contextMetaValue: {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontWeight: theme.fontWeight.medium,
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
textAlign: "right",
|
|
||||||
},
|
|
||||||
previewHeaderText: {
|
previewHeaderText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
iconButtonActive: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
refreshIcon: {
|
refreshIcon: {
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import {
|
||||||
|
iconButtonChromeFrameStyle,
|
||||||
|
iconButtonChromeStyle,
|
||||||
|
} from "@/components/ui/icon-button-chrome";
|
||||||
|
|
||||||
interface HeaderToggleButtonState {
|
interface HeaderToggleButtonState {
|
||||||
hovered: boolean;
|
hovered: boolean;
|
||||||
@@ -42,7 +46,17 @@ export function HeaderToggleButton({
|
|||||||
? ({ "aria-expanded": expandedState } as Record<string, boolean>)
|
? ({ "aria-expanded": expandedState } as Record<string, boolean>)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const combinedStyle = useMemo(() => [headerIconSlotStyle.slot, style], [style]);
|
const combinedStyle = useMemo(
|
||||||
|
() =>
|
||||||
|
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) =>
|
||||||
|
iconButtonChromeStyle({
|
||||||
|
size: "large",
|
||||||
|
state: { hovered: Boolean(hovered), pressed },
|
||||||
|
disabled: Boolean(disabled),
|
||||||
|
style,
|
||||||
|
}),
|
||||||
|
[disabled, style],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
|
||||||
@@ -68,16 +82,6 @@ export function HeaderToggleButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const headerIconSlotStyle = StyleSheet.create((theme) => ({
|
|
||||||
slot: {
|
|
||||||
padding: {
|
|
||||||
xs: theme.spacing[3],
|
|
||||||
md: theme.spacing[2],
|
|
||||||
},
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
tooltipRow: {
|
tooltipRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -90,3 +94,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
shortcut: {},
|
shortcut: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const headerIconSlotStyle = {
|
||||||
|
slot: iconButtonChromeFrameStyle("large"),
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
|||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
|
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
|
||||||
|
import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome";
|
||||||
|
|
||||||
interface MenuHeaderProps {
|
interface MenuHeaderProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -26,7 +27,7 @@ interface SidebarMenuToggleProps {
|
|||||||
|
|
||||||
const MOBILE_MENU_LINE_WIDTH = 16;
|
const MOBILE_MENU_LINE_WIDTH = 16;
|
||||||
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
|
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
|
||||||
const MOBILE_MENU_LINE_HEIGHT = 2;
|
const MOBILE_MENU_LINE_HEIGHT = 1.5;
|
||||||
|
|
||||||
function MobileMenuIcon({ color }: { color: string }) {
|
function MobileMenuIcon({ color }: { color: string }) {
|
||||||
const lineStyle = useMemo(() => [styles.mobileMenuLine, { backgroundColor: color }], [color]);
|
const lineStyle = useMemo(() => [styles.mobileMenuLine, { backgroundColor: color }], [color]);
|
||||||
@@ -84,19 +85,21 @@ function SidebarMenuToggleButton({
|
|||||||
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
|
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
|
||||||
accessibilityState={accessibilityState}
|
accessibilityState={accessibilityState}
|
||||||
>
|
>
|
||||||
{({ hovered, pressed }) => {
|
{isMobile ? (
|
||||||
let color = extraMutedIdleIcon
|
<MobileMenuIcon
|
||||||
? theme.colors.foregroundExtraMuted
|
color={
|
||||||
: theme.colors.foregroundMuted;
|
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
|
||||||
if (hovered || pressed) {
|
}
|
||||||
color = theme.colors.foreground;
|
/>
|
||||||
}
|
) : (
|
||||||
return isMobile ? (
|
<PanelLeft
|
||||||
<MobileMenuIcon color={color} />
|
size={iconButtonChromeGlyphSize("large")}
|
||||||
) : (
|
strokeWidth={1.5}
|
||||||
<PanelLeft size={theme.iconSize.md} color={color} />
|
color={
|
||||||
);
|
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
|
||||||
}}
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</HeaderToggleButton>
|
</HeaderToggleButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1462,7 +1462,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
|
|
||||||
const fileLinkActions = useAssistantFileLinkActions();
|
const fileLinkActions = useAssistantFileLinkActions();
|
||||||
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
||||||
fileLinkActions.open({ href: url }, "side");
|
fileLinkActions.open({ href: url }, "preferred");
|
||||||
// react-native-markdown-display opens the link itself when this returns true.
|
// react-native-markdown-display opens the link itself when this returns true.
|
||||||
// We already handled it above, so return false to avoid duplicate opens.
|
// We already handled it above, so return false to avoid duplicate opens.
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
type StyleProp,
|
type StyleProp,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
import { BottomSheetFlatList, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
@@ -1455,7 +1455,19 @@ function ModelBrowserContent({
|
|||||||
return scrolling === "independent" ? (
|
return scrolling === "independent" ? (
|
||||||
<IndependentProviderList>{allProvidersContent}</IndependentProviderList>
|
<IndependentProviderList>{allProvidersContent}</IndependentProviderList>
|
||||||
) : (
|
) : (
|
||||||
allProvidersContent
|
<BottomSheetScrollView
|
||||||
|
style={styles.virtualizedModelList}
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.virtualizedModelListContent,
|
||||||
|
styles.virtualizedProviderListContent,
|
||||||
|
]}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
testID="compact-provider-list"
|
||||||
|
>
|
||||||
|
{allProvidersContent}
|
||||||
|
</BottomSheetScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { View, type PointerEvent as RNPointerEvent } from "react-native";
|
import { View, type PointerEvent as RNPointerEvent } from "react-native";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { startResizeHandleDrag, type ResizeHandleDrag } from "@/components/resize-handle-drag";
|
import { startResizeHandleDrag, type ResizeHandleDrag } from "@/components/resize-handle-drag";
|
||||||
|
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
|
||||||
|
import {
|
||||||
|
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||||
|
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||||
|
} from "@/components/sidebar-resize-handle-layout";
|
||||||
|
|
||||||
export interface ResizeHandleProps {
|
export interface ResizeHandleProps {
|
||||||
testID?: string;
|
testID?: string;
|
||||||
direction: "horizontal" | "vertical";
|
direction: "horizontal" | "vertical";
|
||||||
|
hitAreaAlignment?: "center" | "end";
|
||||||
groupId: string;
|
groupId: string;
|
||||||
index: number;
|
index: number;
|
||||||
sizes: number[];
|
sizes: number[];
|
||||||
|
containerSize: number;
|
||||||
onPreviewResizeSplit: (groupId: string, sizes: number[]) => void;
|
onPreviewResizeSplit: (groupId: string, sizes: number[]) => void;
|
||||||
onResizeSplit: (groupId: string, sizes: number[]) => void;
|
onResizeSplit: (groupId: string, sizes: number[]) => void;
|
||||||
}
|
}
|
||||||
@@ -30,14 +38,18 @@ function resetWindowHorizontalScroll() {
|
|||||||
export function ResizeHandle({
|
export function ResizeHandle({
|
||||||
testID,
|
testID,
|
||||||
direction,
|
direction,
|
||||||
|
hitAreaAlignment = "center",
|
||||||
groupId,
|
groupId,
|
||||||
index,
|
index,
|
||||||
sizes,
|
sizes,
|
||||||
|
containerSize,
|
||||||
onPreviewResizeSplit,
|
onPreviewResizeSplit,
|
||||||
onResizeSplit,
|
onResizeSplit,
|
||||||
}: ResizeHandleProps) {
|
}: ResizeHandleProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const finePointer = useHasFinePointer();
|
||||||
const pointerStatesRef = useRef(new Map<number, PointerState>());
|
const pointerStatesRef = useRef(new Map<number, PointerState>());
|
||||||
|
const touchDragRef = useRef<ResizeHandleDrag | null>(null);
|
||||||
const cursorBeforeDragRef = useRef<string | null>(null);
|
const cursorBeforeDragRef = useRef<string | null>(null);
|
||||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [active, setActive] = useState(false);
|
const [active, setActive] = useState(false);
|
||||||
@@ -51,13 +63,6 @@ export function ResizeHandle({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerElement = hitAreaElement.parentElement?.parentElement ?? null;
|
|
||||||
if (!containerElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rect = containerElement.getBoundingClientRect();
|
|
||||||
const containerSize = direction === "horizontal" ? rect.width : rect.height;
|
|
||||||
if (containerSize <= 0) {
|
if (containerSize <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -140,9 +145,41 @@ export function ResizeHandle({
|
|||||||
window.addEventListener("pointerup", handlePointerUp);
|
window.addEventListener("pointerup", handlePointerUp);
|
||||||
window.addEventListener("pointercancel", handlePointerUp);
|
window.addEventListener("pointercancel", handlePointerUp);
|
||||||
},
|
},
|
||||||
[direction, groupId, index, onPreviewResizeSplit, onResizeSplit, sizes],
|
[containerSize, direction, groupId, index, onPreviewResizeSplit, onResizeSplit, sizes],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const touchGesture = useMemo(() => {
|
||||||
|
const gesture = Gesture.Pan()
|
||||||
|
.runOnJS(true)
|
||||||
|
.onBegin(() => setDragging(true))
|
||||||
|
.onStart(() => {
|
||||||
|
touchDragRef.current = startResizeHandleDrag({
|
||||||
|
sizes,
|
||||||
|
index,
|
||||||
|
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
|
||||||
|
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.onUpdate((event) => {
|
||||||
|
if (containerSize <= 0) return;
|
||||||
|
const translation = direction === "horizontal" ? event.translationX : event.translationY;
|
||||||
|
touchDragRef.current?.move(translation / containerSize);
|
||||||
|
})
|
||||||
|
.onEnd(() => touchDragRef.current?.finish())
|
||||||
|
.onFinalize(() => {
|
||||||
|
touchDragRef.current = null;
|
||||||
|
setDragging(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return direction === "horizontal"
|
||||||
|
? gesture
|
||||||
|
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
|
||||||
|
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
|
||||||
|
: gesture
|
||||||
|
.activeOffsetY([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
|
||||||
|
.failOffsetX([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET]);
|
||||||
|
}, [containerSize, direction, groupId, index, onPreviewResizeSplit, onResizeSplit, sizes]);
|
||||||
|
|
||||||
const handlePointerEnter = useCallback(() => {
|
const handlePointerEnter = useCallback(() => {
|
||||||
hoverTimerRef.current = setTimeout(() => {
|
hoverTimerRef.current = setTimeout(() => {
|
||||||
setActive(true);
|
setActive(true);
|
||||||
@@ -178,11 +215,36 @@ export function ResizeHandle({
|
|||||||
styles.hitArea,
|
styles.hitArea,
|
||||||
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
|
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
|
||||||
{
|
{
|
||||||
|
...(direction === "horizontal"
|
||||||
|
? { left: hitAreaAlignment === "end" ? 0 : -5 }
|
||||||
|
: { top: hitAreaAlignment === "end" ? 0 : -5 }),
|
||||||
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
|
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
} as object,
|
} as object,
|
||||||
],
|
],
|
||||||
[direction],
|
[direction, hitAreaAlignment],
|
||||||
|
);
|
||||||
|
const touchHitAreaStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
styles.touchHitArea,
|
||||||
|
direction === "horizontal" ? styles.touchHitAreaHorizontal : styles.touchHitAreaVertical,
|
||||||
|
{
|
||||||
|
...(direction === "horizontal"
|
||||||
|
? { left: hitAreaAlignment === "end" ? 0 : -12 }
|
||||||
|
: { top: hitAreaAlignment === "end" ? 0 : -12 }),
|
||||||
|
touchAction: "none",
|
||||||
|
} as object,
|
||||||
|
],
|
||||||
|
[direction, hitAreaAlignment],
|
||||||
|
);
|
||||||
|
const touchGripStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
styles.touchGrip,
|
||||||
|
direction === "horizontal" ? styles.touchGripHorizontal : styles.touchGripVertical,
|
||||||
|
highlighted ? styles.touchGripVisible : styles.touchGripHidden,
|
||||||
|
{ backgroundColor: theme.colors.foreground },
|
||||||
|
],
|
||||||
|
[direction, highlighted, theme.colors.foreground],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -194,14 +256,27 @@ export function ResizeHandle({
|
|||||||
testID={testID ? `${testID}-highlight` : undefined}
|
testID={testID ? `${testID}-highlight` : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<View
|
{finePointer ? (
|
||||||
role="separator"
|
<View
|
||||||
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
|
role="separator"
|
||||||
style={hitAreaStyle}
|
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
|
||||||
onPointerDown={handlePointerDown}
|
style={hitAreaStyle}
|
||||||
onPointerEnter={handlePointerEnter}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerLeave={handlePointerLeave}
|
onPointerEnter={handlePointerEnter}
|
||||||
/>
|
onPointerLeave={handlePointerLeave}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GestureDetector gesture={touchGesture}>
|
||||||
|
<View
|
||||||
|
role="separator"
|
||||||
|
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
|
||||||
|
collapsable={false}
|
||||||
|
style={touchHitAreaStyle}
|
||||||
|
>
|
||||||
|
<View pointerEvents="none" style={touchGripStyle} />
|
||||||
|
</View>
|
||||||
|
</GestureDetector>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -210,6 +285,7 @@ const styles = StyleSheet.create((_theme) => ({
|
|||||||
handle: {
|
handle: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
zIndex: 10,
|
||||||
},
|
},
|
||||||
handleHorizontal: {
|
handleHorizontal: {
|
||||||
width: 1,
|
width: 1,
|
||||||
@@ -240,15 +316,48 @@ const styles = StyleSheet.create((_theme) => ({
|
|||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
},
|
},
|
||||||
hitAreaHorizontal: {
|
hitAreaHorizontal: {
|
||||||
left: -5,
|
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: 10,
|
width: 10,
|
||||||
},
|
},
|
||||||
hitAreaVertical: {
|
hitAreaVertical: {
|
||||||
top: -5,
|
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: 10,
|
height: 10,
|
||||||
},
|
},
|
||||||
|
touchHitArea: {
|
||||||
|
position: "absolute",
|
||||||
|
zIndex: 10,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
touchHitAreaHorizontal: {
|
||||||
|
top: "50%",
|
||||||
|
width: 24,
|
||||||
|
height: 88,
|
||||||
|
transform: [{ translateY: -44 }],
|
||||||
|
},
|
||||||
|
touchHitAreaVertical: {
|
||||||
|
left: "50%",
|
||||||
|
width: 88,
|
||||||
|
height: 24,
|
||||||
|
transform: [{ translateX: -44 }],
|
||||||
|
},
|
||||||
|
touchGrip: {
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
touchGripHorizontal: {
|
||||||
|
width: 4,
|
||||||
|
height: 36,
|
||||||
|
},
|
||||||
|
touchGripVertical: {
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
touchGripHidden: {
|
||||||
|
opacity: 0.12,
|
||||||
|
},
|
||||||
|
touchGripVisible: {
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -37,14 +37,7 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
|||||||
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import {
|
import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native";
|
||||||
ExternalLink,
|
|
||||||
GitPullRequest,
|
|
||||||
Settings,
|
|
||||||
MoreVertical,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react-native";
|
|
||||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||||
@@ -159,6 +152,7 @@ import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
|||||||
import type { HostBadgeModel } from "@/hosts/appearance";
|
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||||
import { useHostBadges } from "@/hosts/use-host-badges";
|
import { useHostBadges } from "@/hosts/use-host-badges";
|
||||||
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
|
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
|
||||||
|
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
|
||||||
|
|
||||||
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
|
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
|
||||||
|
|
||||||
@@ -166,7 +160,6 @@ const projectViewKeyExtractor = (project: SidebarProjectEntry) => project.viewKe
|
|||||||
|
|
||||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
|
||||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||||
const ThemedPlus = withUnistyles(Plus);
|
const ThemedPlus = withUnistyles(Plus);
|
||||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||||
@@ -179,26 +172,6 @@ const foregroundColorMapping = (theme: Theme) => ({
|
|||||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
});
|
});
|
||||||
const redColorMapping = (theme: Theme) => ({
|
|
||||||
color: theme.colors.statusDanger,
|
|
||||||
});
|
|
||||||
const greenColorMapping = (theme: Theme) => ({
|
|
||||||
color: theme.colors.statusSuccess,
|
|
||||||
});
|
|
||||||
const purpleColorMapping = (theme: Theme) => ({
|
|
||||||
color: theme.colors.statusMerged,
|
|
||||||
});
|
|
||||||
|
|
||||||
function getPrIconUniMapping(state: PrHint["state"]) {
|
|
||||||
switch (state) {
|
|
||||||
case "merged":
|
|
||||||
return purpleColorMapping;
|
|
||||||
case "open":
|
|
||||||
return greenColorMapping;
|
|
||||||
case "closed":
|
|
||||||
return redColorMapping;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWorkspaceSelected(input: {
|
function isWorkspaceSelected(input: {
|
||||||
selection: ActiveWorkspaceSelection | null;
|
selection: ActiveWorkspaceSelection | null;
|
||||||
@@ -352,7 +325,6 @@ export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp<ViewS
|
|||||||
const textStyle = isHovered
|
const textStyle = isHovered
|
||||||
? [prBadgeStyles.text, prBadgeStyles.textHovered]
|
? [prBadgeStyles.text, prBadgeStyles.textHovered]
|
||||||
: prBadgeStyles.text;
|
: prBadgeStyles.text;
|
||||||
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
|
|
||||||
const presentation = getForgePresentation(normalizeForge(hint.forge));
|
const presentation = getForgePresentation(normalizeForge(hint.forge));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -370,9 +342,9 @@ export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp<ViewS
|
|||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
>
|
>
|
||||||
{isHovered ? (
|
{isHovered ? (
|
||||||
<ThemedExternalLink size={12} uniProps={iconUniProps} />
|
<ThemedExternalLink size={12} uniProps={foregroundColorMapping} />
|
||||||
) : (
|
) : (
|
||||||
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
|
<PullRequestStateIcon state={hint.state} size={12} />
|
||||||
)}
|
)}
|
||||||
<Text style={textStyle} numberOfLines={1}>
|
<Text style={textStyle} numberOfLines={1}>
|
||||||
{hint.number}
|
{hint.number}
|
||||||
|
|||||||
@@ -2,15 +2,7 @@ import { Fragment, useCallback, useState, type ReactNode } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
|
import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import { ExternalLink, Folder, GitBranch, Globe } from "lucide-react-native";
|
||||||
ExternalLink,
|
|
||||||
Folder,
|
|
||||||
GitBranch,
|
|
||||||
GitMerge,
|
|
||||||
GitPullRequest,
|
|
||||||
GitPullRequestClosed,
|
|
||||||
Globe,
|
|
||||||
} from "lucide-react-native";
|
|
||||||
import {
|
import {
|
||||||
workspaceLabelKey,
|
workspaceLabelKey,
|
||||||
type WorkspaceLabelDefinition,
|
type WorkspaceLabelDefinition,
|
||||||
@@ -23,6 +15,7 @@ import { getForgePresentation, normalizeForge } from "@/git/forge";
|
|||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model";
|
import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
|
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
|
||||||
import { CheckIndicator } from "./check-indicator";
|
import { CheckIndicator } from "./check-indicator";
|
||||||
import type { CheckSummary, CheckSummaryState } from "./check-summary";
|
import type { CheckSummary, CheckSummaryState } from "./check-summary";
|
||||||
import { selectMetaRowItems, type MetaRowItem } from "./meta-items";
|
import { selectMetaRowItems, type MetaRowItem } from "./meta-items";
|
||||||
@@ -44,9 +37,6 @@ const META_ICON_SIZE = HOST_BADGE_ICON_SIZE;
|
|||||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||||
const ThemedFolder = withUnistyles(Folder);
|
const ThemedFolder = withUnistyles(Folder);
|
||||||
const ThemedGitBranch = withUnistyles(GitBranch);
|
const ThemedGitBranch = withUnistyles(GitBranch);
|
||||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
|
||||||
const ThemedGitMerge = withUnistyles(GitMerge);
|
|
||||||
const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed);
|
|
||||||
const ThemedGlobe = withUnistyles(Globe);
|
const ThemedGlobe = withUnistyles(Globe);
|
||||||
|
|
||||||
/** Stable identity so a row without labels doesn't re-select its items on every render. */
|
/** Stable identity so a row without labels doesn't re-select its items on every render. */
|
||||||
@@ -54,7 +44,6 @@ const EMPTY_LABELS: readonly WorkspaceLabelDefinition[] = [];
|
|||||||
|
|
||||||
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMerged });
|
|
||||||
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,7 +201,6 @@ function PullRequestItem({ hint }: { hint: PrHint }) {
|
|||||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||||
|
|
||||||
const Icon = isHovered ? ThemedExternalLink : PR_ICONS[hint.state];
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="link"
|
accessibilityRole="link"
|
||||||
@@ -227,10 +215,11 @@ function PullRequestItem({ hint }: { hint: PrHint }) {
|
|||||||
onHoverOut={handleHoverOut}
|
onHoverOut={handleHoverOut}
|
||||||
style={pressableItemStyle}
|
style={pressableItemStyle}
|
||||||
>
|
>
|
||||||
<Icon
|
{isHovered ? (
|
||||||
size={META_ICON_SIZE}
|
<ThemedExternalLink size={META_ICON_SIZE} uniProps={foregroundMapping} />
|
||||||
uniProps={isHovered ? foregroundMapping : PR_COLOR_MAPPINGS[hint.state]}
|
) : (
|
||||||
/>
|
<PullRequestStateIcon state={hint.state} size={META_ICON_SIZE} />
|
||||||
|
)}
|
||||||
<Text style={isHovered ? styles.prTextHovered : styles.prText} numberOfLines={1}>
|
<Text style={isHovered ? styles.prTextHovered : styles.prText} numberOfLines={1}>
|
||||||
{hint.number}
|
{hint.number}
|
||||||
{/* An open change request is the unremarkable case and says nothing extra; a merged
|
{/* An open change request is the unremarkable case and says nothing extra; a merged
|
||||||
@@ -311,20 +300,6 @@ function ServiceItem({ summary }: { summary: WorkspaceServiceSummary }) {
|
|||||||
|
|
||||||
const successMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
|
const successMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
|
||||||
|
|
||||||
const PR_ICONS = {
|
|
||||||
open: ThemedGitPullRequest,
|
|
||||||
merged: ThemedGitMerge,
|
|
||||||
closed: ThemedGitPullRequestClosed,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Same three colours the hover card gives the same three states — a change request has one
|
|
||||||
// identity, and it shouldn't shift when you hover the row that names it.
|
|
||||||
const PR_COLOR_MAPPINGS = {
|
|
||||||
open: successMapping,
|
|
||||||
merged: mergedMapping,
|
|
||||||
closed: dangerMapping,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const PR_STATE_LABEL_KEYS = {
|
const PR_STATE_LABEL_KEYS = {
|
||||||
merged: "workspace.git.pr.states.merged",
|
merged: "workspace.git.pr.states.merged",
|
||||||
closed: "workspace.git.pr.states.closed",
|
closed: "workspace.git.pr.states.closed",
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveSplitContainerRoot } from "@/components/split-container-focus";
|
import {
|
||||||
|
resolveSplitContainerRoot,
|
||||||
|
splitNodeContainsPane,
|
||||||
|
} from "@/components/split-container-focus";
|
||||||
import type { SplitNode } from "@/stores/workspace-layout-store";
|
import type { SplitNode } from "@/stores/workspace-layout-store";
|
||||||
|
|
||||||
const pane = (id: string): SplitNode => ({
|
const pane = (id: string): SplitNode => ({
|
||||||
@@ -35,25 +38,11 @@ describe("split focus root", () => {
|
|||||||
).toEqual({ root, usesFallbackStrip: false });
|
).toEqual({ root, usesFallbackStrip: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders only a maximized pane without entering focus mode", () => {
|
it("finds the maximized pane without replacing the rendered split tree", () => {
|
||||||
|
expect(splitNodeContainsPane(root, "right")).toBe(true);
|
||||||
|
expect(splitNodeContainsPane(root, "missing")).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
resolveSplitContainerRoot({
|
resolveSplitContainerRoot({ root, focusedPaneId: "left", focusModeEnabled: false }),
|
||||||
root,
|
|
||||||
focusedPaneId: "left",
|
|
||||||
focusModeEnabled: false,
|
|
||||||
maximizedPaneId: "right",
|
|
||||||
}),
|
|
||||||
).toEqual({ root: pane("right"), usesFallbackStrip: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps normal splits when the maximized pane is unavailable", () => {
|
|
||||||
expect(
|
|
||||||
resolveSplitContainerRoot({
|
|
||||||
root,
|
|
||||||
focusedPaneId: "left",
|
|
||||||
focusModeEnabled: false,
|
|
||||||
maximizedPaneId: "missing",
|
|
||||||
}),
|
|
||||||
).toEqual({ root, usesFallbackStrip: false });
|
).toEqual({ root, usesFallbackStrip: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ export function resolveSplitContainerRoot(input: {
|
|||||||
root: SplitNode;
|
root: SplitNode;
|
||||||
focusedPaneId: string | null;
|
focusedPaneId: string | null;
|
||||||
focusModeEnabled: boolean | undefined;
|
focusModeEnabled: boolean | undefined;
|
||||||
maximizedPaneId?: string | null;
|
|
||||||
}): { root: SplitNode; usesFallbackStrip: boolean } {
|
}): { root: SplitNode; usesFallbackStrip: boolean } {
|
||||||
const isolatedPaneId = input.focusModeEnabled ? input.focusedPaneId : input.maximizedPaneId;
|
const isolatedPaneId = input.focusModeEnabled ? input.focusedPaneId : null;
|
||||||
if (!isolatedPaneId) return { root: input.root, usesFallbackStrip: false };
|
if (!isolatedPaneId) return { root: input.root, usesFallbackStrip: false };
|
||||||
const isolatedPane = findPane(input.root, isolatedPaneId);
|
const isolatedPane = findPane(input.root, isolatedPaneId);
|
||||||
if (!isolatedPane || isolatedPane.hidden === true) {
|
if (!isolatedPane || isolatedPane.hidden === true) {
|
||||||
@@ -15,6 +14,12 @@ export function resolveSplitContainerRoot(input: {
|
|||||||
return { root: { kind: "pane", pane: isolatedPane }, usesFallbackStrip: false };
|
return { root: { kind: "pane", pane: isolatedPane }, usesFallbackStrip: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether a split subtree contains the pane currently projected full-size. */
|
||||||
|
export function splitNodeContainsPane(node: SplitNode, paneId: string): boolean {
|
||||||
|
if (node.kind === "pane") return node.pane.id === paneId;
|
||||||
|
return node.group.children.some((child) => splitNodeContainsPane(child, paneId));
|
||||||
|
}
|
||||||
|
|
||||||
function findPane(node: SplitNode, paneId: string): SplitPane | null {
|
function findPane(node: SplitNode, paneId: string): SplitPane | null {
|
||||||
if (node.kind === "pane") return node.pane.id === paneId ? node.pane : null;
|
if (node.kind === "pane") return node.pane.id === paneId ? node.pane : null;
|
||||||
for (const child of node.group.children) {
|
for (const child of node.group.children) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const BUILDS = {
|
||||||
|
// Metro resolves src; Node consumers resolve one of the compiled builds.
|
||||||
|
src: "react-native-svg/src/lib/extract/extractTransform.ts",
|
||||||
|
esm: "react-native-svg/lib/module/lib/extract/extractTransform",
|
||||||
|
cjs: "react-native-svg/lib/commonjs/lib/extract/extractTransform.js",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const CSS_MODULE = "react-native-svg/src/css/index.tsx";
|
||||||
|
const XML_MODULE = "react-native-svg/src/xml.tsx";
|
||||||
|
|
||||||
|
type Extractor = (props: { transform?: unknown }) => unknown;
|
||||||
|
type XmlMiddleware = (root: unknown) => unknown;
|
||||||
|
interface ParsedRoot {
|
||||||
|
props: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
type XmlParser = (source: string, middleware?: XmlMiddleware) => ParsedRoot | null;
|
||||||
|
|
||||||
|
async function loadBuild(extractSpecifier: string) {
|
||||||
|
const [cssModule, extractModule, xmlModule] = (await Promise.all([
|
||||||
|
import(/* @vite-ignore */ CSS_MODULE),
|
||||||
|
import(/* @vite-ignore */ extractSpecifier),
|
||||||
|
import(/* @vite-ignore */ XML_MODULE),
|
||||||
|
])) as [
|
||||||
|
{ inlineStyles?: XmlMiddleware; default?: { inlineStyles?: XmlMiddleware } },
|
||||||
|
{
|
||||||
|
extractTransformSvgView?: Extractor;
|
||||||
|
default?: { extractTransformSvgView?: Extractor };
|
||||||
|
},
|
||||||
|
{ parse?: XmlParser; default?: { parse?: XmlParser } },
|
||||||
|
];
|
||||||
|
const inlineStyles = cssModule.inlineStyles ?? cssModule.default?.inlineStyles;
|
||||||
|
const extract =
|
||||||
|
extractModule.extractTransformSvgView ?? extractModule.default?.extractTransformSvgView;
|
||||||
|
const parseXml = xmlModule.parse ?? xmlModule.default?.parse;
|
||||||
|
if (!inlineStyles || !extract || !parseXml)
|
||||||
|
throw new Error(`could not load react-native-svg build`);
|
||||||
|
|
||||||
|
return (xml: string) => {
|
||||||
|
const root = parseXml(xml, inlineStyles);
|
||||||
|
if (!root) throw new Error(`could not parse root SVG`);
|
||||||
|
const props = { ...root.props };
|
||||||
|
const style = props.style;
|
||||||
|
if (style && typeof style === "object" && "transform" in style) {
|
||||||
|
props.transform = style.transform;
|
||||||
|
}
|
||||||
|
return extract(props);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.each(Object.entries(BUILDS))("root SVG transforms (%s build)", (_name, specifier) => {
|
||||||
|
it("still converts a real SVG transform string", async () => {
|
||||||
|
const extractFromXml = await loadBuild(specifier);
|
||||||
|
|
||||||
|
expect(extractFromXml(`<svg transform="rotate(-90)"></svg>`)).toEqual([{ rotate: "-90deg" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["an inline CSS keyword", `<svg style="transform:none"></svg>`],
|
||||||
|
["an embedded CSS rule", `<svg><style>svg { transform: none; }</style></svg>`],
|
||||||
|
["a CSS angle unit", `<svg transform="rotate(-90deg)"></svg>`],
|
||||||
|
["a CSS length unit", `<svg transform="translate(10px, 0)"></svg>`],
|
||||||
|
])("drops %s from parsed root SVG props", async (_label, xml) => {
|
||||||
|
const extractFromXml = await loadBuild(specifier);
|
||||||
|
|
||||||
|
expect(() => extractFromXml(xml)).not.toThrow();
|
||||||
|
expect(extractFromXml(xml)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user