Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 0edd8c39b9 chore(repo): open dummy pull request 2026-08-25 21:05:13 +02:00
Mohamed Boudra 4bfb682cd9 Cut Playwright feedback time without losing behavioral coverage (#3852)
* test(app): consolidate Playwright journeys

* test(app): stabilize consolidated Playwright flows

* test(app): make streaming dependencies explicit

* test(app): await profile feature persistence

* test(app): sample only actual workspace tabs
2026-08-25 20:55:05 +02:00
Mohamed Boudra c4dc701411 fix(app): migrate persisted Explorer layouts once (#3861)
The first-class Explorer reused the old Side panel persistence identity, so upgrades hydrated ordinary persisted tabs into the dock. Move those tabs into an ordinary side pane during the versioned migration while preserving main layouts, and keep the persisted key spelling stable at the storage boundary.
2026-08-25 20:50:26 +02:00
Matt Cowgerandpaseo-bot[bot] f73f65cd78 feat(app): close settings with Escape (#2828)
* feat(app): close settings with Escape

* test(app): cover settings Escape overlay ownership

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-08-25 18:02:18 +00:00
Christoph Leiter bffb33ed32 feat(app): match and rank Command Center results by query tokens (#2971)
The Command Center tested the whole trimmed query as one substring
against one concatenated haystack, so a query only matched when its
words appeared consecutively, in that order, with that spacing. Typing
`shortcuts` found "Keyboard shortcuts"; typing `short key` found
nothing. Survivors were then ordered by a fixed group rank, so the
best match was rarely first.

Route both through `scoreTextFields`, the matcher the combobox,
provider selection, directory suggestions and the daemon's history
search already share. It splits on whitespace, scores each token
against each field on its own, and requires every token to land
somewhere. `short key`, `key short`, `workspace new` and `project add`
all find their row now.

The score is the filter: `scoreSearchFields` returns null when a token
finds no home, so filtering and ranking are one pass rather than a
boolean predicate that throws the scan away. Workspaces and agents are
scored too, not just filtered, with each row's own ordering kept as the
tiebreak. Fields are split into visible and hidden, so a match on an
agent's `cwd` — which is never rendered — cannot outrank a match on its
title.

Subsequence matching is deliberately off here, unlike the composer's
slash-command list where it makes `/pasbab` find `/paseo-babysit`. It
widens far enough that three-letter typos hit several rows each, and
this list preselects its first row, so a query that should find nothing
would put an unrelated action under Enter. `MatchOptions.subsequence`
defaults to on, leaving every other caller unchanged. The tier has to
be suppressed during scoring rather than filtered afterwards, because
a two-token total of tier 5 is either 0+5 or 4+1.

Sections carry a band so the ones that reorder by relevance are
compared before the pinned ones, which keeps the comparator total: file
rows are matched by the daemon and carry no client-side score, so they
cannot be relevance-compared against rows that do.

Contiguous per-token matching is a strict widening of the old test.
Tokens hold no whitespace and the old haystack joined fields with a
space, so any token found there lay entirely within one field and is
still found. Verified across a query set at zero rows lost, and pinned
by a test.
2026-08-25 17:58:41 +00:00
Mohamed Boudra 8bfae15855 Keep current pull requests visible without wrongful auto-archive (#3799)
* fix(server): keep current PR visible without unsafe archival

PR discovery and merge-triggered archival answer different questions. Follow the checked-out branch for status, while requiring the daemon to observe the same PR move from open to merged before archiving a workspace.

* test(server): align managed worktree PR lookup expectation

Managed metadata without a matching PR target must not hide the unambiguous PR identity from the current branch tracking configuration.
2026-08-25 19:43:18 +02:00
paseo-ai[bot] c778c47b30 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-25 14:08:49 +00:00
Mohamed Boudra 6da9fa43fa chore(release): cut 0.6.0 2026-08-25 15:58:11 +02:00
Mohamed Boudra cd8c868b32 docs: add 0.6.0 changelog 2026-08-25 15:50:49 +02:00
Mohamed Boudra 6f6466d4cb Wait for authoritative provider readiness before prompting (#3821)
* fix(server): trust provider connection lifecycle events

Readiness now waits for the upstream connection event, and later connection events directly trigger the existing gap repair. This removes the parallel synthetic reconnection contract while preserving transport ownership and prompt gating.

* test(server): model OpenCode global event envelopes
2026-08-25 15:28:06 +02:00
Mohamed Boudra 6147991519 test(app): follow filtered launcher order 2026-08-25 14:58:35 +02:00
Mohamed Boudra bbe3f17327 test(app): follow Explorer subagent placement 2026-08-25 13:50:54 +02:00
Mohamed Boudra c914bcb44b feat(app): make Explorer a first-class pane host (#3826) 2026-08-25 13:25:57 +02:00
Mohamed Boudra 54299a2e6c fix(server): let event-stream startup recover once (#3814)
The readiness wait expired at the same boundary as the producer watchdog, so a reconnect already initiated by the event consumer could never benefit the pending turn. Keep dispatch gated, allow that retry to land, and preserve the actual SDK failure details for logs and user-visible reports.
2026-08-24 19:31:11 +02:00
f4b5f32862 test(e2e): boot the Playwright harness on Windows (#3804)
* test(e2e): boot the Playwright harness on Windows

The harness could not start on Windows. Metro was launched with
spawn("npx", ...) and the isolated daemon resolved its runner with
execSync("which tsx") — npx is a .cmd shim that Node's bare spawn
cannot launch, and `which` does not exist there.

Route both through process.execPath instead of the node_modules/.bin
shims. Node refuses to spawn .cmd or .bat without shell: true, and
shell: true is the wrong escape hatch: it concatenates argv unescaped
(DEP0190), splits paths containing spaces, and sends kill signals to
cmd.exe rather than the real child. Spawning Node directly has none of
those properties and is identical on POSIX, so no platform branch is
needed. The same reasoning fixes the published-server npm install,
which passed "npm.cmd" to execFileSync and had been failing with
EINVAL on Windows since Node's CVE-2024-27980 mitigation landed.

Teardown now kills the process tree. A Windows signal reaches only the
direct child, so Metro's and the supervisor's forked workers kept the
listening port and the next run failed to bind.

The daemon startup deadline goes from 20s to 90s. A cold tsx start was
measured at 25s on Windows and exceeded 60s under load; the loop still
fails immediately on a crashed child, so the ceiling only costs time
that a genuinely slow start needs.

The fake editor and gh binaries the harness puts on PATH gain .cmd
wrappers, because Windows resolves PATH entries by PATHEXT and cannot
execute an extension-less shebang file. The wrappers point at the same
payload file so the recorded command name stays "cursor", "code", or
"gh" on every platform.

The asdf-backed local Elixir relay stays POSIX-only and now says so
before spawning.

* test(ci): follow shared e2e daemon launcher

---------

Co-authored-by: ABorakati <ABorakati@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-24 18:20:19 +02:00
paseo-ai[bot] 7db654aa0a fix: update lockfile signatures and Nix hash [skip ci] 2026-08-24 12:40:34 +00:00
Mohamed Boudra 4092c29265 chore(release): cut 0.5.2 2026-08-24 14:30:57 +02:00
Mohamed Boudra 5cedc210a8 docs: add 0.5.2 changelog 2026-08-24 14:27:37 +02:00
Mohamed Boudra 4724d1d9b7 fix(settings): preserve valid legacy font fallback 2026-08-24 14:27:22 +02:00
Mohamed Boudra 8fdca94ead Fix settings resets across app revisions (#3787)
* fix(settings): preserve settings from newer builds

Strict blob validation treated a single newer setting as corruption and then rewrote defaults. Read settings field by field and retain unrecognized data when materializing or migrating known preferences.

* refactor(settings): unify discarded-value warnings

Emit settings warnings where each stored value is parsed so validation cannot drift from diagnostics.

* refactor(settings): validate persisted settings declaratively

Use one loose Zod schema with field defaults so mixed-version settings remain loadable without parallel validation paths.

* fix(settings): preserve explicit services preferences

Keep compatibility fallbacks from overriding explicit settings and prevent read metadata from reaching migration writes.

* refactor(settings): derive migration persistence types

Let the schema prove normalized settings coverage and carry its persisted shape through migrations.
2026-08-24 12:10:13 +02:00
Mohamed Boudra 88f410567d Revert "fix(server): honor Claude bypass permission mode (#3732)"
This reverts commit f998ee1691.
2026-08-24 11:05:19 +02:00
wongangandMohamed Boudra f998ee1691 fix(server): honor Claude bypass permission mode (#3732)
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-24 10:49:12 +02:00
Julien Eid 7a47ec0ee9 Fix crash on Android tablet workspace (#3760)
Fixes #3750
2026-08-24 09:21:25 +02:00
paseo-ai[bot] 4f796181bf fix: update lockfile signatures and Nix hash [skip ci] 2026-08-23 17:46:14 +00:00
Mohamed Boudra f517493591 chore(release): cut 0.5.1 2026-08-23 19:37:03 +02:00
Mohamed Boudra fb74f766c8 docs(changelog): prepare 0.5.1 stable release 2026-08-23 19:32:35 +02:00
Mohamed Boudra e268d1a894 fix(app): stabilize native composer resizing
JavaScript height state competed with native intrinsic layout across draft and agent ownership transitions. Let native inputs own sizing and scrolling while preserving DOM measurement on web.
2026-08-23 18:55:38 +02:00
paseo-ai[bot] b5f5832214 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-23 13:51:42 +00:00
272 changed files with 11742 additions and 6268 deletions
+3 -3
View File
@@ -348,12 +348,12 @@ jobs:
timeout-minutes: 10
run: npx playwright install chromium
- name: Build app dependencies
run: npm run build:app-deps
- name: Build server stack
run: npm run build:server
- name: Build native app dependency
run: npm run build --workspace=@getpaseo/expo-two-way-audio
- name: Install agent CLIs for provider tests
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
+33
View File
@@ -1,5 +1,38 @@
# Changelog
## 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 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
+1 -1
View File
@@ -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/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/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/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 |
+6
View File
@@ -62,6 +62,10 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
Header and toolbar controls use `interactionHighlight` for hovered, pressed, open, and selected
backgrounds. It is a translucent semantic fill so the same control works over the main surface and
the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control also fades its content.
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
@@ -155,6 +159,8 @@ On a narrow desktop route, app navigation yields to the rendered content topolog
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
---
+13 -2
View File
@@ -242,13 +242,13 @@ focused invalidation capture; React Native's generated stacks make that mode hig
Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
For the desktop 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
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
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
@@ -278,6 +278,17 @@ That report includes input and composer height changes plus React work grouped i
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
boundaries are nested. A printable key after an empty newline should not change either height.
### Preview Windows and Linux window controls on macOS
Desktop development can replace native macOS traffic lights with Paseo's custom controls:
```bash
PASEO_DESKTOP_WINDOW_CONTROLS=windows npm run dev:desktop
PASEO_DESKTOP_WINDOW_CONTROLS=linux npm run dev:desktop
```
The override is rejected in packaged builds. Restart the desktop process when changing it.
### Desktop macOS compositor watchdog
macOS display sleep can leave Chromium's GPU-process display link — the vsync
+74
View File
@@ -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
View File
@@ -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.
- **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.
- **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).
- **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`.
+4
View File
@@ -165,6 +165,10 @@ RPC. Snapshot DTOs are deeply readonly and frozen at runtime so plugin code cann
app state or a memoized selection. Panels use one persisted
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
Panels declare `locations: ["workspace", "explorer"]` to opt into Explorer hosting; omission means
workspace only. Location controls hosting, not context. An agent panel target keeps its `agentId`
when moved between hosts. Explorer configuration can create workspace-context panels and remove
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the
-98
View File
@@ -1,98 +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 Changes keyboard shortcut and the diff-stat pill above an agent composer are
supporting opens: they follow the preference and focus an existing Changes tab wherever
the user left it. When Changes is already visible in the Side panel, the diff-stat pill
hides the panel without closing the tab. A different visible Side panel tab switches to
Changes instead.
## 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.
+4
View File
@@ -186,9 +186,13 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- For full-suite confidence, push to CI and check GitHub Actions.
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
- App Playwright shares one warmed Metro server per run and gives every Playwright worker its own isolated daemon and `PASEO_HOME`. Spec files run concurrently without exposing one file's projects, agents, terminals, history, or provider configuration to another worker; tests within a file remain together so file-level setup is not repeated.
- Playwright specs that exercise only the daemon import `daemonTest` from the shared fixtures so they do not create a browser context or page.
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
- The app Playwright harness boots on Windows as well as POSIX. Spawn Node entrypoints through `process.execPath`, not `npx` or `node_modules/.bin` shims: Node refuses to spawn `.cmd` or `.bat` without `shell: true`, and shell mode concatenates argv without escaping and sends kill signals to `cmd.exe` instead of the real child.
- Teardown kills the process tree, because a Windows signal reaches only the direct child and leaves forked workers holding the listening port.
- The `asdf`-backed local Elixir relay stays POSIX-only, so the `relay-deployment` Playwright project is unavailable on Windows.
## Pull-request test routing
+1 -1
View File
@@ -1 +1 @@
sha256-umufh5lfrLUsnvRLzOzw+BYaE/DgH/7x6uGnDwdM4Gk=
sha256-gZ6egDKIDiDi9jmDlPamM+or2Tthwr17DbJ+P66t4RE=
+25 -25
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.5.0",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.5.0",
"version": "0.6.0",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -36140,7 +36140,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37192,12 +37192,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.5.0",
"@getpaseo/protocol": "0.5.0",
"@getpaseo/server": "0.5.0",
"@getpaseo/client": "0.6.0",
"@getpaseo/protocol": "0.6.0",
"@getpaseo/server": "0.6.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37444,10 +37444,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"@getpaseo/protocol": "0.5.0",
"@getpaseo/relay": "0.5.0",
"@getpaseo/protocol": "0.6.0",
"@getpaseo/relay": "0.6.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37458,7 +37458,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.5.0",
"version": "0.6.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37701,7 +37701,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.5.0",
"version": "0.6.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38597,7 +38597,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38626,12 +38626,12 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.5.0",
"@getpaseo/client": "0.6.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38640,14 +38640,14 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.5.0",
"@getpaseo/client": "0.6.0",
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38660,7 +38660,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38878,17 +38878,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.5.0",
"@getpaseo/highlight": "0.5.0",
"@getpaseo/plugin": "0.5.0",
"@getpaseo/protocol": "0.5.0",
"@getpaseo/relay": "0.5.0",
"@getpaseo/client": "0.6.0",
"@getpaseo/highlight": "0.6.0",
"@getpaseo/plugin": "0.6.0",
"@getpaseo/protocol": "0.6.0",
"@getpaseo/relay": "0.6.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -39940,7 +39940,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -66,54 +66,21 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
test.describe("Add Project command-center flow", () => {
test.describe.configure({ timeout: 180_000 });
test("method selection has no search field", async ({ page }) => {
test("method selection shows the daemon's available project sources without search", async ({
page,
}) => {
await gotoAppShell(page);
await openAddProjectFlow(page);
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub");
await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory");
await expect(addProjectFlowInput(page)).toHaveCount(0);
await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0);
await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0);
});
test("the back arrow, search input, and result glyph share one left edge", async ({ page }) => {
await gotoAppShell(page);
await openAddProjectFlow(page);
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "directory-search");
await addProjectFlowInput(page).fill("/tmp");
const backGlyph = addProjectFlowBack(page).locator("svg");
const resultGlyph = addProjectFlow(page)
.locator('[data-testid^="add-project-flow-path-"]')
.first()
.locator("svg");
await expect(resultGlyph).toBeVisible();
const [backBox, inputBox, resultBox, titleBox, resultsBox, footerBox] = await Promise.all([
backGlyph.boundingBox(),
addProjectFlowInput(page).boundingBox(),
resultGlyph.boundingBox(),
page.getByTestId("add-project-flow-title").boundingBox(),
page.getByTestId("add-project-flow-results").boundingBox(),
page.getByTestId("add-project-flow-footer").boundingBox(),
]);
expect(backBox).not.toBeNull();
expect(inputBox).not.toBeNull();
expect(resultBox).not.toBeNull();
expect(titleBox).not.toBeNull();
expect(resultsBox).not.toBeNull();
expect(footerBox).not.toBeNull();
if (!backBox || !inputBox || !resultBox || !titleBox || !resultsBox || !footerBox) return;
expect(Math.abs(backBox.x - inputBox.x)).toBeLessThanOrEqual(2);
expect(Math.abs(resultBox.x - inputBox.x)).toBeLessThanOrEqual(2);
expect(titleBox.height).toBeLessThanOrEqual(24);
expect(resultsBox.y + resultsBox.height).toBeLessThanOrEqual(footerBox.y + 1);
});
test("an offline extra host neither appears nor forces host selection", async ({ page }) => {
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
@@ -273,14 +240,6 @@ test.describe("Add Project command-center flow", () => {
await expectProjectHasNoWorkspaces(projectId);
});
test("the current daemon advertises Clone from GitHub and New directory", async ({ page }) => {
await gotoAppShell(page);
await openAddProjectFlow(page);
await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub");
await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory");
});
test("a complete repository URL remains selectable without a GitHub search result", async ({
page,
}) => {
@@ -442,21 +442,40 @@ function formatFrames(frames: TurnFrame[], centers: number | number[]): string {
.join("\n");
}
function submittedRowContentTop(frame: TurnFrame | undefined): number | undefined {
const viewportTop = frame?.userRow.rect?.top;
const scrollTop = frame?.scroll.scrollTop;
return viewportTop == null || scrollTop == null ? undefined : viewportTop + scrollTop;
}
function expectRowContinuity(frames: TurnFrame[]): void {
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
const baseline = frames[first]?.userRow.rect?.top;
const baselineFrame = frames[first];
const baselineContentTop = submittedRowContentTop(baselineFrame);
const disappeared = frames.findIndex(
(frame, index) => index > first && !hasPaintedLayout(frame.userRow),
);
const reappeared = frames.findIndex(
(frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow),
);
const shifted = frames.findIndex(
(frame, index) =>
const contentShifted = frames.findIndex((frame, index) => {
const contentTop = submittedRowContentTop(frame);
return (
index > first &&
hasPaintedLayout(frame.userRow) &&
baseline !== undefined &&
Math.abs((frame.userRow.rect?.top ?? baseline) - baseline) > 1,
baselineContentTop !== undefined &&
contentTop !== undefined &&
Math.abs(contentTop - baselineContentTop) > 1
);
});
const viewportChanged = frames.findIndex(
(frame, index) =>
index > first &&
[
geometryChanged(frame.scroll.rect?.top, baselineFrame?.scroll.rect?.top),
geometryChanged(frame.scroll.rect?.height, baselineFrame?.scroll.rect?.height),
geometryChanged(frame.scroll.clientHeight, baselineFrame?.scroll.clientHeight),
].some(Boolean),
);
const attachmentMissing = frames.findIndex(
(frame, index) => index >= first && !hasPaintedLayout(frame.attachment),
@@ -468,16 +487,18 @@ function expectRowContinuity(frames: TurnFrame[]): void {
`submitted row disappeared for ${Math.max(0, reappeared - disappeared)} recorded frames (${Math.max(0, (frames[reappeared]?.at ?? frames.at(-1)?.at ?? 0) - frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`,
]
: []),
...(shifted >= 0
...(contentShifted >= 0
? [
`submitted row moved from ${baseline?.toFixed(1)} to ${frames[shifted].userRow.rect?.top.toFixed(1)}`,
`submitted row moved in scroll content from ${baselineContentTop?.toFixed(1)} to ${submittedRowContentTop(frames[contentShifted])?.toFixed(1)}`,
]
: []),
...(viewportChanged >= 0 ? ["scroll viewport geometry changed"] : []),
...(attachmentMissing >= 0 ? ["submitted image attachment left the painted layout"] : []),
];
let failure = Math.max(0, first);
if (attachmentMissing >= 0) failure = attachmentMissing;
if (shifted >= 0) failure = shifted;
if (viewportChanged >= 0) failure = viewportChanged;
if (contentShifted >= 0) failure = contentShifted;
if (disappeared >= 0) failure = disappeared;
expect(
violations,
@@ -1,30 +1,9 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
import {
cancelAgent,
expectComposerDraft,
expectComposerVisible,
submitMessage,
} from "../support/helpers/composer";
import { cancelAgent, expectComposerVisible, submitMessage } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function completeSubmittedTurn(
page: Page,
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
prompt: string,
): Promise<Locator> {
await submitMessage(page, prompt);
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toBeVisible();
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
expect(finish.status).toBe("idle");
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
await expect(userMessage).toHaveAttribute("aria-busy", "false");
return userMessage;
}
async function rewindConversation(page: Page, userMessage: Locator, prompt: string): Promise<void> {
await userMessage.getByText(prompt, { exact: true }).hover();
await userMessage.getByRole("button", { name: "Rewind to this message", exact: true }).click();
@@ -42,29 +21,6 @@ async function expectTurnCompletesNormally(
}
test.describe("Agent message rewind", () => {
test("rewinds a submitted prompt and restores it to the composer", async ({ page }) => {
test.setTimeout(90_000);
const prompt = "Restore this submitted prompt after rewind.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "message-rewind-roundtrip-",
title: "Message rewind roundtrip",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const userMessage = await completeSubmittedTurn(page, agent, prompt);
await rewindConversation(page, userMessage, prompt);
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
await expectComposerDraft(page, prompt);
} finally {
await agent.cleanup();
}
});
test("keeps a pre-acknowledgement turn running after rewind is rejected", async ({ page }) => {
test.setTimeout(90_000);
const prompt = "Delay synthetic user message by 2000ms.";
@@ -103,6 +103,18 @@ async function readProviderModePreference(page: Page, provider: string): Promise
);
}
async function readProviderFeaturePreferences(page: Page, provider: string): Promise<unknown> {
return page.evaluate(
({ preferencesKey, providerId }) => {
const raw = localStorage.getItem(preferencesKey);
if (!raw) return null;
const preferences = JSON.parse(raw) as FormPreferences;
return preferences.providerPreferences?.[providerId]?.featureValues ?? null;
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
);
}
test.describe("Agent profiles repair modeless provider preferences", () => {
test.describe.configure({ timeout: 240_000 });
@@ -207,6 +219,9 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
await openModelPicker(page);
await applyProfileFromPicker(page, "Fast profile");
await expect
.poll(() => readProviderFeaturePreferences(page, featureProviderId), { timeout: 10_000 })
.toEqual({ fast_mode: true });
await submitNewWorkspacePrompt(page, "Create an agent with profile features.");
const createAgentRequest = await createAgentRecorder.waitForRequest();
@@ -10,6 +10,7 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
repoPrefix: "bottom-sheet-reopen-",
title: "Bottom sheet reopen e2e",
initialPrompt: "Prepare a bottom sheet reopen test agent.",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
@@ -22,16 +23,6 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
return session;
}
async function withMobileMockAgent(page: Page, run: () => Promise<void>) {
const session = await openMockAgentAtMobileBreakpoint(page);
try {
await run();
} finally {
await session.cleanup();
}
}
function bottomSheetBackdrop(page: Page) {
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
}
@@ -108,35 +99,36 @@ async function openAndCloseModelSelectorTwice(page: Page) {
}
test.describe("mobile bottom sheet reopen", () => {
test("tab switcher can open, close, reopen, and close again", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openAndCloseTabSwitcherTwice(page);
});
});
test("model selector can open, close, reopen, and close again", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openAndCloseModelSelectorTwice(page);
});
});
test("search selection returns to configuration options", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openModelSelector(page);
const sheet = page.getByTestId("agent-controls-model-sheet");
await page.getByTestId("model-search-all-input").click();
const model = page.getByRole("button", { name: /^Ten second stream/ });
await expect(model).toBeVisible({
timeout: 10_000,
test("sheets reopen and model search returns to configuration", async ({ page }) => {
const session = await openMockAgentAtMobileBreakpoint(page);
try {
await test.step("tab switcher opens, closes, and reopens", async () => {
await openAndCloseTabSwitcherTwice(page);
});
await model.click();
await test.step("model selector opens, closes, and reopens", async () => {
await openAndCloseModelSelectorTwice(page);
});
await expect(sheet).toBeVisible();
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
});
await test.step("model search returns to configuration", async () => {
await openModelSelector(page);
const sheet = page.getByTestId("agent-controls-model-sheet");
await page.getByTestId("model-search-all-input").click();
const model = page.getByRole("button", { name: /^Ten second stream/ });
await expect(model).toBeVisible({
timeout: 10_000,
});
await model.click();
await expect(sheet).toBeVisible();
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
});
} finally {
await session.cleanup();
}
});
});
@@ -46,38 +46,6 @@ test.describe("Branch switcher", () => {
// releases stale sessions from the previous spec; one retry stabilizes it.
test.describe.configure({ retries: 1 });
test("switches the workspace branch from the git diff panel for an opaque workspace id", async ({
page,
}) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({
repoPrefix: "branch-switch-",
repo: { branches: ["main", "dev"] },
});
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
await openChangesPanel(page);
await expectWorkspaceBranch(page, "main");
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
await expectWorkspaceBranch(page, "dev");
await expect
.poll(
async () =>
(await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch,
{ timeout: 30_000 },
)
.toBe("dev");
} finally {
await workspace.cleanup();
}
});
test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({
page,
}) => {
+83 -207
View File
@@ -30,11 +30,7 @@ const cleanupTasks: CleanupTask[] = [];
const APP_SETTINGS_KEY = "@paseo:app-settings";
function changesTree(page: Page) {
return page.getByTestId("changes-tree-rail-tree");
}
function changesContent(page: Page) {
return page.getByTestId("changes-tree-rail-content");
return page.getByTestId("changes-file-tree").filter({ visible: true });
}
async function readFileIfPresent(filePath: string): Promise<string | null> {
@@ -264,7 +260,8 @@ test("Changes opens the populated committed comparison for a clean checkout", as
await openWorkspaceChangesSurface(page, workspace);
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();
});
@@ -272,19 +269,19 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
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 expect(mode).toContainText("Uncommitted");
await mode.click();
await page.getByTestId("changes-diff-mode-committed").click();
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", ["commit", "-m", "Commit working changes"], { cwd: workspace.repoPath });
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,
});
@@ -297,7 +294,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
await openWorkspaceChangesSurface(page, workspace);
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 page.getByTestId("changes-diff-mode-committed").click();
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 page.getByTestId("changes-diff-mode-uncommitted").click();
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible();
const seeCommitted = panel.getByRole("button", { name: "See committed changes" });
await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible();
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
await expect(seeCommitted).toBeVisible();
await seeCommitted.click();
await expect(mode).toContainText("Committed");
@@ -321,7 +319,7 @@ test("changes file actions open below the right-click without a reserved kebab",
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
const deletedFileName = page.getByText("zz-deleted.ts", { exact: true });
const deletedFileName = page.getByTestId("diff-file-1-name");
await expect(deletedFileName).toHaveCSS("user-select", "none");
await deletedFileName.dblclick();
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
@@ -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")))
.toBe(AFTER);
await page.getByTestId("changes-toggle-tree").click();
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
await page.getByTestId("diff-folder-src-duplicate").click();
await expect
@@ -447,8 +444,6 @@ test("changes tree aligns every file status after its diff stat", async ({ page
});
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-tree").click();
const tree = changesTree(page);
const modifiedRow = tree.getByTestId("diff-tree-file-0");
const deletedRow = tree.getByTestId("diff-tree-file-1");
@@ -471,7 +466,6 @@ test("changes context menu recursively collapses descendant folders", async ({ p
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-tree").click();
const tree = changesTree(page);
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
@@ -516,7 +510,6 @@ test("changes context menus expose folder revert and restore a file after confir
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-tree").click();
const tree = changesTree(page);
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
const folderRevert = page.getByTestId("diff-folder-src-revert");
@@ -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 });
await expect(visiblePanel).toBeVisible();
await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
@@ -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 expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
// Collapse-all and the layout switch are overflow-menu items, not toolbar icons.
const diffOptions = visiblePanel.getByRole("button", { name: "Diff options" });
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
await diffOptions.click();
// A workspace tab has nowhere to open itself, so the menu withholds that item.
await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0);
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
await page.getByTestId("changes-toggle-collapse-all").click();
await diffOptions.click();
await expect(page.getByTestId("changes-toggle-collapse-all")).toContainText("Expand all files");
await page.getByTestId("changes-toggle-collapse-all").click();
const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all");
await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files");
await collapseFiles.click();
await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files");
await collapseFiles.click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
const layoutItem = page.getByTestId("changes-toggle-layout");
await diffOptions.click();
await expect(layoutItem).toContainText("Side-by-side diff");
// Leading icon only: the layout is still unified, so no trailing check.
await expect(layoutItem.locator("svg")).toHaveCount(1);
await layoutItem.click();
await diffOptions.click();
await expect(layoutItem.locator("svg")).toHaveCount(2);
const layout = visiblePanel.getByTestId("changes-toggle-layout");
await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff");
await layout.click();
await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff");
await expect(page.getByText("Hide whitespace", { exact: true })).toBeVisible();
await expect(page.getByText("Wrap long lines", { exact: true })).toBeVisible();
await expect(page.getByTestId("changes-refresh")).toHaveText("Refresh");
await page.getByText("Wrap long lines", { exact: true }).click();
await diffOptions.click();
await expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace");
await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace");
const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines");
await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines");
await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute(
"aria-label",
"Refresh",
);
await wrapLines.click();
await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines");
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
@@ -701,151 +695,23 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
});
test("desktop Changes toggles a navigation tree beside the expanded diff document", async ({
page,
}, 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 actions compact and menu-only", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.setViewportSize({ width: 480, height: 900 });
const actions = page.getByTestId("changes-actions-menu-trigger").filter({ visible: true });
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
const compactChangesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
if (!(await compactChangesTab.isVisible())) {
await page.getByTestId("workspace-explorer-toggle").first().click();
}
await expect(compactChangesTab).toBeVisible();
await compactChangesTab.click();
const compactExplorer = page.getByTestId("explorer-content-area").filter({ visible: true });
await expect(compactExplorer.getByTestId("changes-header")).toBeVisible();
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(),
@@ -873,7 +739,9 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
await expect(wrapLines).toBeVisible();
await wrapLines.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 }) => {
@@ -883,7 +751,9 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
const canvas = page.getByTestId("git-diff-canvas");
const root = page.getByTestId("git-diff-canvas-root");
const handle = page.getByTestId("workspace-split-resize-handle").getByRole("separator");
const handle = page
.getByTestId("workspace-explorer-sidebar-resize-handle")
.getByRole("separator");
await expect(handle).toBeVisible();
await expect
.poll(async () => {
@@ -904,11 +774,11 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
};
}),
]);
if (!handleBounds) throw new Error("Workspace split resize handle has no bounds");
if (!handleBounds) throw new Error("Explorer sidebar resize handle has no bounds");
await page.mouse.move(handleBounds.x + handleBounds.width / 2, handleBounds.y + 120);
await page.mouse.down();
await page.mouse.move(handleBounds.x - 120, handleBounds.y + 120);
await page.mouse.move(handleBounds.x + 120, handleBounds.y + 120);
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
const duringDrag = await Promise.all([
@@ -1074,6 +944,7 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
if (!bounds) throw new Error("Diff overlay scrollbar thumb has no bounds");
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.down();
await expect(grab).toHaveCSS("cursor", "grabbing");
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 180);
await page.mouse.up();
@@ -1313,24 +1184,20 @@ async function setOpenChangesPresentation(
page: Page,
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
): Promise<void> {
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
await options.click();
const layoutItem = page.getByTestId("changes-toggle-layout");
const currentLayout = (await layoutItem.locator("svg").count()) === 2 ? "split" : "unified";
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
const layoutItem = diffPanel.getByTestId("changes-toggle-layout");
const currentLayout =
(await layoutItem.getAttribute("aria-label")) === "Switch to unified diff"
? "split"
: "unified";
if (currentLayout !== requestedPresentation.layout) {
await layoutItem.click();
} else {
await page.keyboard.press("Escape");
}
await options.click();
const wrapItem = page.getByTestId("changes-toggle-wrap-lines");
const currentWrapLines = (await wrapItem.locator("svg").count()) === 2;
const wrapItem = diffPanel.getByTestId("changes-toggle-wrap-lines");
const currentWrapLines = (await wrapItem.getAttribute("aria-label")) === "Scroll long lines";
if (currentWrapLines !== requestedPresentation.wrapLines) {
await wrapItem.click();
} else {
await page.keyboard.press("Escape");
}
}
@@ -1365,12 +1232,6 @@ async function releaseBrowserFontLoads(page: Page): Promise<void> {
});
}
async function expectFlatFileList(page: Page): Promise<void> {
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
await expect(page.getByTestId("diff-file-0")).toContainText("src");
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
const canvas = page.getByTestId("git-diff-canvas");
await expect
@@ -1491,6 +1352,7 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-explorer-toggle").first().click();
await openChangesInVisibleExplorer(page);
await expectExpandedMountedTabDiff(page);
}
@@ -1507,13 +1369,27 @@ async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspa
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
await expect(page.getByText("selection.ts")).toBeVisible({ timeout: 30_000 });
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(diffPanel.getByTestId("diff-file-0-name")).toHaveText("selection.ts", {
timeout: 30_000,
});
await expectExpandedMountedTabDiff(page);
}
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await openChangesPanel(page);
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
const explorer = page.getByTestId("workspace-explorer-sidebar");
await expect(explorer).toBeVisible({ timeout: 30_000 });
const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first();
await changesTab.click();
const changedFile = explorer
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
.filter({ visible: true })
.first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await changedFile.click();
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
@@ -1,21 +1,12 @@
import { expect, test, type Page } from "../support/fixtures";
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 APP_SETTINGS_KEY = "@paseo:app-settings";
const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`;
function mainPane(page: Page) {
return page.getByTestId("workspace-pane-main").filter({ visible: true });
}
function sidePanel(page: Page) {
return page.getByTestId("workspace-side-panel").filter({ visible: true });
}
test("Changes shortcut follows the default Side panel placement", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-side-" });
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 });
@@ -24,34 +15,12 @@ test("Changes shortcut follows the default Side panel placement", async ({ page
await page.keyboard.press(CHANGES_SHORTCUT);
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
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(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("Changes shortcut uses the focused pane when Side panel placement is off", async ({
page,
}) => {
await page.addInitScript((settingsKey) => {
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
}, APP_SETTINGS_KEY);
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-pane-" });
try {
await page.setViewportSize({ width: 1400, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await page.keyboard.press(CHANGES_SHORTCUT);
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(sidePanel(page)).toHaveCount(0);
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
@@ -4,6 +4,7 @@ import {
clickChatOutlineRowEdge,
disableChatOutlineFromAppearance,
expectActiveChatOutlinePrompt,
expectActiveChatOutlinePromptMovedFrom,
expectChatOutlinePreview,
expectChatOutlinePrompts,
expectChatOutlinePromptToRemainBare,
@@ -29,7 +30,6 @@ import {
openAgentTimeline,
scrollThroughOlderHistoryPages,
scrollTimelineToNewestLoadedEdge,
scrollTimelineToOldestLoadedEdge,
seedLongMockAgentTimeline,
type LongTimelineAgent,
} from "../support/helpers/timeline-pagination";
@@ -130,11 +130,12 @@ test.describe("desktop chat outline", () => {
await expectOneActiveChatOutlinePrompt(page);
await clickChatOutlineRowEdge(page, 4);
await expectTimelinePromptVisible(page, agent.prompts[3]);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]);
await expectActiveChatOutlinePrompt(page, 4);
await scrollTimelineToOldestLoadedEdge(page);
await expectActiveChatOutlinePrompt(page, 1);
await scrollTimelineToNewestLoadedEdge(page);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectActiveChatOutlinePromptMovedFrom(page, 4);
});
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
@@ -4,6 +4,7 @@ import { closeCommandCenter, openCommandCenter } from "../support/helpers/comman
import {
applyCommandCenterAgentControls,
chooseCommandCenterAgentControl,
openCommandCenterForAgent,
expectCommandCenterAgentControlSelected,
expectFocusedAgentControls,
expectWorkspaceAgentConfiguration,
@@ -12,7 +13,9 @@ import {
} from "../support/helpers/command-center-agent-controls";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { seedWorkspace } from "../support/helpers/seed-client";
import { buildSchedulesRoute } from "@/utils/host-routes";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
@@ -76,6 +79,107 @@ test.describe("Command Center agent controls", () => {
}
});
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
// model. Asserting both model and mode catches either row winning.
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-relevance-",
title: "Command Center relevance",
model: "five-minute-stream",
});
try {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
const input = await openCommandCenterForAgent(page, workspace);
await input.fill("load");
await page.keyboard.press("Enter");
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
} finally {
await workspace.cleanup();
}
});
test("matches query tokens in any order", async ({ page }) => {
// "Mode › Load test" flattens to "mode load test", so the reversed query "test load" is never
// a substring of it. Only per-token matching finds this row.
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-tokenized-",
title: "Command Center tokenized search",
model: "five-minute-stream",
});
try {
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await openCommandCenter(page);
await chooseCommandCenterAgentControl({
page,
query: "test load",
choice: "Mode › Load test",
});
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
} finally {
await workspace.cleanup();
}
});
test("keeps an action first when the action is the best match", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-action-priority-",
title: "Command Center action priority",
model: "five-minute-stream",
});
try {
const input = await openCommandCenterForAgent(page, workspace);
await input.fill("sched");
await page.keyboard.press("Enter");
await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 });
} finally {
await workspace.cleanup();
}
});
test("drops an arrow-key selection once the query changes", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-active-reset-",
title: "Command Center active reset",
model: "five-minute-stream",
});
try {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
const input = await openCommandCenterForAgent(page, workspace);
await input.fill("loa");
// Moves the highlight onto a model row, which still matches after the next keystroke.
await page.keyboard.press("ArrowDown");
await input.fill("load");
await page.keyboard.press("Enter");
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
} finally {
await workspace.cleanup();
}
});
test("applies draft model and setting choices to the created agent", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
await seedMockDraftPreferences(page);
@@ -1,53 +1,14 @@
import { randomUUID } from "node:crypto";
import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { openCommandCenter } from "../support/helpers/command-center";
import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { expectAgentTabActive } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
const PRIMARY_HOST_LABEL = "Primary Host";
const SECONDARY_HOST_ID = "host-command-center-secondary";
test.describe("Command center host labels", () => {
test.describe.configure({ timeout: 180_000 });
test("agent results show the host they live on when more than one host exists", async ({
page,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
const title = `cc-host-${randomUUID().slice(0, 8)}`;
try {
const agent = await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title,
});
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
serverId: SECONDARY_HOST_ID,
label: "Secondary Host",
primaryLabel: PRIMARY_HOST_LABEL,
});
const panel = await openCommandCenter(page);
// The shared daemon may carry agents from other specs, so target this agent by its id.
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(title);
await expect(row).toContainText(PRIMARY_HOST_LABEL);
} finally {
await seeded.cleanup();
}
});
test("selecting an agent result opens its workspace tab", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { Locator } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
const COMMIT_SUBJECT = "Show commit timestamps";
@@ -15,7 +15,7 @@ test("commit history explains when the workspace has no commits ahead of its bas
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
await workspace.navigateTo();
await openChangesPanel(page);
await openChangesTreePanel(page);
const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click();
@@ -35,7 +35,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
await page.setViewportSize({ width: 1400, height: 900 });
await workspace.navigateTo();
await openChangesPanel(page);
await openChangesTreePanel(page);
const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click();
@@ -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");
}
expect(commitToolbarBox.height).toBe(36);
expect(layoutToggleBox.width).toBe(24);
expect(layoutToggleBox.height).toBe(24);
expect(layoutToggleBox.width).toBe(20);
expect(layoutToggleBox.height).toBe(20);
expect(layoutToggleGlyphBox.width).toBe(14);
expect(layoutToggleGlyphBox.height).toBe(14);
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
@@ -38,7 +38,10 @@ async function seedChangedAgent(repoPrefix: string) {
}
}
test("composer diff stat toggles Changes in the preferred Side panel", async ({ page }) => {
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 {
@@ -54,31 +57,24 @@ test("composer diff stat toggles Changes in the preferred Side panel", async ({
await expect(pill).toContainText("-0");
await pill.click();
const sidePanel = page.getByTestId("workspace-side-panel").filter({ visible: true });
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible({
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(sidePanel.getByTestId("working-diff-panel")).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("switching from another Side panel view reveals Changes", async () => {
await test.step("Explorer navigation does not replace the side pane", async () => {
await openFilesPanel(page);
await expect(sidePanel.getByTestId("file-explorer-tree-scroll")).toBeVisible();
await expect(page.getByTestId("workspace-explorer-sidebar")).toContainText("Files");
await pill.click();
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
});
await test.step("clicking visible Changes hides the panel without closing its tab", async () => {
await pill.click();
await expect(sidePanel).toHaveCount(0);
await expect(sidePane.getByTestId("working-diff-panel")).toBeVisible();
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(1);
});
await test.step("clicking again restores Changes", async () => {
await pill.click();
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible();
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
});
} finally {
await workspace.cleanup();
}
@@ -96,19 +92,16 @@ test("composer diff stat opens the compact explorer instead of a Changes tab", a
await page.getByTestId("composer-diff-stat-pill").click();
await expect(page.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
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 when Side panel routing is off", async ({
page,
}) => {
await page.addInitScript((settingsKey) => {
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
}, APP_SETTINGS_KEY);
test("composer diff stat opens Changes in the focused pane by default", async ({ page }) => {
const workspace = await seedChangedAgent("composer-diff-stat-tab-");
try {
@@ -125,7 +118,9 @@ test("composer diff stat opens Changes in the focused pane when Side panel routi
timeout: 30_000,
});
await expect(mainPane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("workspace-side-panel")).toHaveCount(0);
await expect(
page.locator('[data-testid^="workspace-pane-"]').filter({ visible: true }),
).toHaveCount(1);
} finally {
await workspace.cleanup();
}
@@ -99,6 +99,7 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
repoPrefix: "composer-bottom-anchor-",
title: "Composer bottom anchor",
initialPrompt: "Produce enough content to make the chat scrollable.",
model: "ten-second-stream",
});
try {
@@ -1,7 +1,8 @@
import { expect, test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
test.describe("Direct terminal shortcut pane placement", () => {
test("opens a terminal in the focused pane", async ({ page }) => {
@@ -11,21 +12,31 @@ test.describe("Direct terminal shortcut pane placement", () => {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
const focusedPane = page
.getByTestId("workspace-tabs-row")
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
.first();
const primaryPane = page
.getByTestId("workspace-tabs-row")
.filter({ hasNot: page.getByTestId("workspace-tab-working_diff") })
.first();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const focusedPaneChild = page
.getByTestId("split-group-child")
.filter({ has: page.getByTestId("workspace-new-tab-panel") });
const originalPaneChild = page
.getByTestId("split-group-child")
.filter({ hasNot: page.getByTestId("workspace-new-tab-panel") });
await expect(focusedPaneChild).toBeVisible();
const focusedPaneId = await focusedPaneChild
.locator('[data-testid^="workspace-pane-"]')
.getAttribute("data-testid");
const originalPaneId = await originalPaneChild
.locator('[data-testid^="workspace-pane-"]')
.getAttribute("data-testid");
expect(focusedPaneId).not.toBeNull();
expect(originalPaneId).not.toBeNull();
const focusedPane = page.getByTestId(focusedPaneId!);
const originalPane = page.getByTestId(originalPaneId!);
await pressDirectNewTabShortcut(page, "t");
await expect(focusedPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(1, {
timeout: 30_000,
});
await expect(primaryPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
await expect(originalPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
} finally {
await workspace.cleanup();
}
@@ -13,38 +13,25 @@ async function expectLauncherSelection(launcher: Locator, name: string): Promise
await expect(launcher.getByRole("button", { name, exact: true })).toBeFocused();
}
async function openSidePanelWithKeyboard(page: Page): Promise<Locator> {
async function openExplorerWithKeyboard(page: Page): Promise<Locator> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+E`);
const launcher = page
.getByTestId("workspace-side-panel")
.filter({ visible: true })
.getByTestId("workspace-new-tab-panel");
await expect(launcher).toBeVisible({ timeout: 30_000 });
return launcher;
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 focuses the launcher and Enter opens the selected view", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-side-panel-keyboard-" });
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 launcher = await openSidePanelWithKeyboard(page);
await expectLauncherSelection(launcher, "Changes");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Files");
await page.keyboard.press("Enter");
await expect(
page
.getByTestId("workspace-side-panel")
.filter({ visible: true })
.getByTestId("workspace-tab-files"),
).toBeVisible({ timeout: 30_000 });
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();
}
@@ -65,14 +52,12 @@ test.describe("New tab keyboard launcher", () => {
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Terminal");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Changes");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Files");
await expectLauncherSelection(launcher, "Diff");
await page.keyboard.press("Enter");
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
await expect(
page.getByTestId("workspace-tab-working_diff").filter({ visible: true }),
).toBeVisible({ timeout: 30_000 });
} finally {
await workspace.cleanup();
}
@@ -8,7 +8,10 @@ import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import {
openChangesTreePanel,
waitForWorkspaceTabsVisible,
} from "../support/helpers/workspace-tabs";
function visible(page: Page, testId: string): Locator {
return page.getByTestId(testId).filter({ visible: true });
@@ -22,23 +25,18 @@ function draftTabChip(page: Page): Locator {
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
}
/** The explorer pane is the only tab row carrying the Changes tab. */
/** The Explorer has its own fixed tab rail, separate from workspace pane rows. */
function explorerTabRow(page: Page): Locator {
return visible(page, "workspace-tabs-row").filter({
has: page.getByTestId("workspace-tab-working_diff"),
});
return visible(page, "explorer-sidebar-tab-rail");
}
function mainTabRow(page: Page): Locator {
return visible(page, "workspace-tabs-row").filter({
hasNot: page.getByTestId("workspace-tab-working_diff"),
});
return visible(page, "workspace-tabs-row");
}
async function focusExplorerPane(page: Page): Promise<void> {
await openChangesPanel(page);
const changesTab = visible(page, "workspace-tab-working_diff").first();
await expect(changesTab).toHaveAttribute("aria-selected", "true");
async function selectExplorerChanges(page: Page): Promise<void> {
await openChangesTreePanel(page);
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
}
async function closeSeededDraftInMainPane(page: Page): Promise<void> {
@@ -115,8 +113,8 @@ test.describe("explorer pane tab placement", () => {
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
});
await test.step("focus the explorer pane", async () => {
await focusExplorerPane(page);
await test.step("select Changes in Explorer", async () => {
await selectExplorerChanges(page);
});
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
@@ -140,11 +138,8 @@ test.describe("explorer pane tab placement", () => {
await expect(
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toHaveCount(0);
// The background open must not steal the explorer pane's focus.
await expect(visible(page, "workspace-tab-working_diff").first()).toHaveAttribute(
"aria-selected",
"true",
);
// The background open must not change the selected Explorer view.
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
});
await test.step("close the draft; the agent is the main pane's only tab", async () => {
@@ -170,8 +165,9 @@ test.describe("explorer pane tab placement", () => {
body: await page.screenshot(),
contentType: "image/png",
});
// The split must have taken: agent pane + New main + explorer = 3 tab rows.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
await expect(explorerTabRow(page)).toHaveCount(1);
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
});
@@ -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"));
}
/** Only the docked sidebar ever rendered `explorer-header` on a wide layout. */
function dockedSidebar(page: Page): Locator {
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");
function explorerSidebar(page: Page): Locator {
return visible(page, "workspace-explorer-sidebar");
}
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
@@ -65,18 +59,18 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await test.step("no docked sidebar, on load or after toggling", async () => {
await expect(dockedSidebar(page)).toHaveCount(0);
await test.step("the retired flag does not reveal Explorer", async () => {
await expect(explorerSidebar(page)).toHaveCount(0);
const toggle = visible(page, "workspace-explorer-toggle").first();
await toggle.click();
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
await expect(dockedSidebar(page)).toHaveCount(0);
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
await expect(
explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"),
).toBeVisible();
await toggle.click();
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
await expect(dockedSidebar(page)).toHaveCount(0);
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
});
await test.step("the retired keys are migrated out of storage", async () => {
@@ -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 toggle.click();
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
await expect(agentList).toHaveCount(1);
});
await test.step("the shortcut collapses both", async () => {
await page.keyboard.press(`${modifier}+.`);
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
await expect(agentList).toHaveCount(0);
});
await test.step("the shortcut restores both", async () => {
await page.keyboard.press(`${modifier}+.`);
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
});
} finally {
await workspace.cleanup();
@@ -9,6 +9,8 @@ import {
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const APP_SETTINGS_KEY = "@paseo:app-settings";
const RED_PIXEL = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
"base64",
@@ -218,6 +220,12 @@ test.describe("CodeMirror workspace file editing", () => {
});
test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
await page.addInitScript((settingsKey) => {
localStorage.setItem(
settingsKey,
JSON.stringify({ openInSidePane: { explorerFiles: true } }),
);
}, APP_SETTINGS_KEY);
const target = "target.ts:42";
const session = await seedAgentWithFileLink({
target,
@@ -1,142 +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;
async function effectiveBackground(locator: import("@playwright/test").Locator): Promise<string> {
return locator.evaluate((element) => {
let current: Element | null = element;
while (current) {
const color = getComputedStyle(current).backgroundColor;
if (color !== "rgba(0, 0, 0, 0)" && color !== "transparent") return color;
current = current.parentElement;
}
return "transparent";
});
}
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 Files panel starts with an empty content pane beside the tree", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const rail = page.getByTestId("files-tree-rail").filter({ visible: true });
await expect(rail).toBeVisible({ timeout: 30_000 });
const content = rail.getByTestId("files-tree-rail-content");
const tree = rail.getByTestId("files-tree-rail-tree");
await expect(content).toHaveText("Choose a file");
await expect(tree).toContainText("docs");
expect(await effectiveBackground(tree.getByTestId("file-explorer-tree-scroll"))).toBe(
await effectiveBackground(content),
);
});
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();
}
});
});
@@ -11,6 +11,7 @@ import {
countTabsOfKind,
waitForTabBar,
pressNewTabShortcut,
pressDirectNewTabShortcut,
getTabTestIds,
waitForTabWithTitle,
measureTileTransition,
@@ -177,13 +178,11 @@ test.describe("Tab creation", () => {
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
const agent = panel.getByRole("button", { name: /^Agent/ });
const terminal = panel.getByRole("button", { name: /^Terminal/ });
const changes = panel.getByRole("button", { name: /Changes/ });
const files = panel.getByRole("button", { name: /Files/ });
const diff = panel.getByRole("button", { name: /Diff/ });
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
await expect(diff).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
await expect(agent).toBeFocused();
await page.keyboard.press("ArrowDown");
@@ -191,6 +190,12 @@ test.describe("Tab creation", () => {
await page.keyboard.press("ArrowUp");
await expect(agent).toBeFocused();
await pressDirectNewTabShortcut(page, "e");
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
await expect(
page.getByTestId("file-explorer-tree-scroll").filter({ visible: true }),
).toBeVisible();
await page.locator("body").click({ position: { x: 1, y: 1 } });
await panel.click({ position: { x: 20, y: 20 } });
await expect(agent).toBeFocused();
@@ -64,6 +64,7 @@ test.describe("New workspace: launch target memory", () => {
await test.step("launching a terminal profile preselects it the next time New workspace opens", async () => {
await openNewWorkspace();
await selectLaunchOption(page, PROMPT_PROFILE.id);
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
await fillTerminalPrompt(page, "remember me");
await submitTerminalLaunch(page);
await expectWorkspaceOpensWithTerminalTab(page);
@@ -88,6 +89,7 @@ test.describe("New workspace: launch target memory", () => {
await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => {
await selectLaunchOption(page, PROMPT_PROFILE.id);
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
await fillTerminalPrompt(page, "second run");
await submitTerminalLaunch(page);
await expectWorkspaceOpensWithTerminalTab(page);
@@ -153,6 +153,7 @@ test.describe("Rewind sheet", () => {
repoPrefix: "rewind-e2e-",
title: "Rewind e2e",
initialPrompt: firstPrompt,
model: "ten-second-stream",
});
try {
@@ -70,73 +70,55 @@ test.describe("History search", () => {
await tempRepo?.cleanup();
});
test("typing narrows history to matching sessions and clearing restores them", async ({
page,
}) => {
test("searches, ranks, highlights, and clears session history", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
// Seeded newest-first, and at rest history is chronological.
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
await test.step("narrows history and restores chronological grouping", async () => {
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
timeout: 30_000,
});
await search(page, `${NONCE} billing`);
await expectVisibleTitles(page, [TITLES.billing]);
await search(page, `${NONCE} billing`);
await expectVisibleTitles(page, [TITLES.billing]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
// Ranked results are one flat list — a day heading would claim an order
// the list no longer has.
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, { timeout: 30_000 });
await page.getByTestId("sessions-search-clear").click();
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
timeout: 30_000,
});
});
await page.getByTestId("sessions-search-clear").click();
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
});
await test.step("ranks whole-word matches above substrings", async () => {
await search(page, `${NONCE} bill`);
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
});
test("ranks a whole-word hit above one buried inside a word", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
await test.step("highlights exact and typo-resolved matches", async () => {
await search(page, `${NONCE} billing`);
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
// "bill" starts a word in "billing" and hides inside "unbilled", so the
// stronger match leads even though both sessions are equally recent.
await search(page, `${NONCE} bill`);
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
});
await search(page, `${NONCE} bulling`);
await expectVisibleTitles(page, [TITLES.billing]);
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({
timeout: 30_000,
});
});
test("marks the characters each result matched on", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
await test.step("distinguishes no matches from empty history", async () => {
await search(page, `${NONCE} kubernetes`);
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
// The mark is a nested Text run, so the matched slice is its own element.
await search(page, `${NONCE} billing`);
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
// A typo has no characters in the text to point at, so the whole word it
// resolved to is marked.
await search(page, `${NONCE} bulling`);
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
});
test("finds a session through a typo", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
await search(page, `${NONCE} bulling`);
await expectVisibleTitles(page, [TITLES.billing]);
});
test("says the query found nothing, not that history is empty", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
await search(page, `${NONCE} kubernetes`);
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
await page.getByText("Clear search").click();
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
await page.getByText("Clear search").click();
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
});
});
});
@@ -121,6 +121,41 @@ test.describe("Settings sidebar navigation", () => {
await clickSettingsBackToWorkspace(page);
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
test("pressing Escape closes settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await page.keyboard.press("Escape");
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
test("Escape lets settings dropdowns and modals close before leaving settings", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
await test.step("a dropdown owns Escape", async () => {
await openSettingsSection(page, "appearance");
await page.getByLabel(/Theme:/).click();
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/settings(\/|$)/);
});
await test.step("a modal owns Escape", async () => {
await openAddHostFlow(page);
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByText("Add connection", { exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/settings(\/|$)/);
});
});
});
test.describe("Settings — compact master-detail", () => {
-238
View File
@@ -1,238 +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 newTabPanel(scope: Locator): Locator {
return scope.getByTestId("workspace-new-tab-panel");
}
async function launcherButtonLabels(launcher: Locator): Promise<(string | null)[]> {
return launcher
.getByRole("button")
.evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label")));
}
function sidePanelToggle(page: Page): Locator {
return page.getByTestId("workspace-explorer-toggle").first();
}
/** 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 });
const launcher = newTabPanel(sidePanel(page));
await expect(launcher).toBeVisible();
expect((await launcherButtonLabels(launcher)).slice(0, 4)).toEqual([
"Changes",
"Files",
"Terminal",
"Agent",
]);
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 newTabPanel(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("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(newTabPanel(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 = newTabPanel(mainPane(page));
await expect(emptyMain).toBeVisible({ timeout: 15_000 });
await capture(page, testInfo, "06-empty-main-pane-launcher");
const newTabChip = mainPane(page)
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
await newTabChip.hover();
await mainPane(page)
.locator('[data-testid^="workspace-new-tab-close-"]')
.filter({ visible: true })
.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-new-tab-panel")).toHaveCount(0);
await capture(page, testInfo, "07-empty-main-pane-removed");
} finally {
await workspace.cleanup();
}
});
test("closing New cannot remove the last visible 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 newTabPanel(sidePanel(page))
.getByRole("button", { name: "Files", exact: true })
.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 now contains New and is the only pane the user can see. Closing its
// tab cannot remove the final visible pane.
const lastPane = newTabPanel(mainPane(page));
await expect(lastPane).toBeVisible({ timeout: 15_000 });
await expect(lastPane.getByRole("button", { name: "Files", exact: true })).toBeVisible();
const finalNewTab = mainPane(page)
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
await finalNewTab.hover();
await mainPane(page)
.locator('[data-testid^="workspace-new-tab-close-"]')
.filter({ visible: true })
.click();
await expect(lastPane).toBeVisible();
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 newTabPanel(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-button").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();
}
});
});
+26 -35
View File
@@ -41,46 +41,37 @@ async function expectExternalPage(
await popup.close();
}
test("opens troubleshooting tools from the sidebar help menu", async ({ page }) => {
test("opens troubleshooting, support, and release destinations", async ({ page }) => {
await gotoAppShell(page);
await expect(page.getByTestId("sidebar-help")).toBeVisible();
await openHelpMenu(page);
const triggerBox = await page.getByTestId("sidebar-help").evaluate((element) => {
const { y, height } = element.getBoundingClientRect();
return { y, height };
await test.step("opens diagnostics and keyboard shortcuts", async () => {
await openHelpMenu(page);
await expect(page.getByText("Help", { exact: true })).toBeVisible();
await expect(page.getByText("Report an issue", { exact: true })).toBeVisible();
await expect(page.getByText("What's new", { exact: true })).toBeVisible();
await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION);
await page.getByTestId("sidebar-help-diagnostics").click();
await expectDiagnosticReport(page);
await closeSheet(page, "app-diagnostic-sheet");
await openHelpMenu(page);
await page.getByTestId("sidebar-help-shortcuts").click();
await expect(page.getByTestId("keyboard-shortcuts-dialog")).toBeVisible();
await closeSheet(page, "keyboard-shortcuts-dialog");
});
const menuBox = await page.getByTestId("sidebar-help-menu").evaluate((element) => {
const { y, height } = element.getBoundingClientRect();
return { y, height };
await test.step("opens support and release pages", async () => {
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
});
expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(triggerBox.y);
await expect(page.getByText("Help", { exact: true })).toBeVisible();
await expect(page.getByText("Report an issue", { exact: true })).toBeVisible();
await expect(page.getByText("What's new", { exact: true })).toBeVisible();
await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION);
await page.getByTestId("sidebar-help-diagnostics").click();
await expectDiagnosticReport(page);
await closeSheet(page, "app-diagnostic-sheet");
await openHelpMenu(page);
await page.getByTestId("sidebar-help-shortcuts").click();
await expect(page.getByTestId("keyboard-shortcuts-dialog")).toBeVisible();
await closeSheet(page, "keyboard-shortcuts-dialog");
});
test("opens support and release destinations", async ({ page }) => {
await gotoAppShell(page);
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
});
test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => {
@@ -1,39 +0,0 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
test.use({ viewport: { width: 1600, height: 900 } });
async function expectBorderHighlight(
page: Page,
testID: string,
hoverTarget: Locator,
expectedWidth: string,
) {
const handle = page.getByTestId(testID);
await expect(handle).toBeVisible();
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
await hoverTarget.hover();
const highlight = page.getByTestId(`${testID}-highlight`);
await expect(highlight).toBeVisible();
await expect(highlight).toHaveCSS("width", expectedWidth);
await expect(highlight).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
}
test("sidebar and workspace pane borders highlight on hover", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "sidebar-resize-handle-" });
await workspace.navigateTo();
const sidebarHandle = page.getByTestId("left-sidebar-resize-handle");
await expectBorderHighlight(page, "left-sidebar-resize-handle", sidebarHandle, "1px");
await page.getByTestId("workspace-explorer-toggle").first().click();
const splitHandle = page.getByTestId("workspace-split-resize-handle");
await expectBorderHighlight(
page,
"workspace-split-resize-handle",
splitHandle.getByRole("separator"),
"3px",
);
});
@@ -146,20 +146,37 @@ async function installPinRpcGate(
}
test.describe("Pin workspace shortcut", () => {
test("keeps project creation reachable after pinning its only workspace", async ({ page }) => {
test("dispatches pin once and keeps project creation reachable", async ({ page }) => {
const workspace = await seedWorkspace({
repoPrefix: "pin-project-creation-",
title: "Pinned workspace",
});
try {
const gate = await installPinRpcGate(page);
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
await openNewWorkspaceComposer(page, workspace);
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
await test.step("sends one RPC for each pin transition", async () => {
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
expect(gate.sentCount()).toBe(1);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
expect(gate.sentCount()).toBe(2);
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
});
await test.step("keeps the pinned project available to workspace creation", async () => {
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
expect(gate.sentCount()).toBe(3);
await openNewWorkspaceComposer(page, workspace);
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
});
} finally {
await workspace.cleanup();
}
@@ -207,34 +224,6 @@ test.describe("Pin workspace shortcut", () => {
}
});
test("sends exactly one pin RPC per press when the row is rendered and selected", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-expanded-" });
try {
const gate = await installPinRpcGate(page);
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
// Counting frames catches a press that produces zero or two RPCs — a misfiring in-flight
// guard, or a second dispatch path. It cannot detect a duplicate handler registration:
// `keyboardActionDispatcher.dispatch` returns at the first handler that returns true, so a
// shadowed second handler is unobservable from outside by design.
expect(gate.sentCount()).toBe(1);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
expect(gate.sentCount()).toBe(2);
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
} finally {
await workspace.cleanup();
}
});
test("pins the active workspace while its status group is collapsed", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
@@ -118,19 +118,6 @@ test.describe("Sidebar workspace list", () => {
}
});
test("project shows workspace under it", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-workspace-under-project-" });
try {
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
await waitForSidebarWorkspace(page, workspace.workspaceId);
} finally {
await workspace.cleanup();
}
});
test("non-git project shows directory name", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false });
@@ -319,7 +306,7 @@ test.describe("Half-screen desktop layout", () => {
await expect(closedIcon).toBeVisible();
const closedBounds = await closedIcon.boundingBox();
expect(closedBounds).not.toBeNull();
expect(closedBounds?.x).toBeCloseTo(12, 0);
expect(closedBounds?.x).toBeCloseTo(9, 0);
expect(closedBounds?.y).toBe(openBounds?.y);
});
@@ -342,16 +329,19 @@ test.describe("Half-screen desktop layout", () => {
await openFilesPanel(page);
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
await expect(explorerToggle).toHaveAccessibleName("Close side panel");
await expect(
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
).toBeVisible();
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
await expect(page.getByTestId("explorer-sidebar-tab-rail")).toBeVisible();
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(1);
await explorerToggle.click();
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
0,
);
await expect(explorerToggle).toHaveAccessibleName("Open side panel");
await expect(
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
).toHaveCount(0);
await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
} finally {
await workspace.cleanup();
@@ -11,35 +11,37 @@ test.describe("Workspace lifecycle", () => {
// One retry is enough for the daemon to stabilize.
test.describe.configure({ retries: 1 });
test.describe("Main checkout", () => {
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" });
await workspace.navigateTo();
test("main checkout creates an agent chat and terminal in the project root", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "lifecycle-main-" });
await workspace.navigateTo();
await test.step("creates an agent chat", async () => {
await createAgentChatFromLauncher(page);
});
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" });
await workspace.navigateTo();
await test.step("creates a terminal in the project root", async () => {
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, workspace.repoPath);
});
});
test.describe("Worktree workspace", () => {
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" });
await workspace.navigateTo();
test("worktree creates an agent chat and terminal in its directory", async ({
page,
withWorkspace,
}) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-" });
await workspace.navigateTo();
await test.step("creates an agent chat", async () => {
await createAgentChatFromLauncher(page);
});
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" });
await workspace.navigateTo();
await test.step("creates a terminal in the worktree", async () => {
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, workspace.repoPath);
});
@@ -1,6 +1,5 @@
import { randomUUID } from "node:crypto";
import { execSync, spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import type { ChildProcess } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -23,6 +22,7 @@ import {
submitNewWorkspaceEmpty,
} from "../support/helpers/new-workspace";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { killProcessTree, spawnTsx } from "../support/helpers/spawn-node";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
@@ -195,9 +195,11 @@ async function getAvailablePort(): Promise<number> {
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
const startedAt = Date.now();
let lastConnectionError: unknown = null;
while (Date.now() - startedAt < 20_000) {
if (child.exitCode !== null) {
throw new Error(`Restart test daemon exited before listening (exit ${child.exitCode}).`);
while (Date.now() - startedAt < 90_000) {
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(
`Restart test daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).`,
);
}
try {
await new Promise<void>((resolve, reject) => {
@@ -226,21 +228,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
);
}
async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}, 5000);
try {
await once(child, "exit");
} finally {
clearTimeout(timeout);
}
}
async function startRestartDaemon(input: {
paseoHome: string;
origin: string;
@@ -251,8 +238,7 @@ async function startRestartDaemon(input: {
}
const serverDir = path.resolve(__dirname, "../../../server");
const tsxBin = execSync("which tsx").toString().trim();
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
const child = spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], {
cwd: serverDir,
env: withDisabledE2ESpeechEnv({
...process.env,
@@ -276,7 +262,7 @@ async function startRestartDaemon(input: {
try {
await waitForServer(port, child);
} catch (error) {
await stopProcess(child);
await killProcessTree(child);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error },
@@ -285,7 +271,7 @@ async function startRestartDaemon(input: {
return {
port,
close: () => stopProcess(child),
close: () => killProcessTree(child),
};
}
@@ -370,22 +356,6 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
return decodeWorkspaceIdFromPathSegment(match[1]);
}
async function expectWorkspaceRowHasOnlyIndicator(
page: Page,
input: { serverId: string; workspaceId: string; indicator: string },
) {
const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
for (const indicator of ["attention", "done", "failed", "loading", "needs_input", "running"]) {
const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`);
if (indicator === input.indicator) {
await expect(locator).toBeVisible({ timeout: 30_000 });
} else {
await expect(locator).toHaveCount(0);
}
}
}
async function expectWorkspaceRowDoesNotShowIndicator(
page: Page,
input: { serverId: string; workspaceId: string; indicator: string },
@@ -458,11 +428,6 @@ test.describe("Workspace model restart regressions", () => {
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
await waitForSidebarHydration(page);
await expectWorkspaceRowHasOnlyIndicator(page, {
serverId,
workspaceId: seeded.workspaceA,
indicator: "running",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
serverId,
workspaceId: seeded.workspaceB,
@@ -1,5 +1,5 @@
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import type { Locator } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { expectComposerVisible } from "../support/helpers/composer";
@@ -35,10 +35,22 @@ async function expectSameRenderedNode(
);
}
async function expectNodeConnected(node: Awaited<ReturnType<typeof captureRenderedNode>>) {
expect(await node.evaluate((candidate) => candidate.isConnected)).toBe(true);
}
async function getSettingsShortcut(page: Page) {
return page.evaluate(() =>
navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,",
);
}
async function waitForWorkspaceRoute(page: Page, route: string) {
await page.waitForURL((url) => url.pathname === route);
}
test.describe("Workspace pane mounting", () => {
test("opening the first split pane keeps the existing agent composer mounted", async ({
page,
}) => {
test("workspace navigation keeps the existing agent composer mounted", async ({ page }) => {
test.setTimeout(90_000);
const serverId = getServerId();
@@ -65,55 +77,35 @@ test.describe("Workspace pane mounting", () => {
.first()
.elementHandle();
expect(originalComposer).not.toBeNull();
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
await test.step("opening the first split pane preserves the composer", async () => {
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
emptyPanesBeforeSplit + 1,
{ timeout: 30_000 },
);
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(1);
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
expect(originalStillConnected).toBe(true);
} finally {
await workspace.cleanup();
}
});
test("opening Settings hides workspace overlays and keeps the agent composer mounted", async ({
page,
}) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({ repoPrefix: "settings-pane-retention-" });
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-pane-retention-${Date.now()}`,
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
emptyPanesBeforeSplit + 1,
{ timeout: 30_000 },
);
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
1,
);
await expectNodeConnected(originalComposer!);
});
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
await waitForWorkspaceTabsVisible(page);
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
const originalComposer = await captureRenderedNode(composer);
await test.step("desktop Settings closes overlays and preserves the composer", async () => {
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
await tab.click({ button: "right" });
await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click();
const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`);
await expect(renameInput).toBeVisible();
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
await tab.click({ button: "right" });
await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click();
const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`);
await expect(renameInput).toBeVisible();
const settingsShortcut = await page.evaluate(() =>
navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,",
);
await page.keyboard.press(settingsShortcut);
await expect(page).toHaveURL(/\/settings\/general$/);
await expect(renameInput).not.toBeVisible();
await clickSettingsBackToWorkspace(page);
await expectSameRenderedNode(originalComposer, composer);
const settingsShortcut = await getSettingsShortcut(page);
await page.keyboard.press(settingsShortcut);
await expect(page).toHaveURL(/\/settings\/general$/);
await expect(renameInput).not.toBeVisible();
await clickSettingsBackToWorkspace(page);
await expectSameRenderedNode(originalComposer!, composer);
});
} finally {
await workspace.cleanup();
}
@@ -140,18 +132,17 @@ test.describe("Workspace pane mounting", () => {
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
const originalComposer = await captureRenderedNode(composer);
await openCompactSettings(page, buildHostWorkspaceRoute(serverId, workspace.workspaceId));
const workspaceRoute = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
await openCompactSettings(page, workspaceRoute);
await page.goBack();
await page.waitForURL(
(url) => url.pathname === buildHostWorkspaceRoute(serverId, workspace.workspaceId),
);
await waitForWorkspaceRoute(page, workspaceRoute);
await expectSameRenderedNode(originalComposer, composer);
} finally {
await workspace.cleanup();
}
});
test("switching away from a terminal tab keeps its emulator mounted", async ({ page }) => {
test("workspace navigation keeps the terminal emulator mounted", async ({ page }) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({ repoPrefix: "terminal-pane-retention-" });
@@ -170,39 +161,20 @@ test.describe("Workspace pane mounting", () => {
const terminalSurface = terminalSurfaceLocator(page);
const originalTerminal = await captureRenderedNode(terminalSurface);
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
await expectComposerVisible(page);
expect(await originalTerminal.evaluate((node) => node.isConnected)).toBe(true);
await test.step("switching tabs preserves the terminal", async () => {
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
await expectComposerVisible(page);
await expectNodeConnected(originalTerminal);
await clickFirstTerminalTab(page);
await expectSameRenderedNode(originalTerminal, terminalSurface);
} finally {
await workspace.cleanup();
}
});
test("opening Settings and returning keeps the terminal emulator mounted", async ({ page }) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({ repoPrefix: "settings-terminal-retention-" });
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-terminal-retention-${Date.now()}`,
await clickFirstTerminalTab(page);
await expectSameRenderedNode(originalTerminal, terminalSurface);
});
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
await waitForWorkspaceTabsVisible(page);
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
const terminalSurface = terminalSurfaceLocator(page);
const originalTerminal = await captureRenderedNode(terminalSurface);
await openSettings(page);
await clickSettingsBackToWorkspace(page);
await expectSameRenderedNode(originalTerminal, terminalSurface);
await test.step("opening Settings preserves the terminal", async () => {
await openSettings(page);
await clickSettingsBackToWorkspace(page);
await expectSameRenderedNode(originalTerminal, terminalSurface);
});
} finally {
await workspace.cleanup();
}
@@ -1,4 +1,4 @@
import { test, expect } from "../support/fixtures";
import { daemonTest, test, expect } from "../support/fixtures";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
closeSetupTab,
@@ -117,7 +117,7 @@ test.describe("Workspace setup streaming", () => {
}
});
test("streams running and completed setup snapshots for a successful setup", async () => {
daemonTest("streams running and completed setup snapshots for a successful setup", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-success-", {
paseoConfig: {
@@ -204,7 +204,7 @@ test.describe("Workspace setup streaming", () => {
}
});
test("emits a completed empty snapshot when no setup commands exist", async () => {
daemonTest("emits a completed empty snapshot when no setup commands exist", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-none-");
@@ -283,7 +283,7 @@ test.describe("Workspace setup streaming", () => {
}
});
test("launches workspace scripts through an explicit daemon request", async () => {
daemonTest("launches workspace scripts through an explicit daemon request", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-scripts-", {
paseoConfig: {
@@ -39,28 +39,6 @@ test.describe("Workspace archive with worktree backing", () => {
await tempRepo?.cleanup().catch(() => undefined);
});
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `archive-${Date.now()}`,
});
createdWorktreeDirectories.add(worktree.workspaceDirectory);
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
});
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
+13 -11
View File
@@ -32,16 +32,8 @@ const metroTest = base.extend({
},
});
const test = metroTest.extend<
{
paseoE2ESetup: void;
projectOwnership: void;
outdatedDaemon: OutdatedDaemon;
desktopManagedOutdatedDaemon: OutdatedDaemon;
relayConfigOutdatedDaemon: OutdatedDaemon;
projectPickerFixture: TrackedProjectPickerFixture;
withWorkspace: WithWorkspace;
},
const daemonTest = metroTest.extend<
{ projectOwnership: void },
{ e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
>({
e2eForkProviders: [[], { scope: "worker", option: true }],
@@ -94,6 +86,16 @@ const test = metroTest.extend<
},
{ auto: true },
],
});
const test = daemonTest.extend<{
paseoE2ESetup: void;
outdatedDaemon: OutdatedDaemon;
desktopManagedOutdatedDaemon: OutdatedDaemon;
relayConfigOutdatedDaemon: OutdatedDaemon;
projectPickerFixture: TrackedProjectPickerFixture;
withWorkspace: WithWorkspace;
}>({
paseoE2ESetup: [
async ({ page }, provide, testInfo) => {
const daemonPort = getE2EDaemonPort();
@@ -227,4 +229,4 @@ const test = metroTest.extend<
},
});
export { test, metroTest, expect, type Page };
export { daemonTest, test, metroTest, expect, type Page };
+6 -23
View File
@@ -1,5 +1,5 @@
import { spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { killProcessTree } from "./helpers/spawn-node";
import { existsSync } from "node:fs";
import path from "node:path";
import net from "node:net";
@@ -148,28 +148,11 @@ export async function warmMetro(port: number): Promise<void> {
}
}
async function stopProcess(child: ChildProcess | null): Promise<void> {
if (!child || child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
once(child, "exit"),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
resolve();
}, 5_000);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
const appDir = path.resolve(__dirname, "../..");
const child = spawn("npx", ["expo", "start", "--web", "--port", String(port)], {
const expoCli = require.resolve("expo/bin/cli");
// Spawns Node directly to bypass Windows .cmd shim execution restrictions without shell: true.
const child = spawn(process.execPath, [expoCli, "start", "--web", "--port", String(port)], {
cwd: appDir,
env: {
...process.env,
@@ -216,11 +199,11 @@ export default async function globalSetup() {
console.log(`[e2e] Metro warmed on port ${metroPort}`);
return async () => {
await stopProcess(metroProcess);
await killProcessTree(metroProcess);
console.log("[e2e] Metro stopped");
};
} catch (error) {
await stopProcess(metroProcess);
await killProcessTree(metroProcess);
throw error;
}
}
@@ -1,27 +1,27 @@
import { expect, type Page } from "@playwright/test";
import { escapeRegex } from "./regex";
import { openChangesPanel as openWorkspaceChangesPanel } from "./workspace-tabs";
import { openChangesTreePanel } from "./workspace-tabs";
// The branch switcher lives in the Changes tab, not in the workspace header. It renders as a button whose
// accessible name carries the current branch ("Current branch: <name>. Press to
// switch branch."). Matching on the accessible name keeps these helpers tied to
// what a screen reader user hears, and it proves the panel resolved a real
// checkout directory from the opaque workspace id. Scoping to the changes header
// keeps the matcher unambiguous even when the header is shared with diff actions.
// checkout directory from the opaque workspace id. Scoping to the repository
// header keeps the matcher aligned with the row that owns repository identity.
function branchSwitcherTrigger(page: Page, branchName: string) {
return page
.getByTestId("changes-header")
.getByTestId("changes-repository-header")
.getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) })
.filter({ visible: true })
.first();
}
// Opens the fixed pane and lands on the Changes tab, where the switcher and diff live.
// Opens the fixed Explorer Changes view, where the switcher lives.
export async function openChangesPanel(page: Page): Promise<void> {
await openWorkspaceChangesPanel(page);
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
timeout: 30_000,
});
await openChangesTreePanel(page);
await expect(
page.getByTestId("changes-repository-header").filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
}
export async function expectWorkspaceBranch(page: Page, branchName: string): Promise<void> {
@@ -140,6 +140,15 @@ export async function expectActiveChatOutlinePrompt(page: Page, position: number
);
}
export async function expectActiveChatOutlinePromptMovedFrom(
page: Page,
position: number,
): Promise<void> {
const activePrompt = chatOutlineRail(page).getByRole("tab", { selected: true });
await expect(activePrompt).toHaveCount(1);
await expect(activePrompt).not.toHaveAccessibleName(new RegExp(`^${position} of `));
}
export async function expectLiveTurnPromptAboveFoldAndActive(
page: Page,
prompt: string,
@@ -1,12 +1,27 @@
import { expect, type Page } from "@playwright/test";
import { expect, type Locator, type Page } from "@playwright/test";
import type { SeededWorkspace } from "./seed-client";
import { openCommandCenter } from "./command-center";
import { openAgentRoute, type MockAgentWorkspace } from "./mock-agent";
export interface CommandCenterAgentControlChoice {
query: string;
choice: string;
}
/**
* Focuses a seeded mock agent — which is what makes its model and mode contributions register —
* opens the Command Center over it, and hands back the query input. Seeding stays in the test so
* the workspace lifecycle is owned by the `try`/`finally` that cleans it up.
*/
export async function openCommandCenterForAgent(
page: Page,
workspace: Pick<MockAgentWorkspace, "workspaceId" | "agentId">,
): Promise<Locator> {
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
const panel = await openCommandCenter(page);
return panel.getByTestId("command-center-input");
}
export async function chooseCommandCenterAgentControl(input: {
page: Page;
query: string;
@@ -1,6 +1,6 @@
import { fork, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import path from "node:path";
import { killProcessTree } from "./spawn-node";
export interface OutdatedDaemon {
endpoint: string;
@@ -55,14 +55,10 @@ export async function startOutdatedDaemon(options?: {
endpoint: ready.endpoint,
label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host",
serverId: ready.serverId,
async close() {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
await once(child, "exit");
},
close: () => killProcessTree(child),
};
} catch (error) {
child.kill("SIGTERM");
await killProcessTree(child);
throw error;
}
}
+17 -3
View File
@@ -1,4 +1,4 @@
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -19,11 +19,19 @@ async function createFakeEditorBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
let realGhPath = "";
try {
realGhPath = execSync("which gh").toString().trim();
const locator = process.platform === "win32" ? "where.exe" : "which";
const candidates = execFileSync(locator, ["gh"], { encoding: "utf8" })
.split(/\r?\n/u)
.map((candidate) => candidate.trim())
.filter(Boolean);
realGhPath =
candidates.find(
(candidate) =>
process.platform !== "win32" || !/\.(?:cmd|bat)$/iu.test(path.extname(candidate)),
) ?? "";
} catch {
// The local GitHub fixture remains usable without a system gh binary.
}
const fakeEditorSource = `#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
@@ -41,6 +49,9 @@ if (recordPath) {
const editorPath = path.join(binDir, editorCommand);
await writeFile(editorPath, fakeEditorSource);
await chmod(editorPath, 0o755);
if (process.platform === "win32") {
await writeFile(`${editorPath}.cmd`, `@node "%~dp0${editorCommand}" %*\r\n`);
}
}
const fakeGhPath = path.join(binDir, "gh");
@@ -106,6 +117,9 @@ process.exit(result.status ?? 1);
`;
await writeFile(fakeGhPath, fakeGhSource);
await chmod(fakeGhPath, 0o755);
if (process.platform === "win32") {
await writeFile(`${fakeGhPath}.cmd`, '@node "%~dp0gh" %*\r\n');
}
return binDir;
}
@@ -1,16 +1,10 @@
import { once } from "node:events";
import {
spawn,
execFileSync,
execSync,
type ChildProcess,
type SpawnOptions,
} from "node:child_process";
import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { withDisabledE2ESpeechEnv } from "./speech-env";
import { killProcessTree, spawnTsx } from "./spawn-node";
export interface IsolatedHostDaemon {
serverId: string;
@@ -48,12 +42,14 @@ async function getAvailablePort(): Promise<number> {
}
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
const deadline = Date.now() + 20_000;
const deadline = Date.now() + 90_000;
let lastError: unknown = null;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`Isolated host daemon exited before listening (exit ${child.exitCode})`);
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(
`Isolated host daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)})`,
);
}
try {
await new Promise<void>((resolve, reject) => {
@@ -81,19 +77,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
);
}
async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}, 5_000);
try {
await once(child, "exit");
} finally {
clearTimeout(timeout);
}
}
export async function startIsolatedHostDaemon(
serverId: string,
options: IsolatedHostDaemonOptions = {},
@@ -114,11 +97,17 @@ export async function startIsolatedHostDaemon(
path.join(publishedPackageRoot, "package.json"),
`${JSON.stringify({ private: true })}\n`,
);
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
try {
const npmCli = process.env.npm_execpath;
if (!npmCli || path.basename(npmCli).toLowerCase() !== "npm-cli.js") {
throw new Error(
"Published-version E2E requires npm_execpath from npm. Start it through `npm run test:e2e`.",
);
}
execFileSync(
npmCommand,
process.execPath,
[
npmCli,
"install",
"--no-audit",
"--no-fund",
@@ -128,6 +117,9 @@ export async function startIsolatedHostDaemon(
{ cwd: publishedPackageRoot, stdio: "ignore" },
);
} catch (error) {
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
await rm(publishedPackageRoot, { recursive: true, force: true });
throw error;
}
@@ -155,7 +147,6 @@ export async function startIsolatedHostDaemon(
const serverDir = publishedPackageRoot
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
: path.resolve(__dirname, "../../../../server");
const tsxBin = execSync("which tsx").toString().trim();
const spawnDaemon = async (): Promise<ChildProcess> => {
const spawnOptions: SpawnOptions = {
cwd: serverDir,
@@ -175,7 +166,7 @@ export async function startIsolatedHostDaemon(
};
const child = publishedPackageRoot
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
: spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], spawnOptions);
: spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => {
@@ -187,7 +178,7 @@ export async function startIsolatedHostDaemon(
await waitForServer(port, child);
return child;
} catch (error) {
await stopProcess(child);
await killProcessTree(child);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error },
@@ -216,13 +207,13 @@ export async function startIsolatedHostDaemon(
getPid: () => child.pid,
restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await stopProcess(child);
await killProcessTree(child);
child = await spawnDaemon();
},
close: async () => {
if (closed) return;
closed = true;
await stopProcess(child);
await killProcessTree(child);
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
+5 -2
View File
@@ -175,7 +175,7 @@ export async function sampleTabsDuringTransition(
function sample() {
const tabs = Array.from(
document.querySelectorAll<HTMLElement>(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])',
'[data-testid^="workspace-tab-"][role="button"][aria-selected]',
),
).filter((element) => element.getClientRects().length > 0);
scope.__paseoTabTrackFrames?.push(
@@ -188,7 +188,10 @@ export async function sampleTabsDuringTransition(
requestAnimationFrame(sample);
}
}
requestAnimationFrame(sample);
// Establish the known-good pre-action state synchronously. Starting on the
// next animation frame lets the action race the first sample, which can
// misclassify a not-yet-painted test harness frame as a transition blank.
sample();
}, durationMs);
await action();
await page.waitForTimeout(durationMs + 100);
@@ -1,8 +1,8 @@
import { spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { existsSync } from "node:fs";
import net from "node:net";
import path from "node:path";
import { killProcessTree } from "./spawn-node";
export interface LocalElixirRelay {
endpoint: string;
@@ -27,19 +27,6 @@ async function availablePort(): Promise<number> {
});
}
async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const force = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}, 5_000);
try {
await once(child, "exit");
} finally {
clearTimeout(force);
}
}
async function waitUntilReady(
port: number,
child: ChildProcess,
@@ -48,9 +35,9 @@ async function waitUntilReady(
const deadline = Date.now() + 60_000;
let lastError: unknown;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(
`Elixir relay exited before becoming ready (exit ${child.exitCode}).\n${recentOutput()}`,
`Elixir relay exited before becoming ready (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).\n${recentOutput()}`,
);
}
try {
@@ -70,6 +57,12 @@ async function waitUntilReady(
}
export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
if (process.platform === "win32") {
throw new Error(
"The local Elixir relay requires asdf, which is not available on Windows; the relay-deployment Playwright project is POSIX-only.",
);
}
const relayRoot =
process.env.PASEO_RELAY_CHECKOUT ?? path.resolve(__dirname, "../../../../../..", "paseo-relay");
if (!existsSync(path.join(relayRoot, "mix.exs"))) {
@@ -107,14 +100,14 @@ export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
try {
await waitUntilReady(port, child, () => output.join("\n"));
} catch (error) {
await stopProcess(child);
await killProcessTree(child);
throw error;
}
};
const stop = async () => {
if (!child) return;
await stopProcess(child);
await killProcessTree(child);
child = null;
};
@@ -42,7 +42,7 @@ export async function seedMockAgentWorkspace(
workspaceId: workspace.workspaceId,
title: options.title,
modeId: options.modeId ?? "load-test",
model: options.model ?? "ten-second-stream",
model: options.model ?? "e2e-fast-stream",
initialPrompt: options.initialPrompt,
featureValues: options.featureValues,
});
@@ -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({
timeout: 30_000,
});
const panel = await ensureSidePanel(page);
await expect(panel.getByTestId(tabId)).toHaveCount(0);
const explorer = await ensureExplorerSidebar(page);
await expect(explorer.getByTestId(tabId)).toHaveCount(0);
}
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 });
}
function sidePanel(page: Page) {
return visibleTestId(page, "workspace-side-panel").first();
function explorerSidebar(page: Page) {
return visibleTestId(page, "workspace-explorer-sidebar").first();
}
async function selectWorkspaceTab(tab: Locator): Promise<void> {
@@ -70,53 +70,54 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
await expect(tab).toHaveAttribute("aria-selected", "true");
}
/** Reveal the Side panel. It opens empty; the caller decides what goes in it. */
export async function ensureSidePanel(page: Page): Promise<Locator> {
/** Reveal the Explorer sidebar without changing its selected view. */
export async function ensureExplorerSidebar(page: Page): Promise<Locator> {
const toggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(toggle).toBeVisible({ timeout: 30_000 });
const panel = sidePanel(page);
if ((await panel.count()) === 0) {
const explorer = explorerSidebar(page);
if ((await explorer.count()) === 0) {
await toggle.click();
}
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
await expect(explorer).toBeVisible({ timeout: 30_000 });
return explorer;
}
/**
* Reveals the Side panel and brings one of its views up in it. Goes through the
* pane's own `+` menu, which is there whether the pane is empty or already loaded.
*/
async function openSidePanelView(
/** Reveals the Explorer sidebar and selects one of its fixed navigation views. */
async function openExplorerView(
page: Page,
view: { tabTestId: string; launchItemId: string; contentTestId: string; timeout?: number },
view: { tabTestId: string; contentTestId: string; timeout?: number },
): Promise<void> {
const panel = await ensureSidePanel(page);
const tab = panel.getByTestId(view.tabTestId);
if ((await tab.count()) === 0) {
await panel.getByTestId("workspace-new-tab-button").click();
await page
.getByTestId(`workspace-new-tab-menu-${view.launchItemId}`)
.filter({ visible: true })
.click();
}
await selectWorkspaceTab(tab);
const explorer = await ensureExplorerSidebar(page);
const tab = explorer.getByTestId(view.tabTestId);
await tab.click();
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
timeout: view.timeout ?? 30_000,
});
}
export async function openChangesTreePanel(page: Page): Promise<void> {
await openExplorerView(page, {
tabTestId: "explorer-sidebar-tab-changes_tree",
contentTestId: "changes-tree-panel",
});
}
export async function openChangesPanel(page: Page): Promise<void> {
await openSidePanelView(page, {
tabTestId: "workspace-tab-working_diff",
launchItemId: "changes",
contentTestId: "working-diff-panel",
await openChangesTreePanel(page);
const changedFile = page
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
.filter({ visible: true })
.first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await changedFile.click();
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
timeout: 30_000,
});
}
export async function openFilesPanel(page: Page): Promise<void> {
await openSidePanelView(page, {
tabTestId: "workspace-tab-files",
launchItemId: "files",
await openExplorerView(page, {
tabTestId: "explorer-sidebar-tab-files",
contentTestId: "file-explorer-tree-scroll",
});
}
@@ -128,12 +129,10 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
return;
}
await openSidePanelView(page, {
tabTestId: "workspace-tab-pull_request",
launchItemId: "pull-request",
contentTestId: "pr-pane",
timeout: 15_000,
});
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
await trigger.click();
await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
}
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -30,6 +30,7 @@
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
"profile:composer-typing": "node ./scripts/profile-composer-typing.mjs",
"profile:explorer-toggle": "node ./scripts/profile-explorer-toggle.mjs",
"profile:side-pane": "playwright test --config playwright.profile.config.ts side-pane-performance.spec.ts",
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
@@ -0,0 +1,388 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
const APP_SETTINGS_KEY = "@paseo:app-settings";
export interface ProfileTarget {
serverId: string;
workspaceId: string;
}
export interface RenderSummary {
id: string;
renders: number;
mounts: number;
updates: number;
actualDurationMs: number;
maximumActualDurationMs: number;
}
export interface InteractionMeasurement {
name: string;
latencyMs: number;
commits: number;
domMutations: number;
addedElements: number;
removedElements: number;
addedTestIds: string[];
removedTestIds: string[];
trackedNodes: Array<{
selector: string;
existedBefore: boolean;
connected: boolean;
sameNode: boolean;
}>;
renders: RenderSummary[];
reasons: Record<string, Record<string, number>>;
heapAfterGcBytes?: number;
}
interface BrowserMeasurementState {
startedAt: number;
selectors: string[];
originals: Array<Element | null>;
observer?: MutationObserver;
domMutations: number;
addedElements: number;
removedElements: number;
addedTestIds: Set<string>;
removedTestIds: Set<string>;
}
declare global {
interface Window {
__PASEO_SIDE_PANE_MEASUREMENT__?: BrowserMeasurementState;
}
}
function parseCliJson<T>(output: string): T {
const arrayStart = output.indexOf("[\n");
const objectStart = output.indexOf("{\n");
const start = arrayStart >= 0 ? arrayStart : objectStart;
if (start < 0) throw new Error(`Could not find JSON in CLI output: ${output}`);
return JSON.parse(output.slice(start)) as T;
}
export function resolveProfileTarget(): ProfileTarget {
const repoRoot = resolve(process.cwd(), "../..");
const serverId = (
process.env.PASEO_PROFILE_SERVER_ID ??
readFileSync(resolve(repoRoot, ".dev/paseo-home/server-id"), "utf8")
).trim();
const requestedName = process.env.PASEO_PROFILE_WORKSPACE_NAME?.trim() || "Paseo";
const requestedWorkspaceId = process.env.PASEO_PROFILE_WORKSPACE_ID?.trim();
if (requestedWorkspaceId) return { serverId, workspaceId: requestedWorkspaceId };
const output = execFileSync("npm", ["run", "cli", "--", "workspace", "ls", "--json"], {
cwd: repoRoot,
encoding: "utf8",
env: { ...process.env, FORCE_COLOR: "0" },
});
const workspaces =
parseCliJson<Array<{ workspaceId: string; name: string; cwd: string }>>(output);
const workspace = workspaces.find(
(candidate) => candidate.name === requestedName && candidate.cwd === repoRoot,
);
if (!workspace) {
throw new Error(`No Paseo workspace named ${requestedName} points at ${repoRoot}`);
}
return { serverId, workspaceId: workspace.workspaceId };
}
export async function openProfileWorkspace(page: Page, target: ProfileTarget): Promise<void> {
await page.addInitScript(() => {
const preserveRenderProfile = (original: History["pushState"]) =>
function preserveProfileParam(
this: History,
state: unknown,
unused: string,
url?: string | URL | null,
) {
if (url == null) return Reflect.apply(original, this, [state, unused, url]);
const nextUrl = new URL(String(url), location.href);
nextUrl.searchParams.set("renderProfile", "1");
return Reflect.apply(original, this, [
state,
unused,
`${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`,
]);
};
history.pushState = preserveRenderProfile(history.pushState);
history.replaceState = preserveRenderProfile(history.replaceState);
});
await page.goto("/?renderProfile=1", { waitUntil: "domcontentloaded" });
const workspaceRow = page.getByTestId(
`sidebar-workspace-row-${target.serverId}:${target.workspaceId}`,
);
await expect(workspaceRow).toBeVisible({ timeout: 60_000 });
await workspaceRow.click();
await expect(
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
).toBeVisible({
timeout: 60_000,
});
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/workspace/${target.workspaceId}\\?renderProfile=1$`));
}
export async function enableChangesOpenInSidePane(page: Page): Promise<void> {
await page.evaluate((settingsKey) => {
const persisted = JSON.parse(localStorage.getItem(settingsKey) ?? "{}") as Record<
string,
unknown
>;
const currentPreferences =
typeof persisted.openInSidePane === "object" && persisted.openInSidePane !== null
? (persisted.openInSidePane as Record<string, unknown>)
: {};
localStorage.setItem(
settingsKey,
JSON.stringify({
...persisted,
openInSidePane: { ...currentPreferences, explorerChanges: true },
}),
);
}, APP_SETTINGS_KEY);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
).toBeVisible({
timeout: 60_000,
});
}
export async function openChangesExplorer(page: Page): Promise<void> {
const explorer = page.getByTestId("workspace-explorer-sidebar");
if ((await explorer.count()) === 0) {
await page.keyboard.press("Meta+e");
}
await expect(explorer).toBeVisible({ timeout: 30_000 });
const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first();
await expect(changesTab).toBeVisible();
if ((await changesTab.getAttribute("aria-selected")) !== "true") {
await changesTab.click();
}
await expect(page.getByTestId("changes-tree-panel")).toBeVisible({ timeout: 30_000 });
await expect(changedFileRows(page).nth(1)).toBeVisible({ timeout: 30_000 });
}
export function changedFileRows(page: Page): Locator {
return page
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
.filter({ visible: true });
}
export async function selectChangedFile(page: Page, index: number): Promise<void> {
const row = changedFileRows(page).nth(index);
await expect(row).toBeVisible();
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
}
export async function expectWorkingDiffVisible(page: Page): Promise<void> {
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
timeout: 60_000,
});
await expect(page.getByTestId("git-diff-canvas").filter({ visible: true })).toBeVisible({
timeout: 60_000,
});
}
export async function closeWorkingDiffPane(page: Page): Promise<void> {
const tab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true }).first();
await expect(tab).toBeVisible();
await tab.hover();
await page
.locator('[data-testid^="workspace-working-diff-close-"]')
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toHaveCount(0);
}
export async function measureHeapAfterGc(page: Page): Promise<number> {
await page.requestGC();
return page.evaluate(() => {
const memory = performance as Performance & {
memory?: { usedJSHeapSize: number };
};
return memory.memory?.usedJSHeapSize ?? 0;
});
}
async function waitForReactIdle(page: Page, quietMs = 250): Promise<void> {
let previousCount = -1;
let unchangedSince = Date.now();
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
const count = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE__?.length ?? 0);
if (count !== previousCount) {
previousCount = count;
unchangedSince = Date.now();
} else if (Date.now() - unchangedSince >= quietMs) {
return;
}
await page.waitForTimeout(25);
}
throw new Error("React profiler did not become idle");
}
function summarizeRenders(
samples: Array<{
id: string;
phase: "mount" | "update" | "nested-update";
actualDuration: number;
}>,
): RenderSummary[] {
const summaries = new Map<string, RenderSummary>();
for (const sample of samples) {
const summary = summaries.get(sample.id) ?? {
id: sample.id,
renders: 0,
mounts: 0,
updates: 0,
actualDurationMs: 0,
maximumActualDurationMs: 0,
};
summary.renders += 1;
summary.mounts += sample.phase === "mount" ? 1 : 0;
summary.updates += sample.phase === "mount" ? 0 : 1;
summary.actualDurationMs += sample.actualDuration;
summary.maximumActualDurationMs = Math.max(
summary.maximumActualDurationMs,
sample.actualDuration,
);
summaries.set(sample.id, summary);
}
for (const summary of summaries.values()) {
summary.actualDurationMs = Math.round(summary.actualDurationMs * 100) / 100;
summary.maximumActualDurationMs = Math.round(summary.maximumActualDurationMs * 100) / 100;
}
return [...summaries.values()].sort(
(left, right) => right.actualDurationMs - left.actualDurationMs,
);
}
async function beginMeasurement(page: Page, selectors: string[]): Promise<void> {
await waitForReactIdle(page);
await page.evaluate((trackedSelectors) => {
globalThis.__PASEO_RESET_RENDER_PROFILE__?.();
const collectTestIds = (node: Node, output: Set<string>) => {
if (!(node instanceof Element)) return;
const ownTestId = node.getAttribute("data-testid");
if (ownTestId) output.add(ownTestId);
for (const element of node.querySelectorAll("[data-testid]")) {
const testId = element.getAttribute("data-testid");
if (testId) output.add(testId);
}
};
const state: BrowserMeasurementState = {
startedAt: performance.now(),
selectors: trackedSelectors,
originals: trackedSelectors.map((selector) => document.querySelector(selector)),
domMutations: 0,
addedElements: 0,
removedElements: 0,
addedTestIds: new Set(),
removedTestIds: new Set(),
};
state.observer = new MutationObserver((records) => {
state.domMutations += records.length;
for (const record of records) {
for (const node of record.addedNodes) {
state.addedElements +=
node instanceof Element ? 1 + node.querySelectorAll("*").length : 0;
collectTestIds(node, state.addedTestIds);
}
for (const node of record.removedNodes) {
state.removedElements +=
node instanceof Element ? 1 + node.querySelectorAll("*").length : 0;
collectTestIds(node, state.removedTestIds);
}
}
});
state.observer.observe(document.body, { childList: true, subtree: true });
window.__PASEO_SIDE_PANE_MEASUREMENT__ = state;
}, selectors);
}
async function finishMeasurement(page: Page, name: string): Promise<InteractionMeasurement> {
await page.evaluate(
() =>
new Promise<void>((resolveFrame) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame()));
}),
);
await waitForReactIdle(page);
const raw = await page.evaluate((measurementName) => {
const state = window.__PASEO_SIDE_PANE_MEASUREMENT__;
if (!state) throw new Error("Side-pane performance measurement was not started");
state.observer?.disconnect();
const samples = globalThis.__PASEO_RENDER_PROFILE__ ?? [];
return {
name: measurementName,
latencyMs: Math.round((performance.now() - state.startedAt) * 100) / 100,
commits: new Set(samples.map((sample) => sample.commitTime)).size,
domMutations: state.domMutations,
addedElements: state.addedElements,
removedElements: state.removedElements,
addedTestIds: [...state.addedTestIds].sort(),
removedTestIds: [...state.removedTestIds].sort(),
trackedNodes: state.selectors.map((selector, index) => {
const original = state.originals[index] ?? null;
const current = document.querySelector(selector);
return {
selector,
existedBefore: original !== null,
connected: original?.isConnected ?? false,
sameNode: original !== null && original === current,
};
}),
samples,
reasons: globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {},
};
}, name);
return {
...raw,
renders: summarizeRenders(raw.samples),
};
}
export async function measureInteraction(
page: Page,
input: {
name: string;
trackedSelectors: string[];
interact: () => Promise<void>;
expectComplete: () => Promise<void>;
},
): Promise<InteractionMeasurement> {
await beginMeasurement(page, input.trackedSelectors);
await input.interact();
await input.expectComplete();
return finishMeasurement(page, input.name);
}
export function expectTrackedNodePreserved(
measurement: InteractionMeasurement,
selector: string,
): void {
const tracked = measurement.trackedNodes.find((candidate) => candidate.selector === selector);
expect.soft(tracked).toEqual({
selector,
existedBefore: true,
connected: true,
sameNode: true,
});
}
export function expectProfilerDidNotMount(
measurement: InteractionMeasurement,
idPrefix: string,
): void {
const summaries = measurement.renders.filter((summary) => summary.id.startsWith(idPrefix));
expect.soft(summaries.length).toBeGreaterThan(0);
expect.soft(summaries.every((summary) => summary.mounts === 0)).toBe(true);
}
@@ -0,0 +1,119 @@
import { test, expect } from "@playwright/test";
import {
changedFileRows,
closeWorkingDiffPane,
enableChangesOpenInSidePane,
expectProfilerDidNotMount,
expectTrackedNodePreserved,
expectWorkingDiffVisible,
measureInteraction,
measureHeapAfterGc,
openChangesExplorer,
openProfileWorkspace,
resolveProfileTarget,
selectChangedFile,
type InteractionMeasurement,
} from "./helpers/side-pane-performance";
const MAIN_PANE = '[data-testid="workspace-pane-main"]';
const WORKING_DIFF = '[data-testid="working-diff-panel"]';
const DIFF_CANVAS = '[data-testid="git-diff-canvas-root"]';
const DIFF_TAB = '[data-testid="workspace-tab-working_diff"]';
test("side-pane diff transitions preserve mounted work and report their real React cost", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const target = resolveProfileTarget();
const reports: InteractionMeasurement[] = [];
await test.step("open the real main workspace with Changes routed beside it", async () => {
await openProfileWorkspace(page, target);
await enableChangesOpenInSidePane(page);
await openChangesExplorer(page);
expect(await changedFileRows(page).count()).toBeGreaterThanOrEqual(2);
});
await test.step("creating the side pane keeps the main pane mounted", async () => {
const report = await measureInteraction(page, {
name: "create-side-pane",
trackedSelectors: [MAIN_PANE],
interact: () => selectChangedFile(page, 0),
expectComplete: () => expectWorkingDiffVisible(page),
});
reports.push(report);
await testInfo.attach(report.name, {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
expectTrackedNodePreserved(report, MAIN_PANE);
});
await test.step("changing the selected diff file updates the existing diff in place", async () => {
const report = await measureInteraction(page, {
name: "change-diff-focus",
trackedSelectors: [MAIN_PANE, WORKING_DIFF, DIFF_CANVAS, DIFF_TAB],
interact: () => selectChangedFile(page, 1),
expectComplete: () => expectWorkingDiffVisible(page),
});
reports.push(report);
await testInfo.attach(report.name, {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
expectTrackedNodePreserved(report, MAIN_PANE);
expectTrackedNodePreserved(report, WORKING_DIFF);
expectTrackedNodePreserved(report, DIFF_CANVAS);
expectTrackedNodePreserved(report, DIFF_TAB);
expectProfilerDidNotMount(report, "WorkingDiffPanel:");
});
await test.step("repeated cold reopenings measure retained workspace work", async () => {
for (let index = 0; index < 5; index += 1) {
await closeWorkingDiffPane(page);
const report = await measureInteraction(page, {
name: `reopen-side-pane-${index + 1}`,
trackedSelectors: [MAIN_PANE],
interact: () => selectChangedFile(page, index % 2),
expectComplete: () => expectWorkingDiffVisible(page),
});
report.heapAfterGcBytes = await measureHeapAfterGc(page);
reports.push(report);
await testInfo.attach(report.name, {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
expectTrackedNodePreserved(report, MAIN_PANE);
expect(
report.renders.some(
(summary) => summary.id.startsWith("WorkingDiffPanel:") && summary.mounts > 0,
),
).toBe(true);
}
});
const artifact = {
appUrl: testInfo.project.use.baseURL,
target,
reports,
};
await testInfo.attach("side-pane-performance", {
body: JSON.stringify(artifact, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Side pane: ${JSON.stringify(
reports.map((report) => ({
name: report.name,
latencyMs: report.latencyMs,
commits: report.commits,
domMutations: report.domMutations,
addedElements: report.addedElements,
removedElements: report.removedElements,
heapAfterGcBytes: report.heapAfterGcBytes,
trackedNodes: report.trackedNodes,
renders: report.renders.slice(0, 8),
})),
)}`,
);
});
+8 -2
View File
@@ -6,6 +6,12 @@ const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
const relayDeploymentSpec = "**/relay-deployment-reconnect.real.spec.ts";
function videoMode(): "on" | "on-first-retry" | "retain-on-failure" {
if (process.env.E2E_RECORD_VIDEO === "1") return "on";
if (process.env.CI) return "on-first-retry";
return "retain-on-failure";
}
export default defineConfig({
testDir: "./e2e/browser",
globalSetup: "./e2e/support/global-setup.ts",
@@ -21,9 +27,9 @@ export default defineConfig({
reporter: [["list"]],
use: {
baseURL,
trace: "retain-on-failure",
trace: process.env.CI ? "on-first-retry" : "retain-on-failure",
screenshot: "only-on-failure",
video: process.env.E2E_RECORD_VIDEO === "1" ? "on" : "retain-on-failure",
video: videoMode(),
},
projects: [
{
+18
View File
@@ -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",
},
});
+3 -3
View File
@@ -27,7 +27,7 @@ import { useMutation } from "@tanstack/react-query";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, ChevronDown, X } from "lucide-react-native";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openSidePanelView } from "@/workspace-tabs/side-panel";
import { openExplorerSidebarView } from "@/workspace-tabs/explorer-sidebar";
import {
AssistantMessage,
SpeakMessage,
@@ -457,7 +457,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setCurrentPath: false,
});
openSidePanelView({
openExplorerSidebarView({
isCompact: isMobile,
workspaceKey: buildWorkspaceTabPersistenceKey({
serverId: resolvedServerId,
@@ -474,7 +474,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const handleToolCallOpenFile = useStableEvent((filePath: string) => {
handleInlinePathPress({ raw: filePath, path: filePath }, "side");
handleInlinePathPress({ raw: filePath, path: filePath }, "preferred");
});
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
+16 -8
View File
@@ -36,6 +36,7 @@ import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { LeftSidebar } from "@/components/left-sidebar";
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
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 { legacyFavoriteProfileMigration } from "@/agent-profiles/migration";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { updateDesktopWindowChrome } from "@/desktop/electron/window";
import { getDesktopHost } from "@/desktop/host";
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
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 { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
import { KeyboardShiftProvider } from "@/hooks/use-keyboard-shift-style";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
@@ -473,6 +475,10 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
}, [settings.theme, updateSettings]);
const isCompactLayout = useIsCompactFormFactor();
const explorerSidebarPresentation = resolveExplorerSidebarPresentation({
isCompact: isCompactLayout,
});
const usesCompactExplorerHost = explorerSidebarPresentation !== "pane";
useCompactWebViewportZoomLock(isCompactLayout);
const pathname = usePathname();
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
@@ -545,8 +551,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
{sidebarChrome}
</WindowChromeRegion>
) : null}
{isCompactLayout ? (
<CompactExplorerSidebarHost enabled={chromeEnabled}>
{usesCompactExplorerHost ? (
<CompactExplorerSidebarHost
enabled={chromeEnabled}
presentation={explorerSidebarPresentation === "dock" ? "dock" : "overlay"}
>
<WindowChromeRegion corners={chromeEnabled ? "both" : appChromeLayout.contentCorners}>
<View style={flexStyle}>{children}</View>
</WindowChromeRegion>
@@ -574,6 +583,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
</WindowChromeSafeArea>
</WindowChromeRegion>
) : null}
<DesktopWindowControls />
<FloatingPanelPortalHost />
{isCompactLayout ? sidebarChrome : null}
<DownloadToast />
@@ -665,18 +675,16 @@ function DesktopWindowControlsSync() {
const { isLoading } = useAppSettings();
const { theme } = useUnistyles();
const surface0 = theme.colors.surface0;
const foreground = theme.colors.foreground;
useEffect(() => {
if (isLoading || isNative) return;
void updateDesktopWindowControls({
void updateDesktopWindowChrome({
backgroundColor: surface0,
foregroundColor: foreground,
trafficLightOffsetY: -5,
trafficLightOffsetY: -4,
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [isLoading, surface0, foreground]);
}, [isLoading, surface0]);
return null;
}
@@ -176,7 +176,7 @@ describe("useFileLink", () => {
lineStart: undefined,
lineEnd: undefined,
},
disposition: "side",
disposition: "preferred",
},
]);
});
@@ -117,7 +117,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
});
const onPress = useStableEvent(() => {
open(stableSource, "side");
open(stableSource, "preferred");
});
const target = useMemo(() => {
@@ -58,8 +58,10 @@ import { shortenPath } from "@/utils/shorten-path";
import { useCommandCenterContributions } from "./provider";
import {
buildContributionSections,
filterAndRankBuiltInResults,
joinSubtitleParts,
moveActiveResultId,
PINNED_SECTION_BAND,
preserveActiveResultId,
projectCommandCenterRows,
type CommandCenterAgentResult,
@@ -67,6 +69,7 @@ import {
type CommandCenterListRow,
type CommandCenterResult,
type CommandCenterResultSection,
type CommandCenterSearchFields,
type CommandCenterWorkspaceResult,
} from "./results";
import { useWorkspaceFileSearch } from "./workspace-file-search";
@@ -88,7 +91,6 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
}));
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
@@ -103,41 +105,59 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
}
function matchesQuery(searchText: string, query: string): boolean {
const normalized = query.trim().toLowerCase();
return !normalized || searchText.includes(normalized);
function compareWorkspacesByTitle(
left: CommandCenterWorkspaceResult,
right: CommandCenterWorkspaceResult,
): number {
const titleDelta = left.title.localeCompare(right.title, undefined, {
numeric: true,
sensitivity: "base",
});
return titleDelta || left.subtitle.localeCompare(right.subtitle);
}
function limitDefaultCategoryResults<Result>(results: Result[], query: string): Result[] {
return query.trim() ? results : results.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
/** `cwd` is not rendered, so a path match must never outrank a match on text the user can see. */
function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearchFields {
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
}
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
function workspaceSearchFields(result: CommandCenterWorkspaceResult): CommandCenterSearchFields {
return { visible: [result.title, result.subtitle], hidden: [] };
}
/**
* Build every pinned row, in its default order. Deliberately not keyed on `query`: none of this
* work depends on what was typed, and rebuilding it per keystroke means re-running an Intl
* collation sort over every workspace.
*
* The cost is that `formatTimeAgo` is baked into the agent subtitle here, so relative timestamps
* now refresh when agents or projects change rather than on every keystroke.
*/
function useBuiltInRows(open: boolean): {
workspaces: CommandCenterWorkspaceResult[];
agents: CommandCenterAgentResult[];
} {
const { t } = useTranslation();
const { agents } = useAggregatedAgents();
const { projects } = useProjects({ enabled: open });
const showHost = useHosts().length > 1;
return useMemo(() => {
if (!open) return [];
if (!open) return { workspaces: [], agents: [] };
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
for (const project of projects) {
for (const host of project.hosts) {
for (const workspace of host.workspaces) {
if (workspace.archivingAt) continue;
const title = workspace.title ?? workspace.name;
const subtitle = joinSubtitleParts([
showHost ? host.serverName : null,
project.projectName,
workspace.currentBranch,
]);
const searchText = `${title} ${subtitle}`.toLowerCase();
allWorkspaces.push({
kind: "workspace",
id: `workspace:${host.serverId}:${workspace.id}`,
title,
subtitle,
searchText,
title: workspace.title ?? workspace.name,
subtitle: joinSubtitleParts([
showHost ? host.serverName : null,
project.projectName,
workspace.currentBranch,
]),
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
@@ -146,60 +166,66 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
}
}
}
allWorkspaces.sort((left, right) => {
const titleDelta = left.title.localeCompare(right.title, undefined, {
numeric: true,
sensitivity: "base",
});
return titleDelta || left.subtitle.localeCompare(right.subtitle);
});
allWorkspaces.sort(compareWorkspacesByTitle);
const workspaceTitleByKey = new Map(
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
);
const workspaces = limitDefaultCategoryResults(
allWorkspaces.filter((workspace) => matchesQuery(workspace.searchText, query)),
query,
);
const agentResults = limitDefaultCategoryResults(
agents
.map<CommandCenterAgentResult>((agent) => {
const title = agent.title || t("shell.commandCenter.newAgent");
const workspaceTitle = agent.workspaceId
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
: undefined;
const location = workspaceTitle ?? shortenPath(agent.cwd);
const subtitle = joinSubtitleParts([
const agentRows = agents
.map<CommandCenterAgentResult>((agent) => {
const workspaceTitle = agent.workspaceId
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
: undefined;
return {
kind: "agent",
id: `agent:${agent.serverId}:${agent.id}`,
agent,
title: agent.title || t("shell.commandCenter.newAgent"),
subtitle: joinSubtitleParts([
showHost ? agent.serverLabel : null,
location,
workspaceTitle ?? shortenPath(agent.cwd),
formatTimeAgo(agent.lastActivityAt),
]);
return {
kind: "agent",
id: `agent:${agent.serverId}:${agent.id}`,
agent,
title,
subtitle,
searchText: `${title} ${subtitle} ${agent.cwd}`.toLowerCase(),
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
},
};
})
.filter((agent) => matchesQuery(agent.searchText, query))
.sort((left, right) => sortAgents(left.agent, right.agent)),
query,
);
]),
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
},
};
})
.sort((left, right) => sortAgents(left.agent, right.agent));
return { workspaces: allWorkspaces, agents: agentRows };
}, [agents, open, projects, showHost, t]);
}
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
const { t } = useTranslation();
const rows = useBuiltInRows(open);
return useMemo(() => {
if (!open) return [];
return [
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: t("shell.commandCenter.workspaces"),
results: workspaces,
results: filterAndRankBuiltInResults(
rows.workspaces,
query,
workspaceSearchFields,
compareWorkspacesByTitle,
),
},
{
id: "agents",
band: PINNED_SECTION_BAND,
rank: 3,
title: t("shell.commandCenter.agents"),
results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields, (left, right) =>
sortAgents(left.agent, right.agent),
),
},
{ id: "agents", rank: 3, title: t("shell.commandCenter.agents"), results: agentResults },
];
}, [agents, open, projects, query, showHost, t]);
}, [open, query, rows, t]);
}
interface CommandCenterState {
@@ -231,7 +257,7 @@ function useCommandCenterState(): CommandCenterState {
const snapshot = useCommandCenterContributions();
const inputRef = useRef<EditingTextInputHandle>(null);
const previousOpenRef = useRef(open);
const [query, setQuery] = useState("");
const [query, setQueryState] = useState("");
const [activeId, setActiveId] = useState<string | null>(null);
const builtInSections = useBuiltInSections(open, query);
const {
@@ -250,10 +276,20 @@ function useCommandCenterState(): CommandCenterState {
filePath: entry.path,
title: entry.name,
subtitle: entry.directory,
searchText: entry.path.toLowerCase(),
run: () => openFile(entry.path),
}));
return [{ id: "files", rank: 4, title: t("shell.commandCenter.files"), results }];
// File rows are matched by the daemon, so they carry no client-side score and cannot be
// relevance-compared against the rows that do. Pinning them keeps the section comparator
// total: band is decided before any score is read.
return [
{
id: "files",
band: PINNED_SECTION_BAND,
rank: 4,
title: t("shell.commandCenter.files"),
results,
},
];
}, [fileSearchEntries, openFile, t]);
const contributionSections = useMemo(
() => buildContributionSections(snapshot.contributions, query),
@@ -270,6 +306,14 @@ function useCommandCenterState(): CommandCenterState {
);
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
// Editing the query re-ranks everything, so an arrow-key selection made under the previous
// query is no longer meaningful. Reset it in the same update as the query rather than in an
// effect, so there is no intermediate render holding the stale selection.
const setQuery = useCallback((next: string) => {
setQueryState(next);
setActiveId(null);
}, []);
const close = useCallback(() => setOpen(false), [setOpen]);
const select = useCallback(
(result: CommandCenterResult) => {
@@ -312,7 +356,7 @@ function useCommandCenterState(): CommandCenterState {
const timer = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(timer);
}
setQuery("");
setQueryState("");
setActiveId(null);
if (!wasOpen) return;
const element = takeCommandCenterFocusRestoreElement();
+399 -8
View File
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
import type { CommandCenterContribution } from "./contributions";
import {
buildContributionSections,
filterAndRankBuiltInResults,
joinSubtitleParts,
moveActiveResultId,
PINNED_SECTION_BAND,
preserveActiveResultId,
projectCommandCenterRows,
type CommandCenterWorkspaceResult,
@@ -29,17 +31,80 @@ function contribution(input: {
};
}
function workspace(id: string): CommandCenterWorkspaceResult {
function action(input: {
id: string;
group: string;
groupRank: number;
rank?: number;
title: string;
subtitle?: string;
keywords?: readonly string[];
visibility?: "always" | "query";
}): CommandCenterContribution {
return {
kind: "workspace",
id,
title: id,
subtitle: "host",
searchText: id,
id: input.id,
group: input.group,
groupRank: input.groupRank,
rank: input.rank ?? 0,
keywords: input.keywords ?? [],
visibility: input.visibility ?? "always",
run: () => undefined,
presentation: {
kind: "action",
title: input.title,
subtitle: input.subtitle,
sectionTitle: input.group,
},
};
}
function choice(input: {
id: string;
group: string;
groupRank: number;
rank?: number;
path: readonly [string, ...string[]];
keywords?: readonly string[];
}): CommandCenterContribution {
return {
id: input.id,
group: input.group,
groupRank: input.groupRank,
rank: input.rank ?? 0,
keywords: input.keywords ?? [],
visibility: "query",
run: () => undefined,
presentation: { kind: "choice", path: input.path, selected: false },
};
}
/** The real Actions › Schedules contribution, shape-for-shape (root-registration.tsx). */
function schedulesAction(): CommandCenterContribution {
return action({
id: "schedules",
group: "actions",
groupRank: 0,
rank: 4,
title: "Schedules",
keywords: ["schedules", "scheduled", "automation", "recurring"],
});
}
/** The real Mode › Auto mode contribution, shape-for-shape (agent-control-contributions.ts). */
function autoModeChoice(): CommandCenterContribution {
return choice({
id: "mode:auto",
group: "modes",
groupRank: 1.2,
path: ["Mode", "Auto mode"],
keywords: ["access", "permission", "approval", "mode"],
});
}
function workspace(id: string, title = id, subtitle = "host"): CommandCenterWorkspaceResult {
return { kind: "workspace", id, title, subtitle, run: () => undefined };
}
function sectionResultIds(sections: ReturnType<typeof buildContributionSections>): string[] {
const ids: string[] = [];
for (const section of sections) {
@@ -60,7 +125,13 @@ describe("Command Center result projection", () => {
const sections = buildContributionSections(contributions, "o");
const projection = projectCommandCenterRows([
...sections,
{ id: "workspaces", rank: 2, title: "Workspaces", results: [workspace("workspace:1")] },
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: "Workspaces",
results: [workspace("workspace:1")],
},
]);
expect(projection.rows.map((row) => row.key)).toEqual([
"section:models",
@@ -100,7 +171,13 @@ describe("Command Center result projection", () => {
it("keeps keyboard selection aligned with rows beyond the render window", () => {
const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`));
const projection = projectCommandCenterRows([
{ id: "workspaces", rank: 0, title: "Workspaces", results: workspaces },
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 0,
title: "Workspaces",
results: workspaces,
},
]);
let activeId: string | null = workspaces[0].id;
@@ -115,6 +192,320 @@ describe("Command Center result projection", () => {
});
});
describe("Command Center relevance ordering", () => {
it("ranks a label match above a hidden-keyword match at every prefix", () => {
// Reported bug: "Schedules" matched only via its hidden keyword "automation" yet outranked
// "Auto mode", so Enter navigated to Schedules instead of switching permission mode.
// Every prefix counts, not just the full word — you read the list while you type, and "aut"
// is a string-prefix of both "Auto mode" and "automation", so match quality alone ties them.
for (const query of ["a", "au", "aut", "auto"]) {
const sections = buildContributionSections([schedulesAction(), autoModeChoice()], query);
expect(
sections.map((section) => section.id),
`query ${query}`,
).toEqual(["modes", "actions"]);
expect(sectionResultIds(sections), `query ${query}`).toEqual(["mode:auto", "schedules"]);
}
});
it("prefers a visible label over an exact hidden keyword", () => {
const sections = buildContributionSections(
[
action({
id: "schedules",
group: "actions",
groupRank: 0,
rank: 0,
title: "Schedules",
keywords: ["automation"],
}),
action({
id: "automation-hub",
group: "actions",
groupRank: 0,
rank: 1,
title: "Automation hub",
}),
],
"automation",
);
// "automation" is an exact keyword on Schedules and only a prefix of the other label.
// The visible match still wins — the deliberate trade-off of ranking by field role first.
expect(sectionResultIds(sections)).toEqual(["automation-hub", "schedules"]);
});
it("keeps registry order when the query is empty", () => {
const shuffled = [
choice({ id: "mode:auto", group: "modes", groupRank: 1.2, path: ["Mode", "Auto mode"] }),
schedulesAction(),
action({ id: "new-agent", group: "workspace", groupRank: -1, title: "New agent" }),
];
const projection = projectCommandCenterRows(buildContributionSections(shuffled, ""));
// Query-gated choices stay hidden; the rest fall back to groupRank, unchanged by this feature.
expect(projection.selectableResults.map((result) => result.id)).toEqual([
"new-agent",
"schedules",
]);
});
it("still ranks Schedules first for 'sched'", () => {
const sections = buildContributionSections(
[
schedulesAction(),
choice({
id: "mode:auto",
group: "modes",
groupRank: 1.2,
path: ["Mode", "Auto mode"],
keywords: ["rescheduled"],
}),
],
"sched",
);
expect(sectionResultIds(sections)).toEqual(["schedules", "mode:auto"]);
});
it("orders rows within a section by match quality", () => {
const sections = buildContributionSections(
[
action({ id: "reset", group: "actions", groupRank: 0, rank: 0, title: "Reset device" }),
action({ id: "settings", group: "actions", groupRank: 0, rank: 1, title: "Settings" }),
],
"set",
);
// "Settings" is a prefix match; "Reset device" only matches mid-word.
expect(sectionResultIds(sections)).toEqual(["settings", "reset"]);
});
it("breaks an equal-score tie with registry order, not alphabetically", () => {
const sections = buildContributionSections(
[
action({ id: "z-first", group: "actions", groupRank: 0, rank: 0, title: "Auto one" }),
action({ id: "a-second", group: "actions", groupRank: 0, rank: 1, title: "Auto two" }),
],
"auto",
);
expect(sectionResultIds(sections)).toEqual(["z-first", "a-second"]);
});
it("keeps Workspaces below contributions even on a perfect match", () => {
const projection = projectCommandCenterRows([
...buildContributionSections([schedulesAction()], "auto"),
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: "Workspaces",
results: [workspace("auto")],
},
]);
expect(projection.selectableResults.map((result) => result.id)).toEqual(["schedules", "auto"]);
});
it("sorts sections handed to it out of order", () => {
const sections = buildContributionSections([schedulesAction(), autoModeChoice()], "auto");
const workspaces = {
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: "Workspaces",
results: [workspace("auto")],
};
const projection = projectCommandCenterRows([workspaces, ...sections.toReversed()]);
expect(projection.selectableResults.map((result) => result.id)).toEqual([
"mode:auto",
"schedules",
"auto",
]);
});
it("still matches a query spanning two fields", () => {
// Each token is scored against each field on its own, so "mode auto" lands one token in
// path[0] and one in path[1]. Order is not significant — the reverse matches too.
for (const query of ["mode auto", "auto mode"]) {
expect(sectionResultIds(buildContributionSections([autoModeChoice()], query)), query).toEqual(
["mode:auto"],
);
}
});
});
describe("Command Center query tokenization", () => {
const labelAsDesign = () =>
action({
id: "label:design",
group: "workspace",
groupRank: 0,
title: "Label as Design",
});
it("matches a multi-token query in any order", () => {
// The reported bug: "design" found this row and "lab des" found nothing, because the whole
// query was tested as one substring.
for (const query of ["design", "lab des", "des lab", "Label as Design"]) {
expect(sectionResultIds(buildContributionSections([labelAsDesign()], query)), query).toEqual([
"label:design",
]);
}
});
it("requires each token to be adjacent characters, not a subsequence", () => {
// Unlike the composer's slash-command list, where "/pasbab" finds "/paseo-babysit". Dropping
// the subsequence tier is what keeps "No matches" meaning no matches: this list preselects
// its first row, so a query that should find nothing must not put an action under Enter.
for (const query of ["labdes", "lbl", "lasdsgn"]) {
expect(sectionResultIds(buildContributionSections([labelAsDesign()], query)), query).toEqual(
[],
);
}
});
it("rejects the row when any single token matches nothing", () => {
expect(sectionResultIds(buildContributionSections([labelAsDesign()], "lab zzz"))).toEqual([]);
});
it("treats a repeated token as idempotent", () => {
// Each token is scored independently, so both find the same occurrence. Documented, not a bug.
expect(sectionResultIds(buildContributionSections([labelAsDesign()], "lab lab"))).toEqual([
"label:design",
]);
});
it("never drops a row that the old substring filter would have matched", () => {
// Tokens hold no whitespace and the old haystack joined fields with a space, so any token
// found in that haystack lay entirely within one field. Tokenizing can only widen.
const rows = [labelAsDesign(), schedulesAction(), autoModeChoice()];
const haystack = (row: CommandCenterContribution): string =>
[
...(row.presentation.kind === "action"
? [row.presentation.title, row.presentation.subtitle ?? ""]
: row.presentation.path),
...row.keywords,
]
.join(" ")
.toLowerCase();
for (const query of ["design", "sched", "auto mode", "label as", "recurring", "mode"]) {
const substringMatches = rows
.filter((row) => haystack(row).includes(query.toLowerCase()))
.map((row) => row.id);
const tokenMatches = new Set(sectionResultIds(buildContributionSections(rows, query)));
for (const id of substringMatches) {
expect(tokenMatches.has(id), `${query} -> ${id}`).toBe(true);
}
}
});
it("matches one token against the title and another against the subtitle", () => {
const sections = buildContributionSections(
[
action({
id: "open-changes",
group: "workspace",
groupRank: 0,
title: "Open Changes",
subtitle: "Review the working tree",
}),
],
"changes tree",
);
expect(sectionResultIds(sections)).toEqual(["open-changes"]);
});
it("ranks a visible match above one that only a hidden keyword reached", () => {
const sections = buildContributionSections(
[
action({
id: "hidden-only",
group: "actions",
groupRank: 0,
rank: 0,
title: "Unrelated",
keywords: ["label", "design"],
}),
labelAsDesign(),
],
"lab des",
);
expect(sectionResultIds(sections)).toEqual(["label:design", "hidden-only"]);
});
it("returns everything for an empty or whitespace-only query", () => {
for (const query of ["", " "]) {
expect(
sectionResultIds(buildContributionSections([labelAsDesign(), schedulesAction()], query)),
JSON.stringify(query),
).toEqual(["label:design", "schedules"]);
}
});
});
describe("filterAndRankBuiltInResults", () => {
const visibleOnly = (row: CommandCenterWorkspaceResult) => ({
visible: [row.title, row.subtitle],
hidden: [],
});
const byTitle = (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) =>
left.title.localeCompare(right.title);
it("caps an empty query and preserves the caller's order", () => {
const rows = Array.from({ length: 8 }, (_, index) => workspace(`w${index}`));
expect(
filterAndRankBuiltInResults(rows, "", visibleOnly, byTitle).map((row) => row.id),
).toEqual(["w0", "w1", "w2", "w3", "w4"]);
});
it("puts the best match first even when it sorts last alphabetically", () => {
const rows = [
workspace("a", "Zebra design system"),
workspace("b", "Alpha lab notes"),
workspace("c", "Label as Design"),
];
// Alphabetically this is Alpha, Label, Zebra. "lab des" only matches Label as Design.
expect(
filterAndRankBuiltInResults(rows, "lab des", visibleOnly, byTitle).map((row) => row.id),
).toEqual(["c"]);
});
it("ranks a title match above a match that only the hidden field reached", () => {
const rows = [workspace("hidden-hit", "Unrelated"), workspace("title-hit", "Deploy")];
const fields = (row: CommandCenterWorkspaceResult) =>
row.id === "hidden-hit"
? { visible: [row.title], hidden: ["deploy"] }
: { visible: [row.title], hidden: [] };
expect(
filterAndRankBuiltInResults(rows, "deploy", fields, byTitle).map((row) => row.id),
).toEqual(["title-hit", "hidden-hit"]);
});
it("breaks an equal score with the caller's tiebreak, not with match order", () => {
// Both titles are an exact prefix match for "task", so only the tiebreak separates them.
const rows = [workspace("second", "Task zebra"), workspace("first", "Task alpha")];
expect(
filterAndRankBuiltInResults(rows, "task", visibleOnly, byTitle).map((row) => row.id),
).toEqual(["first", "second"]);
});
it("drops a row when one token matches nothing", () => {
const rows = [workspace("a", "Label as Design")];
expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly, byTitle)).toEqual([]);
});
});
describe("joinSubtitleParts", () => {
it("joins all present parts with a middle dot", () => {
expect(joinSubtitleParts(["a", "b", "c"])).toBe("a · b · c");
+179 -19
View File
@@ -1,12 +1,53 @@
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import {
compareMatchScores,
type MatchScore,
scoreTextFields,
} from "@getpaseo/protocol/search/text-match";
import type { CommandCenterContribution } from "./contributions";
/** Sections that reorder by how well they match the query. */
export const CONTRIBUTION_SECTION_BAND = 0;
/** Sections pinned below every contribution section regardless of match quality. */
export const PINNED_SECTION_BAND = 1;
/** Every query token matched something the user can read on the row. */
const VISIBLE_FIELD_RANK = 0;
/** At least one token only matched a hidden keyword. */
const KEYWORD_FIELD_RANK = 1;
export interface CommandCenterScore {
/** VISIBLE_FIELD_RANK or KEYWORD_FIELD_RANK. Compared before match quality. */
fieldRank: number;
match: MatchScore;
}
/**
* Rank by which fields matched before ranking by how well they matched. A row whose visible
* label matches always outranks one that only matches through a hidden keyword, however good
* the keyword match is.
*
* Scoring purely by tier is not enough: "aut" is a string-prefix of both "Auto mode" and
* Schedules' hidden keyword "automation", so the tiers tie and the order falls back to group
* rank, which puts Schedules first. It only came out right at the full word "auto". Order that
* flips mid-word as you type is worse than order that is merely wrong.
*
* The cost is deliberate: an exact hidden-keyword match now loses to any visible match. No
* query over the contributions registered today hits that case — hidden keywords here are
* synonyms nothing else displays ("automation", "recurring", "repo", "shell", "hotkeys").
*/
export function compareCommandCenterScores(
left: CommandCenterScore,
right: CommandCenterScore,
): number {
return left.fieldRank - right.fieldRank || compareMatchScores(left.match, right.match);
}
export interface CommandCenterWorkspaceResult {
kind: "workspace";
id: string;
title: string;
subtitle: string;
searchText: string;
run(): void;
}
@@ -16,7 +57,6 @@ export interface CommandCenterAgentResult {
agent: AggregatedAgent;
title: string;
subtitle: string;
searchText: string;
run(): void;
}
@@ -26,7 +66,6 @@ export interface CommandCenterFileResult {
filePath: string;
title: string;
subtitle: string;
searchText: string;
run(): void;
}
@@ -34,7 +73,8 @@ export interface CommandCenterContributionResult {
kind: "contribution";
id: string;
contribution: CommandCenterContribution;
searchText: string;
/** How well this row matches the query. Absent when the query is empty. */
score?: CommandCenterScore;
run(): void | Promise<void>;
}
@@ -46,15 +86,21 @@ export type CommandCenterResult =
export interface CommandCenterResultSection {
id: string;
/** CONTRIBUTION_SECTION_BAND or PINNED_SECTION_BAND. Compared before anything else. */
band: number;
rank: number;
title?: string;
/** Best score among this section's results. Absent when the query is empty. */
score?: CommandCenterScore;
results: readonly CommandCenterResult[];
}
interface MutableCommandCenterResultSection {
id: string;
band: number;
rank: number;
title?: string;
score?: CommandCenterScore;
results: CommandCenterResult[];
}
@@ -69,9 +115,41 @@ export interface CommandCenterListProjection {
offsets: readonly number[];
}
function matchesQuery(searchText: string, query: string): boolean {
const normalized = query.trim().toLowerCase();
return !normalized || searchText.includes(normalized);
/**
* Fields a row can be matched on, split by whether the user can read them. A row that matches
* only through `hidden` still matches, but always ranks below one whose `visible` text did.
*/
export interface CommandCenterSearchFields {
visible: readonly string[];
hidden: readonly string[];
}
const MATCH_OPTIONS = { subsequence: false } as const;
/**
* The one definition of "how well did this row match", shared by every scored row kind. Null
* means the row is filtered out, so scoring and filtering are the same pass — a separate boolean
* predicate would throw this result away and force the token scan a second time.
*
* Matching is per-token (`scoreTextFields` splits on whitespace and every token must land in
* some field), so token order does not matter and `lab des` finds "Label as Design". This can
* only ever widen the result set: tokens hold no whitespace, so any token that was found in the
* old space-joined haystack lies entirely within one field and is still found here.
*
* Subsequence matching is off, unlike the composer's slash-command list. Each token has to be a
* run of adjacent characters, so `lab des` matches and `labdes` does not. It widens hard enough
* that almost any typo finds something, and this list preselects its first row: a query the user
* expects to find nothing would instead put an unrelated action under Enter.
*/
export function scoreSearchFields(
query: string,
fields: CommandCenterSearchFields,
): CommandCenterScore | null {
const visible = scoreTextFields(query, [...fields.visible], MATCH_OPTIONS);
if (visible) return { fieldRank: VISIBLE_FIELD_RANK, match: visible };
if (fields.hidden.length === 0) return null;
const match = scoreTextFields(query, [...fields.visible, ...fields.hidden], MATCH_OPTIONS);
return match ? { fieldRank: KEYWORD_FIELD_RANK, match } : null;
}
/** Join non-empty subtitle parts with " · ", dropping null/undefined/empty. */
@@ -79,12 +157,18 @@ export function joinSubtitleParts(parts: readonly (string | null | undefined)[])
return parts.filter((part): part is string => Boolean(part)).join(" · ");
}
function contributionSearchText(contribution: CommandCenterContribution): string {
const presentationText =
/**
* Field order carries no meaning: every token is scored against each field on its own, so
* ["Mode", "Auto mode"] and its reverse both match "mode auto" and "auto mode".
*/
function contributionSearchFields(
contribution: CommandCenterContribution,
): CommandCenterSearchFields {
const visible =
contribution.presentation.kind === "action"
? [contribution.presentation.title, contribution.presentation.subtitle ?? ""]
: contribution.presentation.path;
return [...presentationText, ...contribution.keywords].join(" ").toLowerCase();
: [...contribution.presentation.path];
return { visible, hidden: contribution.keywords };
}
function resultHeight(result: CommandCenterResult): number {
@@ -96,17 +180,77 @@ function resultHeight(result: CommandCenterResult): number {
return 36;
}
/**
* Order two sections. Total and transitive: the mixed case (one score present, one absent)
* cannot occur, because scores exist exactly on band-0 sections with a non-empty query, band is
* compared first so cross-band pairs never reach the score clause, and within band 0 either
* every section has a score or none does.
*/
function compareResultSections(
left: CommandCenterResultSection,
right: CommandCenterResultSection,
): number {
if (left.band !== right.band) return left.band - right.band;
if (left.score && right.score) {
const delta = compareCommandCenterScores(left.score, right.score);
if (delta !== 0) return delta;
}
return left.rank - right.rank || left.id.localeCompare(right.id);
}
function resultScore(result: CommandCenterResult): CommandCenterScore | undefined {
return result.kind === "contribution" ? result.score : undefined;
}
/** Rows shown when nothing has been typed, per pinned category. */
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
/**
* Filter and rank already-built rows. Rows in, rows out — building them needs translations,
* navigation callbacks and the project tree, none of which matching cares about, so that stays
* with the caller and this stays testable without mounting React.
*
* `tiebreak` is the caller's own ordering (alphabetical for workspaces, status and recency for
* agents). It decides equal-scoring rows, so an agent awaiting input still outranks an idle one
* that matched exactly as well.
*/
export function filterAndRankBuiltInResults<Row>(
rows: readonly Row[],
query: string,
fieldsOf: (row: Row) => CommandCenterSearchFields,
tiebreak: (left: Row, right: Row) => number,
): Row[] {
if (!query.trim()) return rows.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
const scored: { row: Row; score: CommandCenterScore }[] = [];
for (const row of rows) {
const score = scoreSearchFields(query, fieldsOf(row));
if (score) scored.push({ row, score });
}
scored.sort(
(left, right) =>
compareCommandCenterScores(left.score, right.score) || tiebreak(left.row, right.row),
);
return scored.map((entry) => entry.row);
}
export function buildContributionSections(
contributions: readonly CommandCenterContribution[],
query: string,
): CommandCenterResultSection[] {
const groups = new Map<string, MutableCommandCenterResultSection>();
const registryIndexById = new Map<string, number>();
const hasQuery = Boolean(query.trim());
for (const contribution of contributions) {
for (const [index, contribution] of contributions.entries()) {
if (contribution.visibility === "query" && !hasQuery) continue;
const searchText = contributionSearchText(contribution);
if (!matchesQuery(searchText, query)) continue;
let score: CommandCenterScore | undefined;
if (hasQuery) {
// The score is the filter: null means no token found a home, so the row is out.
const matched = scoreSearchFields(query, contributionSearchFields(contribution));
if (!matched) continue;
score = matched;
}
const existing = groups.get(contribution.group);
const title =
contribution.presentation.kind === "action"
@@ -114,23 +258,39 @@ export function buildContributionSections(
: undefined;
const section = existing ?? {
id: contribution.group,
band: CONTRIBUTION_SECTION_BAND,
rank: contribution.groupRank,
title,
results: [],
};
registryIndexById.set(contribution.id, index);
section.results.push({
kind: "contribution",
id: contribution.id,
contribution,
searchText,
score,
run: contribution.run,
});
if (score && (!section.score || compareCommandCenterScores(score, section.score) < 0)) {
section.score = score;
}
groups.set(contribution.group, section);
}
return [...groups.values()].sort(
(left, right) => left.rank - right.rank || left.id.localeCompare(right.id),
);
const sections = [...groups.values()];
// With no query there is nothing to rank by, so leave the registry order untouched.
if (!hasQuery) return sections;
for (const section of sections) {
section.results.sort((left, right) => {
const leftScore = resultScore(left);
const rightScore = resultScore(right);
const delta = leftScore && rightScore ? compareCommandCenterScores(leftScore, rightScore) : 0;
if (delta !== 0) return delta;
return (registryIndexById.get(left.id) ?? 0) - (registryIndexById.get(right.id) ?? 0);
});
}
return sections.sort(compareResultSections);
}
export function projectCommandCenterRows(
@@ -138,7 +298,7 @@ export function projectCommandCenterRows(
): CommandCenterListProjection {
const populated = sections
.filter((section) => section.results.length > 0)
.sort((left, right) => left.rank - right.rank || left.id.localeCompare(right.id));
.sort(compareResultSections);
const rows: CommandCenterListRow[] = [];
const selectableResults: CommandCenterResult[] = [];
const rowIndexByResultId = new Map<string, number>();
@@ -61,9 +61,8 @@ function source(gitActions: GitActions): {
moveTabUp: "Move tab up",
moveTabDown: "Move tab down",
closePane: "Close pane",
togglePaneMaximization: "Toggle pane maximization",
toggleFocusMode: "Toggle focus mode",
toggleSidePanel: "Toggle side panel",
toggleExplorerSidebar: "Toggle side panel",
},
icons: {},
shortcuts: {},
@@ -40,9 +40,8 @@ export interface WorkspaceCommandCenterLabels {
moveTabUp: string;
moveTabDown: string;
closePane: string;
togglePaneMaximization: string;
toggleFocusMode: string;
toggleSidePanel: string;
toggleExplorerSidebar: string;
}
export interface WorkspaceCommandCenterIcons {
@@ -62,9 +61,8 @@ export interface WorkspaceCommandCenterIcons {
copy?: CommandCenterIcon;
focusPane?: CommandCenterIcon;
moveTab?: CommandCenterIcon;
maximize?: CommandCenterIcon;
focusMode?: CommandCenterIcon;
sidePanel?: CommandCenterIcon;
explorerSidebar?: CommandCenterIcon;
git?(action: GitAction): CommandCenterIcon | undefined;
}
@@ -78,9 +76,8 @@ export interface WorkspaceCommandCenterShortcuts {
nextTab?: ShortcutKey[][];
closeCurrentTab?: ShortcutKey[][];
closePane?: ShortcutKey[][];
togglePaneMaximization?: ShortcutKey[][];
toggleFocusMode?: ShortcutKey[][];
toggleSidePanel?: ShortcutKey[][];
toggleExplorerSidebar?: ShortcutKey[][];
}
export interface WorkspaceCommandCenterSource {
@@ -198,7 +195,9 @@ function buildPanelContributions(
}),
);
if (!source.capabilities.canSplitPanes) continue;
for (const [placementIndex, placement] of (["side-panel", "focused-pane"] as const).entries()) {
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}`,
@@ -447,15 +446,6 @@ function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCe
shortcutKeys: source.shortcuts.closePane,
action: { id: "workspace.pane.close", scope: "workspace" },
},
{
id: "pane:maximize-toggle",
rank: 61,
title: source.labels.togglePaneMaximization,
keywords: ["pane", "maximize", "restore", "toggle"],
icon: source.icons.maximize,
shortcutKeys: source.shortcuts.togglePaneMaximization,
action: { id: "workspace.explorer.maximize.toggle", scope: "workspace" },
},
{
id: "pane:focus-mode-toggle",
rank: 62,
@@ -522,12 +512,12 @@ export function buildWorkspaceCommandCenterContributions(
...buildActiveTabContributions(source),
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
buildQueryAction(source, {
id: "side-panel:toggle",
id: "explorer-sidebar:toggle",
rank: 70,
title: source.labels.toggleSidePanel,
keywords: ["side", "panel", "toggle", "show", "hide"],
icon: source.icons.sidePanel,
shortcutKeys: source.shortcuts.toggleSidePanel,
title: source.labels.toggleExplorerSidebar,
keywords: ["explorer", "sidebar", "toggle", "show", "hide"],
icon: source.icons.explorerSidebar,
shortcutKeys: source.shortcuts.toggleExplorerSidebar,
action: { id: "sidebar.toggle.right", scope: "workspace" },
}),
];
@@ -131,6 +131,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
openWorkspaceFileFromExplorer({
filePath: path,
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
closeExplorerAfterOpen: true,
showMobileAgent: usePanelStore.getState().showMobileAgent,
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
useWorkspaceLayoutStore.getState().openTab({
@@ -11,7 +11,6 @@ import {
GitCompareArrows,
GitPullRequest,
Globe,
Maximize2,
Move,
PanelRight,
Pencil,
@@ -64,14 +63,13 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
copy: getCommandCenterIcon(Copy),
focusPane: getCommandCenterIcon(Focus),
moveTab: getCommandCenterIcon(Move),
maximize: getCommandCenterIcon(Maximize2),
focusMode: getCommandCenterIcon(ArrowDownToLine),
sidePanel: getCommandCenterIcon(PanelRight),
explorerSidebar: getCommandCenterIcon(PanelRight),
};
const OPEN_PANEL_LABEL_KEYS = {
supporting: "shell.commandCenter.open",
"side-panel": "shell.commandCenter.openInSidePanel",
"side-pane": "shell.commandCenter.openInSidePane",
"focused-pane": "shell.commandCenter.openInFocusedPane",
} as const;
@@ -152,9 +150,8 @@ export function useWorkspaceCommandCenterActions(): void {
moveTabUp: t("settings.shortcuts.help.moveTabUp"),
moveTabDown: t("settings.shortcuts.help.moveTabDown"),
closePane: t("settings.shortcuts.help.closePane"),
togglePaneMaximization: t("settings.shortcuts.help.toggleExplorerPaneMaximization"),
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
toggleSidePanel: t("workspace.tabs.sidePanel.toggle"),
toggleExplorerSidebar: t("workspace.tabs.explorerSidebar.toggle"),
},
icons: {
...WORKSPACE_COMMAND_CENTER_ICONS,
@@ -31,9 +31,7 @@ export function resolveWorkspaceCommandCenterShortcuts({
resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined,
closePane:
resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined,
togglePaneMaximization:
resolveShortcutKeysForAction("workspace-explorer-maximize", overrides, platform) ?? undefined,
toggleSidePanel:
toggleExplorerSidebar:
resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined,
};
}
@@ -1,7 +1,7 @@
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 { ChevronDown, GitBranch } from "lucide-react-native";
import { GitBranch } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
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 { useToast } from "@/contexts/toast-context";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
import { ToolbarLabelSelectTrigger } from "@/components/ui/toolbar-label-trigger";
interface BranchSwitcherProps {
currentBranchName: string | null;
@@ -23,9 +24,7 @@ interface BranchSwitcherProps {
const foregroundMutedIconColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedChevronDown = withUnistyles(ChevronDown);
export function BranchSwitcher({
currentBranchName,
@@ -56,14 +55,6 @@ export function BranchSwitcher({
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(
() => <ThemedGitBranch size={14} uniProps={foregroundMutedIconColorMapping} />,
[],
@@ -90,21 +81,16 @@ export function BranchSwitcher({
<View ref={anchorRef} collapsable={false} style={styles.anchor}>
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
<ToolbarLabelSelectTrigger
testID={testID}
label={currentBranchName}
open={isOpen}
onPress={handleOpen}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("branchSwitcher.currentBranch", {
branchName: currentBranchName,
})}
>
<ThemedGitBranch size={12} uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.branchLabel} numberOfLines={1}>
{currentBranchName}
</Text>
<ThemedChevronDown size={12} uniProps={foregroundMutedIconColorMapping} />
</Pressable>
/>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{t("branchSwitcher.triggerTooltip")}</Text>
@@ -136,25 +122,6 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1,
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: {
color: theme.colors.popoverForeground,
fontSize: theme.fontSize.sm,
@@ -1,9 +1,12 @@
import { type ReactNode, useCallback, useEffect, useMemo, useRef } from "react";
import { View } from "react-native";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View, type LayoutChangeEvent } from "react-native";
import { GestureDetector } from "react-native-gesture-handler";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
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 { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
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 { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { isWeb } from "@/constants/platform";
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
import {
resolveCompactExplorerSidebarHostModel,
type CompactExplorerSidebarHostModel,
@@ -94,10 +98,16 @@ function useActiveCompactExplorerSidebarModel(
interface CompactExplorerSidebarHostProps {
children: ReactNode;
enabled: boolean;
presentation: "overlay" | "dock";
}
export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) {
export function CompactExplorerSidebarHost({
children,
enabled,
presentation,
}: CompactExplorerSidebarHostProps) {
const model = useActiveCompactExplorerSidebarModel(enabled);
const [containerWidth, setContainerWidth] = useState(0);
const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
@@ -127,14 +137,54 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
openWorkspaceFileFromExplorer({
filePath,
persistenceKey: model.persistenceKey,
closeExplorerAfterOpen: presentation === "overlay",
showMobileAgent,
openWorkspaceTabInFocusedPane,
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 (
<>
<CompactExplorerOpenGestureSurface
@@ -143,15 +193,7 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
>
{children}
</CompactExplorerOpenGestureSurface>
{enabled && model ? (
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
onOpenFile={handleOpenFile}
/>
) : null}
{explorer}
</>
);
}
@@ -160,4 +202,8 @@ const styles = {
fill: {
flex: 1,
},
row: {
flex: 1,
flexDirection: "row",
},
} 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 { 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
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 { PullRequestContent } from "@/panels/pull-request";
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 {}
@@ -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 {
tab: ExplorerTab;
active: boolean;
@@ -230,7 +352,7 @@ function ExplorerSidebarContent({
<ExplorerTabButton
tab="changes"
active={resolvedTab === "changes"}
label={t("workspace.tabs.sidePanel.changes")}
label={t("workspace.tabs.explorerSidebar.changes")}
onTabPress={onTabPress}
testID="explorer-tab-changes"
/>
@@ -238,7 +360,7 @@ function ExplorerSidebarContent({
<ExplorerTabButton
tab="files"
active={resolvedTab === "files"}
label={t("workspace.tabs.sidePanel.files")}
label={t("workspace.tabs.explorerSidebar.files")}
onTabPress={onTabPress}
testID="explorer-tab-files"
/>
@@ -268,7 +390,7 @@ function ExplorerSidebarContent({
nativeID="explorer-close"
accessible
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.sidePanel.close")}
accessibilityLabel={t("workspace.tabs.explorerSidebar.close")}
hitSlop={8}
>
{({ hovered, pressed }) => (
@@ -335,7 +457,6 @@ function ChangedFilesPane({
);
return (
<ChangesSurface
host="explorer"
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
@@ -370,6 +491,18 @@ function FilesPane({
const PrTabContent = PullRequestContent;
const styles = StyleSheet.create((theme) => ({
nativeDock: {
position: "relative",
height: "100%",
minHeight: 0,
overflow: "hidden",
},
nativeDockContent: {
position: "relative",
flex: 1,
minHeight: 0,
borderLeftWidth: 1,
},
sidebarContent: {
flex: 1,
minHeight: 0,
@@ -17,15 +17,20 @@ import { isNative } from "@/constants/platform";
* 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 = {
...titlebarDragSurfaceStyle,
top: 0,
left: 0,
display: "block",
position: "absolute",
width: "100%",
height: "100%",
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "drag",
};
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 {
ArrowRightToLine,
Copy,
CopyPlus,
Download,
@@ -25,8 +26,11 @@ import {
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
type FileActionGroup = "create" | "open" | "reference" | "manage" | "destructive";
interface FileAction {
key: string;
group: FileActionGroup;
label: string;
icon: LucideIcon;
onSelect: () => void;
@@ -35,10 +39,19 @@ interface FileAction {
testID?: string;
}
function optionalFileAction(
available: boolean,
onSelect: (() => void) | undefined,
action: Omit<FileAction, "onSelect">,
): FileAction | null {
return available && onSelect ? { ...action, onSelect } : null;
}
interface FileActionsContextMenuContentProps {
fileKind: "file" | "directory";
fileExists?: boolean;
onOpenFile?: () => void;
onOpenToSide?: () => void;
onCopyPath?: () => void;
onCopyRelativePath?: () => void;
onReveal?: () => void;
@@ -52,8 +65,6 @@ interface FileActionsContextMenuContentProps {
onDuplicate?: () => void;
onRevert?: () => void;
onDelete?: () => void;
/** Optional metadata block rendered above the actions (e.g. size/modified). */
header?: ReactNode;
testIDPrefix?: string;
}
@@ -65,6 +76,7 @@ export function FileActionsContextMenuContent({
fileKind,
fileExists = true,
onOpenFile,
onOpenToSide,
onCopyPath,
onCopyRelativePath,
onReveal,
@@ -78,7 +90,6 @@ export function FileActionsContextMenuContent({
onDuplicate,
onRevert,
onDelete,
header,
testIDPrefix,
}: FileActionsContextMenuContentProps): ReactElement | null {
const { t } = useTranslation();
@@ -88,6 +99,7 @@ export function FileActionsContextMenuContent({
onNewFile
? {
key: "new-file",
group: "create",
label: t("workspace.fileActions.newFile"),
icon: FilePlus,
onSelect: onNewFile,
@@ -96,6 +108,7 @@ export function FileActionsContextMenuContent({
onNewFolder
? {
key: "new-folder",
group: "create",
label: t("workspace.fileActions.newFolder"),
icon: FolderPlus,
onSelect: onNewFolder,
@@ -104,6 +117,7 @@ export function FileActionsContextMenuContent({
onCollapseFolder
? {
key: "collapse-folder",
group: "open",
label: t("workspace.fileActions.collapseFolder"),
icon: FolderMinus,
onSelect: onCollapseFolder,
@@ -112,30 +126,22 @@ export function FileActionsContextMenuContent({
availableFile && onOpenFile
? {
key: "open-file",
group: "open",
label: t("workspace.fileActions.openFile"),
icon: FileText,
onSelect: onOpenFile,
}
: null,
onRename
? {
key: "rename",
label: t("workspace.fileActions.rename"),
icon: Pencil,
onSelect: onRename,
}
: null,
onDuplicate
? {
key: "duplicate",
label: t("workspace.fileActions.duplicate"),
icon: CopyPlus,
onSelect: onDuplicate,
}
: null,
optionalFileAction(availableFile, onOpenToSide, {
key: "open-to-side",
group: "open",
label: t("workspace.fileActions.openToSide"),
icon: ArrowRightToLine,
}),
onCopyPath
? {
key: "copy-path",
group: "reference",
label: t("workspace.fileActions.copyPath"),
icon: Copy,
onSelect: onCopyPath,
@@ -144,6 +150,7 @@ export function FileActionsContextMenuContent({
onCopyRelativePath
? {
key: "copy-relative-path",
group: "reference",
label: t("workspace.fileActions.copyRelativePath"),
icon: Copy,
onSelect: onCopyRelativePath,
@@ -152,6 +159,7 @@ export function FileActionsContextMenuContent({
onReveal && revealTargetName
? {
key: "reveal",
group: "reference",
label: t("workspace.fileActions.revealIn", { target: revealTargetName }),
icon: FolderOpen,
onSelect: onReveal,
@@ -160,6 +168,7 @@ export function FileActionsContextMenuContent({
availableFile && onDownload
? {
key: "download",
group: "reference",
label: t("workspace.fileActions.download"),
icon: Download,
onSelect: onDownload,
@@ -168,14 +177,34 @@ export function FileActionsContextMenuContent({
availableFile && onAddToChat
? {
key: "add-to-chat",
group: "reference",
label: t("workspace.fileActions.addToChat"),
icon: MessageSquarePlus,
onSelect: onAddToChat,
}
: 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
? {
key: "revert",
group: "destructive",
label: t("workspace.fileActions.revert"),
icon: Undo2,
onSelect: onRevert,
@@ -185,6 +214,7 @@ export function FileActionsContextMenuContent({
onDelete
? {
key: "delete",
group: "destructive",
label: t("workspace.fileActions.delete"),
icon: Trash2,
onSelect: onDelete,
@@ -195,9 +225,7 @@ export function FileActionsContextMenuContent({
const availableActions = specs.filter((action): action is FileAction => action !== null);
return availableActions.map((action, index) =>
Object.assign(action, {
separatorBefore: Boolean(
action.destructive && index > 0 && !availableActions[index - 1].destructive,
),
separatorBefore: index > 0 && action.group !== availableActions[index - 1]?.group,
testID: testIDPrefix ? `${testIDPrefix}-${action.key}` : undefined,
}),
);
@@ -214,6 +242,7 @@ export function FileActionsContextMenuContent({
onNewFile,
onNewFolder,
onOpenFile,
onOpenToSide,
onRename,
onReveal,
onRevert,
@@ -231,12 +260,6 @@ export function FileActionsContextMenuContent({
width={220}
testID={testIDPrefix ? `${testIDPrefix}-context-menu` : undefined}
>
{header ? (
<>
{header}
<ContextMenuSeparator />
</>
) : null}
{actions.map((action) => (
<Fragment key={action.key}>
{action.separatorBefore ? <ContextMenuSeparator /> : null}
@@ -41,7 +41,9 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
PaneContentToolbar,
paneContentToolbarIconSize,
paneContentToolbarIconButtonStyle,
paneContentToolbarTrailingPadding,
ToolbarButton,
ToolbarControls,
} from "@/components/ui/pane-content-toolbar";
import {
useOverlayFlatListScrollbar,
@@ -61,7 +63,6 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useFileExplorerActions } 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 { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { isHiddenExplorerPath } from "@/file-explorer/visibility";
import {
@@ -100,16 +101,6 @@ function DirectoryChevronIcon({ loading, expanded }: { loading: boolean; expande
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 {
serverId: string;
workspaceId?: string | null;
@@ -126,6 +117,7 @@ interface TreeRowItemProps {
revealTargetName?: string;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
onOpenFileToSide?: (path: string) => void;
onNewEntry?: (parentPath: string, kind: "file" | "directory") => void;
onCollapseDirectory?: (path: string) => void;
onRenameEntry?: (entry: ExplorerEntry) => void;
@@ -141,13 +133,6 @@ function sortTriggerStyle({
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: "entry"; row: ExplorerTreeRow }
| { type: "draft"; parentPath: string; kind: "file" | "directory"; depth: number }
@@ -256,6 +241,7 @@ function TreeRowItem({
revealTargetName,
onDownloadEntry,
onAddToChat,
onOpenFileToSide,
onNewEntry,
onCollapseDirectory,
onRenameEntry,
@@ -263,7 +249,6 @@ function TreeRowItem({
onDeleteEntry,
testID,
}: TreeRowItemProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const showNameHover = useCallback(() => setIsHovered(true), []);
const hideNameHover = useCallback(() => setIsHovered(false), []);
@@ -317,6 +302,10 @@ function TreeRowItem({
onAddToChat?.(entry.path);
}, [onAddToChat, entry.path]);
const handleOpenToSide = useCallback(() => {
onOpenFileToSide?.(entry.path);
}, [entry.path, onOpenFileToSide]);
const handleNewFile = useCallback(() => {
onNewEntry?.(entry.path, "file");
}, [onNewEntry, entry.path]);
@@ -341,30 +330,6 @@ function TreeRowItem({
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 (
<ContextMenu>
<ContextMenuTrigger
@@ -407,13 +372,13 @@ function TreeRowItem({
revealTargetName={revealTargetName}
onDownload={handleDownload}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
onOpenToSide={!isDirectory && onOpenFileToSide ? handleOpenToSide : undefined}
onNewFile={onNewEntry ? handleNewFile : undefined}
onNewFolder={onNewEntry ? handleNewFolder : undefined}
onCollapseFolder={isDirectory && isExpanded ? handleCollapseDirectory : undefined}
onRename={onRenameEntry ? handleRename : undefined}
onDuplicate={onDuplicateEntry ? handleDuplicate : undefined}
onDelete={onDeleteEntry ? handleDelete : undefined}
header={metaHeader}
testIDPrefix={testID}
/>
</ContextMenu>
@@ -425,6 +390,7 @@ interface FileExplorerPaneProps {
workspaceId?: string | null;
workspaceRoot: string;
onOpenFile?: (filePath: string) => void;
onOpenFileToSide?: (filePath: string) => void;
onAddToChat?: (path: string) => void;
}
@@ -433,6 +399,7 @@ export function FileExplorerPane({
workspaceId,
workspaceRoot,
onOpenFile,
onOpenFileToSide,
onAddToChat,
}: FileExplorerPaneProps) {
const { t } = useTranslation();
@@ -996,6 +963,7 @@ export function FileExplorerPane({
revealTargetName={fileManagerTarget?.label}
onDownloadEntry={handleDownloadEntry}
onAddToChat={onAddToChat}
onOpenFileToSide={onOpenFileToSide}
onNewEntry={fsEntryOpsEnabled ? handleNewEntry : undefined}
onCollapseDirectory={handleCollapseDirectory}
onRenameEntry={fsEntryOpsEnabled ? handleRenameEntry : undefined}
@@ -1026,6 +994,7 @@ export function FileExplorerPane({
fileManagerTarget,
selectedEntryPath,
onAddToChat,
onOpenFileToSide,
serverId,
workspaceId,
],
@@ -1049,11 +1018,6 @@ export function FileExplorerPane({
});
}, [requestDirectoryListing]);
const toolbarIconButtonStyle = useCallback(
(state: PressableStateCallbackType) => iconButtonStyle(isCompact, state),
[isCompact],
);
if (!hasWorkspaceScope) {
return (
<View style={styles.centerState}>
@@ -1087,7 +1051,6 @@ export function FileExplorerPane({
handleBackFromError={handleBackFromError}
handleRetry={handleRetry}
sortTriggerStyle={sortTriggerStyle}
iconButtonStyle={toolbarIconButtonStyle}
/>
</View>
);
@@ -1162,7 +1125,6 @@ interface FileExplorerPaneContentProps {
handleBackFromError: () => void;
handleRetry: () => void;
sortTriggerStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
iconButtonStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
}
function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
@@ -1186,7 +1148,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
handleBackFromError,
handleRetry,
sortTriggerStyle: sortTriggerStyleProp,
iconButtonStyle: iconButtonStyleProp,
} = props;
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
@@ -1204,17 +1165,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
const emptyLabel = showHiddenFiles
? t("workspace.fileExplorer.empty.noFiles")
: 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) {
return (
@@ -1245,7 +1195,10 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return (
<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
onPress={handleSortCycle}
style={sortTriggerStyleProp}
@@ -1256,86 +1209,82 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.headerActions}>
<ToolbarControls style={styles.headerActions}>
{onNewEntryAtRoot ? (
<>
<Pressable
onPress={handleNewFileAtRoot}
<ToolbarButton
label={t("workspace.fileActions.newFile")}
compact={isCompact}
hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={t("workspace.fileActions.newFile")}
testID="files-new-file"
onPress={handleNewFileAtRoot}
>
<FilePlus
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
</Pressable>
<Pressable
onPress={handleNewFolderAtRoot}
</ToolbarButton>
<ToolbarButton
label={t("workspace.fileActions.newFolder")}
compact={isCompact}
hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={t("workspace.fileActions.newFolder")}
testID="files-new-folder"
onPress={handleNewFolderAtRoot}
>
<FolderPlus
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
</Pressable>
</ToolbarButton>
</>
) : null}
<Pressable
onPress={handleToggleHiddenFiles}
<ToolbarButton
label={hiddenFilesToggleAccessibilityLabel}
selected={!showHiddenFiles}
compact={isCompact}
hitSlop={8}
style={hiddenFilesToggleStyle}
accessibilityRole="button"
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
accessibilityState={hiddenFilesToggleAccessibilityState}
testID="files-hidden-toggle"
onPress={handleToggleHiddenFiles}
>
{showHiddenFiles ? (
<Eye
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
) : (
<EyeOff
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
)}
</Pressable>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={
</ToolbarButton>
<ToolbarButton
label={
isRefreshFetching
? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh")
}
compact={isCompact}
disabled={isRefreshFetching}
hitSlop={8}
testID="files-refresh"
onPress={handleRefresh}
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
<LoadingSpinner
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
) : (
<RotateCw
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
)}
</View>
</Pressable>
</View>
</ToolbarButton>
</ToolbarControls>
</PaneContentToolbar>
<ContextMenu>
<RootCreationContextTarget enabled={Boolean(onNewEntryAtRoot)}>
@@ -1479,6 +1428,7 @@ function TreeRowDispatcher({
revealTargetName,
onDownloadEntry,
onAddToChat,
onOpenFileToSide,
onNewEntry,
onCollapseDirectory,
onRenameEntry,
@@ -1500,6 +1450,7 @@ function TreeRowDispatcher({
revealTargetName?: string;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
onOpenFileToSide?: (path: string) => void;
onNewEntry?: (parentPath: string, kind: "file" | "directory") => void;
onCollapseDirectory?: (path: string) => void;
onRenameEntry?: (entry: ExplorerEntry) => void;
@@ -1530,6 +1481,7 @@ function TreeRowDispatcher({
revealTargetName={revealTargetName}
onDownloadEntry={onDownloadEntry}
onAddToChat={onAddToChat}
onOpenFileToSide={onOpenFileToSide}
onNewEntry={onNewEntry}
onCollapseDirectory={onCollapseDirectory}
onRenameEntry={onRenameEntry}
@@ -1698,7 +1650,7 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingRight: theme.spacing[3],
backgroundColor: theme.colors.surfaceSidebar,
},
sortTrigger: {
flexDirection: "row",
@@ -1711,7 +1663,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.base,
},
sortTriggerHovered: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surfaceSidebarHover,
},
sortTriggerText: {
fontSize: theme.fontSize.sm,
@@ -1720,7 +1672,6 @@ const styles = StyleSheet.create((theme) => ({
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
treeList: {
flex: 1,
@@ -1807,38 +1758,12 @@ const styles = StyleSheet.create((theme) => ({
draftPlaceholder: {
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: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
iconButtonActive: {
backgroundColor: theme.colors.surface2,
},
refreshIcon: {
width: 16,
height: 16,
@@ -5,6 +5,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { isWeb } from "@/constants/platform";
import {
iconButtonChromeFrameStyle,
iconButtonChromeStyle,
} from "@/components/ui/icon-button-chrome";
interface HeaderToggleButtonState {
hovered: boolean;
@@ -42,7 +46,17 @@ export function HeaderToggleButton({
? ({ "aria-expanded": expandedState } as Record<string, boolean>)
: 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 (
<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) => ({
tooltipRow: {
flexDirection: "row",
@@ -90,3 +94,7 @@ const styles = StyleSheet.create((theme) => ({
},
shortcut: {},
}));
export const headerIconSlotStyle = {
slot: iconButtonChromeFrameStyle("large"),
};
@@ -10,6 +10,7 @@ import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome";
interface MenuHeaderProps {
title?: string;
@@ -26,7 +27,7 @@ interface SidebarMenuToggleProps {
const MOBILE_MENU_LINE_WIDTH = 16;
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 }) {
const lineStyle = useMemo(() => [styles.mobileMenuLine, { backgroundColor: color }], [color]);
@@ -84,19 +85,21 @@ function SidebarMenuToggleButton({
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
accessibilityState={accessibilityState}
>
{({ hovered, pressed }) => {
let color = extraMutedIdleIcon
? theme.colors.foregroundExtraMuted
: theme.colors.foregroundMuted;
if (hovered || pressed) {
color = theme.colors.foreground;
}
return isMobile ? (
<MobileMenuIcon color={color} />
) : (
<PanelLeft size={theme.iconSize.md} color={color} />
);
}}
{isMobile ? (
<MobileMenuIcon
color={
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
}
/>
) : (
<PanelLeft
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
color={
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
}
/>
)}
</HeaderToggleButton>
);
}
+1 -1
View File
@@ -1462,7 +1462,7 @@ export const AssistantMessage = memo(function AssistantMessage({
const fileLinkActions = useAssistantFileLinkActions();
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.
// We already handled it above, so return false to avoid duplicate opens.
return false;
+128 -19
View File
@@ -1,14 +1,22 @@
import { useCallback, useMemo, useRef, useState } from "react";
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 { 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 {
testID?: string;
direction: "horizontal" | "vertical";
hitAreaAlignment?: "center" | "end";
groupId: string;
index: number;
sizes: number[];
containerSize: number;
onPreviewResizeSplit: (groupId: string, sizes: number[]) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void;
}
@@ -30,14 +38,18 @@ function resetWindowHorizontalScroll() {
export function ResizeHandle({
testID,
direction,
hitAreaAlignment = "center",
groupId,
index,
sizes,
containerSize,
onPreviewResizeSplit,
onResizeSplit,
}: ResizeHandleProps) {
const { theme } = useUnistyles();
const finePointer = useHasFinePointer();
const pointerStatesRef = useRef(new Map<number, PointerState>());
const touchDragRef = useRef<ResizeHandleDrag | null>(null);
const cursorBeforeDragRef = useRef<string | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [active, setActive] = useState(false);
@@ -51,13 +63,6 @@ export function ResizeHandle({
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) {
return;
}
@@ -140,9 +145,41 @@ export function ResizeHandle({
window.addEventListener("pointerup", 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(() => {
hoverTimerRef.current = setTimeout(() => {
setActive(true);
@@ -178,11 +215,36 @@ export function ResizeHandle({
styles.hitArea,
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",
touchAction: "none",
} 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 (
@@ -194,14 +256,27 @@ export function ResizeHandle({
testID={testID ? `${testID}-highlight` : undefined}
/>
)}
<View
role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
style={hitAreaStyle}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
/>
{finePointer ? (
<View
role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
style={hitAreaStyle}
onPointerDown={handlePointerDown}
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>
);
}
@@ -210,6 +285,7 @@ const styles = StyleSheet.create((_theme) => ({
handle: {
position: "relative",
flexShrink: 0,
zIndex: 10,
},
handleHorizontal: {
width: 1,
@@ -240,15 +316,48 @@ const styles = StyleSheet.create((_theme) => ({
zIndex: 10,
},
hitAreaHorizontal: {
left: -5,
top: 0,
bottom: 0,
width: 10,
},
hitAreaVertical: {
top: -5,
left: 0,
right: 0,
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 { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import {
ExternalLink,
GitPullRequest,
Settings,
MoreVertical,
Plus,
Trash2,
} from "lucide-react-native";
import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
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 { useHostBadges } from "@/hosts/use-host-badges";
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -166,7 +160,6 @@ const projectViewKeyExtractor = (project: SidebarProjectEntry) => project.viewKe
const WORKSPACE_STATUS_DOT_WIDTH = 14;
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedPlus = withUnistyles(Plus);
const ThemedMoreVertical = withUnistyles(MoreVertical);
@@ -179,26 +172,6 @@ const foregroundColorMapping = (theme: Theme) => ({
const foregroundMutedColorMapping = (theme: Theme) => ({
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: {
selection: ActiveWorkspaceSelection | null;
@@ -352,7 +325,6 @@ export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp<ViewS
const textStyle = isHovered
? [prBadgeStyles.text, prBadgeStyles.textHovered]
: prBadgeStyles.text;
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
const presentation = getForgePresentation(normalizeForge(hint.forge));
return (
@@ -370,9 +342,9 @@ export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp<ViewS
style={pressableStyle}
>
{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}>
{hint.number}
@@ -2,15 +2,7 @@ import { Fragment, useCallback, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
ExternalLink,
Folder,
GitBranch,
GitMerge,
GitPullRequest,
GitPullRequestClosed,
Globe,
} from "lucide-react-native";
import { ExternalLink, Folder, GitBranch, Globe } from "lucide-react-native";
import {
workspaceLabelKey,
type WorkspaceLabelDefinition,
@@ -23,6 +15,7 @@ import { getForgePresentation, normalizeForge } from "@/git/forge";
import { openExternalUrl } from "@/utils/open-external-url";
import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model";
import type { Theme } from "@/styles/theme";
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
import { CheckIndicator } from "./check-indicator";
import type { CheckSummary, CheckSummaryState } from "./check-summary";
import { selectMetaRowItems, type MetaRowItem } from "./meta-items";
@@ -44,9 +37,6 @@ const META_ICON_SIZE = HOST_BADGE_ICON_SIZE;
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedFolder = withUnistyles(Folder);
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed);
const ThemedGlobe = withUnistyles(Globe);
/** 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 mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMerged });
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
/**
@@ -212,7 +201,6 @@ function PullRequestItem({ hint }: { hint: PrHint }) {
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const Icon = isHovered ? ThemedExternalLink : PR_ICONS[hint.state];
return (
<Pressable
accessibilityRole="link"
@@ -227,10 +215,11 @@ function PullRequestItem({ hint }: { hint: PrHint }) {
onHoverOut={handleHoverOut}
style={pressableItemStyle}
>
<Icon
size={META_ICON_SIZE}
uniProps={isHovered ? foregroundMapping : PR_COLOR_MAPPINGS[hint.state]}
/>
{isHovered ? (
<ThemedExternalLink size={META_ICON_SIZE} uniProps={foregroundMapping} />
) : (
<PullRequestStateIcon state={hint.state} size={META_ICON_SIZE} />
)}
<Text style={isHovered ? styles.prTextHovered : styles.prText} numberOfLines={1}>
{hint.number}
{/* 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 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 = {
merged: "workspace.git.pr.states.merged",
closed: "workspace.git.pr.states.closed",
@@ -1,5 +1,8 @@
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";
const pane = (id: string): SplitNode => ({
@@ -35,25 +38,11 @@ describe("split focus root", () => {
).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(
resolveSplitContainerRoot({
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",
}),
resolveSplitContainerRoot({ root, focusedPaneId: "left", focusModeEnabled: false }),
).toEqual({ root, usesFallbackStrip: false });
});
});
@@ -4,9 +4,8 @@ export function resolveSplitContainerRoot(input: {
root: SplitNode;
focusedPaneId: string | null;
focusModeEnabled: boolean | undefined;
maximizedPaneId?: string | null;
}): { 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 };
const isolatedPane = findPane(input.root, isolatedPaneId);
if (!isolatedPane || isolatedPane.hidden === true) {
@@ -15,6 +14,12 @@ export function resolveSplitContainerRoot(input: {
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 {
if (node.kind === "pane") return node.pane.id === paneId ? node.pane : null;
for (const child of node.group.children) {
+374 -250
View File
@@ -1,6 +1,5 @@
import {
Fragment,
memo,
useCallback,
useEffect,
useMemo,
@@ -27,7 +26,7 @@ import {
type DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { View, Text } from "react-native";
import { View, Text, type LayoutChangeEvent } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
@@ -36,10 +35,18 @@ import Animated, {
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import {
resolveExplorerSidebarDockSizes,
resolveExplorerSidebarWidth,
} from "@/components/explorer-sidebar-layout";
import { RetainedPanel } from "@/components/retained-panel";
import { resolveSplitContainerRoot } from "@/components/split-container-focus";
import {
resolveSplitContainerRoot,
splitNodeContainsPane,
} from "@/components/split-container-focus";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
import {
removeWindowChromeCorner,
WindowChromeRegion,
WindowChromeSafeArea,
useWindowChromeCorners,
@@ -59,22 +66,20 @@ import {
deriveWorkspacePaneState,
getWorkspacePaneDescriptors,
} from "@/screens/workspace/workspace-pane-state";
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import { useModifiedPanelTabIds } from "@/panels/panel-instance-attributes";
import {
WorkspacePaneContent,
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content";
import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host";
import {
WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row";
import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
} from "@/screens/workspace/workspace-tab-presentation";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
createDefaultLayout,
findPaneById,
useWorkspaceLayoutStore,
type SplitNode,
@@ -83,13 +88,13 @@ import {
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { RenderProfile } from "@/utils/render-profiler";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { isNative } from "@/constants/platform";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations";
interface SplitContainerProps {
layout: WorkspaceLayout;
renderMainHeader?: () => ReactNode;
renderExplorerSidebarHeaderAction?: () => ReactNode;
workspaceKey: string;
normalizedServerId: string;
normalizedWorkspaceId: string;
@@ -125,6 +130,7 @@ interface SplitContainerProps {
position: "left" | "right" | "top" | "bottom";
}) => void;
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
onSelectTabInPane: (paneId: string, tabId: string) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void;
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
focusModeEnabled?: boolean;
@@ -144,6 +150,7 @@ interface SplitPaneDropData {
const EMPTY_SPLIT_NODES: SplitNode[] = [];
const EMPTY_SPLIT_SIZES: number[] = [];
const EXPLORER_SIDEBAR_RESIZE_GROUP_ID = "explorer-sidebar";
function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData {
return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab";
@@ -165,18 +172,21 @@ function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData
return undefined;
}
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMoveTabToPane"> {
interface SplitNodeViewProps extends Omit<
SplitContainerProps,
"layout" | "onMoveTabToPane" | "onSelectTabInPane"
> {
node: SplitNode;
uiTabs: WorkspaceTab[];
focusedPaneId: string | null;
sidePanelPaneId: string | null;
maximizedPaneId: string | null;
onTogglePaneMaximized: (paneId: string) => void;
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
tabDropPreview: TabDropPreview | null;
windowChromeCorners: WindowChromeCorners;
maximizedPaneId: string | null;
workspaceHasSplits: boolean;
onTogglePaneMaximized: (paneId: string) => void;
}
interface SplitPaneViewProps extends Omit<
@@ -199,82 +209,6 @@ interface SplitPaneViewProps extends Omit<
tabDropPreview: TabDropPreview | null;
}
interface MountedTabSlotProps {
tabDescriptor: WorkspaceTabDescriptor;
isVisible: boolean;
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
paneId: string;
onFocusPane: (paneId: string) => void;
buildPaneContentModel: (input: {
paneId: string;
tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel;
}
const MountedTabSlot = memo(function MountedTabSlot({
tabDescriptor,
isVisible,
isWorkspaceFocused,
isPaneFocused,
paneId,
onFocusPane,
buildPaneContentModel,
}: MountedTabSlotProps) {
const content = useMemo(
() =>
buildPaneContentModel({
paneId,
tab: tabDescriptor,
}),
[buildPaneContentModel, paneId, tabDescriptor],
);
const handleFocusPane = useCallback(() => {
onFocusPane(paneId);
}, [onFocusPane, paneId]);
return (
<RenderProfile id={`DesktopMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
<RetainedPanel active={isVisible}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onFocusPane={handleFocusPane}
/>
</RetainedPanel>
</RenderProfile>
);
});
function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
const cacheRef = useRef(new Map<string, WorkspaceTabDescriptor>());
const tabDescriptorMap = useMemo(() => {
const next = new Map<string, WorkspaceTabDescriptor>();
for (const tabDescriptor of tabDescriptors) {
const cachedDescriptor = cacheRef.current.get(tabDescriptor.tabId);
if (
cachedDescriptor &&
cachedDescriptor.key === tabDescriptor.key &&
cachedDescriptor.kind === tabDescriptor.kind &&
cachedDescriptor.state === tabDescriptor.state &&
workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target)
) {
next.set(tabDescriptor.tabId, cachedDescriptor);
continue;
}
next.set(tabDescriptor.tabId, tabDescriptor);
}
return next;
}, [tabDescriptors]);
useEffect(() => {
cacheRef.current = tabDescriptorMap;
}, [tabDescriptorMap]);
return tabDescriptorMap;
}
interface DragMoveRects {
translatedRect: { left: number; top: number; width: number; height: number };
overRect: { left: number; top: number; width: number; height: number };
@@ -372,6 +306,8 @@ const dropCollisionDetection: CollisionDetection = (args) => {
export function SplitContainer({
layout,
renderMainHeader,
renderExplorerSidebarHeaderAction,
workspaceKey,
normalizedServerId,
normalizedWorkspaceId,
@@ -397,6 +333,7 @@ export function SplitContainer({
onSplitPane,
onSplitPaneEmpty,
onMoveTabToPane,
onSelectTabInPane,
onResizeSplit,
onReorderTabsInPane,
focusModeEnabled,
@@ -413,56 +350,10 @@ export function SplitContainer({
} | null>(null);
const maximizedPaneId =
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
const sidePanelPaneId = useWorkspaceLayoutStore(
(state) => state.sidePanelPaneIdByWorkspace[workspaceKey] ?? null,
const explorerSidebarPaneId = useWorkspaceLayoutStore(
(state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null,
);
useEffect(() => {
if (!maximizedPaneId) {
return;
}
const isolatedPane = findPaneById(layout.root, maximizedPaneId);
if (focusModeEnabled || !isolatedPane || isolatedPane.hidden === true) {
setMaximizedPane(null);
}
}, [focusModeEnabled, layout.root, maximizedPaneId]);
const handleTogglePaneMaximized = useCallback(
(paneId: string) => {
setMaximizedPane((currentPane) =>
currentPane?.workspaceKey === workspaceKey && currentPane.paneId === paneId
? null
: { workspaceKey, paneId },
);
},
[workspaceKey],
);
const handleExplorerMaximizeAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (action.id !== "workspace.explorer.maximize.toggle") {
return false;
}
const explorerPane = findPaneById(layout.root, sidePanelPaneId);
if (focusModeEnabled || !explorerPane || explorerPane.hidden === true) {
return true;
}
onFocusPane(explorerPane.id);
handleTogglePaneMaximized(explorerPane.id);
return true;
},
[sidePanelPaneId, focusModeEnabled, handleTogglePaneMaximized, layout.root, onFocusPane],
);
useKeyboardActionHandler({
handlerId: `workspace-explorer-maximize:${workspaceKey}`,
actions: ["workspace.explorer.maximize.toggle"] as const,
enabled: isWorkspaceFocused,
priority: 100,
isActive: () => true,
handle: handleExplorerMaximizeAction,
});
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
@@ -475,15 +366,122 @@ export function SplitContainer({
);
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
const explorerSidebarPane = useMemo(
() => findPaneById(layout.root, explorerSidebarPaneId),
[layout.root, explorerSidebarPaneId],
);
const mainRoot = useMemo(
() => removePaneFromSplitTree(layout.root, explorerSidebarPaneId),
[layout.root, explorerSidebarPaneId],
);
const workspaceHasSplits = mainRoot?.kind === "group";
useEffect(() => {
if (
maximizedPaneId &&
(focusModeEnabled ||
!workspaceHasSplits ||
!mainRoot ||
!splitNodeContainsPane(mainRoot, maximizedPaneId))
) {
setMaximizedPane(null);
}
}, [focusModeEnabled, mainRoot, maximizedPaneId, workspaceHasSplits]);
const handleTogglePaneMaximized = useCallback(
(paneId: string) => {
setMaximizedPane((current) =>
current?.workspaceKey === workspaceKey && current.paneId === paneId
? null
: { workspaceKey, paneId },
);
},
[workspaceKey],
);
const handleCreateExplorerTab = useCallback(
() => onCreateNewTab({ paneId: explorerSidebarPaneId ?? undefined }),
[explorerSidebarPaneId, onCreateNewTab],
);
const handleMoveExplorerTabToMain = useCallback(
(tabId: string) => {
if (layout.focusedPaneId) {
onMoveTabToPane(tabId, layout.focusedPaneId);
}
},
[layout.focusedPaneId, onMoveTabToPane],
);
const splitRoot = useMemo(
() =>
resolveSplitContainerRoot({
root: layout.root,
root: mainRoot ?? createDefaultLayout().root,
focusedPaneId: layout.focusedPaneId,
focusModeEnabled,
maximizedPaneId,
}),
[focusModeEnabled, layout.focusedPaneId, layout.root, maximizedPaneId],
[focusModeEnabled, layout.focusedPaneId, mainRoot],
);
const storedExplorerSidebarWidth = useWorkspaceLayoutStore(
(state) => state.explorerSidebarWidthByWorkspace[workspaceKey],
);
const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar);
const [workspaceShellWidth, setWorkspaceShellWidth] = useState(0);
const [previewExplorerSidebarWidth, setPreviewExplorerSidebarWidth] = useState<number | null>(
null,
);
const requestedExplorerSidebarWidth = previewExplorerSidebarWidth ?? storedExplorerSidebarWidth;
const explorerSidebarWidth = resolveExplorerSidebarWidth({
requestedWidth: requestedExplorerSidebarWidth,
containerWidth: workspaceShellWidth,
});
const explorerSidebarDockSizes = useMemo(
() =>
resolveExplorerSidebarDockSizes({
requestedWidth: requestedExplorerSidebarWidth,
containerWidth: workspaceShellWidth,
}),
[requestedExplorerSidebarWidth, workspaceShellWidth],
);
const renderExplorerSidebarDock = Boolean(
!focusModeEnabled && explorerSidebarPane && explorerSidebarPane.hidden !== true,
);
const mainColumnWindowChromeCorners = renderExplorerSidebarDock
? removeWindowChromeCorner(inheritedWindowChromeCorners, "top-right")
: inheritedWindowChromeCorners;
const mainColumnStyle = styles.mainColumn;
const explorerSidebarDockStyle = useMemo(
() => [styles.explorerSidebarDock, { width: explorerSidebarWidth }],
[explorerSidebarWidth],
);
const handleWorkspaceShellLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = event.nativeEvent.layout.width;
setWorkspaceShellWidth((current) => (current === nextWidth ? current : nextWidth));
}, []);
const previewExplorerSidebarResize = useCallback(
(_groupId: string, sizes: number[]) => {
const nextRatio = sizes[1];
if (nextRatio !== undefined) {
setPreviewExplorerSidebarWidth(
resolveExplorerSidebarWidth({
requestedWidth: nextRatio * workspaceShellWidth,
containerWidth: workspaceShellWidth,
}),
);
}
},
[workspaceShellWidth],
);
const commitExplorerSidebarResize = useCallback(
(_groupId: string, sizes: number[]) => {
setPreviewExplorerSidebarWidth(null);
const nextRatio = sizes[1];
if (nextRatio !== undefined) {
resizeExplorerSidebar(
workspaceKey,
resolveExplorerSidebarWidth({
requestedWidth: nextRatio * workspaceShellWidth,
containerWidth: workspaceShellWidth,
}),
);
}
},
[resizeExplorerSidebar, workspaceKey, workspaceShellWidth],
);
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
const handleDragStart = useCallback((event: DragStartEvent) => {
@@ -521,6 +519,21 @@ export function SplitContainer({
return;
}
const activeTab = uiTabs.find((tab) => tab.tabId === activeData.tabId) ?? null;
const destinationPaneId =
overData?.kind === "workspace-tab" || overData?.kind === "split-pane-drop"
? overData.paneId
: null;
const destinationHost = destinationPaneId === explorerSidebarPaneId ? "explorer" : "main";
if (
!activeTab ||
!panelTargetSupportsHost(normalizedServerId, activeTab.target, destinationHost)
) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (overData?.kind === "workspace-tab") {
const preview = computeTabOverDropPreview({
activeData,
@@ -542,7 +555,7 @@ export function SplitContainer({
setDropPreview(computePaneOverDropPreview({ overData, rects }));
},
[panesById, uiTabs],
[normalizedServerId, panesById, explorerSidebarPaneId, uiTabs],
);
const applyTabDropEnd = useCallback(
@@ -639,47 +652,90 @@ export function SplitContainer({
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
>
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
<SplitNodeView
node={renderRoot}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
sidePanelPaneId={sidePanelPaneId}
maximizedPaneId={maximizedPaneId}
onTogglePaneMaximized={handleTogglePaneMaximized}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateNewTab={onCreateNewTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
<View style={styles.workspaceShell} onLayout={handleWorkspaceShellLayout}>
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
<View style={mainColumnStyle}>
{renderMainHeader?.()}
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
{renderRoot ? (
<SplitNodeView
node={renderRoot}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateNewTab={onCreateNewTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
maximizedPaneId={maximizedPaneId}
workspaceHasSplits={workspaceHasSplits}
onTogglePaneMaximized={handleTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
) : null}
</View>
</WindowChromeRegion>
{renderExplorerSidebarDock && explorerSidebarPane ? (
<>
<ResizeHandle
testID="workspace-explorer-sidebar-resize-handle"
direction="horizontal"
hitAreaAlignment="end"
groupId={EXPLORER_SIDEBAR_RESIZE_GROUP_ID}
index={0}
sizes={explorerSidebarDockSizes}
containerSize={workspaceShellWidth}
onPreviewResizeSplit={previewExplorerSidebarResize}
onResizeSplit={commitExplorerSidebarResize}
/>
<View style={explorerSidebarDockStyle}>
<ExplorerSidebarDock
pane={explorerSidebarPane}
uiTabs={uiTabs}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
closingTabIds={closingTabIds}
onSelectTab={onSelectTabInPane}
onCloseTab={onCloseTab}
onCreateNewTab={handleCreateExplorerTab}
onMoveTabToMain={handleMoveExplorerTabToMain}
buildPaneContentModel={buildPaneContentModel}
onReorderTabsInPane={onReorderTabsInPane}
activeDragTabId={activeDragTabId}
tabDropPreview={tabDropPreview}
headerAction={renderExplorerSidebarHeaderAction?.()}
/>
</View>
</>
) : null}
</View>
<DragOverlay dropAnimation={null}>
{activeDragTabId ? (
<DragOverlayTabChip
@@ -825,16 +881,35 @@ function SplitGroupChild({
* hidden pane give its space back instead of just going invisible. The stored `sizes` are never
* touched, so unhiding restores the width the user dragged to.
*/
function resolveVisibleGroupFlex(children: SplitNode[], sizes: number[]): number[] {
function resolveVisibleGroupFlex(
children: SplitNode[],
sizes: number[],
maximizedPaneId: string | null,
): number[] {
const visibleTotal = children.reduce(
(total, child, index) => (isSplitNodeHidden(child) ? total : total + (sizes[index] ?? 1)),
(total, child, index) =>
isSplitNodeHiddenForPresentation(child, maximizedPaneId)
? total
: total + (sizes[index] ?? 1),
0,
);
if (visibleTotal <= 0) {
return children.map(() => 0);
}
return children.map((child, index) =>
isSplitNodeHidden(child) ? 0 : (sizes[index] ?? 1) / visibleTotal,
isSplitNodeHiddenForPresentation(child, maximizedPaneId)
? 0
: (sizes[index] ?? 1) / visibleTotal,
);
}
function isSplitNodeHiddenForPresentation(
node: SplitNode,
maximizedPaneId: string | null,
): boolean {
return (
isSplitNodeHidden(node) ||
Boolean(maximizedPaneId && !splitNodeContainsPane(node, maximizedPaneId))
);
}
@@ -850,9 +925,6 @@ function SplitNodeView({
workspaceKey,
uiTabs,
focusedPaneId,
sidePanelPaneId,
maximizedPaneId,
onTogglePaneMaximized,
normalizedServerId,
normalizedWorkspaceId,
isWorkspaceFocused,
@@ -882,9 +954,13 @@ function SplitNodeView({
dropPreview,
tabDropPreview,
windowChromeCorners,
maximizedPaneId,
workspaceHasSplits,
onTogglePaneMaximized,
focusModeEnabled,
onExitFocusMode,
}: SplitNodeViewProps) {
const [groupContainerSize, setGroupContainerSize] = useState(0);
const groupId = node.kind === "group" ? node.group.id : null;
const groupDirection = node.kind === "group" ? node.group.direction : null;
@@ -895,8 +971,8 @@ function SplitNodeView({
const groupSizes =
storedGroupSizes ?? (node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES);
const visibleFlex = useMemo(
() => resolveVisibleGroupFlex(groupChildren, groupSizes),
[groupChildren, groupSizes],
() => resolveVisibleGroupFlex(groupChildren, groupSizes, maximizedPaneId),
[groupChildren, groupSizes, maximizedPaneId],
);
const resizeFlex = useSharedValue(visibleFlex);
useEffect(() => {
@@ -904,9 +980,19 @@ function SplitNodeView({
}, [resizeFlex, visibleFlex]);
const previewResizeSplit = useCallback(
(_groupId: string, sizes: number[]) => {
resizeFlex.value = resolveVisibleGroupFlex(groupChildren, sizes);
resizeFlex.value = resolveVisibleGroupFlex(groupChildren, sizes, maximizedPaneId);
},
[groupChildren, resizeFlex],
[groupChildren, maximizedPaneId, resizeFlex],
);
const handleGroupLayout = useCallback(
(event: LayoutChangeEvent) => {
const nextSize =
groupDirection === "horizontal"
? event.nativeEvent.layout.width
: event.nativeEvent.layout.height;
setGroupContainerSize((current) => (current === nextSize ? current : nextSize));
},
[groupDirection],
);
const groupStyle = useMemo(
@@ -919,15 +1005,14 @@ function SplitNodeView({
if (node.kind === "pane") {
return (
<RetainedPanel active={node.pane.hidden !== true}>
<RetainedPanel
active={node.pane.hidden !== true && (!maximizedPaneId || node.pane.id === maximizedPaneId)}
>
<WindowChromeRegion corners={windowChromeCorners}>
<SplitPaneView
pane={node.pane}
uiTabs={uiTabs}
isFocused={node.pane.id === focusedPaneId}
sidePanelPaneId={sidePanelPaneId}
maximizedPaneId={maximizedPaneId}
onTogglePaneMaximized={onTogglePaneMaximized}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
@@ -955,6 +1040,9 @@ function SplitNodeView({
showDropZones={showDropZones}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
maximizedPaneId={maximizedPaneId}
workspaceHasSplits={workspaceHasSplits}
onTogglePaneMaximized={onTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
@@ -964,18 +1052,19 @@ function SplitNodeView({
}
return (
<View style={groupStyle}>
<View style={groupStyle} onLayout={handleGroupLayout}>
{node.group.children.map((child, index) => (
<Fragment key={getNodeKey(child)}>
<SplitGroupChild resizeFlex={resizeFlex} index={index} hidden={isSplitNodeHidden(child)}>
<SplitGroupChild
resizeFlex={resizeFlex}
index={index}
hidden={isSplitNodeHiddenForPresentation(child, maximizedPaneId)}
>
<SplitNodeView
node={child}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={focusedPaneId}
sidePanelPaneId={sidePanelPaneId}
maximizedPaneId={maximizedPaneId}
onTogglePaneMaximized={onTogglePaneMaximized}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
@@ -1005,19 +1094,23 @@ function SplitNodeView({
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={windowChromeCorners}
maximizedPaneId={maximizedPaneId}
workspaceHasSplits={workspaceHasSplits}
onTogglePaneMaximized={onTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
</SplitGroupChild>
{index < node.group.children.length - 1 &&
!isSplitNodeHidden(child) &&
!isSplitNodeHidden(node.group.children[index + 1]) ? (
!isSplitNodeHiddenForPresentation(child, maximizedPaneId) &&
!isSplitNodeHiddenForPresentation(node.group.children[index + 1], maximizedPaneId) ? (
<ResizeHandle
testID="workspace-split-resize-handle"
direction={node.group.direction}
groupId={node.group.id}
index={index}
sizes={groupSizes}
containerSize={groupContainerSize}
onPreviewResizeSplit={previewResizeSplit}
onResizeSplit={onResizeSplit}
/>
@@ -1053,19 +1146,18 @@ function SplitPaneView({
buildPaneContentModel,
onFocusPane,
onSplitPane: _onSplitPane,
onSplitPaneEmpty: _onSplitPaneEmpty,
onSplitPaneEmpty,
onReorderTabsInPane,
activeDragTabId,
showDropZones,
dropPreview,
tabDropPreview,
sidePanelPaneId,
maximizedPaneId,
workspaceHasSplits,
onTogglePaneMaximized,
focusModeEnabled,
onExitFocusMode,
}: SplitPaneViewProps) {
const { theme: _theme } = useUnistyles();
const paneRef = useRef<View | null>(null);
const stableOnFocusPane = useStableEvent(onFocusPane);
const paneState = useMemo(
@@ -1077,24 +1169,7 @@ function SplitPaneView({
[pane, uiTabs],
);
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
const paneTabIds = useMemo(() => paneTabs.map((tab) => tab.tabId), [paneTabs]);
const modifiedPaneTabIds = useModifiedPanelTabIds({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabIds: paneTabIds,
});
const tabDescriptorMap = useStableTabDescriptorMap(paneTabs);
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
const { mountedTabIds } = useMountedTabSet({
activeTabId: activeTabDescriptor?.tabId ?? null,
allTabIds: paneTabIds,
retainedTabIds: modifiedPaneTabIds,
cap: 3,
});
const mountedPaneTabIds = useMemo(
() => paneTabIds.filter((tabId) => mountedTabIds.has(tabId)),
[mountedTabIds, paneTabIds],
);
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
() =>
paneTabs.map((tab) => ({
@@ -1162,7 +1237,15 @@ function SplitPaneView({
},
[onReorderTabsInPane, paneId],
);
const handleToggleMaximized = useCallback(
const handleSplitRight = useCallback(
() => onSplitPaneEmpty({ targetPaneId: paneId, position: "right" }),
[onSplitPaneEmpty, paneId],
);
const handleSplitDown = useCallback(
() => onSplitPaneEmpty({ targetPaneId: paneId, position: "bottom" }),
[onSplitPaneEmpty, paneId],
);
const handleTogglePaneMaximized = useCallback(
() => onTogglePaneMaximized(paneId),
[onTogglePaneMaximized, paneId],
);
@@ -1172,7 +1255,7 @@ function SplitPaneView({
ref={paneRef}
collapsable={false}
style={styles.pane}
testID={pane.id === sidePanelPaneId ? "workspace-side-panel" : `workspace-pane-${pane.id}`}
testID={`workspace-pane-${pane.id}`}
>
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
<TitlebarDragRegion />
@@ -1201,34 +1284,29 @@ function SplitPaneView({
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
showPaneMaximizeAction={pane.id === sidePanelPaneId && !focusModeEnabled}
paneMaximized={pane.id === maximizedPaneId}
onTogglePaneMaximized={handleToggleMaximized}
showPaneSplitActions={!focusModeEnabled}
showPaneMaximizeAction={workspaceHasSplits && !focusModeEnabled}
paneMaximized={paneId === maximizedPaneId}
onTogglePaneMaximized={handleTogglePaneMaximized}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
focusModeEnabled={Boolean(focusModeEnabled)}
onExitFocusMode={onExitFocusMode}
/>
</WindowChromeSafeArea>
<View style={styles.paneContent}>
{mountedPaneTabIds.map((tabId) => {
const tabDescriptor = tabDescriptorMap.get(tabId);
if (!tabDescriptor) {
return null;
}
return (
<MountedTabSlot
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
);
})}
<WorkspacePanelHost
paneId={pane.id}
tabs={paneTabs}
activeTabId={activeTabDescriptor?.tabId ?? null}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View>
</View>
@@ -1274,7 +1352,53 @@ function wrapRootPaneForStableMount(node: SplitNode): SplitNode {
};
}
function removePaneFromSplitTree(node: SplitNode, paneId: string | null): SplitNode | null {
if (!paneId) {
return node;
}
if (node.kind === "pane") {
return node.pane.id === paneId ? null : node;
}
const children = node.group.children.flatMap((child) => {
const nextChild = removePaneFromSplitTree(child, paneId);
return nextChild ? [nextChild] : [];
});
if (children.length === 0) {
return null;
}
if (children.length === 1) {
return children[0] ?? null;
}
return {
kind: "group",
group: {
...node.group,
children,
sizes: children.map(() => 1 / children.length),
},
};
}
const styles = StyleSheet.create((theme) => ({
workspaceShell: {
flex: 1,
minWidth: 0,
minHeight: 0,
flexDirection: "row",
},
mainColumn: {
flex: 1,
flexShrink: 1,
minWidth: 0,
minHeight: 0,
},
explorerSidebarDock: {
flexShrink: 0,
minWidth: 240,
minHeight: 0,
backgroundColor: theme.colors.surfaceSidebar,
},
group: {
flex: 1,
minWidth: 0,
@@ -18,7 +18,8 @@ export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[2];
* The extra 2px is optical: text ink ends inside its layout box, while the
* header icons' strokes extend to theirs.
*/
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 2;
/** Shared painted-edge rail for pane headers, toolbars, and tree/diff rows. */
export const WORKSPACE_PANE_TRAILING_GLYPH_RAIL = SPACING[2];
/** Left padding for a row in either workspace tree. */
export function treeRowPaddingLeft(depth: number): number {
@@ -49,7 +50,7 @@ export const workspaceTreeRowStyles = StyleSheet.create((theme: Theme) => ({
alignItems: "center",
justifyContent: "space-between",
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING,
paddingRight: WORKSPACE_PANE_TRAILING_GLYPH_RAIL,
},
active: {
backgroundColor: theme.colors.surfaceSidebarHover,

Some files were not shown because too many files have changed in this diff Show More