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
263 changed files with 11331 additions and 5719 deletions
+3 -3
View File
@@ -348,12 +348,12 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
run: npx playwright install chromium run: npx playwright install chromium
- name: Build app dependencies
run: npm run build:app-deps
- name: Build server stack - name: Build server stack
run: npm run build:server run: npm run build:server
- name: Build native app dependency
run: npm run build --workspace=@getpaseo/expo-two-way-audio
- name: Install agent CLIs for provider tests - name: Install agent CLIs for provider tests
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
+20
View File
@@ -1,5 +1,25 @@
# Changelog # 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 ## 0.5.2 - 2026-08-24
### Fixed ### Fixed
+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/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows | | [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints | | [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
| [docs/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference | | [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and routing preferences |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness | | [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha | | [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows | | [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
+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. `ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
Header and toolbar controls use `interactionHighlight` for hovered, pressed, open, and selected
backgrounds. It is a translucent semantic fill so the same control works over the main surface and
the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control also fades its content.
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog. `destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs. Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
@@ -155,6 +159,8 @@ On a narrow desktop route, app navigation yields to the rendered content topolog
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate. Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR. A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
--- ---
+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 Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis. the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
For the desktop Side panel toggle, run the app against the root checkout's daemon and use: For the desktop Explorer sidebar toggle, run the app against the root checkout's daemon and use:
```bash ```bash
npm run profile:explorer-toggle --workspace=@getpaseo/app npm run profile:explorer-toggle --workspace=@getpaseo/app
``` ```
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Side panel pane, The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Explorer pane,
records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or `PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
@@ -278,6 +278,17 @@ That report includes input and composer height changes plus React work grouped i
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
boundaries are nested. A printable key after an empty newline should not change either height. boundaries are nested. A printable key after an empty newline should not change either height.
### Preview Windows and Linux window controls on macOS
Desktop development can replace native macOS traffic lights with Paseo's custom controls:
```bash
PASEO_DESKTOP_WINDOW_CONTROLS=windows npm run dev:desktop
PASEO_DESKTOP_WINDOW_CONTROLS=linux npm run dev:desktop
```
The override is rejected in packaged builds. Restart the desktop process when changing it.
### Desktop macOS compositor watchdog ### Desktop macOS compositor watchdog
macOS display sleep can leave Chromium's GPU-process display link — the vsync macOS display sleep can leave Chromium's GPU-process display link — the vsync
+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. - **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym. - **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label. - **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Side panel** — The companion surface beside the user's work, holding Changes, Files, the pull request, and tabs an agent opens alongside its output. UI: "Side panel". Code: `packages/app/src/workspace-tabs/side-panel.ts`; the persisted pane id and layout key keep the pre-rename `explorer` spelling. See [side-panel.md](side-panel.md). Forbidden: "Explorer" as the name of the surface — **File Explorer** is only the Files view inside it. - **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`. - **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
+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 app state or a memoized selection. Panels use one persisted
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the `plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
current installed-plugin catalog. A missing contribution renders unavailable inside the tab. current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
Panels declare `locations: ["workspace", "explorer"]` to opt into Explorer hosting; omission means
workspace only. Location controls hosting, not context. An agent panel target keeps its `agentId`
when moved between hosts. Explorer configuration can create workspace-context panels and remove
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations. Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the
-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. - For full-suite confidence, push to CI and check GitHub Actions.
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow. - Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
- App Playwright shares one warmed Metro server per run and gives every Playwright worker its own isolated daemon and `PASEO_HOME`. Spec files run concurrently without exposing one file's projects, agents, terminals, history, or provider configuration to another worker; tests within a file remain together so file-level setup is not repeated. - App Playwright shares one warmed Metro server per run and gives every Playwright worker its own isolated daemon and `PASEO_HOME`. Spec files run concurrently without exposing one file's projects, agents, terminals, history, or provider configuration to another worker; tests within a file remain together so file-level setup is not repeated.
- Playwright specs that exercise only the daemon import `daemonTest` from the shared fixtures so they do not create a browser context or page.
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`. - Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics. - Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay. - Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
- The app Playwright harness boots on Windows as well as POSIX. Spawn Node entrypoints through `process.execPath`, not `npx` or `node_modules/.bin` shims: Node refuses to spawn `.cmd` or `.bat` without `shell: true`, and shell mode concatenates argv without escaping and sends kill signals to `cmd.exe` instead of the real child.
- Teardown kills the process tree, because a Windows signal reaches only the direct child and leaves forked workers holding the listening port.
- The `asdf`-backed local Elixir relay stays POSIX-only, so the `relay-deployment` Playwright project is unavailable on Windows.
## Pull-request test routing ## Pull-request test routing
+1 -1
View File
@@ -1 +1 @@
sha256-o8d/nX0FdM6QB7c/RWpo1USsz8UNmwohSqkS7tflayc= sha256-gZ6egDKIDiDi9jmDlPamM+or2Tthwr17DbJ+P66t4RE=
+25 -25
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.5.2", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.5.2", "version": "0.6.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"workspaces": [ "workspaces": [
@@ -36140,7 +36140,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.5.2", "version": "0.6.0",
"dependencies": { "dependencies": {
"@codemirror/commands": "6.10.4", "@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
@@ -37192,12 +37192,12 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.5.2", "version": "0.6.0",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.5.2", "@getpaseo/client": "0.6.0",
"@getpaseo/protocol": "0.5.2", "@getpaseo/protocol": "0.6.0",
"@getpaseo/server": "0.5.2", "@getpaseo/server": "0.6.0",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -37444,10 +37444,10 @@
}, },
"packages/client": { "packages/client": {
"name": "@getpaseo/client", "name": "@getpaseo/client",
"version": "0.5.2", "version": "0.6.0",
"dependencies": { "dependencies": {
"@getpaseo/protocol": "0.5.2", "@getpaseo/protocol": "0.6.0",
"@getpaseo/relay": "0.5.2", "@getpaseo/relay": "0.6.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@@ -37458,7 +37458,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.5.2", "version": "0.6.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@getpaseo/cli": "*", "@getpaseo/cli": "*",
@@ -37701,7 +37701,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.5.2", "version": "0.6.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
@@ -38597,7 +38597,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.5.2", "version": "0.6.0",
"dependencies": { "dependencies": {
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3", "@codemirror/legacy-modes": "^6.5.3",
@@ -38626,12 +38626,12 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@getpaseo/plugin", "name": "@getpaseo/plugin",
"version": "0.5.2", "version": "0.6.0",
"dependencies": { "dependencies": {
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@getpaseo/client": "0.5.2", "@getpaseo/client": "0.6.0",
"@types/node": "^20.9.0", "@types/node": "^20.9.0",
"@types/react": "~19.2.0", "@types/react": "~19.2.0",
"react": "19.1.0", "react": "19.1.0",
@@ -38640,14 +38640,14 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"peerDependencies": { "peerDependencies": {
"@getpaseo/client": "0.5.2", "@getpaseo/client": "0.6.0",
"react": "19.1.0", "react": "19.1.0",
"zod": "^4.4.3" "zod": "^4.4.3"
} }
}, },
"packages/protocol": { "packages/protocol": {
"name": "@getpaseo/protocol", "name": "@getpaseo/protocol",
"version": "0.5.2", "version": "0.6.0",
"dependencies": { "dependencies": {
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@@ -38660,7 +38660,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.5.2", "version": "0.6.0",
"dependencies": { "dependencies": {
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
@@ -38878,17 +38878,17 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.5.2", "version": "0.6.0",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.220", "@anthropic-ai/claude-agent-sdk": "0.3.220",
"@anthropic-ai/sdk": "^0.104.2", "@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8", "@babel/parser": "^7.29.8",
"@getpaseo/client": "0.5.2", "@getpaseo/client": "0.6.0",
"@getpaseo/highlight": "0.5.2", "@getpaseo/highlight": "0.6.0",
"@getpaseo/plugin": "0.5.2", "@getpaseo/plugin": "0.6.0",
"@getpaseo/protocol": "0.5.2", "@getpaseo/protocol": "0.6.0",
"@getpaseo/relay": "0.5.2", "@getpaseo/relay": "0.6.0",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.14.46",
@@ -39940,7 +39940,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.5.2", "version": "0.6.0",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1", "@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.5.2", "version": "0.6.0",
"private": true, "private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents", "description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [ "keywords": [
@@ -66,54 +66,21 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
test.describe("Add Project command-center flow", () => { test.describe("Add Project command-center flow", () => {
test.describe.configure({ timeout: 180_000 }); test.describe.configure({ timeout: 180_000 });
test("method selection has no search field", async ({ page }) => { test("method selection shows the daemon's available project sources without search", async ({
page,
}) => {
await gotoAppShell(page); await gotoAppShell(page);
await openAddProjectFlow(page); await openAddProjectFlow(page);
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible(); await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub");
await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory");
await expect(addProjectFlowInput(page)).toHaveCount(0); await expect(addProjectFlowInput(page)).toHaveCount(0);
await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0); await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0);
await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0); await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0);
}); });
test("the back arrow, search input, and result glyph share one left edge", async ({ page }) => {
await gotoAppShell(page);
await openAddProjectFlow(page);
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "directory-search");
await addProjectFlowInput(page).fill("/tmp");
const backGlyph = addProjectFlowBack(page).locator("svg");
const resultGlyph = addProjectFlow(page)
.locator('[data-testid^="add-project-flow-path-"]')
.first()
.locator("svg");
await expect(resultGlyph).toBeVisible();
const [backBox, inputBox, resultBox, titleBox, resultsBox, footerBox] = await Promise.all([
backGlyph.boundingBox(),
addProjectFlowInput(page).boundingBox(),
resultGlyph.boundingBox(),
page.getByTestId("add-project-flow-title").boundingBox(),
page.getByTestId("add-project-flow-results").boundingBox(),
page.getByTestId("add-project-flow-footer").boundingBox(),
]);
expect(backBox).not.toBeNull();
expect(inputBox).not.toBeNull();
expect(resultBox).not.toBeNull();
expect(titleBox).not.toBeNull();
expect(resultsBox).not.toBeNull();
expect(footerBox).not.toBeNull();
if (!backBox || !inputBox || !resultBox || !titleBox || !resultsBox || !footerBox) return;
expect(Math.abs(backBox.x - inputBox.x)).toBeLessThanOrEqual(2);
expect(Math.abs(resultBox.x - inputBox.x)).toBeLessThanOrEqual(2);
expect(titleBox.height).toBeLessThanOrEqual(24);
expect(resultsBox.y + resultsBox.height).toBeLessThanOrEqual(footerBox.y + 1);
});
test("an offline extra host neither appears nor forces host selection", async ({ page }) => { test("an offline extra host neither appears nor forces host selection", async ({ page }) => {
await gotoAppShell(page); await gotoAppShell(page);
await addOfflineHostAndReload(page, { await addOfflineHostAndReload(page, {
@@ -273,14 +240,6 @@ test.describe("Add Project command-center flow", () => {
await expectProjectHasNoWorkspaces(projectId); await expectProjectHasNoWorkspaces(projectId);
}); });
test("the current daemon advertises Clone from GitHub and New directory", async ({ page }) => {
await gotoAppShell(page);
await openAddProjectFlow(page);
await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub");
await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory");
});
test("a complete repository URL remains selectable without a GitHub search result", async ({ test("a complete repository URL remains selectable without a GitHub search result", async ({
page, page,
}) => { }) => {
@@ -442,21 +442,40 @@ function formatFrames(frames: TurnFrame[], centers: number | number[]): string {
.join("\n"); .join("\n");
} }
function submittedRowContentTop(frame: TurnFrame | undefined): number | undefined {
const viewportTop = frame?.userRow.rect?.top;
const scrollTop = frame?.scroll.scrollTop;
return viewportTop == null || scrollTop == null ? undefined : viewportTop + scrollTop;
}
function expectRowContinuity(frames: TurnFrame[]): void { function expectRowContinuity(frames: TurnFrame[]): void {
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow)); const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
const baseline = frames[first]?.userRow.rect?.top; const baselineFrame = frames[first];
const baselineContentTop = submittedRowContentTop(baselineFrame);
const disappeared = frames.findIndex( const disappeared = frames.findIndex(
(frame, index) => index > first && !hasPaintedLayout(frame.userRow), (frame, index) => index > first && !hasPaintedLayout(frame.userRow),
); );
const reappeared = frames.findIndex( const reappeared = frames.findIndex(
(frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow), (frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow),
); );
const shifted = frames.findIndex( const contentShifted = frames.findIndex((frame, index) => {
(frame, index) => const contentTop = submittedRowContentTop(frame);
return (
index > first && index > first &&
hasPaintedLayout(frame.userRow) && hasPaintedLayout(frame.userRow) &&
baseline !== undefined && baselineContentTop !== undefined &&
Math.abs((frame.userRow.rect?.top ?? baseline) - baseline) > 1, contentTop !== undefined &&
Math.abs(contentTop - baselineContentTop) > 1
);
});
const viewportChanged = frames.findIndex(
(frame, index) =>
index > first &&
[
geometryChanged(frame.scroll.rect?.top, baselineFrame?.scroll.rect?.top),
geometryChanged(frame.scroll.rect?.height, baselineFrame?.scroll.rect?.height),
geometryChanged(frame.scroll.clientHeight, baselineFrame?.scroll.clientHeight),
].some(Boolean),
); );
const attachmentMissing = frames.findIndex( const attachmentMissing = frames.findIndex(
(frame, index) => index >= first && !hasPaintedLayout(frame.attachment), (frame, index) => index >= first && !hasPaintedLayout(frame.attachment),
@@ -468,16 +487,18 @@ function expectRowContinuity(frames: TurnFrame[]): void {
`submitted row disappeared for ${Math.max(0, reappeared - disappeared)} recorded frames (${Math.max(0, (frames[reappeared]?.at ?? frames.at(-1)?.at ?? 0) - frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`, `submitted row disappeared for ${Math.max(0, reappeared - disappeared)} recorded frames (${Math.max(0, (frames[reappeared]?.at ?? frames.at(-1)?.at ?? 0) - frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`,
] ]
: []), : []),
...(shifted >= 0 ...(contentShifted >= 0
? [ ? [
`submitted row moved from ${baseline?.toFixed(1)} to ${frames[shifted].userRow.rect?.top.toFixed(1)}`, `submitted row moved in scroll content from ${baselineContentTop?.toFixed(1)} to ${submittedRowContentTop(frames[contentShifted])?.toFixed(1)}`,
] ]
: []), : []),
...(viewportChanged >= 0 ? ["scroll viewport geometry changed"] : []),
...(attachmentMissing >= 0 ? ["submitted image attachment left the painted layout"] : []), ...(attachmentMissing >= 0 ? ["submitted image attachment left the painted layout"] : []),
]; ];
let failure = Math.max(0, first); let failure = Math.max(0, first);
if (attachmentMissing >= 0) failure = attachmentMissing; if (attachmentMissing >= 0) failure = attachmentMissing;
if (shifted >= 0) failure = shifted; if (viewportChanged >= 0) failure = viewportChanged;
if (contentShifted >= 0) failure = contentShifted;
if (disappeared >= 0) failure = disappeared; if (disappeared >= 0) failure = disappeared;
expect( expect(
violations, violations,
@@ -1,30 +1,9 @@
import type { Locator } from "@playwright/test"; import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures"; import { expect, test, type Page } from "../support/fixtures";
import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream"; import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
import { import { cancelAgent, expectComposerVisible, submitMessage } from "../support/helpers/composer";
cancelAgent,
expectComposerDraft,
expectComposerVisible,
submitMessage,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function completeSubmittedTurn(
page: Page,
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
prompt: string,
): Promise<Locator> {
await submitMessage(page, prompt);
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toBeVisible();
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
expect(finish.status).toBe("idle");
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
await expect(userMessage).toHaveAttribute("aria-busy", "false");
return userMessage;
}
async function rewindConversation(page: Page, userMessage: Locator, prompt: string): Promise<void> { async function rewindConversation(page: Page, userMessage: Locator, prompt: string): Promise<void> {
await userMessage.getByText(prompt, { exact: true }).hover(); await userMessage.getByText(prompt, { exact: true }).hover();
await userMessage.getByRole("button", { name: "Rewind to this message", exact: true }).click(); await userMessage.getByRole("button", { name: "Rewind to this message", exact: true }).click();
@@ -42,29 +21,6 @@ async function expectTurnCompletesNormally(
} }
test.describe("Agent message rewind", () => { test.describe("Agent message rewind", () => {
test("rewinds a submitted prompt and restores it to the composer", async ({ page }) => {
test.setTimeout(90_000);
const prompt = "Restore this submitted prompt after rewind.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "message-rewind-roundtrip-",
title: "Message rewind roundtrip",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const userMessage = await completeSubmittedTurn(page, agent, prompt);
await rewindConversation(page, userMessage, prompt);
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
await expectComposerDraft(page, prompt);
} finally {
await agent.cleanup();
}
});
test("keeps a pre-acknowledgement turn running after rewind is rejected", async ({ page }) => { test("keeps a pre-acknowledgement turn running after rewind is rejected", async ({ page }) => {
test.setTimeout(90_000); test.setTimeout(90_000);
const prompt = "Delay synthetic user message by 2000ms."; const prompt = "Delay synthetic user message by 2000ms.";
@@ -103,6 +103,18 @@ async function readProviderModePreference(page: Page, provider: string): Promise
); );
} }
async function readProviderFeaturePreferences(page: Page, provider: string): Promise<unknown> {
return page.evaluate(
({ preferencesKey, providerId }) => {
const raw = localStorage.getItem(preferencesKey);
if (!raw) return null;
const preferences = JSON.parse(raw) as FormPreferences;
return preferences.providerPreferences?.[providerId]?.featureValues ?? null;
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
);
}
test.describe("Agent profiles repair modeless provider preferences", () => { test.describe("Agent profiles repair modeless provider preferences", () => {
test.describe.configure({ timeout: 240_000 }); test.describe.configure({ timeout: 240_000 });
@@ -207,6 +219,9 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
await openModelPicker(page); await openModelPicker(page);
await applyProfileFromPicker(page, "Fast profile"); await applyProfileFromPicker(page, "Fast profile");
await expect
.poll(() => readProviderFeaturePreferences(page, featureProviderId), { timeout: 10_000 })
.toEqual({ fast_mode: true });
await submitNewWorkspacePrompt(page, "Create an agent with profile features."); await submitNewWorkspacePrompt(page, "Create an agent with profile features.");
const createAgentRequest = await createAgentRecorder.waitForRequest(); const createAgentRequest = await createAgentRecorder.waitForRequest();
@@ -10,6 +10,7 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
repoPrefix: "bottom-sheet-reopen-", repoPrefix: "bottom-sheet-reopen-",
title: "Bottom sheet reopen e2e", title: "Bottom sheet reopen e2e",
initialPrompt: "Prepare a bottom sheet reopen test agent.", initialPrompt: "Prepare a bottom sheet reopen test agent.",
model: "ten-second-stream",
}); });
await openAgentRoute(page, session); await openAgentRoute(page, session);
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({ await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
@@ -22,16 +23,6 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
return session; return session;
} }
async function withMobileMockAgent(page: Page, run: () => Promise<void>) {
const session = await openMockAgentAtMobileBreakpoint(page);
try {
await run();
} finally {
await session.cleanup();
}
}
function bottomSheetBackdrop(page: Page) { function bottomSheetBackdrop(page: Page) {
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first(); return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
} }
@@ -108,35 +99,36 @@ async function openAndCloseModelSelectorTwice(page: Page) {
} }
test.describe("mobile bottom sheet reopen", () => { test.describe("mobile bottom sheet reopen", () => {
test("tab switcher can open, close, reopen, and close again", async ({ page }) => { test("sheets reopen and model search returns to configuration", async ({ page }) => {
await withMobileMockAgent(page, async () => { const session = await openMockAgentAtMobileBreakpoint(page);
await openAndCloseTabSwitcherTwice(page); try {
}); await test.step("tab switcher opens, closes, and reopens", async () => {
}); await openAndCloseTabSwitcherTwice(page);
test("model selector can open, close, reopen, and close again", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openAndCloseModelSelectorTwice(page);
});
});
test("search selection returns to configuration options", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openModelSelector(page);
const sheet = page.getByTestId("agent-controls-model-sheet");
await page.getByTestId("model-search-all-input").click();
const model = page.getByRole("button", { name: /^Ten second stream/ });
await expect(model).toBeVisible({
timeout: 10_000,
}); });
await model.click(); await test.step("model selector opens, closes, and reopens", async () => {
await openAndCloseModelSelectorTwice(page);
});
await expect(sheet).toBeVisible(); await test.step("model search returns to configuration", async () => {
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible(); await openModelSelector(page);
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream"); const sheet = page.getByTestId("agent-controls-model-sheet");
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
}); await page.getByTestId("model-search-all-input").click();
const model = page.getByRole("button", { name: /^Ten second stream/ });
await expect(model).toBeVisible({
timeout: 10_000,
});
await model.click();
await expect(sheet).toBeVisible();
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
});
} finally {
await session.cleanup();
}
}); });
}); });
@@ -46,38 +46,6 @@ test.describe("Branch switcher", () => {
// releases stale sessions from the previous spec; one retry stabilizes it. // releases stale sessions from the previous spec; one retry stabilizes it.
test.describe.configure({ retries: 1 }); test.describe.configure({ retries: 1 });
test("switches the workspace branch from the git diff panel for an opaque workspace id", async ({
page,
}) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({
repoPrefix: "branch-switch-",
repo: { branches: ["main", "dev"] },
});
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
await openChangesPanel(page);
await expectWorkspaceBranch(page, "main");
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
await expectWorkspaceBranch(page, "dev");
await expect
.poll(
async () =>
(await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch,
{ timeout: 30_000 },
)
.toBe("dev");
} finally {
await workspace.cleanup();
}
});
test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({ test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({
page, page,
}) => { }) => {
+83 -207
View File
@@ -30,11 +30,7 @@ const cleanupTasks: CleanupTask[] = [];
const APP_SETTINGS_KEY = "@paseo:app-settings"; const APP_SETTINGS_KEY = "@paseo:app-settings";
function changesTree(page: Page) { function changesTree(page: Page) {
return page.getByTestId("changes-tree-rail-tree"); return page.getByTestId("changes-file-tree").filter({ visible: true });
}
function changesContent(page: Page) {
return page.getByTestId("changes-tree-rail-content");
} }
async function readFileIfPresent(filePath: string): Promise<string | null> { async function readFileIfPresent(filePath: string): Promise<string | null> {
@@ -264,7 +260,8 @@ test("Changes opens the populated committed comparison for a clean checkout", as
await openWorkspaceChangesSurface(page, workspace); await openWorkspaceChangesSurface(page, workspace);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("changes-diff-status-trigger")).toContainText("Committed"); const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible(); await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
}); });
@@ -272,19 +269,19 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn
const workspace = await createWorkspaceWithMountedTabDiff(); const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace); await openWorkspaceChanges(page, workspace);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const mode = panel.getByTestId("changes-diff-status-trigger"); const mode = tree.getByTestId("changes-diff-status-trigger");
await expect(mode).toContainText("Uncommitted"); await expect(mode).toContainText("Uncommitted");
await mode.click(); await mode.click();
await page.getByTestId("changes-diff-mode-committed").click(); await page.getByTestId("changes-diff-mode-committed").click();
await expect(mode).toContainText("Committed"); await expect(mode).toContainText("Committed");
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toBeVisible(); await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toBeVisible();
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath }); execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
execFileSync("git", ["commit", "-m", "Commit working changes"], { cwd: workspace.repoPath }); execFileSync("git", ["commit", "-m", "Commit working changes"], { cwd: workspace.repoPath });
await expect(mode).toContainText("Committed"); await expect(mode).toContainText("Committed");
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, { await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, {
timeout: 30_000, timeout: 30_000,
}); });
@@ -297,7 +294,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
await openWorkspaceChangesSurface(page, workspace); await openWorkspaceChangesSurface(page, workspace);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const mode = panel.getByTestId("changes-diff-status-trigger"); const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const mode = tree.getByTestId("changes-diff-status-trigger");
await mode.click(); await mode.click();
await page.getByTestId("changes-diff-mode-committed").click(); await page.getByTestId("changes-diff-mode-committed").click();
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible(); await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
@@ -305,8 +303,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
await mode.click(); await mode.click();
await page.getByTestId("changes-diff-mode-uncommitted").click(); await page.getByTestId("changes-diff-mode-uncommitted").click();
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible(); await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible();
const seeCommitted = panel.getByRole("button", { name: "See committed changes" }); const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
await expect(seeCommitted).toBeVisible(); await expect(seeCommitted).toBeVisible();
await seeCommitted.click(); await seeCommitted.click();
await expect(mode).toContainText("Committed"); await expect(mode).toContainText("Committed");
@@ -321,7 +319,7 @@ test("changes file actions open below the right-click without a reserved kebab",
await openWorkspaceChanges(page, workspace); await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts"); await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
const deletedFileName = page.getByText("zz-deleted.ts", { exact: true }); const deletedFileName = page.getByTestId("diff-file-1-name");
await expect(deletedFileName).toHaveCSS("user-select", "none"); await expect(deletedFileName).toHaveCSS("user-select", "none");
await deletedFileName.dblclick(); await deletedFileName.dblclick();
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe(""); expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
@@ -432,7 +430,6 @@ test("changes context menus duplicate files and folders", async ({ page }) => {
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts"))) .poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
.toBe(AFTER); .toBe(AFTER);
await page.getByTestId("changes-toggle-tree").click();
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" }); await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
await page.getByTestId("diff-folder-src-duplicate").click(); await page.getByTestId("diff-folder-src-duplicate").click();
await expect await expect
@@ -447,8 +444,6 @@ test("changes tree aligns every file status after its diff stat", async ({ page
}); });
await useUnwrappedDiffLines(page); await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace); await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-tree").click();
const tree = changesTree(page); const tree = changesTree(page);
const modifiedRow = tree.getByTestId("diff-tree-file-0"); const modifiedRow = tree.getByTestId("diff-tree-file-0");
const deletedRow = tree.getByTestId("diff-tree-file-1"); const deletedRow = tree.getByTestId("diff-tree-file-1");
@@ -471,7 +466,6 @@ test("changes context menu recursively collapses descendant folders", async ({ p
await useUnwrappedDiffLines(page); await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace); await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-tree").click();
const tree = changesTree(page); const tree = changesTree(page);
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
@@ -516,7 +510,6 @@ test("changes context menus expose folder revert and restore a file after confir
await useUnwrappedDiffLines(page); await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace); await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-tree").click();
const tree = changesTree(page); const tree = changesTree(page);
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" }); await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
const folderRevert = page.getByTestId("diff-folder-src-revert"); const folderRevert = page.getByTestId("diff-folder-src-revert");
@@ -649,6 +642,11 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true }); const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(visiblePanel).toBeVisible(); await expect(visiblePanel).toBeVisible();
await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible(); await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts"); await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0); await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
@@ -658,34 +656,30 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
await visiblePanel.getByTestId("diff-file-0-toggle").click(); await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible(); await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
// Collapse-all and the layout switch are overflow-menu items, not toolbar icons. await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0);
const diffOptions = visiblePanel.getByRole("button", { name: "Diff options" });
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
await diffOptions.click();
// A workspace tab has nowhere to open itself, so the menu withholds that item.
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0); await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
await page.getByTestId("changes-toggle-collapse-all").click(); const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all");
await diffOptions.click(); await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files");
await expect(page.getByTestId("changes-toggle-collapse-all")).toContainText("Expand all files"); await collapseFiles.click();
await page.getByTestId("changes-toggle-collapse-all").click(); await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files");
await collapseFiles.click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
const layoutItem = page.getByTestId("changes-toggle-layout"); const layout = visiblePanel.getByTestId("changes-toggle-layout");
await diffOptions.click(); await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff");
await expect(layoutItem).toContainText("Side-by-side diff"); await layout.click();
// Leading icon only: the layout is still unified, so no trailing check. await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff");
await expect(layoutItem.locator("svg")).toHaveCount(1);
await layoutItem.click();
await diffOptions.click();
await expect(layoutItem.locator("svg")).toHaveCount(2);
await expect(page.getByText("Hide whitespace", { exact: true })).toBeVisible(); const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace");
await expect(page.getByText("Wrap long lines", { exact: true })).toBeVisible(); await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace");
await expect(page.getByTestId("changes-refresh")).toHaveText("Refresh"); const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines");
await page.getByText("Wrap long lines", { exact: true }).click(); await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines");
await diffOptions.click(); await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute(
await expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible(); "aria-label",
await page.keyboard.press("Escape"); "Refresh",
);
await wrapLines.click();
await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines");
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1); await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE); await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
@@ -701,151 +695,23 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible(); await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
}); });
test("desktop Changes toggles a navigation tree beside the expanded diff document", async ({
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 }) => { test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff(); const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page); await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace); await openWorkspaceChanges(page, workspace);
await page.setViewportSize({ width: 480, height: 900 }); await page.setViewportSize({ width: 480, height: 900 });
const actions = page.getByTestId("changes-actions-menu-trigger").filter({ visible: true }); const compactChangesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
const options = page.getByRole("button", { name: "Diff options" }).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([ const [actionsBox, optionsBox, glyphBox] = await Promise.all([
actions.boundingBox(), actions.boundingBox(),
options.boundingBox(), options.boundingBox(),
@@ -873,7 +739,9 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
await expect(wrapLines).toBeVisible(); await expect(wrapLines).toBeVisible();
await wrapLines.click(); await wrapLines.click();
await options.click(); await options.click();
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Scroll long lines"); await expect(
page.getByTestId("changes-options-menu-content").getByTestId("changes-toggle-wrap-lines"),
).toContainText("Scroll long lines");
}); });
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => { test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
@@ -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 canvas = page.getByTestId("git-diff-canvas");
const root = page.getByTestId("git-diff-canvas-root"); const root = page.getByTestId("git-diff-canvas-root");
const handle = page.getByTestId("workspace-split-resize-handle").getByRole("separator"); const handle = page
.getByTestId("workspace-explorer-sidebar-resize-handle")
.getByRole("separator");
await expect(handle).toBeVisible(); await expect(handle).toBeVisible();
await expect await expect
.poll(async () => { .poll(async () => {
@@ -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.move(handleBounds.x + handleBounds.width / 2, handleBounds.y + 120);
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(handleBounds.x - 120, handleBounds.y + 120); await page.mouse.move(handleBounds.x + 120, handleBounds.y + 120);
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))); await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
const duringDrag = await Promise.all([ const duringDrag = await Promise.all([
@@ -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"); if (!bounds) throw new Error("Diff overlay scrollbar thumb has no bounds");
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.down(); await page.mouse.down();
await expect(grab).toHaveCSS("cursor", "grabbing");
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 180); await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 180);
await page.mouse.up(); await page.mouse.up();
@@ -1313,24 +1184,20 @@ async function setOpenChangesPresentation(
page: Page, page: Page,
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean }, requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
): Promise<void> { ): Promise<void> {
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true }); const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
const layoutItem = diffPanel.getByTestId("changes-toggle-layout");
await options.click(); const currentLayout =
const layoutItem = page.getByTestId("changes-toggle-layout"); (await layoutItem.getAttribute("aria-label")) === "Switch to unified diff"
const currentLayout = (await layoutItem.locator("svg").count()) === 2 ? "split" : "unified"; ? "split"
: "unified";
if (currentLayout !== requestedPresentation.layout) { if (currentLayout !== requestedPresentation.layout) {
await layoutItem.click(); await layoutItem.click();
} else {
await page.keyboard.press("Escape");
} }
await options.click(); const wrapItem = diffPanel.getByTestId("changes-toggle-wrap-lines");
const wrapItem = page.getByTestId("changes-toggle-wrap-lines"); const currentWrapLines = (await wrapItem.getAttribute("aria-label")) === "Scroll long lines";
const currentWrapLines = (await wrapItem.locator("svg").count()) === 2;
if (currentWrapLines !== requestedPresentation.wrapLines) { if (currentWrapLines !== requestedPresentation.wrapLines) {
await wrapItem.click(); await wrapItem.click();
} else {
await page.keyboard.press("Escape");
} }
} }
@@ -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> { async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
const canvas = page.getByTestId("git-diff-canvas"); const canvas = page.getByTestId("git-diff-canvas");
await expect await expect
@@ -1491,6 +1352,7 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
await page.setViewportSize({ width: 1400, height: 900 }); await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id)); await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-explorer-toggle").first().click();
await openChangesInVisibleExplorer(page); await openChangesInVisibleExplorer(page);
await expectExpandedMountedTabDiff(page); await expectExpandedMountedTabDiff(page);
} }
@@ -1507,13 +1369,27 @@ async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspa
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id)); await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page); await openChangesPanel(page);
await expect(page.getByText("selection.ts")).toBeVisible({ timeout: 30_000 }); const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(diffPanel.getByTestId("diff-file-0-name")).toHaveText("selection.ts", {
timeout: 30_000,
});
await expectExpandedMountedTabDiff(page); await expectExpandedMountedTabDiff(page);
} }
async function openChangesInVisibleExplorer(page: Page): Promise<void> { async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await openChangesPanel(page); const explorer = page.getByTestId("workspace-explorer-sidebar");
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 }); await expect(explorer).toBeVisible({ timeout: 30_000 });
const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first();
await changesTab.click();
const changedFile = explorer
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
.filter({ visible: true })
.first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await changedFile.click();
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
} }
async function expectExpandedMountedTabDiff(page: Page): Promise<void> { async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
@@ -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 { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client"; import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const APP_SETTINGS_KEY = "@paseo:app-settings";
const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`; const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`;
function mainPane(page: Page) { test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) => {
return page.getByTestId("workspace-pane-main").filter({ visible: true }); const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-explorer-" });
}
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-" });
try { try {
await page.setViewportSize({ width: 1400, height: 900 }); 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 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, timeout: 30_000,
}); });
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(0); await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
} finally { await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
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);
} finally { } finally {
await workspace.cleanup(); await workspace.cleanup();
} }
@@ -4,6 +4,7 @@ import {
clickChatOutlineRowEdge, clickChatOutlineRowEdge,
disableChatOutlineFromAppearance, disableChatOutlineFromAppearance,
expectActiveChatOutlinePrompt, expectActiveChatOutlinePrompt,
expectActiveChatOutlinePromptMovedFrom,
expectChatOutlinePreview, expectChatOutlinePreview,
expectChatOutlinePrompts, expectChatOutlinePrompts,
expectChatOutlinePromptToRemainBare, expectChatOutlinePromptToRemainBare,
@@ -29,7 +30,6 @@ import {
openAgentTimeline, openAgentTimeline,
scrollThroughOlderHistoryPages, scrollThroughOlderHistoryPages,
scrollTimelineToNewestLoadedEdge, scrollTimelineToNewestLoadedEdge,
scrollTimelineToOldestLoadedEdge,
seedLongMockAgentTimeline, seedLongMockAgentTimeline,
type LongTimelineAgent, type LongTimelineAgent,
} from "../support/helpers/timeline-pagination"; } from "../support/helpers/timeline-pagination";
@@ -130,11 +130,12 @@ test.describe("desktop chat outline", () => {
await expectOneActiveChatOutlinePrompt(page); await expectOneActiveChatOutlinePrompt(page);
await clickChatOutlineRowEdge(page, 4); await clickChatOutlineRowEdge(page, 4);
await expectTimelinePromptVisible(page, agent.prompts[3]); await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]);
await expectActiveChatOutlinePrompt(page, 4); await expectActiveChatOutlinePrompt(page, 4);
await scrollTimelineToOldestLoadedEdge(page); await scrollTimelineToNewestLoadedEdge(page);
await expectActiveChatOutlinePrompt(page, 1); await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectActiveChatOutlinePromptMovedFrom(page, 4);
}); });
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => { test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
@@ -4,6 +4,7 @@ import { closeCommandCenter, openCommandCenter } from "../support/helpers/comman
import { import {
applyCommandCenterAgentControls, applyCommandCenterAgentControls,
chooseCommandCenterAgentControl, chooseCommandCenterAgentControl,
openCommandCenterForAgent,
expectCommandCenterAgentControlSelected, expectCommandCenterAgentControlSelected,
expectFocusedAgentControls, expectFocusedAgentControls,
expectWorkspaceAgentConfiguration, expectWorkspaceAgentConfiguration,
@@ -12,7 +13,9 @@ import {
} from "../support/helpers/command-center-agent-controls"; } from "../support/helpers/command-center-agent-controls";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { seedWorkspace } from "../support/helpers/seed-client"; import { seedWorkspace } from "../support/helpers/seed-client";
import { buildSchedulesRoute } from "@/utils/host-routes";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
@@ -76,6 +79,107 @@ test.describe("Command Center agent controls", () => {
} }
}); });
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
// model. Asserting both model and mode catches either row winning.
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-relevance-",
title: "Command Center relevance",
model: "five-minute-stream",
});
try {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
const input = await openCommandCenterForAgent(page, workspace);
await input.fill("load");
await page.keyboard.press("Enter");
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
} finally {
await workspace.cleanup();
}
});
test("matches query tokens in any order", async ({ page }) => {
// "Mode › Load test" flattens to "mode load test", so the reversed query "test load" is never
// a substring of it. Only per-token matching finds this row.
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-tokenized-",
title: "Command Center tokenized search",
model: "five-minute-stream",
});
try {
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await openCommandCenter(page);
await chooseCommandCenterAgentControl({
page,
query: "test load",
choice: "Mode › Load test",
});
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
} finally {
await workspace.cleanup();
}
});
test("keeps an action first when the action is the best match", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-action-priority-",
title: "Command Center action priority",
model: "five-minute-stream",
});
try {
const input = await openCommandCenterForAgent(page, workspace);
await input.fill("sched");
await page.keyboard.press("Enter");
await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 });
} finally {
await workspace.cleanup();
}
});
test("drops an arrow-key selection once the query changes", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-active-reset-",
title: "Command Center active reset",
model: "five-minute-stream",
});
try {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
const input = await openCommandCenterForAgent(page, workspace);
await input.fill("loa");
// Moves the highlight onto a model row, which still matches after the next keystroke.
await page.keyboard.press("ArrowDown");
await input.fill("load");
await page.keyboard.press("Enter");
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
} finally {
await workspace.cleanup();
}
});
test("applies draft model and setting choices to the created agent", async ({ page }) => { test("applies draft model and setting choices to the created agent", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" }); const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
await seedMockDraftPreferences(page); await seedMockDraftPreferences(page);
@@ -1,53 +1,14 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { expect } from "@playwright/test";
import { test } from "../support/fixtures"; import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app"; import { gotoAppShell } from "../support/helpers/app";
import { createIdleAgent } from "../support/helpers/archive-tab"; import { createIdleAgent } from "../support/helpers/archive-tab";
import { openCommandCenter } from "../support/helpers/command-center"; import { openCommandCenter } from "../support/helpers/command-center";
import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { expectAgentTabActive } from "../support/helpers/launcher"; import { expectAgentTabActive } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client"; import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
const PRIMARY_HOST_LABEL = "Primary Host";
const SECONDARY_HOST_ID = "host-command-center-secondary";
test.describe("Command center host labels", () => { test.describe("Command center host labels", () => {
test.describe.configure({ timeout: 180_000 }); test.describe.configure({ timeout: 180_000 });
test("agent results show the host they live on when more than one host exists", async ({
page,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
const title = `cc-host-${randomUUID().slice(0, 8)}`;
try {
const agent = await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title,
});
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
serverId: SECONDARY_HOST_ID,
label: "Secondary Host",
primaryLabel: PRIMARY_HOST_LABEL,
});
const panel = await openCommandCenter(page);
// The shared daemon may carry agents from other specs, so target this agent by its id.
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(title);
await expect(row).toContainText(PRIMARY_HOST_LABEL);
} finally {
await seeded.cleanup();
}
});
test("selecting an agent result opens its workspace tab", async ({ page }) => { test("selecting an agent result opens its workspace tab", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" }); const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
const title = `cc-navigation-${randomUUID().slice(0, 8)}`; const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { Locator } from "@playwright/test"; import type { Locator } from "@playwright/test";
import { test, expect } from "../support/fixtures"; import { test, expect } from "../support/fixtures";
import { openChangesPanel } from "../support/helpers/workspace-tabs"; import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
const COMMIT_SUBJECT = "Show commit timestamps"; const COMMIT_SUBJECT = "Show commit timestamps";
@@ -15,7 +15,7 @@ test("commit history explains when the workspace has no commits ahead of its bas
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" }); execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
await workspace.navigateTo(); await workspace.navigateTo();
await openChangesPanel(page); await openChangesTreePanel(page);
const commitsSection = page.getByRole("button", { name: /Commits/i }); const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 }); await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click(); await commitsSection.click();
@@ -35,7 +35,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
await page.setViewportSize({ width: 1400, height: 900 }); await page.setViewportSize({ width: 1400, height: 900 });
await workspace.navigateTo(); await workspace.navigateTo();
await openChangesPanel(page); await openChangesTreePanel(page);
const commitsSection = page.getByRole("button", { name: /Commits/i }); const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 }); await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click(); await commitsSection.click();
@@ -60,8 +60,8 @@ test("commit history shows dates and shares diff layout preferences", async ({
throw new Error("Commit-diff toolbar geometry could not be measured"); throw new Error("Commit-diff toolbar geometry could not be measured");
} }
expect(commitToolbarBox.height).toBe(36); expect(commitToolbarBox.height).toBe(36);
expect(layoutToggleBox.width).toBe(24); expect(layoutToggleBox.width).toBe(20);
expect(layoutToggleBox.height).toBe(24); expect(layoutToggleBox.height).toBe(20);
expect(layoutToggleGlyphBox.width).toBe(14); expect(layoutToggleGlyphBox.width).toBe(14);
expect(layoutToggleGlyphBox.height).toBe(14); expect(layoutToggleGlyphBox.height).toBe(14);
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff"); await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
@@ -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-"); const workspace = await seedChangedAgent("composer-diff-stat-side-");
try { try {
@@ -54,31 +57,24 @@ test("composer diff stat toggles Changes in the preferred Side panel", async ({
await expect(pill).toContainText("-0"); await expect(pill).toContainText("-0");
await pill.click(); await pill.click();
const sidePanel = page.getByTestId("workspace-side-panel").filter({ visible: true }); const sidePane = page
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible({ .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, 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 openFilesPanel(page);
await expect(sidePanel.getByTestId("file-explorer-tree-scroll")).toBeVisible(); await expect(page.getByTestId("workspace-explorer-sidebar")).toContainText("Files");
await pill.click(); await pill.click();
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible(); await expect(sidePane.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(page.getByTestId("workspace-tab-working_diff")).toHaveCount(1); 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 { } finally {
await workspace.cleanup(); 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 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); await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
} finally { } finally {
await workspace.cleanup(); await workspace.cleanup();
} }
}); });
test("composer diff stat opens Changes in the focused pane when Side panel routing is off", async ({ test("composer diff stat opens Changes in the focused pane by default", async ({ page }) => {
page,
}) => {
await page.addInitScript((settingsKey) => {
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
}, APP_SETTINGS_KEY);
const workspace = await seedChangedAgent("composer-diff-stat-tab-"); const workspace = await seedChangedAgent("composer-diff-stat-tab-");
try { try {
@@ -125,7 +118,9 @@ test("composer diff stat opens Changes in the focused pane when Side panel routi
timeout: 30_000, timeout: 30_000,
}); });
await expect(mainPane.getByTestId("working-diff-panel")).toBeVisible({ 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 { } finally {
await workspace.cleanup(); await workspace.cleanup();
} }
@@ -99,6 +99,7 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
repoPrefix: "composer-bottom-anchor-", repoPrefix: "composer-bottom-anchor-",
title: "Composer bottom anchor", title: "Composer bottom anchor",
initialPrompt: "Produce enough content to make the chat scrollable.", initialPrompt: "Produce enough content to make the chat scrollable.",
model: "ten-second-stream",
}); });
try { try {
@@ -1,7 +1,8 @@
import { expect, test } from "../support/fixtures"; import { expect, test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher"; import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client"; import { seedWorkspace } from "../support/helpers/seed-client";
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
test.describe("Direct terminal shortcut pane placement", () => { test.describe("Direct terminal shortcut pane placement", () => {
test("opens a terminal in the focused pane", async ({ page }) => { test("opens a terminal in the focused pane", async ({ page }) => {
@@ -11,21 +12,31 @@ test.describe("Direct terminal shortcut pane placement", () => {
await gotoWorkspace(page, workspace.workspaceId); await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page); await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const focusedPane = page const focusedPaneChild = page
.getByTestId("workspace-tabs-row") .getByTestId("split-group-child")
.filter({ has: page.getByTestId("workspace-tab-working_diff") }) .filter({ has: page.getByTestId("workspace-new-tab-panel") });
.first(); const originalPaneChild = page
const primaryPane = page .getByTestId("split-group-child")
.getByTestId("workspace-tabs-row") .filter({ hasNot: page.getByTestId("workspace-new-tab-panel") });
.filter({ hasNot: page.getByTestId("workspace-tab-working_diff") }) await expect(focusedPaneChild).toBeVisible();
.first(); const focusedPaneId = await focusedPaneChild
.locator('[data-testid^="workspace-pane-"]')
.getAttribute("data-testid");
const originalPaneId = await originalPaneChild
.locator('[data-testid^="workspace-pane-"]')
.getAttribute("data-testid");
expect(focusedPaneId).not.toBeNull();
expect(originalPaneId).not.toBeNull();
const focusedPane = page.getByTestId(focusedPaneId!);
const originalPane = page.getByTestId(originalPaneId!);
await pressDirectNewTabShortcut(page, "t"); await pressDirectNewTabShortcut(page, "t");
await expect(focusedPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(1, { await expect(focusedPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(1, {
timeout: 30_000, timeout: 30_000,
}); });
await expect(primaryPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0); await expect(originalPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
} finally { } finally {
await workspace.cleanup(); await workspace.cleanup();
} }
@@ -13,38 +13,25 @@ async function expectLauncherSelection(launcher: Locator, name: string): Promise
await expect(launcher.getByRole("button", { name, exact: true })).toBeFocused(); 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"; const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+E`); await page.keyboard.press(`${modifier}+E`);
const launcher = page const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
.getByTestId("workspace-side-panel") await expect(explorer).toBeVisible({ timeout: 30_000 });
.filter({ visible: true }) return explorer;
.getByTestId("workspace-new-tab-panel");
await expect(launcher).toBeVisible({ timeout: 30_000 });
return launcher;
} }
test.describe("New tab keyboard launcher", () => { test.describe("New tab keyboard launcher", () => {
test("Cmd+E focuses the launcher and Enter opens the selected view", async ({ page }) => { test("Cmd+E reveals the repository Changes tree", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-side-panel-keyboard-" }); const workspace = await seedWorkspace({ repoPrefix: "explorer-keyboard-" });
try { try {
await gotoWorkspace(page, workspace.workspaceId); await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
const launcher = await openSidePanelWithKeyboard(page); const explorer = await openExplorerWithKeyboard(page);
await expectLauncherSelection(launcher, "Changes"); await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
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 });
} finally { } finally {
await workspace.cleanup(); await workspace.cleanup();
} }
@@ -65,14 +52,12 @@ test.describe("New tab keyboard launcher", () => {
await page.keyboard.press("ArrowDown"); await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Terminal"); await expectLauncherSelection(launcher, "Terminal");
await page.keyboard.press("ArrowDown"); await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Changes"); await expectLauncherSelection(launcher, "Diff");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Files");
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible({ await expect(
timeout: 30_000, page.getByTestId("workspace-tab-working_diff").filter({ visible: true }),
}); ).toBeVisible({ timeout: 30_000 });
} finally { } finally {
await workspace.cleanup(); await workspace.cleanup();
} }
@@ -8,7 +8,10 @@ import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures"; import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher"; import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client"; import { seedWorkspace } from "../support/helpers/seed-client";
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; import {
openChangesTreePanel,
waitForWorkspaceTabsVisible,
} from "../support/helpers/workspace-tabs";
function visible(page: Page, testId: string): Locator { function visible(page: Page, testId: string): Locator {
return page.getByTestId(testId).filter({ visible: true }); return page.getByTestId(testId).filter({ visible: true });
@@ -22,23 +25,18 @@ function draftTabChip(page: Page): Locator {
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true }); return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
} }
/** The explorer pane is the only tab row carrying the Changes tab. */ /** The Explorer has its own fixed tab rail, separate from workspace pane rows. */
function explorerTabRow(page: Page): Locator { function explorerTabRow(page: Page): Locator {
return visible(page, "workspace-tabs-row").filter({ return visible(page, "explorer-sidebar-tab-rail");
has: page.getByTestId("workspace-tab-working_diff"),
});
} }
function mainTabRow(page: Page): Locator { function mainTabRow(page: Page): Locator {
return visible(page, "workspace-tabs-row").filter({ return visible(page, "workspace-tabs-row");
hasNot: page.getByTestId("workspace-tab-working_diff"),
});
} }
async function focusExplorerPane(page: Page): Promise<void> { async function selectExplorerChanges(page: Page): Promise<void> {
await openChangesPanel(page); await openChangesTreePanel(page);
const changesTab = visible(page, "workspace-tab-working_diff").first(); await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
await expect(changesTab).toHaveAttribute("aria-selected", "true");
} }
async function closeSeededDraftInMainPane(page: Page): Promise<void> { async function closeSeededDraftInMainPane(page: Page): Promise<void> {
@@ -115,8 +113,8 @@ test.describe("explorer pane tab placement", () => {
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 }); await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
}); });
await test.step("focus the explorer pane", async () => { await test.step("select Changes in Explorer", async () => {
await focusExplorerPane(page); await selectExplorerChanges(page);
}); });
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => { await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
@@ -140,11 +138,8 @@ test.describe("explorer pane tab placement", () => {
await expect( await expect(
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`), explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toHaveCount(0); ).toHaveCount(0);
// The background open must not steal the explorer pane's focus. // The background open must not change the selected Explorer view.
await expect(visible(page, "workspace-tab-working_diff").first()).toHaveAttribute( await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
"aria-selected",
"true",
);
}); });
await test.step("close the draft; the agent is the main pane's only tab", async () => { await test.step("close the draft; the agent is the main pane's only tab", async () => {
@@ -170,8 +165,9 @@ test.describe("explorer pane tab placement", () => {
body: await page.screenshot(), body: await page.screenshot(),
contentType: "image/png", contentType: "image/png",
}); });
// The split must have taken: agent pane + New main + explorer = 3 tab rows. // The split must have taken: agent pane + New main. Explorer keeps its own rail.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 }); await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
await expect(explorerTabRow(page)).toHaveCount(1);
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible(); 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")); return page.evaluate(() => localStorage.getItem("panel-state"));
} }
/** Only the docked sidebar ever rendered `explorer-header` on a wide layout. */ function explorerSidebar(page: Page): Locator {
function dockedSidebar(page: Page): Locator { return visible(page, "workspace-explorer-sidebar");
return visible(page, "explorer-header");
}
/** The desktop companion surface is a hideable side panel. */
function sidePanel(page: Page): Locator {
return visible(page, "workspace-side-panel");
} }
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */ /** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
@@ -65,18 +59,18 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await gotoWorkspace(page, workspace.workspaceId); await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await test.step("no docked sidebar, on load or after toggling", async () => { await test.step("the retired flag does not reveal Explorer", async () => {
await expect(dockedSidebar(page)).toHaveCount(0); await expect(explorerSidebar(page)).toHaveCount(0);
const toggle = visible(page, "workspace-explorer-toggle").first(); const toggle = visible(page, "workspace-explorer-toggle").first();
await toggle.click(); await toggle.click();
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 }); await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible(); await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
await expect(dockedSidebar(page)).toHaveCount(0); await expect(
explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"),
).toBeVisible();
await toggle.click(); await toggle.click();
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 }); await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
await expect(dockedSidebar(page)).toHaveCount(0);
}); });
await test.step("the retired keys are migrated out of storage", async () => { await test.step("the retired keys are migrated out of storage", async () => {
@@ -107,22 +101,22 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await test.step("open the explorer so both sides are showing", async () => { await test.step("open the explorer so both sides are showing", async () => {
await toggle.click(); await toggle.click();
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 }); await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible(); await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
await expect(agentList).toHaveCount(1); await expect(agentList).toHaveCount(1);
}); });
await test.step("the shortcut collapses both", async () => { await test.step("the shortcut collapses both", async () => {
await page.keyboard.press(`${modifier}+.`); await page.keyboard.press(`${modifier}+.`);
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 }); await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
await expect(agentList).toHaveCount(0); await expect(agentList).toHaveCount(0);
}); });
await test.step("the shortcut restores both", async () => { await test.step("the shortcut restores both", async () => {
await page.keyboard.press(`${modifier}+.`); await page.keyboard.press(`${modifier}+.`);
await expect(agentList).toHaveCount(1, { timeout: 15_000 }); await expect(agentList).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 }); await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible(); await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
}); });
} finally { } finally {
await workspace.cleanup(); await workspace.cleanup();
@@ -9,6 +9,8 @@ import {
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const APP_SETTINGS_KEY = "@paseo:app-settings";
const RED_PIXEL = Buffer.from( const RED_PIXEL = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=", "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
"base64", "base64",
@@ -218,6 +220,12 @@ test.describe("CodeMirror workspace file editing", () => {
}); });
test("clicking the editor focuses its pane beside an agent", async ({ page }) => { test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
await page.addInitScript((settingsKey) => {
localStorage.setItem(
settingsKey,
JSON.stringify({ openInSidePane: { explorerFiles: true } }),
);
}, APP_SETTINGS_KEY);
const target = "target.ts:42"; const target = "target.ts:42";
const session = await seedAgentWithFileLink({ const session = await seedAgentWithFileLink({
target, target,
@@ -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, countTabsOfKind,
waitForTabBar, waitForTabBar,
pressNewTabShortcut, pressNewTabShortcut,
pressDirectNewTabShortcut,
getTabTestIds, getTabTestIds,
waitForTabWithTitle, waitForTabWithTitle,
measureTileTransition, measureTileTransition,
@@ -177,13 +178,11 @@ test.describe("Tab creation", () => {
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true }); const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
const agent = panel.getByRole("button", { name: /^Agent/ }); const agent = panel.getByRole("button", { name: /^Agent/ });
const terminal = panel.getByRole("button", { name: /^Terminal/ }); const terminal = panel.getByRole("button", { name: /^Terminal/ });
const changes = panel.getByRole("button", { name: /Changes/ }); const diff = panel.getByRole("button", { name: /Diff/ });
const files = panel.getByRole("button", { name: /Files/ });
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/; const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`)); await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`)); await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*G`)); await expect(diff).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
await expect(agent).toBeFocused(); await expect(agent).toBeFocused();
await page.keyboard.press("ArrowDown"); await page.keyboard.press("ArrowDown");
@@ -191,6 +190,12 @@ test.describe("Tab creation", () => {
await page.keyboard.press("ArrowUp"); await page.keyboard.press("ArrowUp");
await expect(agent).toBeFocused(); 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 page.locator("body").click({ position: { x: 1, y: 1 } });
await panel.click({ position: { x: 20, y: 20 } }); await panel.click({ position: { x: 20, y: 20 } });
await expect(agent).toBeFocused(); 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 test.step("launching a terminal profile preselects it the next time New workspace opens", async () => {
await openNewWorkspace(); await openNewWorkspace();
await selectLaunchOption(page, PROMPT_PROFILE.id); await selectLaunchOption(page, PROMPT_PROFILE.id);
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
await fillTerminalPrompt(page, "remember me"); await fillTerminalPrompt(page, "remember me");
await submitTerminalLaunch(page); await submitTerminalLaunch(page);
await expectWorkspaceOpensWithTerminalTab(page); await expectWorkspaceOpensWithTerminalTab(page);
@@ -88,6 +89,7 @@ test.describe("New workspace: launch target memory", () => {
await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => { await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => {
await selectLaunchOption(page, PROMPT_PROFILE.id); await selectLaunchOption(page, PROMPT_PROFILE.id);
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
await fillTerminalPrompt(page, "second run"); await fillTerminalPrompt(page, "second run");
await submitTerminalLaunch(page); await submitTerminalLaunch(page);
await expectWorkspaceOpensWithTerminalTab(page); await expectWorkspaceOpensWithTerminalTab(page);
@@ -153,6 +153,7 @@ test.describe("Rewind sheet", () => {
repoPrefix: "rewind-e2e-", repoPrefix: "rewind-e2e-",
title: "Rewind e2e", title: "Rewind e2e",
initialPrompt: firstPrompt, initialPrompt: firstPrompt,
model: "ten-second-stream",
}); });
try { try {
@@ -70,73 +70,55 @@ test.describe("History search", () => {
await tempRepo?.cleanup(); await tempRepo?.cleanup();
}); });
test("typing narrows history to matching sessions and clearing restores them", async ({ test("searches, ranks, highlights, and clears session history", async ({ page }) => {
page,
}) => {
await resetSeededPageState(page); await resetSeededPageState(page);
await openSessions(page); await openSessions(page);
// Seeded newest-first, and at rest history is chronological. await test.step("narrows history and restores chronological grouping", async () => {
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]); await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 }); await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
timeout: 30_000,
});
await search(page, `${NONCE} billing`); await search(page, `${NONCE} billing`);
await expectVisibleTitles(page, [TITLES.billing]); await expectVisibleTitles(page, [TITLES.billing]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
// Ranked results are one flat list — a day heading would claim an order await page.getByTestId("sessions-search-clear").click();
// the list no longer has. await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, { timeout: 30_000 }); await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
timeout: 30_000,
});
});
await page.getByTestId("sessions-search-clear").click(); await test.step("ranks whole-word matches above substrings", async () => {
await expect(page.getByTestId("sessions-search-input")).toHaveValue(""); await search(page, `${NONCE} bill`);
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]); await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 }); });
});
test("ranks a whole-word hit above one buried inside a word", async ({ page }) => { await test.step("highlights exact and typo-resolved matches", async () => {
await resetSeededPageState(page); await search(page, `${NONCE} billing`);
await openSessions(page); const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
// "bill" starts a word in "billing" and hides inside "unbilled", so the await search(page, `${NONCE} bulling`);
// stronger match leads even though both sessions are equally recent. await expectVisibleTitles(page, [TITLES.billing]);
await search(page, `${NONCE} bill`); const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]); await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({
}); timeout: 30_000,
});
});
test("marks the characters each result matched on", async ({ page }) => { await test.step("distinguishes no matches from empty history", async () => {
await resetSeededPageState(page); await search(page, `${NONCE} kubernetes`);
await openSessions(page); await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
// The mark is a nested Text run, so the matched slice is its own element. await page.getByText("Clear search").click();
await search(page, `${NONCE} billing`); await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first(); });
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
// A typo has no characters in the text to point at, so the whole word it
// resolved to is marked.
await search(page, `${NONCE} bulling`);
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
});
test("finds a session through a typo", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
await search(page, `${NONCE} bulling`);
await expectVisibleTitles(page, [TITLES.billing]);
});
test("says the query found nothing, not that history is empty", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
await search(page, `${NONCE} kubernetes`);
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
await page.getByText("Clear search").click();
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
}); });
}); });
@@ -121,6 +121,41 @@ test.describe("Settings sidebar navigation", () => {
await clickSettingsBackToWorkspace(page); await clickSettingsBackToWorkspace(page);
await expect(page).not.toHaveURL(/\/settings(\/|$)/); await expect(page).not.toHaveURL(/\/settings(\/|$)/);
}); });
test("pressing Escape closes settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await page.keyboard.press("Escape");
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
test("Escape lets settings dropdowns and modals close before leaving settings", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
await test.step("a dropdown owns Escape", async () => {
await openSettingsSection(page, "appearance");
await page.getByLabel(/Theme:/).click();
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/settings(\/|$)/);
});
await test.step("a modal owns Escape", async () => {
await openAddHostFlow(page);
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByText("Add connection", { exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/settings(\/|$)/);
});
});
}); });
test.describe("Settings — compact master-detail", () => { test.describe("Settings — compact master-detail", () => {
-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(); await popup.close();
} }
test("opens troubleshooting tools from the sidebar help menu", async ({ page }) => { test("opens troubleshooting, support, and release destinations", async ({ page }) => {
await gotoAppShell(page); await gotoAppShell(page);
await expect(page.getByTestId("sidebar-help")).toBeVisible(); await expect(page.getByTestId("sidebar-help")).toBeVisible();
await openHelpMenu(page); await test.step("opens diagnostics and keyboard shortcuts", async () => {
const triggerBox = await page.getByTestId("sidebar-help").evaluate((element) => { await openHelpMenu(page);
const { y, height } = element.getBoundingClientRect(); await expect(page.getByText("Help", { exact: true })).toBeVisible();
return { y, height }; await expect(page.getByText("Report an issue", { exact: true })).toBeVisible();
await expect(page.getByText("What's new", { exact: true })).toBeVisible();
await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION);
await page.getByTestId("sidebar-help-diagnostics").click();
await expectDiagnosticReport(page);
await closeSheet(page, "app-diagnostic-sheet");
await openHelpMenu(page);
await page.getByTestId("sidebar-help-shortcuts").click();
await expect(page.getByTestId("keyboard-shortcuts-dialog")).toBeVisible();
await closeSheet(page, "keyboard-shortcuts-dialog");
}); });
const menuBox = await page.getByTestId("sidebar-help-menu").evaluate((element) => {
const { y, height } = element.getBoundingClientRect(); await test.step("opens support and release pages", async () => {
return { y, height }; await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
}); });
expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(triggerBox.y);
await expect(page.getByText("Help", { exact: true })).toBeVisible();
await expect(page.getByText("Report an issue", { exact: true })).toBeVisible();
await expect(page.getByText("What's new", { exact: true })).toBeVisible();
await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION);
await page.getByTestId("sidebar-help-diagnostics").click();
await expectDiagnosticReport(page);
await closeSheet(page, "app-diagnostic-sheet");
await openHelpMenu(page);
await page.getByTestId("sidebar-help-shortcuts").click();
await expect(page.getByTestId("keyboard-shortcuts-dialog")).toBeVisible();
await closeSheet(page, "keyboard-shortcuts-dialog");
});
test("opens support and release destinations", async ({ page }) => {
await gotoAppShell(page);
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
await openHelpMenu(page);
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
}); });
test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => { test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => {
@@ -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.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({ const workspace = await seedWorkspace({
repoPrefix: "pin-project-creation-", repoPrefix: "pin-project-creation-",
title: "Pinned workspace", title: "Pinned workspace",
}); });
try { try {
const gate = await installPinRpcGate(page);
await gotoAppShell(page); await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId); await openWorkspace(page, workspace.workspaceId);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
await openNewWorkspaceComposer(page, workspace); await test.step("sends one RPC for each pin transition", async () => {
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName); 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 { } finally {
await workspace.cleanup(); 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 }) => { test("pins the active workspace while its status group is collapsed", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" }); const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
@@ -118,19 +118,6 @@ test.describe("Sidebar workspace list", () => {
} }
}); });
test("project shows workspace under it", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-workspace-under-project-" });
try {
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
await waitForSidebarWorkspace(page, workspace.workspaceId);
} finally {
await workspace.cleanup();
}
});
test("non-git project shows directory name", async ({ page }) => { test("non-git project shows directory name", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false }); const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false });
@@ -319,7 +306,7 @@ test.describe("Half-screen desktop layout", () => {
await expect(closedIcon).toBeVisible(); await expect(closedIcon).toBeVisible();
const closedBounds = await closedIcon.boundingBox(); const closedBounds = await closedIcon.boundingBox();
expect(closedBounds).not.toBeNull(); expect(closedBounds).not.toBeNull();
expect(closedBounds?.x).toBeCloseTo(12, 0); expect(closedBounds?.x).toBeCloseTo(9, 0);
expect(closedBounds?.y).toBe(openBounds?.y); expect(closedBounds?.y).toBe(openBounds?.y);
}); });
@@ -342,16 +329,19 @@ test.describe("Half-screen desktop layout", () => {
await openFilesPanel(page); await openFilesPanel(page);
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first(); const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible(); await expect(
await expect(explorerToggle).toHaveAccessibleName("Close side panel"); page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
).toBeVisible();
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2); await expect(page.getByTestId("explorer-sidebar-tab-rail")).toBeVisible();
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(1);
await explorerToggle.click(); await explorerToggle.click();
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount( await expect(
0, page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
); ).toHaveCount(0);
await expect(explorerToggle).toHaveAccessibleName("Open side panel"); await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
} finally { } finally {
await workspace.cleanup(); await workspace.cleanup();
@@ -11,35 +11,37 @@ test.describe("Workspace lifecycle", () => {
// One retry is enough for the daemon to stabilize. // One retry is enough for the daemon to stabilize.
test.describe.configure({ retries: 1 }); test.describe.configure({ retries: 1 });
test.describe("Main checkout", () => { test("main checkout creates an agent chat and terminal in the project root", async ({
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => { page,
test.setTimeout(60_000); withWorkspace,
const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" }); }) => {
await workspace.navigateTo(); test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "lifecycle-main-" });
await workspace.navigateTo();
await test.step("creates an agent chat", async () => {
await createAgentChatFromLauncher(page); await createAgentChatFromLauncher(page);
}); });
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => { await test.step("creates a terminal in the project root", async () => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" });
await workspace.navigateTo();
await createStandaloneTerminalFromLauncher(page); await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, workspace.repoPath); await expectTerminalCwd(page, workspace.repoPath);
}); });
}); });
test.describe("Worktree workspace", () => { test("worktree creates an agent chat and terminal in its directory", async ({
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => { page,
test.setTimeout(90_000); withWorkspace,
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" }); }) => {
await workspace.navigateTo(); test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-" });
await workspace.navigateTo();
await test.step("creates an agent chat", async () => {
await createAgentChatFromLauncher(page); await createAgentChatFromLauncher(page);
}); });
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => { await test.step("creates a terminal in the worktree", async () => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" });
await workspace.navigateTo();
await createStandaloneTerminalFromLauncher(page); await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, workspace.repoPath); await expectTerminalCwd(page, workspace.repoPath);
}); });
@@ -1,6 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { execSync, spawn, type ChildProcess } from "node:child_process"; import type { ChildProcess } from "node:child_process";
import { once } from "node:events";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -23,6 +22,7 @@ import {
submitNewWorkspaceEmpty, submitNewWorkspaceEmpty,
} from "../support/helpers/new-workspace"; } from "../support/helpers/new-workspace";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar"; import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { killProcessTree, spawnTsx } from "../support/helpers/spawn-node";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs"; import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
@@ -195,9 +195,11 @@ async function getAvailablePort(): Promise<number> {
async function waitForServer(port: number, child: ChildProcess): Promise<void> { async function waitForServer(port: number, child: ChildProcess): Promise<void> {
const startedAt = Date.now(); const startedAt = Date.now();
let lastConnectionError: unknown = null; let lastConnectionError: unknown = null;
while (Date.now() - startedAt < 20_000) { while (Date.now() - startedAt < 90_000) {
if (child.exitCode !== null) { if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(`Restart test daemon exited before listening (exit ${child.exitCode}).`); throw new Error(
`Restart test daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).`,
);
} }
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -226,21 +228,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
); );
} }
async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}, 5000);
try {
await once(child, "exit");
} finally {
clearTimeout(timeout);
}
}
async function startRestartDaemon(input: { async function startRestartDaemon(input: {
paseoHome: string; paseoHome: string;
origin: string; origin: string;
@@ -251,8 +238,7 @@ async function startRestartDaemon(input: {
} }
const serverDir = path.resolve(__dirname, "../../../server"); const serverDir = path.resolve(__dirname, "../../../server");
const tsxBin = execSync("which tsx").toString().trim(); const child = spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], {
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir, cwd: serverDir,
env: withDisabledE2ESpeechEnv({ env: withDisabledE2ESpeechEnv({
...process.env, ...process.env,
@@ -276,7 +262,7 @@ async function startRestartDaemon(input: {
try { try {
await waitForServer(port, child); await waitForServer(port, child);
} catch (error) { } catch (error) {
await stopProcess(child); await killProcessTree(child);
throw new Error( throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`, `${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error }, { cause: error },
@@ -285,7 +271,7 @@ async function startRestartDaemon(input: {
return { return {
port, port,
close: () => stopProcess(child), close: () => killProcessTree(child),
}; };
} }
@@ -370,22 +356,6 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
return decodeWorkspaceIdFromPathSegment(match[1]); return decodeWorkspaceIdFromPathSegment(match[1]);
} }
async function expectWorkspaceRowHasOnlyIndicator(
page: Page,
input: { serverId: string; workspaceId: string; indicator: string },
) {
const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
for (const indicator of ["attention", "done", "failed", "loading", "needs_input", "running"]) {
const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`);
if (indicator === input.indicator) {
await expect(locator).toBeVisible({ timeout: 30_000 });
} else {
await expect(locator).toHaveCount(0);
}
}
}
async function expectWorkspaceRowDoesNotShowIndicator( async function expectWorkspaceRowDoesNotShowIndicator(
page: Page, page: Page,
input: { serverId: string; workspaceId: string; indicator: string }, input: { serverId: string; workspaceId: string; indicator: string },
@@ -458,11 +428,6 @@ test.describe("Workspace model restart regressions", () => {
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA)); await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
await waitForSidebarHydration(page); await waitForSidebarHydration(page);
await expectWorkspaceRowHasOnlyIndicator(page, {
serverId,
workspaceId: seeded.workspaceA,
indicator: "running",
});
await expectWorkspaceRowDoesNotShowIndicator(page, { await expectWorkspaceRowDoesNotShowIndicator(page, {
serverId, serverId,
workspaceId: seeded.workspaceB, workspaceId: seeded.workspaceB,
@@ -1,5 +1,5 @@
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import type { Locator } from "@playwright/test"; import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures"; import { expect, test } from "../support/fixtures";
import { createIdleAgent } from "../support/helpers/archive-tab"; import { createIdleAgent } from "../support/helpers/archive-tab";
import { expectComposerVisible } from "../support/helpers/composer"; import { expectComposerVisible } from "../support/helpers/composer";
@@ -35,10 +35,22 @@ async function expectSameRenderedNode(
); );
} }
async function expectNodeConnected(node: Awaited<ReturnType<typeof captureRenderedNode>>) {
expect(await node.evaluate((candidate) => candidate.isConnected)).toBe(true);
}
async function getSettingsShortcut(page: Page) {
return page.evaluate(() =>
navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,",
);
}
async function waitForWorkspaceRoute(page: Page, route: string) {
await page.waitForURL((url) => url.pathname === route);
}
test.describe("Workspace pane mounting", () => { test.describe("Workspace pane mounting", () => {
test("opening the first split pane keeps the existing agent composer mounted", async ({ test("workspace navigation keeps the existing agent composer mounted", async ({ page }) => {
page,
}) => {
test.setTimeout(90_000); test.setTimeout(90_000);
const serverId = getServerId(); const serverId = getServerId();
@@ -65,55 +77,35 @@ test.describe("Workspace pane mounting", () => {
.first() .first()
.elementHandle(); .elementHandle();
expect(originalComposer).not.toBeNull(); 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 runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount( await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
emptyPanesBeforeSplit + 1, emptyPanesBeforeSplit + 1,
{ timeout: 30_000 }, { timeout: 30_000 },
); );
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(1); await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
1,
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected); );
expect(originalStillConnected).toBe(true); await expectNodeConnected(originalComposer!);
} 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 page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
await waitForWorkspaceTabsVisible(page);
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first(); const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
const originalComposer = await captureRenderedNode(composer); await test.step("desktop Settings closes overlays and preserves the composer", async () => {
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
await tab.click({ button: "right" });
await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click();
const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`);
await expect(renameInput).toBeVisible();
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first(); const settingsShortcut = await getSettingsShortcut(page);
await tab.click({ button: "right" }); await page.keyboard.press(settingsShortcut);
await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click(); await expect(page).toHaveURL(/\/settings\/general$/);
const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`); await expect(renameInput).not.toBeVisible();
await expect(renameInput).toBeVisible(); await clickSettingsBackToWorkspace(page);
await expectSameRenderedNode(originalComposer!, composer);
const settingsShortcut = await page.evaluate(() => });
navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,",
);
await page.keyboard.press(settingsShortcut);
await expect(page).toHaveURL(/\/settings\/general$/);
await expect(renameInput).not.toBeVisible();
await clickSettingsBackToWorkspace(page);
await expectSameRenderedNode(originalComposer, composer);
} finally { } finally {
await workspace.cleanup(); await workspace.cleanup();
} }
@@ -140,18 +132,17 @@ test.describe("Workspace pane mounting", () => {
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first(); const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
const originalComposer = await captureRenderedNode(composer); const originalComposer = await captureRenderedNode(composer);
await openCompactSettings(page, buildHostWorkspaceRoute(serverId, workspace.workspaceId)); const workspaceRoute = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
await openCompactSettings(page, workspaceRoute);
await page.goBack(); await page.goBack();
await page.waitForURL( await waitForWorkspaceRoute(page, workspaceRoute);
(url) => url.pathname === buildHostWorkspaceRoute(serverId, workspace.workspaceId),
);
await expectSameRenderedNode(originalComposer, composer); await expectSameRenderedNode(originalComposer, composer);
} finally { } finally {
await workspace.cleanup(); await workspace.cleanup();
} }
}); });
test("switching away from a terminal tab keeps its emulator mounted", async ({ page }) => { test("workspace navigation keeps the terminal emulator mounted", async ({ page }) => {
test.setTimeout(90_000); test.setTimeout(90_000);
const serverId = getServerId(); const serverId = getServerId();
const workspace = await seedWorkspace({ repoPrefix: "terminal-pane-retention-" }); const workspace = await seedWorkspace({ repoPrefix: "terminal-pane-retention-" });
@@ -170,39 +161,20 @@ test.describe("Workspace pane mounting", () => {
const terminalSurface = terminalSurfaceLocator(page); const terminalSurface = terminalSurfaceLocator(page);
const originalTerminal = await captureRenderedNode(terminalSurface); const originalTerminal = await captureRenderedNode(terminalSurface);
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click(); await test.step("switching tabs preserves the terminal", async () => {
await expectComposerVisible(page); await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
expect(await originalTerminal.evaluate((node) => node.isConnected)).toBe(true); await expectComposerVisible(page);
await expectNodeConnected(originalTerminal);
await clickFirstTerminalTab(page); await clickFirstTerminalTab(page);
await expectSameRenderedNode(originalTerminal, terminalSurface); await expectSameRenderedNode(originalTerminal, terminalSurface);
} finally {
await workspace.cleanup();
}
});
test("opening Settings and returning keeps the terminal emulator mounted", async ({ page }) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({ repoPrefix: "settings-terminal-retention-" });
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-terminal-retention-${Date.now()}`,
}); });
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId)); await test.step("opening Settings preserves the terminal", async () => {
await waitForWorkspaceTabsVisible(page); await openSettings(page);
await clickNewTerminal(page); await clickSettingsBackToWorkspace(page);
await expectTerminalSurfaceVisible(page); await expectSameRenderedNode(originalTerminal, terminalSurface);
const terminalSurface = terminalSurfaceLocator(page); });
const originalTerminal = await captureRenderedNode(terminalSurface);
await openSettings(page);
await clickSettingsBackToWorkspace(page);
await expectSameRenderedNode(originalTerminal, terminalSurface);
} finally { } finally {
await workspace.cleanup(); await workspace.cleanup();
} }
@@ -1,4 +1,4 @@
import { test, expect } from "../support/fixtures"; import { daemonTest, test, expect } from "../support/fixtures";
import { createTempGitRepo } from "../support/helpers/workspace"; import { createTempGitRepo } from "../support/helpers/workspace";
import { import {
closeSetupTab, 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 client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-success-", { const repo = await createTempGitRepo("setup-success-", {
paseoConfig: { 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 client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-none-"); 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 client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-scripts-", { const repo = await createTempGitRepo("setup-scripts-", {
paseoConfig: { paseoConfig: {
@@ -39,28 +39,6 @@ test.describe("Workspace archive with worktree backing", () => {
await tempRepo?.cleanup().catch(() => undefined); await tempRepo?.cleanup().catch(() => undefined);
}); });
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `archive-${Date.now()}`,
});
createdWorktreeDirectories.add(worktree.workspaceDirectory);
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
});
test("a managed worktree remains until its last workspace is archived", async ({ page }) => { test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
const serverId = getServerId(); const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path); await openProjectViaDaemon(client, tempRepo.path);
+13 -11
View File
@@ -32,16 +32,8 @@ const metroTest = base.extend({
}, },
}); });
const test = metroTest.extend< const daemonTest = metroTest.extend<
{ { projectOwnership: void },
paseoE2ESetup: void;
projectOwnership: void;
outdatedDaemon: OutdatedDaemon;
desktopManagedOutdatedDaemon: OutdatedDaemon;
relayConfigOutdatedDaemon: OutdatedDaemon;
projectPickerFixture: TrackedProjectPickerFixture;
withWorkspace: WithWorkspace;
},
{ e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient } { e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
>({ >({
e2eForkProviders: [[], { scope: "worker", option: true }], e2eForkProviders: [[], { scope: "worker", option: true }],
@@ -94,6 +86,16 @@ const test = metroTest.extend<
}, },
{ auto: true }, { auto: true },
], ],
});
const test = daemonTest.extend<{
paseoE2ESetup: void;
outdatedDaemon: OutdatedDaemon;
desktopManagedOutdatedDaemon: OutdatedDaemon;
relayConfigOutdatedDaemon: OutdatedDaemon;
projectPickerFixture: TrackedProjectPickerFixture;
withWorkspace: WithWorkspace;
}>({
paseoE2ESetup: [ paseoE2ESetup: [
async ({ page }, provide, testInfo) => { async ({ page }, provide, testInfo) => {
const daemonPort = getE2EDaemonPort(); const daemonPort = getE2EDaemonPort();
@@ -227,4 +229,4 @@ const test = metroTest.extend<
}, },
}); });
export { test, metroTest, expect, type Page }; export { daemonTest, test, metroTest, expect, type Page };
+6 -23
View File
@@ -1,5 +1,5 @@
import { spawn, type ChildProcess } from "node:child_process"; import { spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events"; import { killProcessTree } from "./helpers/spawn-node";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import net from "node:net"; import net from "node:net";
@@ -148,28 +148,11 @@ export async function warmMetro(port: number): Promise<void> {
} }
} }
async function stopProcess(child: ChildProcess | null): Promise<void> {
if (!child || child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
once(child, "exit"),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
resolve();
}, 5_000);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess { function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
const appDir = path.resolve(__dirname, "../.."); const appDir = path.resolve(__dirname, "../..");
const child = spawn("npx", ["expo", "start", "--web", "--port", String(port)], { const expoCli = require.resolve("expo/bin/cli");
// Spawns Node directly to bypass Windows .cmd shim execution restrictions without shell: true.
const child = spawn(process.execPath, [expoCli, "start", "--web", "--port", String(port)], {
cwd: appDir, cwd: appDir,
env: { env: {
...process.env, ...process.env,
@@ -216,11 +199,11 @@ export default async function globalSetup() {
console.log(`[e2e] Metro warmed on port ${metroPort}`); console.log(`[e2e] Metro warmed on port ${metroPort}`);
return async () => { return async () => {
await stopProcess(metroProcess); await killProcessTree(metroProcess);
console.log("[e2e] Metro stopped"); console.log("[e2e] Metro stopped");
}; };
} catch (error) { } catch (error) {
await stopProcess(metroProcess); await killProcessTree(metroProcess);
throw error; throw error;
} }
} }
@@ -1,27 +1,27 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { escapeRegex } from "./regex"; import { escapeRegex } from "./regex";
import { openChangesPanel as openWorkspaceChangesPanel } from "./workspace-tabs"; import { openChangesTreePanel } from "./workspace-tabs";
// The branch switcher lives in the Changes tab, not in the workspace header. It renders as a button whose // The branch switcher lives in the Changes tab, not in the workspace header. It renders as a button whose
// accessible name carries the current branch ("Current branch: <name>. Press to // accessible name carries the current branch ("Current branch: <name>. Press to
// switch branch."). Matching on the accessible name keeps these helpers tied to // switch branch."). Matching on the accessible name keeps these helpers tied to
// what a screen reader user hears, and it proves the panel resolved a real // what a screen reader user hears, and it proves the panel resolved a real
// checkout directory from the opaque workspace id. Scoping to the changes header // checkout directory from the opaque workspace id. Scoping to the repository
// keeps the matcher unambiguous even when the header is shared with diff actions. // header keeps the matcher aligned with the row that owns repository identity.
function branchSwitcherTrigger(page: Page, branchName: string) { function branchSwitcherTrigger(page: Page, branchName: string) {
return page return page
.getByTestId("changes-header") .getByTestId("changes-repository-header")
.getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) }) .getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) })
.filter({ visible: true }) .filter({ visible: true })
.first(); .first();
} }
// Opens the fixed pane and lands on the Changes tab, where the switcher and diff live. // Opens the fixed Explorer Changes view, where the switcher lives.
export async function openChangesPanel(page: Page): Promise<void> { export async function openChangesPanel(page: Page): Promise<void> {
await openWorkspaceChangesPanel(page); await openChangesTreePanel(page);
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({ await expect(
timeout: 30_000, page.getByTestId("changes-repository-header").filter({ visible: true }).first(),
}); ).toBeVisible({ timeout: 30_000 });
} }
export async function expectWorkspaceBranch(page: Page, branchName: string): Promise<void> { export async function expectWorkspaceBranch(page: Page, branchName: string): Promise<void> {
@@ -140,6 +140,15 @@ export async function expectActiveChatOutlinePrompt(page: Page, position: number
); );
} }
export async function expectActiveChatOutlinePromptMovedFrom(
page: Page,
position: number,
): Promise<void> {
const activePrompt = chatOutlineRail(page).getByRole("tab", { selected: true });
await expect(activePrompt).toHaveCount(1);
await expect(activePrompt).not.toHaveAccessibleName(new RegExp(`^${position} of `));
}
export async function expectLiveTurnPromptAboveFoldAndActive( export async function expectLiveTurnPromptAboveFoldAndActive(
page: Page, page: Page,
prompt: string, prompt: string,
@@ -1,12 +1,27 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Locator, type Page } from "@playwright/test";
import type { SeededWorkspace } from "./seed-client"; import type { SeededWorkspace } from "./seed-client";
import { openCommandCenter } from "./command-center"; import { openCommandCenter } from "./command-center";
import { openAgentRoute, type MockAgentWorkspace } from "./mock-agent";
export interface CommandCenterAgentControlChoice { export interface CommandCenterAgentControlChoice {
query: string; query: string;
choice: string; choice: string;
} }
/**
* Focuses a seeded mock agent — which is what makes its model and mode contributions register —
* opens the Command Center over it, and hands back the query input. Seeding stays in the test so
* the workspace lifecycle is owned by the `try`/`finally` that cleans it up.
*/
export async function openCommandCenterForAgent(
page: Page,
workspace: Pick<MockAgentWorkspace, "workspaceId" | "agentId">,
): Promise<Locator> {
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
const panel = await openCommandCenter(page);
return panel.getByTestId("command-center-input");
}
export async function chooseCommandCenterAgentControl(input: { export async function chooseCommandCenterAgentControl(input: {
page: Page; page: Page;
query: string; query: string;
@@ -1,6 +1,6 @@
import { fork, type ChildProcess } from "node:child_process"; import { fork, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import path from "node:path"; import path from "node:path";
import { killProcessTree } from "./spawn-node";
export interface OutdatedDaemon { export interface OutdatedDaemon {
endpoint: string; endpoint: string;
@@ -55,14 +55,10 @@ export async function startOutdatedDaemon(options?: {
endpoint: ready.endpoint, endpoint: ready.endpoint,
label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host", label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host",
serverId: ready.serverId, serverId: ready.serverId,
async close() { close: () => killProcessTree(child),
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
await once(child, "exit");
},
}; };
} catch (error) { } catch (error) {
child.kill("SIGTERM"); await killProcessTree(child);
throw error; throw error;
} }
} }
+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 { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -19,11 +19,19 @@ async function createFakeEditorBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-")); const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
let realGhPath = ""; let realGhPath = "";
try { try {
realGhPath = execSync("which gh").toString().trim(); const locator = process.platform === "win32" ? "where.exe" : "which";
const candidates = execFileSync(locator, ["gh"], { encoding: "utf8" })
.split(/\r?\n/u)
.map((candidate) => candidate.trim())
.filter(Boolean);
realGhPath =
candidates.find(
(candidate) =>
process.platform !== "win32" || !/\.(?:cmd|bat)$/iu.test(path.extname(candidate)),
) ?? "";
} catch { } catch {
// The local GitHub fixture remains usable without a system gh binary. // The local GitHub fixture remains usable without a system gh binary.
} }
const fakeEditorSource = `#!/usr/bin/env node const fakeEditorSource = `#!/usr/bin/env node
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
@@ -41,6 +49,9 @@ if (recordPath) {
const editorPath = path.join(binDir, editorCommand); const editorPath = path.join(binDir, editorCommand);
await writeFile(editorPath, fakeEditorSource); await writeFile(editorPath, fakeEditorSource);
await chmod(editorPath, 0o755); await chmod(editorPath, 0o755);
if (process.platform === "win32") {
await writeFile(`${editorPath}.cmd`, `@node "%~dp0${editorCommand}" %*\r\n`);
}
} }
const fakeGhPath = path.join(binDir, "gh"); const fakeGhPath = path.join(binDir, "gh");
@@ -106,6 +117,9 @@ process.exit(result.status ?? 1);
`; `;
await writeFile(fakeGhPath, fakeGhSource); await writeFile(fakeGhPath, fakeGhSource);
await chmod(fakeGhPath, 0o755); await chmod(fakeGhPath, 0o755);
if (process.platform === "win32") {
await writeFile(`${fakeGhPath}.cmd`, '@node "%~dp0gh" %*\r\n');
}
return binDir; return binDir;
} }
@@ -1,16 +1,10 @@
import { once } from "node:events"; import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process";
import {
spawn,
execFileSync,
execSync,
type ChildProcess,
type SpawnOptions,
} from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { mkdtemp, rm, writeFile } from "node:fs/promises";
import net from "node:net"; import net from "node:net";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { withDisabledE2ESpeechEnv } from "./speech-env"; import { withDisabledE2ESpeechEnv } from "./speech-env";
import { killProcessTree, spawnTsx } from "./spawn-node";
export interface IsolatedHostDaemon { export interface IsolatedHostDaemon {
serverId: string; serverId: string;
@@ -48,12 +42,14 @@ async function getAvailablePort(): Promise<number> {
} }
async function waitForServer(port: number, child: ChildProcess): Promise<void> { async function waitForServer(port: number, child: ChildProcess): Promise<void> {
const deadline = Date.now() + 20_000; const deadline = Date.now() + 90_000;
let lastError: unknown = null; let lastError: unknown = null;
while (Date.now() < deadline) { while (Date.now() < deadline) {
if (child.exitCode !== null) { if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(`Isolated host daemon exited before listening (exit ${child.exitCode})`); throw new Error(
`Isolated host daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)})`,
);
} }
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -81,19 +77,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
); );
} }
async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}, 5_000);
try {
await once(child, "exit");
} finally {
clearTimeout(timeout);
}
}
export async function startIsolatedHostDaemon( export async function startIsolatedHostDaemon(
serverId: string, serverId: string,
options: IsolatedHostDaemonOptions = {}, options: IsolatedHostDaemonOptions = {},
@@ -114,11 +97,17 @@ export async function startIsolatedHostDaemon(
path.join(publishedPackageRoot, "package.json"), path.join(publishedPackageRoot, "package.json"),
`${JSON.stringify({ private: true })}\n`, `${JSON.stringify({ private: true })}\n`,
); );
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
try { try {
const npmCli = process.env.npm_execpath;
if (!npmCli || path.basename(npmCli).toLowerCase() !== "npm-cli.js") {
throw new Error(
"Published-version E2E requires npm_execpath from npm. Start it through `npm run test:e2e`.",
);
}
execFileSync( execFileSync(
npmCommand, process.execPath,
[ [
npmCli,
"install", "install",
"--no-audit", "--no-audit",
"--no-fund", "--no-fund",
@@ -128,6 +117,9 @@ export async function startIsolatedHostDaemon(
{ cwd: publishedPackageRoot, stdio: "ignore" }, { cwd: publishedPackageRoot, stdio: "ignore" },
); );
} catch (error) { } catch (error) {
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
await rm(publishedPackageRoot, { recursive: true, force: true }); await rm(publishedPackageRoot, { recursive: true, force: true });
throw error; throw error;
} }
@@ -155,7 +147,6 @@ export async function startIsolatedHostDaemon(
const serverDir = publishedPackageRoot const serverDir = publishedPackageRoot
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server") ? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
: path.resolve(__dirname, "../../../../server"); : path.resolve(__dirname, "../../../../server");
const tsxBin = execSync("which tsx").toString().trim();
const spawnDaemon = async (): Promise<ChildProcess> => { const spawnDaemon = async (): Promise<ChildProcess> => {
const spawnOptions: SpawnOptions = { const spawnOptions: SpawnOptions = {
cwd: serverDir, cwd: serverDir,
@@ -175,7 +166,7 @@ export async function startIsolatedHostDaemon(
}; };
const child = publishedPackageRoot const child = publishedPackageRoot
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions) ? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
: spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], spawnOptions); : spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
let stderr = ""; let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => { child.stderr?.on("data", (chunk: Buffer) => {
@@ -187,7 +178,7 @@ export async function startIsolatedHostDaemon(
await waitForServer(port, child); await waitForServer(port, child);
return child; return child;
} catch (error) { } catch (error) {
await stopProcess(child); await killProcessTree(child);
throw new Error( throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`, `${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error }, { cause: error },
@@ -216,13 +207,13 @@ export async function startIsolatedHostDaemon(
getPid: () => child.pid, getPid: () => child.pid,
restart: async () => { restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`); if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await stopProcess(child); await killProcessTree(child);
child = await spawnDaemon(); child = await spawnDaemon();
}, },
close: async () => { close: async () => {
if (closed) return; if (closed) return;
closed = true; closed = true;
await stopProcess(child); await killProcessTree(child);
if (!options.preserveHome) { if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true }); await rm(paseoHome, { recursive: true, force: true });
} }
+5 -2
View File
@@ -175,7 +175,7 @@ export async function sampleTabsDuringTransition(
function sample() { function sample() {
const tabs = Array.from( const tabs = Array.from(
document.querySelectorAll<HTMLElement>( document.querySelectorAll<HTMLElement>(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])', '[data-testid^="workspace-tab-"][role="button"][aria-selected]',
), ),
).filter((element) => element.getClientRects().length > 0); ).filter((element) => element.getClientRects().length > 0);
scope.__paseoTabTrackFrames?.push( scope.__paseoTabTrackFrames?.push(
@@ -188,7 +188,10 @@ export async function sampleTabsDuringTransition(
requestAnimationFrame(sample); requestAnimationFrame(sample);
} }
} }
requestAnimationFrame(sample); // Establish the known-good pre-action state synchronously. Starting on the
// next animation frame lets the action race the first sample, which can
// misclassify a not-yet-painted test harness frame as a transition blank.
sample();
}, durationMs); }, durationMs);
await action(); await action();
await page.waitForTimeout(durationMs + 100); await page.waitForTimeout(durationMs + 100);
@@ -1,8 +1,8 @@
import { spawn, type ChildProcess } from "node:child_process"; import { spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import net from "node:net"; import net from "node:net";
import path from "node:path"; import path from "node:path";
import { killProcessTree } from "./spawn-node";
export interface LocalElixirRelay { export interface LocalElixirRelay {
endpoint: string; endpoint: string;
@@ -27,19 +27,6 @@ async function availablePort(): Promise<number> {
}); });
} }
async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const force = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}, 5_000);
try {
await once(child, "exit");
} finally {
clearTimeout(force);
}
}
async function waitUntilReady( async function waitUntilReady(
port: number, port: number,
child: ChildProcess, child: ChildProcess,
@@ -48,9 +35,9 @@ async function waitUntilReady(
const deadline = Date.now() + 60_000; const deadline = Date.now() + 60_000;
let lastError: unknown; let lastError: unknown;
while (Date.now() < deadline) { while (Date.now() < deadline) {
if (child.exitCode !== null) { if (child.exitCode !== null || child.signalCode !== null) {
throw new Error( throw new Error(
`Elixir relay exited before becoming ready (exit ${child.exitCode}).\n${recentOutput()}`, `Elixir relay exited before becoming ready (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).\n${recentOutput()}`,
); );
} }
try { try {
@@ -70,6 +57,12 @@ async function waitUntilReady(
} }
export async function startLocalElixirRelay(): Promise<LocalElixirRelay> { export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
if (process.platform === "win32") {
throw new Error(
"The local Elixir relay requires asdf, which is not available on Windows; the relay-deployment Playwright project is POSIX-only.",
);
}
const relayRoot = const relayRoot =
process.env.PASEO_RELAY_CHECKOUT ?? path.resolve(__dirname, "../../../../../..", "paseo-relay"); process.env.PASEO_RELAY_CHECKOUT ?? path.resolve(__dirname, "../../../../../..", "paseo-relay");
if (!existsSync(path.join(relayRoot, "mix.exs"))) { if (!existsSync(path.join(relayRoot, "mix.exs"))) {
@@ -107,14 +100,14 @@ export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
try { try {
await waitUntilReady(port, child, () => output.join("\n")); await waitUntilReady(port, child, () => output.join("\n"));
} catch (error) { } catch (error) {
await stopProcess(child); await killProcessTree(child);
throw error; throw error;
} }
}; };
const stop = async () => { const stop = async () => {
if (!child) return; if (!child) return;
await stopProcess(child); await killProcessTree(child);
child = null; child = null;
}; };
@@ -42,7 +42,7 @@ export async function seedMockAgentWorkspace(
workspaceId: workspace.workspaceId, workspaceId: workspace.workspaceId,
title: options.title, title: options.title,
modeId: options.modeId ?? "load-test", modeId: options.modeId ?? "load-test",
model: options.model ?? "ten-second-stream", model: options.model ?? "e2e-fast-stream",
initialPrompt: options.initialPrompt, initialPrompt: options.initialPrompt,
featureValues: options.featureValues, featureValues: options.featureValues,
}); });
@@ -0,0 +1,84 @@
import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import { once } from "node:events";
/**
* Runs a repo-local TypeScript entrypoint through Node's own tsx loader instead
* of the `node_modules/.bin/tsx` shim.
*
* The shim is a platform trap, not a convenience: on Windows it is `tsx.cmd`,
* and Node refuses to spawn `.cmd`/`.bat` without `shell: true` (EINVAL, from
* the CVE-2024-27980 mitigation). Opting into `shell: true` to get around that
* trades one bug for three — argv is concatenated unescaped (DEP0190), a path
* containing spaces splits, and the kill signal lands on `cmd.exe` while the
* real child keeps the port. Spawning `process.execPath` has none of those
* properties and is byte-identical on POSIX, so there is no platform branch.
*/
export function spawnTsx(entrypoint: string, args: string[], options: SpawnOptions): ChildProcess {
if (entrypoint.startsWith("-")) {
throw new Error(`TypeScript entrypoint must be a path, received ${entrypoint}`);
}
return spawn(process.execPath, ["--import", "tsx", "--", entrypoint, ...args], options);
}
/**
* Terminates a child and everything it forked, then waits for the exit.
*
* `child.kill()` on Windows maps to `TerminateProcess` against the direct child
* only. Metro and the daemon supervisor both fork workers, so the signal leaves
* grandchildren holding the listening port and the next run fails to bind.
* `taskkill /T` walks the tree; POSIX keeps the SIGTERM-then-SIGKILL ladder.
*/
export async function killProcessTree(child: ChildProcess | null): Promise<void> {
if (!child || hasExited(child)) return;
const exited = once(child, "exit").then(() => undefined);
if (process.platform === "win32") {
const pid = child.pid;
if (pid === undefined) {
throw new Error("Cannot terminate a Windows process tree without a PID");
}
// Register the exit listener before taskkill to avoid missing a fast exit.
// Bound taskkill itself so a stuck system utility cannot hang teardown.
const completed = Promise.withResolvers<Error | null>();
execFile("taskkill", ["/pid", String(pid), "/T", "/F"], { timeout: 5_000 }, (error) =>
completed.resolve(error),
);
const taskkillError = await completed.promise;
if (taskkillError && !hasExited(child)) {
try {
child.kill("SIGKILL");
} catch {
// The child can exit between the state check and the direct kill.
}
await waitForExitOrTimeout(exited, 5_000);
throw new Error(`Failed to terminate process tree for PID ${pid}`, { cause: taskkillError });
}
if (!hasExited(child) && !(await waitForExitOrTimeout(exited, 5_000))) {
throw new Error(`Process tree for PID ${pid} did not exit after taskkill`);
}
return;
}
child.kill("SIGTERM");
if (await waitForExitOrTimeout(exited, 5_000)) return;
child.kill("SIGKILL");
if (!(await waitForExitOrTimeout(exited, 5_000))) {
throw new Error(`Process ${String(child.pid)} did not exit after SIGKILL`);
}
}
function hasExited(child: ChildProcess): boolean {
return child.exitCode !== null || child.signalCode !== null;
}
async function waitForExitOrTimeout(exited: Promise<void>, timeoutMs: number): Promise<boolean> {
const timedOut = Promise.withResolvers<boolean>();
const timeout = setTimeout(() => timedOut.resolve(false), timeoutMs);
try {
return await Promise.race([exited.then(() => true), timedOut.promise]);
} finally {
clearTimeout(timeout);
}
}
@@ -1,84 +0,0 @@
import { expect, type Locator, type Page } from "@playwright/test";
export interface SplitPaneGeometry {
containerWidth: number;
explorerWidth: number;
visibleWidth: number;
separatorCount: number;
occupiedWidth: number;
}
function splitSeparator(page: Page): Locator {
return page.getByRole("separator", { name: "" });
}
export async function openExplorerSplit(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open side panel" }).click();
await expect(splitSeparator(page)).toHaveCount(1, { timeout: 30_000 });
}
export async function hideExplorerSplit(page: Page): Promise<void> {
await page.getByRole("button", { name: "Close side panel" }).click();
await expect(splitSeparator(page)).toHaveCount(0, { timeout: 30_000 });
}
export async function resizeExplorerSplit(page: Page, deltaX: number): Promise<void> {
const separator = splitSeparator(page);
await expect(separator).toHaveCount(1);
const box = await separator.boundingBox();
expect(box).not.toBeNull();
if (!box) throw new Error("Split separator has no bounding box");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + deltaX, box.y + box.height / 2, { steps: 10 });
await page.mouse.up();
}
export async function stampExplorerNode(page: Page): Promise<void> {
await page
.getByTestId("split-group-child")
.last()
.evaluate((node) => {
Reflect.set(window, "__splitPaneExplorerNode", node);
node.setAttribute("data-split-pane-identity", "retained");
});
}
export async function expectExplorerNodeRetained(page: Page): Promise<void> {
const stampedNode = page.locator('[data-split-pane-identity="retained"]');
await expect(stampedNode).toHaveCount(1);
expect(
await stampedNode.evaluate((node) => Reflect.get(window, "__splitPaneExplorerNode") === node),
).toBe(true);
}
export async function measureExplorerSplit(page: Page): Promise<SplitPaneGeometry> {
const explorerChild = page.locator('[data-split-pane-identity="retained"]');
await expect(explorerChild).toHaveCount(1);
return explorerChild.evaluate((explorerNode) => {
const visibleNode = Array.from(explorerNode.parentElement?.children ?? [])
.filter(
(candidate) =>
candidate !== explorerNode &&
(candidate as HTMLElement).dataset.testid === "split-group-child",
)
.sort(
(left, right) => right.getBoundingClientRect().width - left.getBoundingClientRect().width,
)[0];
if (!(visibleNode instanceof HTMLElement)) throw new Error("Visible split child not found");
const visible = visibleNode.getBoundingClientRect();
const explorer = explorerNode.getBoundingClientRect();
const container = explorerNode.parentElement?.getBoundingClientRect();
if (!container) throw new Error("Split child has no container");
const separatorCount =
explorerNode.parentElement?.querySelectorAll('[role="separator"]').length ?? 0;
return {
containerWidth: container.width,
explorerWidth: explorer.width,
visibleWidth: visible.width,
separatorCount,
occupiedWidth: explorer.width + visible.width,
};
});
}
@@ -42,8 +42,8 @@ export async function expectFailedSetupTabSeededInMainPane(
await expect(page.getByTestId(tabId).filter({ visible: true }).first()).toBeVisible({ await expect(page.getByTestId(tabId).filter({ visible: true }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
const panel = await ensureSidePanel(page); const explorer = await ensureExplorerSidebar(page);
await expect(panel.getByTestId(tabId)).toHaveCount(0); await expect(explorer.getByTestId(tabId)).toHaveCount(0);
} }
export async function closeSetupTab(page: Page, workspaceId: string): Promise<void> { export async function closeSetupTab(page: Page, workspaceId: string): Promise<void> {
@@ -57,8 +57,8 @@ function visibleTestId(page: Page, testId: string) {
return page.getByTestId(testId).filter({ visible: true }); return page.getByTestId(testId).filter({ visible: true });
} }
function sidePanel(page: Page) { function explorerSidebar(page: Page) {
return visibleTestId(page, "workspace-side-panel").first(); return visibleTestId(page, "workspace-explorer-sidebar").first();
} }
async function selectWorkspaceTab(tab: Locator): Promise<void> { async function selectWorkspaceTab(tab: Locator): Promise<void> {
@@ -70,53 +70,54 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
await expect(tab).toHaveAttribute("aria-selected", "true"); await expect(tab).toHaveAttribute("aria-selected", "true");
} }
/** Reveal the Side panel. It opens empty; the caller decides what goes in it. */ /** Reveal the Explorer sidebar without changing its selected view. */
export async function ensureSidePanel(page: Page): Promise<Locator> { export async function ensureExplorerSidebar(page: Page): Promise<Locator> {
const toggle = page.getByTestId("workspace-explorer-toggle").first(); const toggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(toggle).toBeVisible({ timeout: 30_000 }); await expect(toggle).toBeVisible({ timeout: 30_000 });
const panel = sidePanel(page); const explorer = explorerSidebar(page);
if ((await panel.count()) === 0) { if ((await explorer.count()) === 0) {
await toggle.click(); await toggle.click();
} }
await expect(panel).toBeVisible({ timeout: 30_000 }); await expect(explorer).toBeVisible({ timeout: 30_000 });
return panel; return explorer;
} }
/** /** Reveals the Explorer sidebar and selects one of its fixed navigation views. */
* Reveals the Side panel and brings one of its views up in it. Goes through the async function openExplorerView(
* pane's own `+` menu, which is there whether the pane is empty or already loaded.
*/
async function openSidePanelView(
page: Page, page: Page,
view: { tabTestId: string; launchItemId: string; contentTestId: string; timeout?: number }, view: { tabTestId: string; contentTestId: string; timeout?: number },
): Promise<void> { ): Promise<void> {
const panel = await ensureSidePanel(page); const explorer = await ensureExplorerSidebar(page);
const tab = panel.getByTestId(view.tabTestId); const tab = explorer.getByTestId(view.tabTestId);
if ((await tab.count()) === 0) { await tab.click();
await panel.getByTestId("workspace-new-tab-button").click();
await page
.getByTestId(`workspace-new-tab-menu-${view.launchItemId}`)
.filter({ visible: true })
.click();
}
await selectWorkspaceTab(tab);
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({ await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
timeout: view.timeout ?? 30_000, timeout: view.timeout ?? 30_000,
}); });
} }
export async function openChangesTreePanel(page: Page): Promise<void> {
await openExplorerView(page, {
tabTestId: "explorer-sidebar-tab-changes_tree",
contentTestId: "changes-tree-panel",
});
}
export async function openChangesPanel(page: Page): Promise<void> { export async function openChangesPanel(page: Page): Promise<void> {
await openSidePanelView(page, { await openChangesTreePanel(page);
tabTestId: "workspace-tab-working_diff", const changedFile = page
launchItemId: "changes", .locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
contentTestId: "working-diff-panel", .filter({ visible: true })
.first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await changedFile.click();
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
timeout: 30_000,
}); });
} }
export async function openFilesPanel(page: Page): Promise<void> { export async function openFilesPanel(page: Page): Promise<void> {
await openSidePanelView(page, { await openExplorerView(page, {
tabTestId: "workspace-tab-files", tabTestId: "explorer-sidebar-tab-files",
launchItemId: "files",
contentTestId: "file-explorer-tree-scroll", 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 }); await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
return; return;
} }
await openSidePanelView(page, { const trigger = visibleTestId(page, "workspace-new-tab-button").first();
tabTestId: "workspace-tab-pull_request", await trigger.click();
launchItemId: "pull-request", await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
contentTestId: "pr-pane", await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
timeout: 15_000,
});
} }
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> { export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.5.2", "version": "0.6.0",
"private": true, "private": true,
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
@@ -30,6 +30,7 @@
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web", "build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
"profile:composer-typing": "node ./scripts/profile-composer-typing.mjs", "profile:composer-typing": "node ./scripts/profile-composer-typing.mjs",
"profile:explorer-toggle": "node ./scripts/profile-explorer-toggle.mjs", "profile:explorer-toggle": "node ./scripts/profile-explorer-toggle.mjs",
"profile:side-pane": "playwright test --config playwright.profile.config.ts side-pane-performance.spec.ts",
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs", "profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs", "profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main", "deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
@@ -0,0 +1,388 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
const APP_SETTINGS_KEY = "@paseo:app-settings";
export interface ProfileTarget {
serverId: string;
workspaceId: string;
}
export interface RenderSummary {
id: string;
renders: number;
mounts: number;
updates: number;
actualDurationMs: number;
maximumActualDurationMs: number;
}
export interface InteractionMeasurement {
name: string;
latencyMs: number;
commits: number;
domMutations: number;
addedElements: number;
removedElements: number;
addedTestIds: string[];
removedTestIds: string[];
trackedNodes: Array<{
selector: string;
existedBefore: boolean;
connected: boolean;
sameNode: boolean;
}>;
renders: RenderSummary[];
reasons: Record<string, Record<string, number>>;
heapAfterGcBytes?: number;
}
interface BrowserMeasurementState {
startedAt: number;
selectors: string[];
originals: Array<Element | null>;
observer?: MutationObserver;
domMutations: number;
addedElements: number;
removedElements: number;
addedTestIds: Set<string>;
removedTestIds: Set<string>;
}
declare global {
interface Window {
__PASEO_SIDE_PANE_MEASUREMENT__?: BrowserMeasurementState;
}
}
function parseCliJson<T>(output: string): T {
const arrayStart = output.indexOf("[\n");
const objectStart = output.indexOf("{\n");
const start = arrayStart >= 0 ? arrayStart : objectStart;
if (start < 0) throw new Error(`Could not find JSON in CLI output: ${output}`);
return JSON.parse(output.slice(start)) as T;
}
export function resolveProfileTarget(): ProfileTarget {
const repoRoot = resolve(process.cwd(), "../..");
const serverId = (
process.env.PASEO_PROFILE_SERVER_ID ??
readFileSync(resolve(repoRoot, ".dev/paseo-home/server-id"), "utf8")
).trim();
const requestedName = process.env.PASEO_PROFILE_WORKSPACE_NAME?.trim() || "Paseo";
const requestedWorkspaceId = process.env.PASEO_PROFILE_WORKSPACE_ID?.trim();
if (requestedWorkspaceId) return { serverId, workspaceId: requestedWorkspaceId };
const output = execFileSync("npm", ["run", "cli", "--", "workspace", "ls", "--json"], {
cwd: repoRoot,
encoding: "utf8",
env: { ...process.env, FORCE_COLOR: "0" },
});
const workspaces =
parseCliJson<Array<{ workspaceId: string; name: string; cwd: string }>>(output);
const workspace = workspaces.find(
(candidate) => candidate.name === requestedName && candidate.cwd === repoRoot,
);
if (!workspace) {
throw new Error(`No Paseo workspace named ${requestedName} points at ${repoRoot}`);
}
return { serverId, workspaceId: workspace.workspaceId };
}
export async function openProfileWorkspace(page: Page, target: ProfileTarget): Promise<void> {
await page.addInitScript(() => {
const preserveRenderProfile = (original: History["pushState"]) =>
function preserveProfileParam(
this: History,
state: unknown,
unused: string,
url?: string | URL | null,
) {
if (url == null) return Reflect.apply(original, this, [state, unused, url]);
const nextUrl = new URL(String(url), location.href);
nextUrl.searchParams.set("renderProfile", "1");
return Reflect.apply(original, this, [
state,
unused,
`${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`,
]);
};
history.pushState = preserveRenderProfile(history.pushState);
history.replaceState = preserveRenderProfile(history.replaceState);
});
await page.goto("/?renderProfile=1", { waitUntil: "domcontentloaded" });
const workspaceRow = page.getByTestId(
`sidebar-workspace-row-${target.serverId}:${target.workspaceId}`,
);
await expect(workspaceRow).toBeVisible({ timeout: 60_000 });
await workspaceRow.click();
await expect(
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
).toBeVisible({
timeout: 60_000,
});
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/workspace/${target.workspaceId}\\?renderProfile=1$`));
}
export async function enableChangesOpenInSidePane(page: Page): Promise<void> {
await page.evaluate((settingsKey) => {
const persisted = JSON.parse(localStorage.getItem(settingsKey) ?? "{}") as Record<
string,
unknown
>;
const currentPreferences =
typeof persisted.openInSidePane === "object" && persisted.openInSidePane !== null
? (persisted.openInSidePane as Record<string, unknown>)
: {};
localStorage.setItem(
settingsKey,
JSON.stringify({
...persisted,
openInSidePane: { ...currentPreferences, explorerChanges: true },
}),
);
}, APP_SETTINGS_KEY);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
).toBeVisible({
timeout: 60_000,
});
}
export async function openChangesExplorer(page: Page): Promise<void> {
const explorer = page.getByTestId("workspace-explorer-sidebar");
if ((await explorer.count()) === 0) {
await page.keyboard.press("Meta+e");
}
await expect(explorer).toBeVisible({ timeout: 30_000 });
const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first();
await expect(changesTab).toBeVisible();
if ((await changesTab.getAttribute("aria-selected")) !== "true") {
await changesTab.click();
}
await expect(page.getByTestId("changes-tree-panel")).toBeVisible({ timeout: 30_000 });
await expect(changedFileRows(page).nth(1)).toBeVisible({ timeout: 30_000 });
}
export function changedFileRows(page: Page): Locator {
return page
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
.filter({ visible: true });
}
export async function selectChangedFile(page: Page, index: number): Promise<void> {
const row = changedFileRows(page).nth(index);
await expect(row).toBeVisible();
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
}
export async function expectWorkingDiffVisible(page: Page): Promise<void> {
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
timeout: 60_000,
});
await expect(page.getByTestId("git-diff-canvas").filter({ visible: true })).toBeVisible({
timeout: 60_000,
});
}
export async function closeWorkingDiffPane(page: Page): Promise<void> {
const tab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true }).first();
await expect(tab).toBeVisible();
await tab.hover();
await page
.locator('[data-testid^="workspace-working-diff-close-"]')
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toHaveCount(0);
}
export async function measureHeapAfterGc(page: Page): Promise<number> {
await page.requestGC();
return page.evaluate(() => {
const memory = performance as Performance & {
memory?: { usedJSHeapSize: number };
};
return memory.memory?.usedJSHeapSize ?? 0;
});
}
async function waitForReactIdle(page: Page, quietMs = 250): Promise<void> {
let previousCount = -1;
let unchangedSince = Date.now();
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
const count = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE__?.length ?? 0);
if (count !== previousCount) {
previousCount = count;
unchangedSince = Date.now();
} else if (Date.now() - unchangedSince >= quietMs) {
return;
}
await page.waitForTimeout(25);
}
throw new Error("React profiler did not become idle");
}
function summarizeRenders(
samples: Array<{
id: string;
phase: "mount" | "update" | "nested-update";
actualDuration: number;
}>,
): RenderSummary[] {
const summaries = new Map<string, RenderSummary>();
for (const sample of samples) {
const summary = summaries.get(sample.id) ?? {
id: sample.id,
renders: 0,
mounts: 0,
updates: 0,
actualDurationMs: 0,
maximumActualDurationMs: 0,
};
summary.renders += 1;
summary.mounts += sample.phase === "mount" ? 1 : 0;
summary.updates += sample.phase === "mount" ? 0 : 1;
summary.actualDurationMs += sample.actualDuration;
summary.maximumActualDurationMs = Math.max(
summary.maximumActualDurationMs,
sample.actualDuration,
);
summaries.set(sample.id, summary);
}
for (const summary of summaries.values()) {
summary.actualDurationMs = Math.round(summary.actualDurationMs * 100) / 100;
summary.maximumActualDurationMs = Math.round(summary.maximumActualDurationMs * 100) / 100;
}
return [...summaries.values()].sort(
(left, right) => right.actualDurationMs - left.actualDurationMs,
);
}
async function beginMeasurement(page: Page, selectors: string[]): Promise<void> {
await waitForReactIdle(page);
await page.evaluate((trackedSelectors) => {
globalThis.__PASEO_RESET_RENDER_PROFILE__?.();
const collectTestIds = (node: Node, output: Set<string>) => {
if (!(node instanceof Element)) return;
const ownTestId = node.getAttribute("data-testid");
if (ownTestId) output.add(ownTestId);
for (const element of node.querySelectorAll("[data-testid]")) {
const testId = element.getAttribute("data-testid");
if (testId) output.add(testId);
}
};
const state: BrowserMeasurementState = {
startedAt: performance.now(),
selectors: trackedSelectors,
originals: trackedSelectors.map((selector) => document.querySelector(selector)),
domMutations: 0,
addedElements: 0,
removedElements: 0,
addedTestIds: new Set(),
removedTestIds: new Set(),
};
state.observer = new MutationObserver((records) => {
state.domMutations += records.length;
for (const record of records) {
for (const node of record.addedNodes) {
state.addedElements +=
node instanceof Element ? 1 + node.querySelectorAll("*").length : 0;
collectTestIds(node, state.addedTestIds);
}
for (const node of record.removedNodes) {
state.removedElements +=
node instanceof Element ? 1 + node.querySelectorAll("*").length : 0;
collectTestIds(node, state.removedTestIds);
}
}
});
state.observer.observe(document.body, { childList: true, subtree: true });
window.__PASEO_SIDE_PANE_MEASUREMENT__ = state;
}, selectors);
}
async function finishMeasurement(page: Page, name: string): Promise<InteractionMeasurement> {
await page.evaluate(
() =>
new Promise<void>((resolveFrame) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame()));
}),
);
await waitForReactIdle(page);
const raw = await page.evaluate((measurementName) => {
const state = window.__PASEO_SIDE_PANE_MEASUREMENT__;
if (!state) throw new Error("Side-pane performance measurement was not started");
state.observer?.disconnect();
const samples = globalThis.__PASEO_RENDER_PROFILE__ ?? [];
return {
name: measurementName,
latencyMs: Math.round((performance.now() - state.startedAt) * 100) / 100,
commits: new Set(samples.map((sample) => sample.commitTime)).size,
domMutations: state.domMutations,
addedElements: state.addedElements,
removedElements: state.removedElements,
addedTestIds: [...state.addedTestIds].sort(),
removedTestIds: [...state.removedTestIds].sort(),
trackedNodes: state.selectors.map((selector, index) => {
const original = state.originals[index] ?? null;
const current = document.querySelector(selector);
return {
selector,
existedBefore: original !== null,
connected: original?.isConnected ?? false,
sameNode: original !== null && original === current,
};
}),
samples,
reasons: globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {},
};
}, name);
return {
...raw,
renders: summarizeRenders(raw.samples),
};
}
export async function measureInteraction(
page: Page,
input: {
name: string;
trackedSelectors: string[];
interact: () => Promise<void>;
expectComplete: () => Promise<void>;
},
): Promise<InteractionMeasurement> {
await beginMeasurement(page, input.trackedSelectors);
await input.interact();
await input.expectComplete();
return finishMeasurement(page, input.name);
}
export function expectTrackedNodePreserved(
measurement: InteractionMeasurement,
selector: string,
): void {
const tracked = measurement.trackedNodes.find((candidate) => candidate.selector === selector);
expect.soft(tracked).toEqual({
selector,
existedBefore: true,
connected: true,
sameNode: true,
});
}
export function expectProfilerDidNotMount(
measurement: InteractionMeasurement,
idPrefix: string,
): void {
const summaries = measurement.renders.filter((summary) => summary.id.startsWith(idPrefix));
expect.soft(summaries.length).toBeGreaterThan(0);
expect.soft(summaries.every((summary) => summary.mounts === 0)).toBe(true);
}
@@ -0,0 +1,119 @@
import { test, expect } from "@playwright/test";
import {
changedFileRows,
closeWorkingDiffPane,
enableChangesOpenInSidePane,
expectProfilerDidNotMount,
expectTrackedNodePreserved,
expectWorkingDiffVisible,
measureInteraction,
measureHeapAfterGc,
openChangesExplorer,
openProfileWorkspace,
resolveProfileTarget,
selectChangedFile,
type InteractionMeasurement,
} from "./helpers/side-pane-performance";
const MAIN_PANE = '[data-testid="workspace-pane-main"]';
const WORKING_DIFF = '[data-testid="working-diff-panel"]';
const DIFF_CANVAS = '[data-testid="git-diff-canvas-root"]';
const DIFF_TAB = '[data-testid="workspace-tab-working_diff"]';
test("side-pane diff transitions preserve mounted work and report their real React cost", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const target = resolveProfileTarget();
const reports: InteractionMeasurement[] = [];
await test.step("open the real main workspace with Changes routed beside it", async () => {
await openProfileWorkspace(page, target);
await enableChangesOpenInSidePane(page);
await openChangesExplorer(page);
expect(await changedFileRows(page).count()).toBeGreaterThanOrEqual(2);
});
await test.step("creating the side pane keeps the main pane mounted", async () => {
const report = await measureInteraction(page, {
name: "create-side-pane",
trackedSelectors: [MAIN_PANE],
interact: () => selectChangedFile(page, 0),
expectComplete: () => expectWorkingDiffVisible(page),
});
reports.push(report);
await testInfo.attach(report.name, {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
expectTrackedNodePreserved(report, MAIN_PANE);
});
await test.step("changing the selected diff file updates the existing diff in place", async () => {
const report = await measureInteraction(page, {
name: "change-diff-focus",
trackedSelectors: [MAIN_PANE, WORKING_DIFF, DIFF_CANVAS, DIFF_TAB],
interact: () => selectChangedFile(page, 1),
expectComplete: () => expectWorkingDiffVisible(page),
});
reports.push(report);
await testInfo.attach(report.name, {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
expectTrackedNodePreserved(report, MAIN_PANE);
expectTrackedNodePreserved(report, WORKING_DIFF);
expectTrackedNodePreserved(report, DIFF_CANVAS);
expectTrackedNodePreserved(report, DIFF_TAB);
expectProfilerDidNotMount(report, "WorkingDiffPanel:");
});
await test.step("repeated cold reopenings measure retained workspace work", async () => {
for (let index = 0; index < 5; index += 1) {
await closeWorkingDiffPane(page);
const report = await measureInteraction(page, {
name: `reopen-side-pane-${index + 1}`,
trackedSelectors: [MAIN_PANE],
interact: () => selectChangedFile(page, index % 2),
expectComplete: () => expectWorkingDiffVisible(page),
});
report.heapAfterGcBytes = await measureHeapAfterGc(page);
reports.push(report);
await testInfo.attach(report.name, {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
expectTrackedNodePreserved(report, MAIN_PANE);
expect(
report.renders.some(
(summary) => summary.id.startsWith("WorkingDiffPanel:") && summary.mounts > 0,
),
).toBe(true);
}
});
const artifact = {
appUrl: testInfo.project.use.baseURL,
target,
reports,
};
await testInfo.attach("side-pane-performance", {
body: JSON.stringify(artifact, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Side pane: ${JSON.stringify(
reports.map((report) => ({
name: report.name,
latencyMs: report.latencyMs,
commits: report.commits,
domMutations: report.domMutations,
addedElements: report.addedElements,
removedElements: report.removedElements,
heapAfterGcBytes: report.heapAfterGcBytes,
trackedNodes: report.trackedNodes,
renders: report.renders.slice(0, 8),
})),
)}`,
);
});
+8 -2
View File
@@ -6,6 +6,12 @@ const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`; process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
const relayDeploymentSpec = "**/relay-deployment-reconnect.real.spec.ts"; const relayDeploymentSpec = "**/relay-deployment-reconnect.real.spec.ts";
function videoMode(): "on" | "on-first-retry" | "retain-on-failure" {
if (process.env.E2E_RECORD_VIDEO === "1") return "on";
if (process.env.CI) return "on-first-retry";
return "retain-on-failure";
}
export default defineConfig({ export default defineConfig({
testDir: "./e2e/browser", testDir: "./e2e/browser",
globalSetup: "./e2e/support/global-setup.ts", globalSetup: "./e2e/support/global-setup.ts",
@@ -21,9 +27,9 @@ export default defineConfig({
reporter: [["list"]], reporter: [["list"]],
use: { use: {
baseURL, baseURL,
trace: "retain-on-failure", trace: process.env.CI ? "on-first-retry" : "retain-on-failure",
screenshot: "only-on-failure", screenshot: "only-on-failure",
video: process.env.E2E_RECORD_VIDEO === "1" ? "on" : "retain-on-failure", video: videoMode(),
}, },
projects: [ projects: [
{ {
+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 Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, ChevronDown, X } from "lucide-react-native"; import { Check, ChevronDown, X } from "lucide-react-native";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openSidePanelView } from "@/workspace-tabs/side-panel"; import { openExplorerSidebarView } from "@/workspace-tabs/explorer-sidebar";
import { import {
AssistantMessage, AssistantMessage,
SpeakMessage, SpeakMessage,
@@ -457,7 +457,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setCurrentPath: false, setCurrentPath: false,
}); });
openSidePanelView({ openExplorerSidebarView({
isCompact: isMobile, isCompact: isMobile,
workspaceKey: buildWorkspaceTabPersistenceKey({ workspaceKey: buildWorkspaceTabPersistenceKey({
serverId: resolvedServerId, serverId: resolvedServerId,
@@ -474,7 +474,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
); );
const handleToolCallOpenFile = useStableEvent((filePath: string) => { const handleToolCallOpenFile = useStableEvent((filePath: string) => {
handleInlinePathPress({ raw: filePath, path: filePath }, "side"); handleInlinePathPress({ raw: filePath, path: filePath }, "preferred");
}); });
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent( const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
+16 -8
View File
@@ -36,6 +36,7 @@ import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"
import { AppDiagnosticHost } from "@/components/app-diagnostic-host"; import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { LeftSidebar } from "@/components/left-sidebar"; import { LeftSidebar } from "@/components/left-sidebar";
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header"; import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model"; import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler"; import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host"; import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
@@ -76,7 +77,7 @@ import { AgentNavigationListener } from "@/desktop/agent-navigation";
import { LegacyAgentSkillsMigration } from "@/agent-skills/legacy-migration"; import { LegacyAgentSkillsMigration } from "@/agent-skills/legacy-migration";
import { legacyFavoriteProfileMigration } from "@/agent-profiles/migration"; import { legacyFavoriteProfileMigration } from "@/agent-profiles/migration";
import { listenToDesktopEvent } from "@/desktop/electron/events"; import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window"; import { updateDesktopWindowChrome } from "@/desktop/electron/window";
import { getDesktopHost } from "@/desktop/host"; import { getDesktopHost } from "@/desktop/host";
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings"; import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source"; import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
@@ -86,6 +87,7 @@ import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-ac
import { useLatchedBoolean } from "@/hooks/use-latched-boolean"; import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useFaviconStatus } from "@/hooks/use-favicon-status"; import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
import { KeyboardShiftProvider } from "@/hooks/use-keyboard-shift-style"; import { KeyboardShiftProvider } from "@/hooks/use-keyboard-shift-style";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock"; import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project"; import { useOpenProject } from "@/hooks/use-open-project";
@@ -473,6 +475,10 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
}, [settings.theme, updateSettings]); }, [settings.theme, updateSettings]);
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = useIsCompactFormFactor();
const explorerSidebarPresentation = resolveExplorerSidebarPresentation({
isCompact: isCompactLayout,
});
const usesCompactExplorerHost = explorerSidebarPresentation !== "pane";
useCompactWebViewportZoomLock(isCompactLayout); useCompactWebViewportZoomLock(isCompactLayout);
const pathname = usePathname(); const pathname = usePathname();
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null; const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
@@ -545,8 +551,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
{sidebarChrome} {sidebarChrome}
</WindowChromeRegion> </WindowChromeRegion>
) : null} ) : null}
{isCompactLayout ? ( {usesCompactExplorerHost ? (
<CompactExplorerSidebarHost enabled={chromeEnabled}> <CompactExplorerSidebarHost
enabled={chromeEnabled}
presentation={explorerSidebarPresentation === "dock" ? "dock" : "overlay"}
>
<WindowChromeRegion corners={chromeEnabled ? "both" : appChromeLayout.contentCorners}> <WindowChromeRegion corners={chromeEnabled ? "both" : appChromeLayout.contentCorners}>
<View style={flexStyle}>{children}</View> <View style={flexStyle}>{children}</View>
</WindowChromeRegion> </WindowChromeRegion>
@@ -574,6 +583,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
</WindowChromeSafeArea> </WindowChromeSafeArea>
</WindowChromeRegion> </WindowChromeRegion>
) : null} ) : null}
<DesktopWindowControls />
<FloatingPanelPortalHost /> <FloatingPanelPortalHost />
{isCompactLayout ? sidebarChrome : null} {isCompactLayout ? sidebarChrome : null}
<DownloadToast /> <DownloadToast />
@@ -665,18 +675,16 @@ function DesktopWindowControlsSync() {
const { isLoading } = useAppSettings(); const { isLoading } = useAppSettings();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const surface0 = theme.colors.surface0; const surface0 = theme.colors.surface0;
const foreground = theme.colors.foreground;
useEffect(() => { useEffect(() => {
if (isLoading || isNative) return; if (isLoading || isNative) return;
void updateDesktopWindowControls({ void updateDesktopWindowChrome({
backgroundColor: surface0, backgroundColor: surface0,
foregroundColor: foreground, trafficLightOffsetY: -4,
trafficLightOffsetY: -5,
}).catch((error) => { }).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error); console.warn("[DesktopWindow] Failed to update window controls overlay", error);
}); });
}, [isLoading, surface0, foreground]); }, [isLoading, surface0]);
return null; return null;
} }
@@ -176,7 +176,7 @@ describe("useFileLink", () => {
lineStart: undefined, lineStart: undefined,
lineEnd: undefined, lineEnd: undefined,
}, },
disposition: "side", disposition: "preferred",
}, },
]); ]);
}); });
@@ -117,7 +117,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
}); });
const onPress = useStableEvent(() => { const onPress = useStableEvent(() => {
open(stableSource, "side"); open(stableSource, "preferred");
}); });
const target = useMemo(() => { const target = useMemo(() => {
@@ -58,8 +58,10 @@ import { shortenPath } from "@/utils/shorten-path";
import { useCommandCenterContributions } from "./provider"; import { useCommandCenterContributions } from "./provider";
import { import {
buildContributionSections, buildContributionSections,
filterAndRankBuiltInResults,
joinSubtitleParts, joinSubtitleParts,
moveActiveResultId, moveActiveResultId,
PINNED_SECTION_BAND,
preserveActiveResultId, preserveActiveResultId,
projectCommandCenterRows, projectCommandCenterRows,
type CommandCenterAgentResult, type CommandCenterAgentResult,
@@ -67,6 +69,7 @@ import {
type CommandCenterListRow, type CommandCenterListRow,
type CommandCenterResult, type CommandCenterResult,
type CommandCenterResultSection, type CommandCenterResultSection,
type CommandCenterSearchFields,
type CommandCenterWorkspaceResult, type CommandCenterWorkspaceResult,
} from "./results"; } from "./results";
import { useWorkspaceFileSearch } from "./workspace-file-search"; import { useWorkspaceFileSearch } from "./workspace-file-search";
@@ -88,7 +91,6 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
})); }));
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"]; const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const; const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0; 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(); return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
} }
function matchesQuery(searchText: string, query: string): boolean { function compareWorkspacesByTitle(
const normalized = query.trim().toLowerCase(); left: CommandCenterWorkspaceResult,
return !normalized || searchText.includes(normalized); 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[] { /** `cwd` is not rendered, so a path match must never outrank a match on text the user can see. */
return query.trim() ? results : results.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT); 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 { t } = useTranslation();
const { agents } = useAggregatedAgents(); const { agents } = useAggregatedAgents();
const { projects } = useProjects({ enabled: open }); const { projects } = useProjects({ enabled: open });
const showHost = useHosts().length > 1; const showHost = useHosts().length > 1;
return useMemo(() => { return useMemo(() => {
if (!open) return []; if (!open) return { workspaces: [], agents: [] };
const allWorkspaces: CommandCenterWorkspaceResult[] = []; const allWorkspaces: CommandCenterWorkspaceResult[] = [];
for (const project of projects) { for (const project of projects) {
for (const host of project.hosts) { for (const host of project.hosts) {
for (const workspace of host.workspaces) { for (const workspace of host.workspaces) {
if (workspace.archivingAt) continue; 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({ allWorkspaces.push({
kind: "workspace", kind: "workspace",
id: `workspace:${host.serverId}:${workspace.id}`, id: `workspace:${host.serverId}:${workspace.id}`,
title, title: workspace.title ?? workspace.name,
subtitle, subtitle: joinSubtitleParts([
searchText, showHost ? host.serverName : null,
project.projectName,
workspace.currentBranch,
]),
run: () => { run: () => {
clearCommandCenterFocusRestoreElement(); clearCommandCenterFocusRestoreElement();
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id }); navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
@@ -146,60 +166,66 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
} }
} }
} }
allWorkspaces.sort((left, right) => { allWorkspaces.sort(compareWorkspacesByTitle);
const titleDelta = left.title.localeCompare(right.title, undefined, {
numeric: true,
sensitivity: "base",
});
return titleDelta || left.subtitle.localeCompare(right.subtitle);
});
const workspaceTitleByKey = new Map( const workspaceTitleByKey = new Map(
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]), allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
); );
const workspaces = limitDefaultCategoryResults( const agentRows = agents
allWorkspaces.filter((workspace) => matchesQuery(workspace.searchText, query)), .map<CommandCenterAgentResult>((agent) => {
query, const workspaceTitle = agent.workspaceId
); ? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
const agentResults = limitDefaultCategoryResults( : undefined;
agents return {
.map<CommandCenterAgentResult>((agent) => { kind: "agent",
const title = agent.title || t("shell.commandCenter.newAgent"); id: `agent:${agent.serverId}:${agent.id}`,
const workspaceTitle = agent.workspaceId agent,
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`) title: agent.title || t("shell.commandCenter.newAgent"),
: undefined; subtitle: joinSubtitleParts([
const location = workspaceTitle ?? shortenPath(agent.cwd);
const subtitle = joinSubtitleParts([
showHost ? agent.serverLabel : null, showHost ? agent.serverLabel : null,
location, workspaceTitle ?? shortenPath(agent.cwd),
formatTimeAgo(agent.lastActivityAt), formatTimeAgo(agent.lastActivityAt),
]); ]),
return { run: () => {
kind: "agent", clearCommandCenterFocusRestoreElement();
id: `agent:${agent.serverId}:${agent.id}`, navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
agent, },
title, };
subtitle, })
searchText: `${title} ${subtitle} ${agent.cwd}`.toLowerCase(), .sort((left, right) => sortAgents(left.agent, right.agent));
run: () => { return { workspaces: allWorkspaces, agents: agentRows };
clearCommandCenterFocusRestoreElement(); }, [agents, open, projects, showHost, t]);
navigateToAgent({ serverId: agent.serverId, agentId: agent.id }); }
},
}; function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
}) const { t } = useTranslation();
.filter((agent) => matchesQuery(agent.searchText, query)) const rows = useBuiltInRows(open);
.sort((left, right) => sortAgents(left.agent, right.agent)),
query, return useMemo(() => {
); if (!open) return [];
return [ return [
{ {
id: "workspaces", id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2, rank: 2,
title: t("shell.commandCenter.workspaces"), 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 { interface CommandCenterState {
@@ -231,7 +257,7 @@ function useCommandCenterState(): CommandCenterState {
const snapshot = useCommandCenterContributions(); const snapshot = useCommandCenterContributions();
const inputRef = useRef<EditingTextInputHandle>(null); const inputRef = useRef<EditingTextInputHandle>(null);
const previousOpenRef = useRef(open); const previousOpenRef = useRef(open);
const [query, setQuery] = useState(""); const [query, setQueryState] = useState("");
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const builtInSections = useBuiltInSections(open, query); const builtInSections = useBuiltInSections(open, query);
const { const {
@@ -250,10 +276,20 @@ function useCommandCenterState(): CommandCenterState {
filePath: entry.path, filePath: entry.path,
title: entry.name, title: entry.name,
subtitle: entry.directory, subtitle: entry.directory,
searchText: entry.path.toLowerCase(),
run: () => openFile(entry.path), 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]); }, [fileSearchEntries, openFile, t]);
const contributionSections = useMemo( const contributionSections = useMemo(
() => buildContributionSections(snapshot.contributions, query), () => buildContributionSections(snapshot.contributions, query),
@@ -270,6 +306,14 @@ function useCommandCenterState(): CommandCenterState {
); );
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults); 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 close = useCallback(() => setOpen(false), [setOpen]);
const select = useCallback( const select = useCallback(
(result: CommandCenterResult) => { (result: CommandCenterResult) => {
@@ -312,7 +356,7 @@ function useCommandCenterState(): CommandCenterState {
const timer = setTimeout(() => inputRef.current?.focus(), 0); const timer = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
setQuery(""); setQueryState("");
setActiveId(null); setActiveId(null);
if (!wasOpen) return; if (!wasOpen) return;
const element = takeCommandCenterFocusRestoreElement(); const element = takeCommandCenterFocusRestoreElement();
+399 -8
View File
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
import type { CommandCenterContribution } from "./contributions"; import type { CommandCenterContribution } from "./contributions";
import { import {
buildContributionSections, buildContributionSections,
filterAndRankBuiltInResults,
joinSubtitleParts, joinSubtitleParts,
moveActiveResultId, moveActiveResultId,
PINNED_SECTION_BAND,
preserveActiveResultId, preserveActiveResultId,
projectCommandCenterRows, projectCommandCenterRows,
type CommandCenterWorkspaceResult, 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 { return {
kind: "workspace", id: input.id,
id, group: input.group,
title: id, groupRank: input.groupRank,
subtitle: "host", rank: input.rank ?? 0,
searchText: id, keywords: input.keywords ?? [],
visibility: input.visibility ?? "always",
run: () => undefined, 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[] { function sectionResultIds(sections: ReturnType<typeof buildContributionSections>): string[] {
const ids: string[] = []; const ids: string[] = [];
for (const section of sections) { for (const section of sections) {
@@ -60,7 +125,13 @@ describe("Command Center result projection", () => {
const sections = buildContributionSections(contributions, "o"); const sections = buildContributionSections(contributions, "o");
const projection = projectCommandCenterRows([ const projection = projectCommandCenterRows([
...sections, ...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([ expect(projection.rows.map((row) => row.key)).toEqual([
"section:models", "section:models",
@@ -100,7 +171,13 @@ describe("Command Center result projection", () => {
it("keeps keyboard selection aligned with rows beyond the render window", () => { it("keeps keyboard selection aligned with rows beyond the render window", () => {
const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`)); const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`));
const projection = projectCommandCenterRows([ 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; 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", () => { describe("joinSubtitleParts", () => {
it("joins all present parts with a middle dot", () => { it("joins all present parts with a middle dot", () => {
expect(joinSubtitleParts(["a", "b", "c"])).toBe("a · b · c"); 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 type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import {
compareMatchScores,
type MatchScore,
scoreTextFields,
} from "@getpaseo/protocol/search/text-match";
import type { CommandCenterContribution } from "./contributions"; 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 { export interface CommandCenterWorkspaceResult {
kind: "workspace"; kind: "workspace";
id: string; id: string;
title: string; title: string;
subtitle: string; subtitle: string;
searchText: string;
run(): void; run(): void;
} }
@@ -16,7 +57,6 @@ export interface CommandCenterAgentResult {
agent: AggregatedAgent; agent: AggregatedAgent;
title: string; title: string;
subtitle: string; subtitle: string;
searchText: string;
run(): void; run(): void;
} }
@@ -26,7 +66,6 @@ export interface CommandCenterFileResult {
filePath: string; filePath: string;
title: string; title: string;
subtitle: string; subtitle: string;
searchText: string;
run(): void; run(): void;
} }
@@ -34,7 +73,8 @@ export interface CommandCenterContributionResult {
kind: "contribution"; kind: "contribution";
id: string; id: string;
contribution: CommandCenterContribution; contribution: CommandCenterContribution;
searchText: string; /** How well this row matches the query. Absent when the query is empty. */
score?: CommandCenterScore;
run(): void | Promise<void>; run(): void | Promise<void>;
} }
@@ -46,15 +86,21 @@ export type CommandCenterResult =
export interface CommandCenterResultSection { export interface CommandCenterResultSection {
id: string; id: string;
/** CONTRIBUTION_SECTION_BAND or PINNED_SECTION_BAND. Compared before anything else. */
band: number;
rank: number; rank: number;
title?: string; title?: string;
/** Best score among this section's results. Absent when the query is empty. */
score?: CommandCenterScore;
results: readonly CommandCenterResult[]; results: readonly CommandCenterResult[];
} }
interface MutableCommandCenterResultSection { interface MutableCommandCenterResultSection {
id: string; id: string;
band: number;
rank: number; rank: number;
title?: string; title?: string;
score?: CommandCenterScore;
results: CommandCenterResult[]; results: CommandCenterResult[];
} }
@@ -69,9 +115,41 @@ export interface CommandCenterListProjection {
offsets: readonly number[]; offsets: readonly number[];
} }
function matchesQuery(searchText: string, query: string): boolean { /**
const normalized = query.trim().toLowerCase(); * Fields a row can be matched on, split by whether the user can read them. A row that matches
return !normalized || searchText.includes(normalized); * 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. */ /** 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(" · "); 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.kind === "action"
? [contribution.presentation.title, contribution.presentation.subtitle ?? ""] ? [contribution.presentation.title, contribution.presentation.subtitle ?? ""]
: contribution.presentation.path; : [...contribution.presentation.path];
return [...presentationText, ...contribution.keywords].join(" ").toLowerCase(); return { visible, hidden: contribution.keywords };
} }
function resultHeight(result: CommandCenterResult): number { function resultHeight(result: CommandCenterResult): number {
@@ -96,17 +180,77 @@ function resultHeight(result: CommandCenterResult): number {
return 36; 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( export function buildContributionSections(
contributions: readonly CommandCenterContribution[], contributions: readonly CommandCenterContribution[],
query: string, query: string,
): CommandCenterResultSection[] { ): CommandCenterResultSection[] {
const groups = new Map<string, MutableCommandCenterResultSection>(); const groups = new Map<string, MutableCommandCenterResultSection>();
const registryIndexById = new Map<string, number>();
const hasQuery = Boolean(query.trim()); const hasQuery = Boolean(query.trim());
for (const contribution of contributions) { for (const [index, contribution] of contributions.entries()) {
if (contribution.visibility === "query" && !hasQuery) continue; if (contribution.visibility === "query" && !hasQuery) continue;
const searchText = contributionSearchText(contribution); let score: CommandCenterScore | undefined;
if (!matchesQuery(searchText, query)) continue; 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 existing = groups.get(contribution.group);
const title = const title =
contribution.presentation.kind === "action" contribution.presentation.kind === "action"
@@ -114,23 +258,39 @@ export function buildContributionSections(
: undefined; : undefined;
const section = existing ?? { const section = existing ?? {
id: contribution.group, id: contribution.group,
band: CONTRIBUTION_SECTION_BAND,
rank: contribution.groupRank, rank: contribution.groupRank,
title, title,
results: [], results: [],
}; };
registryIndexById.set(contribution.id, index);
section.results.push({ section.results.push({
kind: "contribution", kind: "contribution",
id: contribution.id, id: contribution.id,
contribution, contribution,
searchText, score,
run: contribution.run, run: contribution.run,
}); });
if (score && (!section.score || compareCommandCenterScores(score, section.score) < 0)) {
section.score = score;
}
groups.set(contribution.group, section); groups.set(contribution.group, section);
} }
return [...groups.values()].sort( const sections = [...groups.values()];
(left, right) => left.rank - right.rank || left.id.localeCompare(right.id), // 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( export function projectCommandCenterRows(
@@ -138,7 +298,7 @@ export function projectCommandCenterRows(
): CommandCenterListProjection { ): CommandCenterListProjection {
const populated = sections const populated = sections
.filter((section) => section.results.length > 0) .filter((section) => section.results.length > 0)
.sort((left, right) => left.rank - right.rank || left.id.localeCompare(right.id)); .sort(compareResultSections);
const rows: CommandCenterListRow[] = []; const rows: CommandCenterListRow[] = [];
const selectableResults: CommandCenterResult[] = []; const selectableResults: CommandCenterResult[] = [];
const rowIndexByResultId = new Map<string, number>(); const rowIndexByResultId = new Map<string, number>();
@@ -61,9 +61,8 @@ function source(gitActions: GitActions): {
moveTabUp: "Move tab up", moveTabUp: "Move tab up",
moveTabDown: "Move tab down", moveTabDown: "Move tab down",
closePane: "Close pane", closePane: "Close pane",
togglePaneMaximization: "Toggle pane maximization",
toggleFocusMode: "Toggle focus mode", toggleFocusMode: "Toggle focus mode",
toggleSidePanel: "Toggle side panel", toggleExplorerSidebar: "Toggle side panel",
}, },
icons: {}, icons: {},
shortcuts: {}, shortcuts: {},
@@ -40,9 +40,8 @@ export interface WorkspaceCommandCenterLabels {
moveTabUp: string; moveTabUp: string;
moveTabDown: string; moveTabDown: string;
closePane: string; closePane: string;
togglePaneMaximization: string;
toggleFocusMode: string; toggleFocusMode: string;
toggleSidePanel: string; toggleExplorerSidebar: string;
} }
export interface WorkspaceCommandCenterIcons { export interface WorkspaceCommandCenterIcons {
@@ -62,9 +61,8 @@ export interface WorkspaceCommandCenterIcons {
copy?: CommandCenterIcon; copy?: CommandCenterIcon;
focusPane?: CommandCenterIcon; focusPane?: CommandCenterIcon;
moveTab?: CommandCenterIcon; moveTab?: CommandCenterIcon;
maximize?: CommandCenterIcon;
focusMode?: CommandCenterIcon; focusMode?: CommandCenterIcon;
sidePanel?: CommandCenterIcon; explorerSidebar?: CommandCenterIcon;
git?(action: GitAction): CommandCenterIcon | undefined; git?(action: GitAction): CommandCenterIcon | undefined;
} }
@@ -78,9 +76,8 @@ export interface WorkspaceCommandCenterShortcuts {
nextTab?: ShortcutKey[][]; nextTab?: ShortcutKey[][];
closeCurrentTab?: ShortcutKey[][]; closeCurrentTab?: ShortcutKey[][];
closePane?: ShortcutKey[][]; closePane?: ShortcutKey[][];
togglePaneMaximization?: ShortcutKey[][];
toggleFocusMode?: ShortcutKey[][]; toggleFocusMode?: ShortcutKey[][];
toggleSidePanel?: ShortcutKey[][]; toggleExplorerSidebar?: ShortcutKey[][];
} }
export interface WorkspaceCommandCenterSource { export interface WorkspaceCommandCenterSource {
@@ -198,7 +195,9 @@ function buildPanelContributions(
}), }),
); );
if (!source.capabilities.canSplitPanes) continue; 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( contributions.push(
buildQueryAction(source, { buildQueryAction(source, {
id: `tab:open:${panel.target}:${placement}`, id: `tab:open:${panel.target}:${placement}`,
@@ -447,15 +446,6 @@ function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCe
shortcutKeys: source.shortcuts.closePane, shortcutKeys: source.shortcuts.closePane,
action: { id: "workspace.pane.close", scope: "workspace" }, 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", id: "pane:focus-mode-toggle",
rank: 62, rank: 62,
@@ -522,12 +512,12 @@ export function buildWorkspaceCommandCenterContributions(
...buildActiveTabContributions(source), ...buildActiveTabContributions(source),
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []), ...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
buildQueryAction(source, { buildQueryAction(source, {
id: "side-panel:toggle", id: "explorer-sidebar:toggle",
rank: 70, rank: 70,
title: source.labels.toggleSidePanel, title: source.labels.toggleExplorerSidebar,
keywords: ["side", "panel", "toggle", "show", "hide"], keywords: ["explorer", "sidebar", "toggle", "show", "hide"],
icon: source.icons.sidePanel, icon: source.icons.explorerSidebar,
shortcutKeys: source.shortcuts.toggleSidePanel, shortcutKeys: source.shortcuts.toggleExplorerSidebar,
action: { id: "sidebar.toggle.right", scope: "workspace" }, action: { id: "sidebar.toggle.right", scope: "workspace" },
}), }),
]; ];
@@ -131,6 +131,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
openWorkspaceFileFromExplorer({ openWorkspaceFileFromExplorer({
filePath: path, filePath: path,
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }), persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
closeExplorerAfterOpen: true,
showMobileAgent: usePanelStore.getState().showMobileAgent, showMobileAgent: usePanelStore.getState().showMobileAgent,
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) => openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
useWorkspaceLayoutStore.getState().openTab({ useWorkspaceLayoutStore.getState().openTab({
@@ -11,7 +11,6 @@ import {
GitCompareArrows, GitCompareArrows,
GitPullRequest, GitPullRequest,
Globe, Globe,
Maximize2,
Move, Move,
PanelRight, PanelRight,
Pencil, Pencil,
@@ -64,14 +63,13 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
copy: getCommandCenterIcon(Copy), copy: getCommandCenterIcon(Copy),
focusPane: getCommandCenterIcon(Focus), focusPane: getCommandCenterIcon(Focus),
moveTab: getCommandCenterIcon(Move), moveTab: getCommandCenterIcon(Move),
maximize: getCommandCenterIcon(Maximize2),
focusMode: getCommandCenterIcon(ArrowDownToLine), focusMode: getCommandCenterIcon(ArrowDownToLine),
sidePanel: getCommandCenterIcon(PanelRight), explorerSidebar: getCommandCenterIcon(PanelRight),
}; };
const OPEN_PANEL_LABEL_KEYS = { const OPEN_PANEL_LABEL_KEYS = {
supporting: "shell.commandCenter.open", supporting: "shell.commandCenter.open",
"side-panel": "shell.commandCenter.openInSidePanel", "side-pane": "shell.commandCenter.openInSidePane",
"focused-pane": "shell.commandCenter.openInFocusedPane", "focused-pane": "shell.commandCenter.openInFocusedPane",
} as const; } as const;
@@ -152,9 +150,8 @@ export function useWorkspaceCommandCenterActions(): void {
moveTabUp: t("settings.shortcuts.help.moveTabUp"), moveTabUp: t("settings.shortcuts.help.moveTabUp"),
moveTabDown: t("settings.shortcuts.help.moveTabDown"), moveTabDown: t("settings.shortcuts.help.moveTabDown"),
closePane: t("settings.shortcuts.help.closePane"), closePane: t("settings.shortcuts.help.closePane"),
togglePaneMaximization: t("settings.shortcuts.help.toggleExplorerPaneMaximization"),
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"), toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
toggleSidePanel: t("workspace.tabs.sidePanel.toggle"), toggleExplorerSidebar: t("workspace.tabs.explorerSidebar.toggle"),
}, },
icons: { icons: {
...WORKSPACE_COMMAND_CENTER_ICONS, ...WORKSPACE_COMMAND_CENTER_ICONS,
@@ -31,9 +31,7 @@ export function resolveWorkspaceCommandCenterShortcuts({
resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined, resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined,
closePane: closePane:
resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined, resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined,
togglePaneMaximization: toggleExplorerSidebar:
resolveShortcutKeysForAction("workspace-explorer-maximize", overrides, platform) ?? undefined,
toggleSidePanel:
resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined, resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined,
}; };
} }
@@ -1,7 +1,7 @@
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useMemo, useRef } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { Text, View } from "react-native";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native"; import { GitBranch } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
@@ -10,6 +10,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher"; import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
import { ToolbarLabelSelectTrigger } from "@/components/ui/toolbar-label-trigger";
interface BranchSwitcherProps { interface BranchSwitcherProps {
currentBranchName: string | null; currentBranchName: string | null;
@@ -23,9 +24,7 @@ interface BranchSwitcherProps {
const foregroundMutedIconColorMapping = (theme: Theme) => ({ const foregroundMutedIconColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}); });
const ThemedGitBranch = withUnistyles(GitBranch); const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedChevronDown = withUnistyles(ChevronDown);
export function BranchSwitcher({ export function BranchSwitcher({
currentBranchName, currentBranchName,
@@ -56,14 +55,6 @@ export function BranchSwitcher({
const handleOpen = useCallback(() => setIsOpen(true), [setIsOpen]); const handleOpen = useCallback(() => setIsOpen(true), [setIsOpen]);
const triggerStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
(Boolean(hovered) || pressed) && styles.triggerHovered,
],
[],
);
const branchLeadingSlot = useMemo( const branchLeadingSlot = useMemo(
() => <ThemedGitBranch size={14} uniProps={foregroundMutedIconColorMapping} />, () => <ThemedGitBranch size={14} uniProps={foregroundMutedIconColorMapping} />,
[], [],
@@ -90,21 +81,16 @@ export function BranchSwitcher({
<View ref={anchorRef} collapsable={false} style={styles.anchor}> <View ref={anchorRef} collapsable={false} style={styles.anchor}>
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Pressable <ToolbarLabelSelectTrigger
testID={testID} testID={testID}
label={currentBranchName}
open={isOpen}
onPress={handleOpen} onPress={handleOpen}
style={triggerStyle}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={t("branchSwitcher.currentBranch", { accessibilityLabel={t("branchSwitcher.currentBranch", {
branchName: currentBranchName, branchName: currentBranchName,
})} })}
> />
<ThemedGitBranch size={12} uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.branchLabel} numberOfLines={1}>
{currentBranchName}
</Text>
<ThemedChevronDown size={12} uniProps={foregroundMutedIconColorMapping} />
</Pressable>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom"> <TooltipContent side="bottom">
<Text style={styles.tooltipText}>{t("branchSwitcher.triggerTooltip")}</Text> <Text style={styles.tooltipText}>{t("branchSwitcher.triggerTooltip")}</Text>
@@ -136,25 +122,6 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1, flexShrink: 1,
minWidth: 0, minWidth: 0,
}, },
trigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
minWidth: 0,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
borderRadius: theme.borderRadius.md,
flexShrink: 1,
},
triggerHovered: {
backgroundColor: theme.colors.surface1,
},
branchLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
flexShrink: 1,
},
tooltipText: { tooltipText: {
color: theme.colors.popoverForeground, color: theme.colors.popoverForeground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
@@ -1,9 +1,12 @@
import { type ReactNode, useCallback, useEffect, useMemo, useRef } from "react"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View } from "react-native"; import { View, type LayoutChangeEvent } from "react-native";
import { GestureDetector } from "react-native-gesture-handler"; import { GestureDetector } from "react-native-gesture-handler";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks"; import { useWorkspace } from "@/stores/session-store-hooks";
import { CompactExplorerSidebar } from "@/components/compact-explorer-sidebar"; import {
CompactExplorerSidebar,
NativeExplorerSidebarDock,
} from "@/components/compact-explorer-sidebar";
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures"; import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
@@ -12,6 +15,7 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command"; import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
import { import {
resolveCompactExplorerSidebarHostModel, resolveCompactExplorerSidebarHostModel,
type CompactExplorerSidebarHostModel, type CompactExplorerSidebarHostModel,
@@ -94,10 +98,16 @@ function useActiveCompactExplorerSidebarModel(
interface CompactExplorerSidebarHostProps { interface CompactExplorerSidebarHostProps {
children: ReactNode; children: ReactNode;
enabled: boolean; enabled: boolean;
presentation: "overlay" | "dock";
} }
export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) { export function CompactExplorerSidebarHost({
children,
enabled,
presentation,
}: CompactExplorerSidebarHostProps) {
const model = useActiveCompactExplorerSidebarModel(enabled); const model = useActiveCompactExplorerSidebarModel(enabled);
const [containerWidth, setContainerWidth] = useState(0);
const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer); const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const openTab = useWorkspaceLayoutStore((state) => state.openTab); const openTab = useWorkspaceLayoutStore((state) => state.openTab);
@@ -127,14 +137,54 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
openWorkspaceFileFromExplorer({ openWorkspaceFileFromExplorer({
filePath, filePath,
persistenceKey: model.persistenceKey, persistenceKey: model.persistenceKey,
closeExplorerAfterOpen: presentation === "overlay",
showMobileAgent, showMobileAgent,
openWorkspaceTabInFocusedPane, openWorkspaceTabInFocusedPane,
focusWorkspaceTab, focusWorkspaceTab,
}); });
}, },
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, showMobileAgent], [focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, presentation, showMobileAgent],
); );
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = event.nativeEvent.layout.width;
setContainerWidth((current) => (current === nextWidth ? current : nextWidth));
}, []);
const explorer =
enabled && model ? (
<DiffDocumentWorkspaceCacheProvider key={model.persistenceKey}>
{presentation === "dock" ? (
<NativeExplorerSidebarDock
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
persistenceKey={model.persistenceKey}
containerWidth={containerWidth}
onOpenFile={handleOpenFile}
/>
) : (
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
onOpenFile={handleOpenFile}
/>
)}
</DiffDocumentWorkspaceCacheProvider>
) : null;
if (presentation === "dock") {
return (
<View style={styles.row} onLayout={handleContainerLayout}>
<View style={styles.fill}>{children}</View>
{explorer}
</View>
);
}
return ( return (
<> <>
<CompactExplorerOpenGestureSurface <CompactExplorerOpenGestureSurface
@@ -143,15 +193,7 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
> >
{children} {children}
</CompactExplorerOpenGestureSurface> </CompactExplorerOpenGestureSurface>
{enabled && model ? ( {explorer}
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
onOpenFile={handleOpenFile}
/>
) : null}
</> </>
); );
} }
@@ -160,4 +202,8 @@ const styles = {
fill: { fill: {
flex: 1, flex: 1,
}, },
row: {
flex: 1,
flexDirection: "row",
},
} as const; } as const;
@@ -1,5 +1,8 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View, Text, Pressable } from "react-native"; import { View, Text, Pressable } from "react-native";
import { Gesture } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
@@ -29,6 +32,13 @@ import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability"; import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability";
import { PullRequestContent } from "@/panels/pull-request"; import { PullRequestContent } from "@/panels/pull-request";
import { useAddFileToChat } from "@/panels/use-add-file-to-chat"; import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout";
import { resolveExplorerSidebarWidth } from "@/components/explorer-sidebar-layout";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {} function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -139,6 +149,118 @@ export function CompactExplorerSidebar({
); );
} }
interface NativeExplorerSidebarDockProps extends ExplorerSidebarProps {
persistenceKey: string;
containerWidth: number;
}
export function NativeExplorerSidebarDock({
serverId,
workspaceId,
workspaceRoot,
isGit,
onOpenFile,
persistenceKey,
containerWidth,
}: NativeExplorerSidebarDockProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const storedWidth = useWorkspaceLayoutStore(
(state) => state.explorerSidebarWidthByWorkspace[persistenceKey],
);
const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar);
const visibleWidth = resolveExplorerSidebarWidth({
requestedWidth: storedWidth,
containerWidth,
});
const resizeWidth = useSharedValue(visibleWidth);
const startWidthRef = useRef(visibleWidth);
const [resizePressed, setResizePressed] = useState(false);
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
serverId,
workspaceRoot,
isGit,
});
useEffect(() => {
resizeWidth.value = visibleWidth;
}, [resizeWidth, visibleWidth]);
const showResizeGrip = useCallback(() => setResizePressed(true), []);
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
const commitWidth = useCallback(
(width: number) => resizeExplorerSidebar(persistenceKey, width),
[persistenceKey, resizeExplorerSidebar],
);
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.enabled(true)
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onBegin(() => scheduleOnRN(showResizeGrip))
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
.onStart((event) => {
startWidthRef.current = visibleWidth + event.translationX;
resizeWidth.value = visibleWidth;
})
.onUpdate((event) => {
resizeWidth.value = resolveExplorerSidebarWidth({
requestedWidth: startWidthRef.current - event.translationX,
containerWidth,
});
})
.onEnd(() => runOnJS(commitWidth)(resizeWidth.value))
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
[commitWidth, containerWidth, hideResizeGrip, resizeWidth, showResizeGrip, visibleWidth],
);
const animatedWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value }));
const dockStyle = useMemo(
() => [
styles.nativeDock,
{
display: isOpen ? ("flex" as const) : ("none" as const),
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
backgroundColor: theme.colors.surfaceSidebar,
},
animatedWidthStyle,
],
[animatedWidthStyle, insets.top, isOpen, theme.colors.surfaceSidebar],
);
const dockContentStyle = useMemo(
() => [styles.nativeDockContent, { borderLeftColor: theme.colors.border }],
[theme.colors.border],
);
return (
<RetainedPanelActivity active={isOpen}>
<Animated.View style={dockStyle} testID="native-explorer-sidebar-dock">
<View style={dockContentStyle}>
<SidebarResizeHandle
edge="left"
gesture={resizeGesture}
pressed={resizePressed}
testID="native-explorer-sidebar-resize-handle"
/>
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={showMobileAgent}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</View>
</Animated.View>
</RetainedPanelActivity>
);
}
interface ExplorerTabButtonProps { interface ExplorerTabButtonProps {
tab: ExplorerTab; tab: ExplorerTab;
active: boolean; active: boolean;
@@ -230,7 +352,7 @@ function ExplorerSidebarContent({
<ExplorerTabButton <ExplorerTabButton
tab="changes" tab="changes"
active={resolvedTab === "changes"} active={resolvedTab === "changes"}
label={t("workspace.tabs.sidePanel.changes")} label={t("workspace.tabs.explorerSidebar.changes")}
onTabPress={onTabPress} onTabPress={onTabPress}
testID="explorer-tab-changes" testID="explorer-tab-changes"
/> />
@@ -238,7 +360,7 @@ function ExplorerSidebarContent({
<ExplorerTabButton <ExplorerTabButton
tab="files" tab="files"
active={resolvedTab === "files"} active={resolvedTab === "files"}
label={t("workspace.tabs.sidePanel.files")} label={t("workspace.tabs.explorerSidebar.files")}
onTabPress={onTabPress} onTabPress={onTabPress}
testID="explorer-tab-files" testID="explorer-tab-files"
/> />
@@ -268,7 +390,7 @@ function ExplorerSidebarContent({
nativeID="explorer-close" nativeID="explorer-close"
accessible accessible
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.sidePanel.close")} accessibilityLabel={t("workspace.tabs.explorerSidebar.close")}
hitSlop={8} hitSlop={8}
> >
{({ hovered, pressed }) => ( {({ hovered, pressed }) => (
@@ -335,7 +457,6 @@ function ChangedFilesPane({
); );
return ( return (
<ChangesSurface <ChangesSurface
host="explorer"
serverId={serverId} serverId={serverId}
workspaceId={workspaceId} workspaceId={workspaceId}
cwd={workspaceRoot} cwd={workspaceRoot}
@@ -370,6 +491,18 @@ function FilesPane({
const PrTabContent = PullRequestContent; const PrTabContent = PullRequestContent;
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
nativeDock: {
position: "relative",
height: "100%",
minHeight: 0,
overflow: "hidden",
},
nativeDockContent: {
position: "relative",
flex: 1,
minHeight: 0,
borderLeftWidth: 1,
},
sidebarContent: { sidebarContent: {
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
@@ -17,15 +17,20 @@ import { isNative } from "@/constants/platform";
* On macOS, Electron handles edge resize natively. * On macOS, Electron handles edge resize natively.
*/ */
export const titlebarDragSurfaceStyle: React.CSSProperties = {
cursor: "default",
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "drag",
};
const DRAG_OVERLAY_STYLE: React.CSSProperties = { const DRAG_OVERLAY_STYLE: React.CSSProperties = {
...titlebarDragSurfaceStyle,
top: 0, top: 0,
left: 0, left: 0,
display: "block", display: "block",
position: "absolute", position: "absolute",
width: "100%", width: "100%",
height: "100%", height: "100%",
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "drag",
}; };
const TOP_RESIZER_STYLE: React.CSSProperties = { const TOP_RESIZER_STYLE: React.CSSProperties = {
@@ -0,0 +1,208 @@
import { useCallback, useMemo } from "react";
import { Pressable, View } from "react-native";
import Svg, { Line, Rect } from "react-native-svg";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
closeDesktopWindow,
minimizeDesktopWindow,
toggleDesktopMaximize,
} from "@/desktop/electron/window";
import {
getDesktopWindowControlsPresentation,
type DesktopWindowControlsPresentation,
} from "@/desktop/window-chrome-presentation";
import { useCustomDesktopWindowControls } from "@/utils/desktop-window";
import type { Theme } from "@/styles/theme";
type WindowControlKind = "minimize" | "maximize" | "restore" | "close";
function WindowControlGlyph({
kind,
color,
size,
strokeWidth,
}: {
kind: WindowControlKind;
color: string;
size: number;
strokeWidth: number;
}) {
if (kind === "minimize") {
return (
<Svg width={size} height={size} viewBox="0 0 16 16" style={styles.glyph}>
<Line x1={3} y1={8} x2={13} y2={8} stroke={color} strokeWidth={strokeWidth} />
</Svg>
);
}
if (kind === "restore") {
return (
<Svg width={size} height={size} viewBox="0 0 16 16" style={styles.glyph}>
<Rect
x={5.5}
y={3.5}
width={7}
height={7}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
/>
<Line x1={3.5} y1={5.5} x2={3.5} y2={12.5} stroke={color} strokeWidth={strokeWidth} />
<Line x1={3} y1={12.5} x2={10.5} y2={12.5} stroke={color} strokeWidth={strokeWidth} />
</Svg>
);
}
if (kind === "close") {
return (
<Svg width={size} height={size} viewBox="0 0 16 16" style={styles.glyph}>
<Line x1={4} y1={4} x2={12} y2={12} stroke={color} strokeWidth={strokeWidth} />
<Line x1={12} y1={4} x2={4} y2={12} stroke={color} strokeWidth={strokeWidth} />
</Svg>
);
}
return (
<Svg width={size} height={size} viewBox="0 0 16 16" style={styles.glyph}>
<Rect
x={3.5}
y={3.5}
width={9}
height={9}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
/>
</Svg>
);
}
const ThemedWindowControlGlyph = withUnistyles(WindowControlGlyph);
const mutedGlyphMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const whiteGlyphMapping = () => ({ color: "#ffffff" });
function WindowControl({
kind,
label,
onPress,
presentation,
}: {
kind: WindowControlKind;
label: string;
onPress: () => void;
presentation: DesktopWindowControlsPresentation;
}) {
const glyph = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) => {
const highlighted = hovered || pressed;
const closeHighlighted = kind === "close" && highlighted;
return (
<View
style={[
styles.controlSurface,
presentation.hoverShape === "circle"
? styles.circularControlSurface
: styles.fullControlSurface,
highlighted ? styles.controlHovered : null,
closeHighlighted ? styles.closeHovered : null,
]}
>
<ThemedWindowControlGlyph
kind={kind}
size={presentation.glyphSize}
strokeWidth={presentation.glyphStrokeWidth}
uniProps={closeHighlighted ? whiteGlyphMapping : mutedGlyphMapping}
/>
</View>
);
},
[kind, presentation],
);
const controlStyle = useMemo(
() => [styles.control, { width: presentation.controlWidth }],
[presentation.controlWidth],
);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onPress}
style={controlStyle}
>
{glyph}
</Pressable>
);
}
export function DesktopWindowControls() {
const { t } = useTranslation();
const { visible, mode, isMaximized } = useCustomDesktopWindowControls();
const minimize = useCallback(() => void minimizeDesktopWindow(), []);
const toggleMaximize = useCallback(() => void toggleDesktopMaximize(), []);
const close = useCallback(() => void closeDesktopWindow(), []);
if (!visible || !mode) return null;
const presentation = getDesktopWindowControlsPresentation(mode);
return (
<View
style={[styles.controls, { height: presentation.controlHeight }]}
testID="desktop-window-controls"
>
<WindowControl
kind="minimize"
label={t("desktop.windowControls.minimize")}
onPress={minimize}
presentation={presentation}
/>
<WindowControl
kind={isMaximized ? "restore" : "maximize"}
label={t(
isMaximized ? "desktop.windowControls.restore" : "desktop.windowControls.maximize",
)}
onPress={toggleMaximize}
presentation={presentation}
/>
<WindowControl
kind="close"
label={t("desktop.windowControls.close")}
onPress={close}
presentation={presentation}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
controls: {
position: "absolute",
top: 0,
right: 0,
zIndex: 3000,
flexDirection: "row",
},
control: {
height: "100%",
alignItems: "center",
justifyContent: "center",
},
controlSurface: {
alignItems: "center",
justifyContent: "center",
},
fullControlSurface: {
width: "100%",
height: "100%",
},
circularControlSurface: {
width: 24,
height: 24,
borderRadius: 12,
},
glyph: {
flexShrink: 0,
},
controlHovered: {
backgroundColor: theme.colors.interactionHighlight,
},
closeHovered: {
backgroundColor: "rgba(232, 17, 35, 0.9)",
},
}));
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import {
resolveExplorerSidebarDockSizes,
resolveExplorerSidebarWidth,
} from "@/components/explorer-sidebar-layout";
describe("Explorer sidebar layout", () => {
it("keeps the sidebar width fixed when the workspace body changes size", () => {
const narrow = resolveExplorerSidebarDockSizes({ requestedWidth: 320, containerWidth: 1200 });
const wide = resolveExplorerSidebarDockSizes({ requestedWidth: 320, containerWidth: 1520 });
expect(narrow[1] * 1200).toBeCloseTo(320);
expect(wide[1] * 1520).toBeCloseTo(320);
});
it("has no fixed maximum while preserving room for the workspace body", () => {
expect(resolveExplorerSidebarWidth({ requestedWidth: 100, containerWidth: 1200 })).toBe(240);
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1600 })).toBe(900);
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(800);
expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(350);
});
});
@@ -0,0 +1,27 @@
const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 320;
const MIN_EXPLORER_SIDEBAR_WIDTH = 240;
const MIN_WORKSPACE_BODY_WIDTH = 400;
export function resolveExplorerSidebarWidth(input: {
requestedWidth?: number;
containerWidth: number;
}): number {
const requestedWidth = input.requestedWidth ?? DEFAULT_EXPLORER_SIDEBAR_WIDTH;
const maximumVisibleWidth =
input.containerWidth > 0
? Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, input.containerWidth - MIN_WORKSPACE_BODY_WIDTH)
: requestedWidth;
return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maximumVisibleWidth, requestedWidth));
}
export function resolveExplorerSidebarDockSizes(input: {
requestedWidth?: number;
containerWidth: number;
}): number[] {
if (input.containerWidth <= 0) {
return [1, 0];
}
const width = resolveExplorerSidebarWidth(input);
const ratio = width / input.containerWidth;
return [1 - ratio, ratio];
}
@@ -1,6 +1,7 @@
import { Fragment, useMemo, type ReactElement, type ReactNode } from "react"; import { Fragment, useMemo, type ReactElement } from "react";
import { withUnistyles } from "react-native-unistyles"; import { withUnistyles } from "react-native-unistyles";
import { import {
ArrowRightToLine,
Copy, Copy,
CopyPlus, CopyPlus,
Download, Download,
@@ -25,8 +26,11 @@ import {
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive }); const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
type FileActionGroup = "create" | "open" | "reference" | "manage" | "destructive";
interface FileAction { interface FileAction {
key: string; key: string;
group: FileActionGroup;
label: string; label: string;
icon: LucideIcon; icon: LucideIcon;
onSelect: () => void; onSelect: () => void;
@@ -35,10 +39,19 @@ interface FileAction {
testID?: string; testID?: string;
} }
function optionalFileAction(
available: boolean,
onSelect: (() => void) | undefined,
action: Omit<FileAction, "onSelect">,
): FileAction | null {
return available && onSelect ? { ...action, onSelect } : null;
}
interface FileActionsContextMenuContentProps { interface FileActionsContextMenuContentProps {
fileKind: "file" | "directory"; fileKind: "file" | "directory";
fileExists?: boolean; fileExists?: boolean;
onOpenFile?: () => void; onOpenFile?: () => void;
onOpenToSide?: () => void;
onCopyPath?: () => void; onCopyPath?: () => void;
onCopyRelativePath?: () => void; onCopyRelativePath?: () => void;
onReveal?: () => void; onReveal?: () => void;
@@ -52,8 +65,6 @@ interface FileActionsContextMenuContentProps {
onDuplicate?: () => void; onDuplicate?: () => void;
onRevert?: () => void; onRevert?: () => void;
onDelete?: () => void; onDelete?: () => void;
/** Optional metadata block rendered above the actions (e.g. size/modified). */
header?: ReactNode;
testIDPrefix?: string; testIDPrefix?: string;
} }
@@ -65,6 +76,7 @@ export function FileActionsContextMenuContent({
fileKind, fileKind,
fileExists = true, fileExists = true,
onOpenFile, onOpenFile,
onOpenToSide,
onCopyPath, onCopyPath,
onCopyRelativePath, onCopyRelativePath,
onReveal, onReveal,
@@ -78,7 +90,6 @@ export function FileActionsContextMenuContent({
onDuplicate, onDuplicate,
onRevert, onRevert,
onDelete, onDelete,
header,
testIDPrefix, testIDPrefix,
}: FileActionsContextMenuContentProps): ReactElement | null { }: FileActionsContextMenuContentProps): ReactElement | null {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -88,6 +99,7 @@ export function FileActionsContextMenuContent({
onNewFile onNewFile
? { ? {
key: "new-file", key: "new-file",
group: "create",
label: t("workspace.fileActions.newFile"), label: t("workspace.fileActions.newFile"),
icon: FilePlus, icon: FilePlus,
onSelect: onNewFile, onSelect: onNewFile,
@@ -96,6 +108,7 @@ export function FileActionsContextMenuContent({
onNewFolder onNewFolder
? { ? {
key: "new-folder", key: "new-folder",
group: "create",
label: t("workspace.fileActions.newFolder"), label: t("workspace.fileActions.newFolder"),
icon: FolderPlus, icon: FolderPlus,
onSelect: onNewFolder, onSelect: onNewFolder,
@@ -104,6 +117,7 @@ export function FileActionsContextMenuContent({
onCollapseFolder onCollapseFolder
? { ? {
key: "collapse-folder", key: "collapse-folder",
group: "open",
label: t("workspace.fileActions.collapseFolder"), label: t("workspace.fileActions.collapseFolder"),
icon: FolderMinus, icon: FolderMinus,
onSelect: onCollapseFolder, onSelect: onCollapseFolder,
@@ -112,30 +126,22 @@ export function FileActionsContextMenuContent({
availableFile && onOpenFile availableFile && onOpenFile
? { ? {
key: "open-file", key: "open-file",
group: "open",
label: t("workspace.fileActions.openFile"), label: t("workspace.fileActions.openFile"),
icon: FileText, icon: FileText,
onSelect: onOpenFile, onSelect: onOpenFile,
} }
: null, : null,
onRename optionalFileAction(availableFile, onOpenToSide, {
? { key: "open-to-side",
key: "rename", group: "open",
label: t("workspace.fileActions.rename"), label: t("workspace.fileActions.openToSide"),
icon: Pencil, icon: ArrowRightToLine,
onSelect: onRename, }),
}
: null,
onDuplicate
? {
key: "duplicate",
label: t("workspace.fileActions.duplicate"),
icon: CopyPlus,
onSelect: onDuplicate,
}
: null,
onCopyPath onCopyPath
? { ? {
key: "copy-path", key: "copy-path",
group: "reference",
label: t("workspace.fileActions.copyPath"), label: t("workspace.fileActions.copyPath"),
icon: Copy, icon: Copy,
onSelect: onCopyPath, onSelect: onCopyPath,
@@ -144,6 +150,7 @@ export function FileActionsContextMenuContent({
onCopyRelativePath onCopyRelativePath
? { ? {
key: "copy-relative-path", key: "copy-relative-path",
group: "reference",
label: t("workspace.fileActions.copyRelativePath"), label: t("workspace.fileActions.copyRelativePath"),
icon: Copy, icon: Copy,
onSelect: onCopyRelativePath, onSelect: onCopyRelativePath,
@@ -152,6 +159,7 @@ export function FileActionsContextMenuContent({
onReveal && revealTargetName onReveal && revealTargetName
? { ? {
key: "reveal", key: "reveal",
group: "reference",
label: t("workspace.fileActions.revealIn", { target: revealTargetName }), label: t("workspace.fileActions.revealIn", { target: revealTargetName }),
icon: FolderOpen, icon: FolderOpen,
onSelect: onReveal, onSelect: onReveal,
@@ -160,6 +168,7 @@ export function FileActionsContextMenuContent({
availableFile && onDownload availableFile && onDownload
? { ? {
key: "download", key: "download",
group: "reference",
label: t("workspace.fileActions.download"), label: t("workspace.fileActions.download"),
icon: Download, icon: Download,
onSelect: onDownload, onSelect: onDownload,
@@ -168,14 +177,34 @@ export function FileActionsContextMenuContent({
availableFile && onAddToChat availableFile && onAddToChat
? { ? {
key: "add-to-chat", key: "add-to-chat",
group: "reference",
label: t("workspace.fileActions.addToChat"), label: t("workspace.fileActions.addToChat"),
icon: MessageSquarePlus, icon: MessageSquarePlus,
onSelect: onAddToChat, onSelect: onAddToChat,
} }
: null, : null,
onRename
? {
key: "rename",
group: "manage",
label: t("workspace.fileActions.rename"),
icon: Pencil,
onSelect: onRename,
}
: null,
onDuplicate
? {
key: "duplicate",
group: "manage",
label: t("workspace.fileActions.duplicate"),
icon: CopyPlus,
onSelect: onDuplicate,
}
: null,
onRevert onRevert
? { ? {
key: "revert", key: "revert",
group: "destructive",
label: t("workspace.fileActions.revert"), label: t("workspace.fileActions.revert"),
icon: Undo2, icon: Undo2,
onSelect: onRevert, onSelect: onRevert,
@@ -185,6 +214,7 @@ export function FileActionsContextMenuContent({
onDelete onDelete
? { ? {
key: "delete", key: "delete",
group: "destructive",
label: t("workspace.fileActions.delete"), label: t("workspace.fileActions.delete"),
icon: Trash2, icon: Trash2,
onSelect: onDelete, onSelect: onDelete,
@@ -195,9 +225,7 @@ export function FileActionsContextMenuContent({
const availableActions = specs.filter((action): action is FileAction => action !== null); const availableActions = specs.filter((action): action is FileAction => action !== null);
return availableActions.map((action, index) => return availableActions.map((action, index) =>
Object.assign(action, { Object.assign(action, {
separatorBefore: Boolean( separatorBefore: index > 0 && action.group !== availableActions[index - 1]?.group,
action.destructive && index > 0 && !availableActions[index - 1].destructive,
),
testID: testIDPrefix ? `${testIDPrefix}-${action.key}` : undefined, testID: testIDPrefix ? `${testIDPrefix}-${action.key}` : undefined,
}), }),
); );
@@ -214,6 +242,7 @@ export function FileActionsContextMenuContent({
onNewFile, onNewFile,
onNewFolder, onNewFolder,
onOpenFile, onOpenFile,
onOpenToSide,
onRename, onRename,
onReveal, onReveal,
onRevert, onRevert,
@@ -231,12 +260,6 @@ export function FileActionsContextMenuContent({
width={220} width={220}
testID={testIDPrefix ? `${testIDPrefix}-context-menu` : undefined} testID={testIDPrefix ? `${testIDPrefix}-context-menu` : undefined}
> >
{header ? (
<>
{header}
<ContextMenuSeparator />
</>
) : null}
{actions.map((action) => ( {actions.map((action) => (
<Fragment key={action.key}> <Fragment key={action.key}>
{action.separatorBefore ? <ContextMenuSeparator /> : null} {action.separatorBefore ? <ContextMenuSeparator /> : null}
@@ -41,7 +41,9 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { import {
PaneContentToolbar, PaneContentToolbar,
paneContentToolbarIconSize, paneContentToolbarIconSize,
paneContentToolbarIconButtonStyle, paneContentToolbarTrailingPadding,
ToolbarButton,
ToolbarControls,
} from "@/components/ui/pane-content-toolbar"; } from "@/components/ui/pane-content-toolbar";
import { import {
useOverlayFlatListScrollbar, useOverlayFlatListScrollbar,
@@ -61,7 +63,6 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store"; import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { isHiddenExplorerPath } from "@/file-explorer/visibility"; import { isHiddenExplorerPath } from "@/file-explorer/visibility";
import { import {
@@ -100,16 +101,6 @@ function DirectoryChevronIcon({ loading, expanded }: { loading: boolean; expande
return <TreeChevron expanded={expanded} />; return <TreeChevron expanded={expanded} />;
} }
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
interface TreeRowItemProps { interface TreeRowItemProps {
serverId: string; serverId: string;
workspaceId?: string | null; workspaceId?: string | null;
@@ -126,6 +117,7 @@ interface TreeRowItemProps {
revealTargetName?: string; revealTargetName?: string;
onDownloadEntry: (entry: ExplorerEntry) => void; onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void; onAddToChat?: (path: string) => void;
onOpenFileToSide?: (path: string) => void;
onNewEntry?: (parentPath: string, kind: "file" | "directory") => void; onNewEntry?: (parentPath: string, kind: "file" | "directory") => void;
onCollapseDirectory?: (path: string) => void; onCollapseDirectory?: (path: string) => void;
onRenameEntry?: (entry: ExplorerEntry) => void; onRenameEntry?: (entry: ExplorerEntry) => void;
@@ -141,13 +133,6 @@ function sortTriggerStyle({
return [styles.sortTrigger, (Boolean(hovered) || pressed) && styles.sortTriggerHovered]; return [styles.sortTrigger, (Boolean(hovered) || pressed) && styles.sortTriggerHovered];
} }
function iconButtonStyle(
isCompact: boolean,
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
) {
return paneContentToolbarIconButtonStyle({ hovered, pressed }, false, isCompact);
}
type ExplorerListRow = type ExplorerListRow =
| { type: "entry"; row: ExplorerTreeRow } | { type: "entry"; row: ExplorerTreeRow }
| { type: "draft"; parentPath: string; kind: "file" | "directory"; depth: number } | { type: "draft"; parentPath: string; kind: "file" | "directory"; depth: number }
@@ -256,6 +241,7 @@ function TreeRowItem({
revealTargetName, revealTargetName,
onDownloadEntry, onDownloadEntry,
onAddToChat, onAddToChat,
onOpenFileToSide,
onNewEntry, onNewEntry,
onCollapseDirectory, onCollapseDirectory,
onRenameEntry, onRenameEntry,
@@ -263,7 +249,6 @@ function TreeRowItem({
onDeleteEntry, onDeleteEntry,
testID, testID,
}: TreeRowItemProps) { }: TreeRowItemProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const showNameHover = useCallback(() => setIsHovered(true), []); const showNameHover = useCallback(() => setIsHovered(true), []);
const hideNameHover = useCallback(() => setIsHovered(false), []); const hideNameHover = useCallback(() => setIsHovered(false), []);
@@ -317,6 +302,10 @@ function TreeRowItem({
onAddToChat?.(entry.path); onAddToChat?.(entry.path);
}, [onAddToChat, entry.path]); }, [onAddToChat, entry.path]);
const handleOpenToSide = useCallback(() => {
onOpenFileToSide?.(entry.path);
}, [entry.path, onOpenFileToSide]);
const handleNewFile = useCallback(() => { const handleNewFile = useCallback(() => {
onNewEntry?.(entry.path, "file"); onNewEntry?.(entry.path, "file");
}, [onNewEntry, entry.path]); }, [onNewEntry, entry.path]);
@@ -341,30 +330,6 @@ function TreeRowItem({
onDeleteEntry?.(entry); onDeleteEntry?.(entry);
}, [onDeleteEntry, entry]); }, [onDeleteEntry, entry]);
const metaHeader = useMemo(
() => (
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
),
[entry.modifiedAt, entry.size, t],
);
return ( return (
<ContextMenu> <ContextMenu>
<ContextMenuTrigger <ContextMenuTrigger
@@ -407,13 +372,13 @@ function TreeRowItem({
revealTargetName={revealTargetName} revealTargetName={revealTargetName}
onDownload={handleDownload} onDownload={handleDownload}
onAddToChat={onAddToChat ? handleAddToChat : undefined} onAddToChat={onAddToChat ? handleAddToChat : undefined}
onOpenToSide={!isDirectory && onOpenFileToSide ? handleOpenToSide : undefined}
onNewFile={onNewEntry ? handleNewFile : undefined} onNewFile={onNewEntry ? handleNewFile : undefined}
onNewFolder={onNewEntry ? handleNewFolder : undefined} onNewFolder={onNewEntry ? handleNewFolder : undefined}
onCollapseFolder={isDirectory && isExpanded ? handleCollapseDirectory : undefined} onCollapseFolder={isDirectory && isExpanded ? handleCollapseDirectory : undefined}
onRename={onRenameEntry ? handleRename : undefined} onRename={onRenameEntry ? handleRename : undefined}
onDuplicate={onDuplicateEntry ? handleDuplicate : undefined} onDuplicate={onDuplicateEntry ? handleDuplicate : undefined}
onDelete={onDeleteEntry ? handleDelete : undefined} onDelete={onDeleteEntry ? handleDelete : undefined}
header={metaHeader}
testIDPrefix={testID} testIDPrefix={testID}
/> />
</ContextMenu> </ContextMenu>
@@ -425,6 +390,7 @@ interface FileExplorerPaneProps {
workspaceId?: string | null; workspaceId?: string | null;
workspaceRoot: string; workspaceRoot: string;
onOpenFile?: (filePath: string) => void; onOpenFile?: (filePath: string) => void;
onOpenFileToSide?: (filePath: string) => void;
onAddToChat?: (path: string) => void; onAddToChat?: (path: string) => void;
} }
@@ -433,6 +399,7 @@ export function FileExplorerPane({
workspaceId, workspaceId,
workspaceRoot, workspaceRoot,
onOpenFile, onOpenFile,
onOpenFileToSide,
onAddToChat, onAddToChat,
}: FileExplorerPaneProps) { }: FileExplorerPaneProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -996,6 +963,7 @@ export function FileExplorerPane({
revealTargetName={fileManagerTarget?.label} revealTargetName={fileManagerTarget?.label}
onDownloadEntry={handleDownloadEntry} onDownloadEntry={handleDownloadEntry}
onAddToChat={onAddToChat} onAddToChat={onAddToChat}
onOpenFileToSide={onOpenFileToSide}
onNewEntry={fsEntryOpsEnabled ? handleNewEntry : undefined} onNewEntry={fsEntryOpsEnabled ? handleNewEntry : undefined}
onCollapseDirectory={handleCollapseDirectory} onCollapseDirectory={handleCollapseDirectory}
onRenameEntry={fsEntryOpsEnabled ? handleRenameEntry : undefined} onRenameEntry={fsEntryOpsEnabled ? handleRenameEntry : undefined}
@@ -1026,6 +994,7 @@ export function FileExplorerPane({
fileManagerTarget, fileManagerTarget,
selectedEntryPath, selectedEntryPath,
onAddToChat, onAddToChat,
onOpenFileToSide,
serverId, serverId,
workspaceId, workspaceId,
], ],
@@ -1049,11 +1018,6 @@ export function FileExplorerPane({
}); });
}, [requestDirectoryListing]); }, [requestDirectoryListing]);
const toolbarIconButtonStyle = useCallback(
(state: PressableStateCallbackType) => iconButtonStyle(isCompact, state),
[isCompact],
);
if (!hasWorkspaceScope) { if (!hasWorkspaceScope) {
return ( return (
<View style={styles.centerState}> <View style={styles.centerState}>
@@ -1087,7 +1051,6 @@ export function FileExplorerPane({
handleBackFromError={handleBackFromError} handleBackFromError={handleBackFromError}
handleRetry={handleRetry} handleRetry={handleRetry}
sortTriggerStyle={sortTriggerStyle} sortTriggerStyle={sortTriggerStyle}
iconButtonStyle={toolbarIconButtonStyle}
/> />
</View> </View>
); );
@@ -1162,7 +1125,6 @@ interface FileExplorerPaneContentProps {
handleBackFromError: () => void; handleBackFromError: () => void;
handleRetry: () => void; handleRetry: () => void;
sortTriggerStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>; sortTriggerStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
iconButtonStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
} }
function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
@@ -1186,7 +1148,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
handleBackFromError, handleBackFromError,
handleRetry, handleRetry,
sortTriggerStyle: sortTriggerStyleProp, sortTriggerStyle: sortTriggerStyleProp,
iconButtonStyle: iconButtonStyleProp,
} = props; } = props;
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles); const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
@@ -1204,17 +1165,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
const emptyLabel = showHiddenFiles const emptyLabel = showHiddenFiles
? t("workspace.fileExplorer.empty.noFiles") ? t("workspace.fileExplorer.empty.noFiles")
: t("workspace.fileExplorer.empty.noVisibleFiles"); : t("workspace.fileExplorer.empty.noVisibleFiles");
const hiddenFilesToggleStyle = useCallback(
(state: PressableStateCallbackType) => [
iconButtonStyleProp(state),
!showHiddenFiles && styles.iconButtonActive,
],
[showHiddenFiles, iconButtonStyleProp],
);
const hiddenFilesToggleAccessibilityState = useMemo(
() => ({ selected: !showHiddenFiles }),
[showHiddenFiles],
);
if (error) { if (error) {
return ( return (
@@ -1245,7 +1195,10 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return ( return (
<View style={[styles.treePane, styles.treePaneFill]}> <View style={[styles.treePane, styles.treePaneFill]}>
<PaneContentToolbar style={styles.paneHeader} testID="files-pane-header"> <PaneContentToolbar
style={[styles.paneHeader, { paddingRight: paneContentToolbarTrailingPadding(isCompact) }]}
testID="files-pane-header"
>
<Pressable <Pressable
onPress={handleSortCycle} onPress={handleSortCycle}
style={sortTriggerStyleProp} style={sortTriggerStyleProp}
@@ -1256,86 +1209,82 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
</Text> </Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} /> <ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable> </Pressable>
<View style={styles.headerActions}> <ToolbarControls style={styles.headerActions}>
{onNewEntryAtRoot ? ( {onNewEntryAtRoot ? (
<> <>
<Pressable <ToolbarButton
onPress={handleNewFileAtRoot} label={t("workspace.fileActions.newFile")}
compact={isCompact}
hitSlop={8} hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={t("workspace.fileActions.newFile")}
testID="files-new-file" testID="files-new-file"
onPress={handleNewFileAtRoot}
> >
<FilePlus <FilePlus
size={paneContentToolbarIconSize(isCompact)} size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted} color={theme.colors.foregroundExtraMuted}
/> />
</Pressable> </ToolbarButton>
<Pressable <ToolbarButton
onPress={handleNewFolderAtRoot} label={t("workspace.fileActions.newFolder")}
compact={isCompact}
hitSlop={8} hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={t("workspace.fileActions.newFolder")}
testID="files-new-folder" testID="files-new-folder"
onPress={handleNewFolderAtRoot}
> >
<FolderPlus <FolderPlus
size={paneContentToolbarIconSize(isCompact)} size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted} color={theme.colors.foregroundExtraMuted}
/> />
</Pressable> </ToolbarButton>
</> </>
) : null} ) : null}
<Pressable <ToolbarButton
onPress={handleToggleHiddenFiles} label={hiddenFilesToggleAccessibilityLabel}
selected={!showHiddenFiles}
compact={isCompact}
hitSlop={8} hitSlop={8}
style={hiddenFilesToggleStyle}
accessibilityRole="button"
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
accessibilityState={hiddenFilesToggleAccessibilityState}
testID="files-hidden-toggle" testID="files-hidden-toggle"
onPress={handleToggleHiddenFiles}
> >
{showHiddenFiles ? ( {showHiddenFiles ? (
<Eye <Eye
size={paneContentToolbarIconSize(isCompact)} size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted} color={theme.colors.foregroundExtraMuted}
/> />
) : ( ) : (
<EyeOff <EyeOff
size={paneContentToolbarIconSize(isCompact)} size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted} color={theme.colors.foregroundExtraMuted}
/> />
)} )}
</Pressable> </ToolbarButton>
<Pressable <ToolbarButton
onPress={handleRefresh} label={
disabled={isRefreshFetching}
hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={
isRefreshFetching isRefreshFetching
? t("workspace.fileExplorer.actions.refreshing") ? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh") : t("workspace.fileExplorer.actions.refresh")
} }
compact={isCompact}
disabled={isRefreshFetching}
hitSlop={8}
testID="files-refresh" testID="files-refresh"
onPress={handleRefresh}
> >
<View style={styles.refreshIcon}> <View style={styles.refreshIcon}>
{isRefreshFetching ? ( {isRefreshFetching ? (
<LoadingSpinner <LoadingSpinner
size={paneContentToolbarIconSize(isCompact)} size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted} color={theme.colors.foregroundExtraMuted}
/> />
) : ( ) : (
<RotateCw <RotateCw
size={paneContentToolbarIconSize(isCompact)} size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted} color={theme.colors.foregroundExtraMuted}
/> />
)} )}
</View> </View>
</Pressable> </ToolbarButton>
</View> </ToolbarControls>
</PaneContentToolbar> </PaneContentToolbar>
<ContextMenu> <ContextMenu>
<RootCreationContextTarget enabled={Boolean(onNewEntryAtRoot)}> <RootCreationContextTarget enabled={Boolean(onNewEntryAtRoot)}>
@@ -1479,6 +1428,7 @@ function TreeRowDispatcher({
revealTargetName, revealTargetName,
onDownloadEntry, onDownloadEntry,
onAddToChat, onAddToChat,
onOpenFileToSide,
onNewEntry, onNewEntry,
onCollapseDirectory, onCollapseDirectory,
onRenameEntry, onRenameEntry,
@@ -1500,6 +1450,7 @@ function TreeRowDispatcher({
revealTargetName?: string; revealTargetName?: string;
onDownloadEntry: (entry: ExplorerEntry) => void; onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void; onAddToChat?: (path: string) => void;
onOpenFileToSide?: (path: string) => void;
onNewEntry?: (parentPath: string, kind: "file" | "directory") => void; onNewEntry?: (parentPath: string, kind: "file" | "directory") => void;
onCollapseDirectory?: (path: string) => void; onCollapseDirectory?: (path: string) => void;
onRenameEntry?: (entry: ExplorerEntry) => void; onRenameEntry?: (entry: ExplorerEntry) => void;
@@ -1530,6 +1481,7 @@ function TreeRowDispatcher({
revealTargetName={revealTargetName} revealTargetName={revealTargetName}
onDownloadEntry={onDownloadEntry} onDownloadEntry={onDownloadEntry}
onAddToChat={onAddToChat} onAddToChat={onAddToChat}
onOpenFileToSide={onOpenFileToSide}
onNewEntry={onNewEntry} onNewEntry={onNewEntry}
onCollapseDirectory={onCollapseDirectory} onCollapseDirectory={onCollapseDirectory}
onRenameEntry={onRenameEntry} onRenameEntry={onRenameEntry}
@@ -1698,7 +1650,7 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
paddingRight: theme.spacing[3], backgroundColor: theme.colors.surfaceSidebar,
}, },
sortTrigger: { sortTrigger: {
flexDirection: "row", flexDirection: "row",
@@ -1711,7 +1663,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.base, borderRadius: theme.borderRadius.base,
}, },
sortTriggerHovered: { sortTriggerHovered: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surfaceSidebarHover,
}, },
sortTriggerText: { sortTriggerText: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
@@ -1720,7 +1672,6 @@ const styles = StyleSheet.create((theme) => ({
headerActions: { headerActions: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1],
}, },
treeList: { treeList: {
flex: 1, flex: 1,
@@ -1807,38 +1758,12 @@ const styles = StyleSheet.create((theme) => ({
draftPlaceholder: { draftPlaceholder: {
color: theme.colors.foregroundExtraMuted, color: theme.colors.foregroundExtraMuted,
}, },
contextMetaBlock: {
paddingVertical: theme.spacing[1],
},
contextMetaRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
minHeight: 32,
paddingHorizontal: theme.spacing[3],
},
contextMetaLabel: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
flexShrink: 0,
},
contextMetaValue: {
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
flex: 1,
minWidth: 0,
textAlign: "right",
},
previewHeaderText: { previewHeaderText: {
flex: 1, flex: 1,
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
}, },
iconButtonActive: {
backgroundColor: theme.colors.surface2,
},
refreshIcon: { refreshIcon: {
width: 16, width: 16,
height: 16, height: 16,
@@ -5,6 +5,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import {
iconButtonChromeFrameStyle,
iconButtonChromeStyle,
} from "@/components/ui/icon-button-chrome";
interface HeaderToggleButtonState { interface HeaderToggleButtonState {
hovered: boolean; hovered: boolean;
@@ -42,7 +46,17 @@ export function HeaderToggleButton({
? ({ "aria-expanded": expandedState } as Record<string, boolean>) ? ({ "aria-expanded": expandedState } as Record<string, boolean>)
: null; : null;
const combinedStyle = useMemo(() => [headerIconSlotStyle.slot, style], [style]); const combinedStyle = useMemo(
() =>
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) =>
iconButtonChromeStyle({
size: "large",
state: { hovered: Boolean(hovered), pressed },
disabled: Boolean(disabled),
style,
}),
[disabled, style],
);
return ( return (
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
@@ -68,16 +82,6 @@ export function HeaderToggleButton({
); );
} }
export const headerIconSlotStyle = StyleSheet.create((theme) => ({
slot: {
padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg,
},
}));
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
tooltipRow: { tooltipRow: {
flexDirection: "row", flexDirection: "row",
@@ -90,3 +94,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
shortcut: {}, shortcut: {},
})); }));
export const headerIconSlotStyle = {
slot: iconButtonChromeFrameStyle("large"),
};
@@ -10,6 +10,7 @@ import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window"; import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome";
interface MenuHeaderProps { interface MenuHeaderProps {
title?: string; title?: string;
@@ -26,7 +27,7 @@ interface SidebarMenuToggleProps {
const MOBILE_MENU_LINE_WIDTH = 16; const MOBILE_MENU_LINE_WIDTH = 16;
const MOBILE_MENU_LINE_SHORT_WIDTH = 8; const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
const MOBILE_MENU_LINE_HEIGHT = 2; const MOBILE_MENU_LINE_HEIGHT = 1.5;
function MobileMenuIcon({ color }: { color: string }) { function MobileMenuIcon({ color }: { color: string }) {
const lineStyle = useMemo(() => [styles.mobileMenuLine, { backgroundColor: color }], [color]); const lineStyle = useMemo(() => [styles.mobileMenuLine, { backgroundColor: color }], [color]);
@@ -84,19 +85,21 @@ function SidebarMenuToggleButton({
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")} accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
accessibilityState={accessibilityState} accessibilityState={accessibilityState}
> >
{({ hovered, pressed }) => { {isMobile ? (
let color = extraMutedIdleIcon <MobileMenuIcon
? theme.colors.foregroundExtraMuted color={
: theme.colors.foregroundMuted; extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
if (hovered || pressed) { }
color = theme.colors.foreground; />
} ) : (
return isMobile ? ( <PanelLeft
<MobileMenuIcon color={color} /> size={iconButtonChromeGlyphSize("large")}
) : ( strokeWidth={1.5}
<PanelLeft size={theme.iconSize.md} color={color} /> color={
); extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
}} }
/>
)}
</HeaderToggleButton> </HeaderToggleButton>
); );
} }
+1 -1
View File
@@ -1462,7 +1462,7 @@ export const AssistantMessage = memo(function AssistantMessage({
const fileLinkActions = useAssistantFileLinkActions(); const fileLinkActions = useAssistantFileLinkActions();
const handleMarkdownLinkPress = useStableEvent((url: string) => { const handleMarkdownLinkPress = useStableEvent((url: string) => {
fileLinkActions.open({ href: url }, "side"); fileLinkActions.open({ href: url }, "preferred");
// react-native-markdown-display opens the link itself when this returns true. // react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens. // We already handled it above, so return false to avoid duplicate opens.
return false; return false;
+128 -19
View File
@@ -1,14 +1,22 @@
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { View, type PointerEvent as RNPointerEvent } from "react-native"; import { View, type PointerEvent as RNPointerEvent } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { startResizeHandleDrag, type ResizeHandleDrag } from "@/components/resize-handle-drag"; import { startResizeHandleDrag, type ResizeHandleDrag } from "@/components/resize-handle-drag";
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout";
export interface ResizeHandleProps { export interface ResizeHandleProps {
testID?: string; testID?: string;
direction: "horizontal" | "vertical"; direction: "horizontal" | "vertical";
hitAreaAlignment?: "center" | "end";
groupId: string; groupId: string;
index: number; index: number;
sizes: number[]; sizes: number[];
containerSize: number;
onPreviewResizeSplit: (groupId: string, sizes: number[]) => void; onPreviewResizeSplit: (groupId: string, sizes: number[]) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void; onResizeSplit: (groupId: string, sizes: number[]) => void;
} }
@@ -30,14 +38,18 @@ function resetWindowHorizontalScroll() {
export function ResizeHandle({ export function ResizeHandle({
testID, testID,
direction, direction,
hitAreaAlignment = "center",
groupId, groupId,
index, index,
sizes, sizes,
containerSize,
onPreviewResizeSplit, onPreviewResizeSplit,
onResizeSplit, onResizeSplit,
}: ResizeHandleProps) { }: ResizeHandleProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const finePointer = useHasFinePointer();
const pointerStatesRef = useRef(new Map<number, PointerState>()); const pointerStatesRef = useRef(new Map<number, PointerState>());
const touchDragRef = useRef<ResizeHandleDrag | null>(null);
const cursorBeforeDragRef = useRef<string | null>(null); const cursorBeforeDragRef = useRef<string | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [active, setActive] = useState(false); const [active, setActive] = useState(false);
@@ -51,13 +63,6 @@ export function ResizeHandle({
return; return;
} }
const containerElement = hitAreaElement.parentElement?.parentElement ?? null;
if (!containerElement) {
return;
}
const rect = containerElement.getBoundingClientRect();
const containerSize = direction === "horizontal" ? rect.width : rect.height;
if (containerSize <= 0) { if (containerSize <= 0) {
return; return;
} }
@@ -140,9 +145,41 @@ export function ResizeHandle({
window.addEventListener("pointerup", handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp); window.addEventListener("pointercancel", handlePointerUp);
}, },
[direction, groupId, index, onPreviewResizeSplit, onResizeSplit, sizes], [containerSize, direction, groupId, index, onPreviewResizeSplit, onResizeSplit, sizes],
); );
const touchGesture = useMemo(() => {
const gesture = Gesture.Pan()
.runOnJS(true)
.onBegin(() => setDragging(true))
.onStart(() => {
touchDragRef.current = startResizeHandleDrag({
sizes,
index,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
});
})
.onUpdate((event) => {
if (containerSize <= 0) return;
const translation = direction === "horizontal" ? event.translationX : event.translationY;
touchDragRef.current?.move(translation / containerSize);
})
.onEnd(() => touchDragRef.current?.finish())
.onFinalize(() => {
touchDragRef.current = null;
setDragging(false);
});
return direction === "horizontal"
? gesture
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
: gesture
.activeOffsetY([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
.failOffsetX([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET]);
}, [containerSize, direction, groupId, index, onPreviewResizeSplit, onResizeSplit, sizes]);
const handlePointerEnter = useCallback(() => { const handlePointerEnter = useCallback(() => {
hoverTimerRef.current = setTimeout(() => { hoverTimerRef.current = setTimeout(() => {
setActive(true); setActive(true);
@@ -178,11 +215,36 @@ export function ResizeHandle({
styles.hitArea, styles.hitArea,
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical, direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
{ {
...(direction === "horizontal"
? { left: hitAreaAlignment === "end" ? 0 : -5 }
: { top: hitAreaAlignment === "end" ? 0 : -5 }),
cursor: direction === "horizontal" ? "col-resize" : "row-resize", cursor: direction === "horizontal" ? "col-resize" : "row-resize",
touchAction: "none", touchAction: "none",
} as object, } as object,
], ],
[direction], [direction, hitAreaAlignment],
);
const touchHitAreaStyle = useMemo(
() => [
styles.touchHitArea,
direction === "horizontal" ? styles.touchHitAreaHorizontal : styles.touchHitAreaVertical,
{
...(direction === "horizontal"
? { left: hitAreaAlignment === "end" ? 0 : -12 }
: { top: hitAreaAlignment === "end" ? 0 : -12 }),
touchAction: "none",
} as object,
],
[direction, hitAreaAlignment],
);
const touchGripStyle = useMemo(
() => [
styles.touchGrip,
direction === "horizontal" ? styles.touchGripHorizontal : styles.touchGripVertical,
highlighted ? styles.touchGripVisible : styles.touchGripHidden,
{ backgroundColor: theme.colors.foreground },
],
[direction, highlighted, theme.colors.foreground],
); );
return ( return (
@@ -194,14 +256,27 @@ export function ResizeHandle({
testID={testID ? `${testID}-highlight` : undefined} testID={testID ? `${testID}-highlight` : undefined}
/> />
)} )}
<View {finePointer ? (
role="separator" <View
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"} role="separator"
style={hitAreaStyle} aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
onPointerDown={handlePointerDown} style={hitAreaStyle}
onPointerEnter={handlePointerEnter} onPointerDown={handlePointerDown}
onPointerLeave={handlePointerLeave} onPointerEnter={handlePointerEnter}
/> onPointerLeave={handlePointerLeave}
/>
) : (
<GestureDetector gesture={touchGesture}>
<View
role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
collapsable={false}
style={touchHitAreaStyle}
>
<View pointerEvents="none" style={touchGripStyle} />
</View>
</GestureDetector>
)}
</View> </View>
); );
} }
@@ -210,6 +285,7 @@ const styles = StyleSheet.create((_theme) => ({
handle: { handle: {
position: "relative", position: "relative",
flexShrink: 0, flexShrink: 0,
zIndex: 10,
}, },
handleHorizontal: { handleHorizontal: {
width: 1, width: 1,
@@ -240,15 +316,48 @@ const styles = StyleSheet.create((_theme) => ({
zIndex: 10, zIndex: 10,
}, },
hitAreaHorizontal: { hitAreaHorizontal: {
left: -5,
top: 0, top: 0,
bottom: 0, bottom: 0,
width: 10, width: 10,
}, },
hitAreaVertical: { hitAreaVertical: {
top: -5,
left: 0, left: 0,
right: 0, right: 0,
height: 10, height: 10,
}, },
touchHitArea: {
position: "absolute",
zIndex: 10,
alignItems: "center",
justifyContent: "center",
},
touchHitAreaHorizontal: {
top: "50%",
width: 24,
height: 88,
transform: [{ translateY: -44 }],
},
touchHitAreaVertical: {
left: "50%",
width: 88,
height: 24,
transform: [{ translateX: -44 }],
},
touchGrip: {
borderRadius: 2,
},
touchGripHorizontal: {
width: 4,
height: 36,
},
touchGripVertical: {
width: 36,
height: 4,
},
touchGripHidden: {
opacity: 0.12,
},
touchGripVisible: {
opacity: 0.3,
},
})); }));
@@ -37,14 +37,7 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { type GestureType } from "react-native-gesture-handler"; import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native";
ExternalLink,
GitPullRequest,
Settings,
MoreVertical,
Plus,
Trash2,
} from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types"; import type { DraggableListDragHandleProps } from "./draggable-list.types";
@@ -159,6 +152,7 @@ import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import type { HostBadgeModel } from "@/hosts/appearance"; import type { HostBadgeModel } from "@/hosts/appearance";
import { useHostBadges } from "@/hosts/use-host-badges"; import { useHostBadges } from "@/hosts/use-host-badges";
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model"; import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey; const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -166,7 +160,6 @@ const projectViewKeyExtractor = (project: SidebarProjectEntry) => project.viewKe
const WORKSPACE_STATUS_DOT_WIDTH = 14; const WORKSPACE_STATUS_DOT_WIDTH = 14;
const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedPlus = withUnistyles(Plus); const ThemedPlus = withUnistyles(Plus);
const ThemedMoreVertical = withUnistyles(MoreVertical); const ThemedMoreVertical = withUnistyles(MoreVertical);
@@ -179,26 +172,6 @@ const foregroundColorMapping = (theme: Theme) => ({
const foregroundMutedColorMapping = (theme: Theme) => ({ const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}); });
const redColorMapping = (theme: Theme) => ({
color: theme.colors.statusDanger,
});
const greenColorMapping = (theme: Theme) => ({
color: theme.colors.statusSuccess,
});
const purpleColorMapping = (theme: Theme) => ({
color: theme.colors.statusMerged,
});
function getPrIconUniMapping(state: PrHint["state"]) {
switch (state) {
case "merged":
return purpleColorMapping;
case "open":
return greenColorMapping;
case "closed":
return redColorMapping;
}
}
function isWorkspaceSelected(input: { function isWorkspaceSelected(input: {
selection: ActiveWorkspaceSelection | null; selection: ActiveWorkspaceSelection | null;
@@ -352,7 +325,6 @@ export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp<ViewS
const textStyle = isHovered const textStyle = isHovered
? [prBadgeStyles.text, prBadgeStyles.textHovered] ? [prBadgeStyles.text, prBadgeStyles.textHovered]
: prBadgeStyles.text; : prBadgeStyles.text;
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
const presentation = getForgePresentation(normalizeForge(hint.forge)); const presentation = getForgePresentation(normalizeForge(hint.forge));
return ( return (
@@ -370,9 +342,9 @@ export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp<ViewS
style={pressableStyle} style={pressableStyle}
> >
{isHovered ? ( {isHovered ? (
<ThemedExternalLink size={12} uniProps={iconUniProps} /> <ThemedExternalLink size={12} uniProps={foregroundColorMapping} />
) : ( ) : (
<ThemedGitPullRequest size={12} uniProps={iconUniProps} /> <PullRequestStateIcon state={hint.state} size={12} />
)} )}
<Text style={textStyle} numberOfLines={1}> <Text style={textStyle} numberOfLines={1}>
{hint.number} {hint.number}
@@ -2,15 +2,7 @@ import { Fragment, useCallback, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type GestureResponderEvent } from "react-native"; import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { import { ExternalLink, Folder, GitBranch, Globe } from "lucide-react-native";
ExternalLink,
Folder,
GitBranch,
GitMerge,
GitPullRequest,
GitPullRequestClosed,
Globe,
} from "lucide-react-native";
import { import {
workspaceLabelKey, workspaceLabelKey,
type WorkspaceLabelDefinition, type WorkspaceLabelDefinition,
@@ -23,6 +15,7 @@ import { getForgePresentation, normalizeForge } from "@/git/forge";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model"; import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
import { CheckIndicator } from "./check-indicator"; import { CheckIndicator } from "./check-indicator";
import type { CheckSummary, CheckSummaryState } from "./check-summary"; import type { CheckSummary, CheckSummaryState } from "./check-summary";
import { selectMetaRowItems, type MetaRowItem } from "./meta-items"; import { selectMetaRowItems, type MetaRowItem } from "./meta-items";
@@ -44,9 +37,6 @@ const META_ICON_SIZE = HOST_BADGE_ICON_SIZE;
const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedFolder = withUnistyles(Folder); const ThemedFolder = withUnistyles(Folder);
const ThemedGitBranch = withUnistyles(GitBranch); const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed);
const ThemedGlobe = withUnistyles(Globe); const ThemedGlobe = withUnistyles(Globe);
/** Stable identity so a row without labels doesn't re-select its items on every render. */ /** Stable identity so a row without labels doesn't re-select its items on every render. */
@@ -54,7 +44,6 @@ const EMPTY_LABELS: readonly WorkspaceLabelDefinition[] = [];
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMerged });
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger }); const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
/** /**
@@ -212,7 +201,6 @@ function PullRequestItem({ hint }: { hint: PrHint }) {
const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []); const handleHoverOut = useCallback(() => setIsHovered(false), []);
const Icon = isHovered ? ThemedExternalLink : PR_ICONS[hint.state];
return ( return (
<Pressable <Pressable
accessibilityRole="link" accessibilityRole="link"
@@ -227,10 +215,11 @@ function PullRequestItem({ hint }: { hint: PrHint }) {
onHoverOut={handleHoverOut} onHoverOut={handleHoverOut}
style={pressableItemStyle} style={pressableItemStyle}
> >
<Icon {isHovered ? (
size={META_ICON_SIZE} <ThemedExternalLink size={META_ICON_SIZE} uniProps={foregroundMapping} />
uniProps={isHovered ? foregroundMapping : PR_COLOR_MAPPINGS[hint.state]} ) : (
/> <PullRequestStateIcon state={hint.state} size={META_ICON_SIZE} />
)}
<Text style={isHovered ? styles.prTextHovered : styles.prText} numberOfLines={1}> <Text style={isHovered ? styles.prTextHovered : styles.prText} numberOfLines={1}>
{hint.number} {hint.number}
{/* An open change request is the unremarkable case and says nothing extra; a merged {/* An open change request is the unremarkable case and says nothing extra; a merged
@@ -311,20 +300,6 @@ function ServiceItem({ summary }: { summary: WorkspaceServiceSummary }) {
const successMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess }); const successMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
const PR_ICONS = {
open: ThemedGitPullRequest,
merged: ThemedGitMerge,
closed: ThemedGitPullRequestClosed,
} as const;
// Same three colours the hover card gives the same three states — a change request has one
// identity, and it shouldn't shift when you hover the row that names it.
const PR_COLOR_MAPPINGS = {
open: successMapping,
merged: mergedMapping,
closed: dangerMapping,
} as const;
const PR_STATE_LABEL_KEYS = { const PR_STATE_LABEL_KEYS = {
merged: "workspace.git.pr.states.merged", merged: "workspace.git.pr.states.merged",
closed: "workspace.git.pr.states.closed", closed: "workspace.git.pr.states.closed",
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveSplitContainerRoot } from "@/components/split-container-focus"; import {
resolveSplitContainerRoot,
splitNodeContainsPane,
} from "@/components/split-container-focus";
import type { SplitNode } from "@/stores/workspace-layout-store"; import type { SplitNode } from "@/stores/workspace-layout-store";
const pane = (id: string): SplitNode => ({ const pane = (id: string): SplitNode => ({
@@ -35,25 +38,11 @@ describe("split focus root", () => {
).toEqual({ root, usesFallbackStrip: false }); ).toEqual({ root, usesFallbackStrip: false });
}); });
it("renders only a maximized pane without entering focus mode", () => { it("finds the maximized pane without replacing the rendered split tree", () => {
expect(splitNodeContainsPane(root, "right")).toBe(true);
expect(splitNodeContainsPane(root, "missing")).toBe(false);
expect( expect(
resolveSplitContainerRoot({ resolveSplitContainerRoot({ root, focusedPaneId: "left", focusModeEnabled: false }),
root,
focusedPaneId: "left",
focusModeEnabled: false,
maximizedPaneId: "right",
}),
).toEqual({ root: pane("right"), usesFallbackStrip: false });
});
it("keeps normal splits when the maximized pane is unavailable", () => {
expect(
resolveSplitContainerRoot({
root,
focusedPaneId: "left",
focusModeEnabled: false,
maximizedPaneId: "missing",
}),
).toEqual({ root, usesFallbackStrip: false }); ).toEqual({ root, usesFallbackStrip: false });
}); });
}); });
@@ -4,9 +4,8 @@ export function resolveSplitContainerRoot(input: {
root: SplitNode; root: SplitNode;
focusedPaneId: string | null; focusedPaneId: string | null;
focusModeEnabled: boolean | undefined; focusModeEnabled: boolean | undefined;
maximizedPaneId?: string | null;
}): { root: SplitNode; usesFallbackStrip: boolean } { }): { root: SplitNode; usesFallbackStrip: boolean } {
const isolatedPaneId = input.focusModeEnabled ? input.focusedPaneId : input.maximizedPaneId; const isolatedPaneId = input.focusModeEnabled ? input.focusedPaneId : null;
if (!isolatedPaneId) return { root: input.root, usesFallbackStrip: false }; if (!isolatedPaneId) return { root: input.root, usesFallbackStrip: false };
const isolatedPane = findPane(input.root, isolatedPaneId); const isolatedPane = findPane(input.root, isolatedPaneId);
if (!isolatedPane || isolatedPane.hidden === true) { if (!isolatedPane || isolatedPane.hidden === true) {
@@ -15,6 +14,12 @@ export function resolveSplitContainerRoot(input: {
return { root: { kind: "pane", pane: isolatedPane }, usesFallbackStrip: false }; return { root: { kind: "pane", pane: isolatedPane }, usesFallbackStrip: false };
} }
/** Whether a split subtree contains the pane currently projected full-size. */
export function splitNodeContainsPane(node: SplitNode, paneId: string): boolean {
if (node.kind === "pane") return node.pane.id === paneId;
return node.group.children.some((child) => splitNodeContainsPane(child, paneId));
}
function findPane(node: SplitNode, paneId: string): SplitPane | null { function findPane(node: SplitNode, paneId: string): SplitPane | null {
if (node.kind === "pane") return node.pane.id === paneId ? node.pane : null; if (node.kind === "pane") return node.pane.id === paneId ? node.pane : null;
for (const child of node.group.children) { for (const child of node.group.children) {
+374 -250
View File
@@ -1,6 +1,5 @@
import { import {
Fragment, Fragment,
memo,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
@@ -27,7 +26,7 @@ import {
type DragStartEvent, type DragStartEvent,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { View, Text } from "react-native"; import { View, Text, type LayoutChangeEvent } from "react-native";
import Animated, { import Animated, {
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
@@ -36,10 +35,18 @@ import Animated, {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle"; import { ResizeHandle } from "@/components/resize-handle";
import {
resolveExplorerSidebarDockSizes,
resolveExplorerSidebarWidth,
} from "@/components/explorer-sidebar-layout";
import { RetainedPanel } from "@/components/retained-panel"; 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 { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
import { import {
removeWindowChromeCorner,
WindowChromeRegion, WindowChromeRegion,
WindowChromeSafeArea, WindowChromeSafeArea,
useWindowChromeCorners, useWindowChromeCorners,
@@ -59,22 +66,20 @@ import {
deriveWorkspacePaneState, deriveWorkspacePaneState,
getWorkspacePaneDescriptors, getWorkspacePaneDescriptors,
} from "@/screens/workspace/workspace-pane-state"; } from "@/screens/workspace/workspace-pane-state";
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content";
import { useModifiedPanelTabIds } from "@/panels/panel-instance-attributes"; import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host";
import {
WorkspacePaneContent,
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import { import {
WorkspaceDesktopTabsRow, WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem, type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row"; } from "@/screens/workspace/workspace-desktop-tabs-row";
import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar";
import { import {
WorkspaceTabPresentationResolver, WorkspaceTabPresentationResolver,
WorkspaceTabIcon, WorkspaceTabIcon,
} from "@/screens/workspace/workspace-tab-presentation"; } from "@/screens/workspace/workspace-tab-presentation";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { import {
createDefaultLayout,
findPaneById, findPaneById,
useWorkspaceLayoutStore, useWorkspaceLayoutStore,
type SplitNode, type SplitNode,
@@ -83,13 +88,13 @@ import {
} from "@/stores/workspace-layout-store"; } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/workspace-tabs/model"; import type { WorkspaceTab } from "@/workspace-tabs/model";
import { RenderProfile } from "@/utils/render-profiler"; import { RenderProfile } from "@/utils/render-profiler";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
interface SplitContainerProps { interface SplitContainerProps {
layout: WorkspaceLayout; layout: WorkspaceLayout;
renderMainHeader?: () => ReactNode;
renderExplorerSidebarHeaderAction?: () => ReactNode;
workspaceKey: string; workspaceKey: string;
normalizedServerId: string; normalizedServerId: string;
normalizedWorkspaceId: string; normalizedWorkspaceId: string;
@@ -125,6 +130,7 @@ interface SplitContainerProps {
position: "left" | "right" | "top" | "bottom"; position: "left" | "right" | "top" | "bottom";
}) => void; }) => void;
onMoveTabToPane: (tabId: string, toPaneId: string) => void; onMoveTabToPane: (tabId: string, toPaneId: string) => void;
onSelectTabInPane: (paneId: string, tabId: string) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void; onResizeSplit: (groupId: string, sizes: number[]) => void;
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void; onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
focusModeEnabled?: boolean; focusModeEnabled?: boolean;
@@ -144,6 +150,7 @@ interface SplitPaneDropData {
const EMPTY_SPLIT_NODES: SplitNode[] = []; const EMPTY_SPLIT_NODES: SplitNode[] = [];
const EMPTY_SPLIT_SIZES: number[] = []; const EMPTY_SPLIT_SIZES: number[] = [];
const EXPLORER_SIDEBAR_RESIZE_GROUP_ID = "explorer-sidebar";
function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData { function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData {
return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab"; return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab";
@@ -165,18 +172,21 @@ function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData
return undefined; return undefined;
} }
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMoveTabToPane"> { interface SplitNodeViewProps extends Omit<
SplitContainerProps,
"layout" | "onMoveTabToPane" | "onSelectTabInPane"
> {
node: SplitNode; node: SplitNode;
uiTabs: WorkspaceTab[]; uiTabs: WorkspaceTab[];
focusedPaneId: string | null; focusedPaneId: string | null;
sidePanelPaneId: string | null;
maximizedPaneId: string | null;
onTogglePaneMaximized: (paneId: string) => void;
activeDragTabId: string | null; activeDragTabId: string | null;
showDropZones: boolean; showDropZones: boolean;
dropPreview: SplitDropZoneHover | null; dropPreview: SplitDropZoneHover | null;
tabDropPreview: TabDropPreview | null; tabDropPreview: TabDropPreview | null;
windowChromeCorners: WindowChromeCorners; windowChromeCorners: WindowChromeCorners;
maximizedPaneId: string | null;
workspaceHasSplits: boolean;
onTogglePaneMaximized: (paneId: string) => void;
} }
interface SplitPaneViewProps extends Omit< interface SplitPaneViewProps extends Omit<
@@ -199,82 +209,6 @@ interface SplitPaneViewProps extends Omit<
tabDropPreview: TabDropPreview | null; 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 { interface DragMoveRects {
translatedRect: { left: number; top: number; width: number; height: number }; translatedRect: { left: number; top: number; width: number; height: number };
overRect: { 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({ export function SplitContainer({
layout, layout,
renderMainHeader,
renderExplorerSidebarHeaderAction,
workspaceKey, workspaceKey,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
@@ -397,6 +333,7 @@ export function SplitContainer({
onSplitPane, onSplitPane,
onSplitPaneEmpty, onSplitPaneEmpty,
onMoveTabToPane, onMoveTabToPane,
onSelectTabInPane,
onResizeSplit, onResizeSplit,
onReorderTabsInPane, onReorderTabsInPane,
focusModeEnabled, focusModeEnabled,
@@ -413,56 +350,10 @@ export function SplitContainer({
} | null>(null); } | null>(null);
const maximizedPaneId = const maximizedPaneId =
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null; maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
const sidePanelPaneId = useWorkspaceLayoutStore( const explorerSidebarPaneId = useWorkspaceLayoutStore(
(state) => state.sidePanelPaneIdByWorkspace[workspaceKey] ?? null, (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( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { activationConstraint: {
@@ -475,15 +366,122 @@ export function SplitContainer({
); );
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]); 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( const splitRoot = useMemo(
() => () =>
resolveSplitContainerRoot({ resolveSplitContainerRoot({
root: layout.root, root: mainRoot ?? createDefaultLayout().root,
focusedPaneId: layout.focusedPaneId, focusedPaneId: layout.focusedPaneId,
focusModeEnabled, 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 renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
@@ -521,6 +519,21 @@ export function SplitContainer({
return; 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") { if (overData?.kind === "workspace-tab") {
const preview = computeTabOverDropPreview({ const preview = computeTabOverDropPreview({
activeData, activeData,
@@ -542,7 +555,7 @@ export function SplitContainer({
setDropPreview(computePaneOverDropPreview({ overData, rects })); setDropPreview(computePaneOverDropPreview({ overData, rects }));
}, },
[panesById, uiTabs], [normalizedServerId, panesById, explorerSidebarPaneId, uiTabs],
); );
const applyTabDropEnd = useCallback( const applyTabDropEnd = useCallback(
@@ -639,47 +652,90 @@ export function SplitContainer({
onDragCancel={handleDragCancel} onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />} <View style={styles.workspaceShell} onLayout={handleWorkspaceShellLayout}>
<SplitNodeView <WindowChromeRegion corners={mainColumnWindowChromeCorners}>
node={renderRoot} <View style={mainColumnStyle}>
workspaceKey={workspaceKey} {renderMainHeader?.()}
uiTabs={uiTabs} {splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
focusedPaneId={layout.focusedPaneId} {renderRoot ? (
sidePanelPaneId={sidePanelPaneId} <SplitNodeView
maximizedPaneId={maximizedPaneId} node={renderRoot}
onTogglePaneMaximized={handleTogglePaneMaximized} workspaceKey={workspaceKey}
normalizedServerId={normalizedServerId} uiTabs={uiTabs}
normalizedWorkspaceId={normalizedWorkspaceId} focusedPaneId={layout.focusedPaneId}
isWorkspaceFocused={isWorkspaceFocused} normalizedServerId={normalizedServerId}
hoveredCloseTabKey={hoveredCloseTabKey} normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredCloseTabKey={setHoveredCloseTabKey} isWorkspaceFocused={isWorkspaceFocused}
closingTabIds={closingTabIds} hoveredCloseTabKey={hoveredCloseTabKey}
onNavigateTab={onNavigateTab} setHoveredCloseTabKey={setHoveredCloseTabKey}
onCloseTab={onCloseTab} closingTabIds={closingTabIds}
onCopyResumeCommand={onCopyResumeCommand} onNavigateTab={onNavigateTab}
onCopyAgentId={onCopyAgentId} onCloseTab={onCloseTab}
onCopyTerminalId={onCopyTerminalId} onCopyResumeCommand={onCopyResumeCommand}
onCopyFilePath={onCopyFilePath} onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent} onCopyTerminalId={onCopyTerminalId}
onRenameTab={onRenameTab} onCopyFilePath={onCopyFilePath}
onCloseTabsToLeft={onCloseTabsToLeft} onReloadAgent={onReloadAgent}
onCloseTabsToRight={onCloseTabsToRight} onRenameTab={onRenameTab}
onCloseOtherTabs={onCloseOtherTabs} onCloseTabsToLeft={onCloseTabsToLeft}
onCreateNewTab={onCreateNewTab} onCloseTabsToRight={onCloseTabsToRight}
buildPaneContentModel={buildPaneContentModel} onCloseOtherTabs={onCloseOtherTabs}
onFocusPane={onFocusPane} onCreateNewTab={onCreateNewTab}
onSplitPane={onSplitPane} buildPaneContentModel={buildPaneContentModel}
onSplitPaneEmpty={onSplitPaneEmpty} onFocusPane={onFocusPane}
onResizeSplit={onResizeSplit} onSplitPane={onSplitPane}
onReorderTabsInPane={onReorderTabsInPane} onSplitPaneEmpty={onSplitPaneEmpty}
activeDragTabId={activeDragTabId} onResizeSplit={onResizeSplit}
showDropZones={activeDragTabId !== null} onReorderTabsInPane={onReorderTabsInPane}
dropPreview={dropPreview} activeDragTabId={activeDragTabId}
tabDropPreview={tabDropPreview} showDropZones={activeDragTabId !== null}
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners} dropPreview={dropPreview}
focusModeEnabled={focusModeEnabled} tabDropPreview={tabDropPreview}
onExitFocusMode={onExitFocusMode} 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}> <DragOverlay dropAnimation={null}>
{activeDragTabId ? ( {activeDragTabId ? (
<DragOverlayTabChip <DragOverlayTabChip
@@ -825,16 +881,35 @@ function SplitGroupChild({
* hidden pane give its space back instead of just going invisible. The stored `sizes` are never * 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. * 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( 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, 0,
); );
if (visibleTotal <= 0) { if (visibleTotal <= 0) {
return children.map(() => 0); return children.map(() => 0);
} }
return children.map((child, index) => 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, workspaceKey,
uiTabs, uiTabs,
focusedPaneId, focusedPaneId,
sidePanelPaneId,
maximizedPaneId,
onTogglePaneMaximized,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
isWorkspaceFocused, isWorkspaceFocused,
@@ -882,9 +954,13 @@ function SplitNodeView({
dropPreview, dropPreview,
tabDropPreview, tabDropPreview,
windowChromeCorners, windowChromeCorners,
maximizedPaneId,
workspaceHasSplits,
onTogglePaneMaximized,
focusModeEnabled, focusModeEnabled,
onExitFocusMode, onExitFocusMode,
}: SplitNodeViewProps) { }: SplitNodeViewProps) {
const [groupContainerSize, setGroupContainerSize] = useState(0);
const groupId = node.kind === "group" ? node.group.id : null; const groupId = node.kind === "group" ? node.group.id : null;
const groupDirection = node.kind === "group" ? node.group.direction : null; const groupDirection = node.kind === "group" ? node.group.direction : null;
@@ -895,8 +971,8 @@ function SplitNodeView({
const groupSizes = const groupSizes =
storedGroupSizes ?? (node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES); storedGroupSizes ?? (node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES);
const visibleFlex = useMemo( const visibleFlex = useMemo(
() => resolveVisibleGroupFlex(groupChildren, groupSizes), () => resolveVisibleGroupFlex(groupChildren, groupSizes, maximizedPaneId),
[groupChildren, groupSizes], [groupChildren, groupSizes, maximizedPaneId],
); );
const resizeFlex = useSharedValue(visibleFlex); const resizeFlex = useSharedValue(visibleFlex);
useEffect(() => { useEffect(() => {
@@ -904,9 +980,19 @@ function SplitNodeView({
}, [resizeFlex, visibleFlex]); }, [resizeFlex, visibleFlex]);
const previewResizeSplit = useCallback( const previewResizeSplit = useCallback(
(_groupId: string, sizes: number[]) => { (_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( const groupStyle = useMemo(
@@ -919,15 +1005,14 @@ function SplitNodeView({
if (node.kind === "pane") { if (node.kind === "pane") {
return ( return (
<RetainedPanel active={node.pane.hidden !== true}> <RetainedPanel
active={node.pane.hidden !== true && (!maximizedPaneId || node.pane.id === maximizedPaneId)}
>
<WindowChromeRegion corners={windowChromeCorners}> <WindowChromeRegion corners={windowChromeCorners}>
<SplitPaneView <SplitPaneView
pane={node.pane} pane={node.pane}
uiTabs={uiTabs} uiTabs={uiTabs}
isFocused={node.pane.id === focusedPaneId} isFocused={node.pane.id === focusedPaneId}
sidePanelPaneId={sidePanelPaneId}
maximizedPaneId={maximizedPaneId}
onTogglePaneMaximized={onTogglePaneMaximized}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused} isWorkspaceFocused={isWorkspaceFocused}
@@ -955,6 +1040,9 @@ function SplitNodeView({
showDropZones={showDropZones} showDropZones={showDropZones}
dropPreview={dropPreview} dropPreview={dropPreview}
tabDropPreview={tabDropPreview} tabDropPreview={tabDropPreview}
maximizedPaneId={maximizedPaneId}
workspaceHasSplits={workspaceHasSplits}
onTogglePaneMaximized={onTogglePaneMaximized}
focusModeEnabled={focusModeEnabled} focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode} onExitFocusMode={onExitFocusMode}
/> />
@@ -964,18 +1052,19 @@ function SplitNodeView({
} }
return ( return (
<View style={groupStyle}> <View style={groupStyle} onLayout={handleGroupLayout}>
{node.group.children.map((child, index) => ( {node.group.children.map((child, index) => (
<Fragment key={getNodeKey(child)}> <Fragment key={getNodeKey(child)}>
<SplitGroupChild resizeFlex={resizeFlex} index={index} hidden={isSplitNodeHidden(child)}> <SplitGroupChild
resizeFlex={resizeFlex}
index={index}
hidden={isSplitNodeHiddenForPresentation(child, maximizedPaneId)}
>
<SplitNodeView <SplitNodeView
node={child} node={child}
workspaceKey={workspaceKey} workspaceKey={workspaceKey}
uiTabs={uiTabs} uiTabs={uiTabs}
focusedPaneId={focusedPaneId} focusedPaneId={focusedPaneId}
sidePanelPaneId={sidePanelPaneId}
maximizedPaneId={maximizedPaneId}
onTogglePaneMaximized={onTogglePaneMaximized}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused} isWorkspaceFocused={isWorkspaceFocused}
@@ -1005,19 +1094,23 @@ function SplitNodeView({
dropPreview={dropPreview} dropPreview={dropPreview}
tabDropPreview={tabDropPreview} tabDropPreview={tabDropPreview}
windowChromeCorners={windowChromeCorners} windowChromeCorners={windowChromeCorners}
maximizedPaneId={maximizedPaneId}
workspaceHasSplits={workspaceHasSplits}
onTogglePaneMaximized={onTogglePaneMaximized}
focusModeEnabled={focusModeEnabled} focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode} onExitFocusMode={onExitFocusMode}
/> />
</SplitGroupChild> </SplitGroupChild>
{index < node.group.children.length - 1 && {index < node.group.children.length - 1 &&
!isSplitNodeHidden(child) && !isSplitNodeHiddenForPresentation(child, maximizedPaneId) &&
!isSplitNodeHidden(node.group.children[index + 1]) ? ( !isSplitNodeHiddenForPresentation(node.group.children[index + 1], maximizedPaneId) ? (
<ResizeHandle <ResizeHandle
testID="workspace-split-resize-handle" testID="workspace-split-resize-handle"
direction={node.group.direction} direction={node.group.direction}
groupId={node.group.id} groupId={node.group.id}
index={index} index={index}
sizes={groupSizes} sizes={groupSizes}
containerSize={groupContainerSize}
onPreviewResizeSplit={previewResizeSplit} onPreviewResizeSplit={previewResizeSplit}
onResizeSplit={onResizeSplit} onResizeSplit={onResizeSplit}
/> />
@@ -1053,19 +1146,18 @@ function SplitPaneView({
buildPaneContentModel, buildPaneContentModel,
onFocusPane, onFocusPane,
onSplitPane: _onSplitPane, onSplitPane: _onSplitPane,
onSplitPaneEmpty: _onSplitPaneEmpty, onSplitPaneEmpty,
onReorderTabsInPane, onReorderTabsInPane,
activeDragTabId, activeDragTabId,
showDropZones, showDropZones,
dropPreview, dropPreview,
tabDropPreview, tabDropPreview,
sidePanelPaneId,
maximizedPaneId, maximizedPaneId,
workspaceHasSplits,
onTogglePaneMaximized, onTogglePaneMaximized,
focusModeEnabled, focusModeEnabled,
onExitFocusMode, onExitFocusMode,
}: SplitPaneViewProps) { }: SplitPaneViewProps) {
const { theme: _theme } = useUnistyles();
const paneRef = useRef<View | null>(null); const paneRef = useRef<View | null>(null);
const stableOnFocusPane = useStableEvent(onFocusPane); const stableOnFocusPane = useStableEvent(onFocusPane);
const paneState = useMemo( const paneState = useMemo(
@@ -1077,24 +1169,7 @@ function SplitPaneView({
[pane, uiTabs], [pane, uiTabs],
); );
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]); 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 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[]>( const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
() => () =>
paneTabs.map((tab) => ({ paneTabs.map((tab) => ({
@@ -1162,7 +1237,15 @@ function SplitPaneView({
}, },
[onReorderTabsInPane, paneId], [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),
[onTogglePaneMaximized, paneId], [onTogglePaneMaximized, paneId],
); );
@@ -1172,7 +1255,7 @@ function SplitPaneView({
ref={paneRef} ref={paneRef}
collapsable={false} collapsable={false}
style={styles.pane} 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}> <WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
<TitlebarDragRegion /> <TitlebarDragRegion />
@@ -1201,34 +1284,29 @@ function SplitPaneView({
tabDropPreviewIndex={ tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
} }
showPaneMaximizeAction={pane.id === sidePanelPaneId && !focusModeEnabled} showPaneSplitActions={!focusModeEnabled}
paneMaximized={pane.id === maximizedPaneId} showPaneMaximizeAction={workspaceHasSplits && !focusModeEnabled}
onTogglePaneMaximized={handleToggleMaximized} paneMaximized={paneId === maximizedPaneId}
onTogglePaneMaximized={handleTogglePaneMaximized}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
focusModeEnabled={Boolean(focusModeEnabled)} focusModeEnabled={Boolean(focusModeEnabled)}
onExitFocusMode={onExitFocusMode} onExitFocusMode={onExitFocusMode}
/> />
</WindowChromeSafeArea> </WindowChromeSafeArea>
<View style={styles.paneContent}> <View style={styles.paneContent}>
{mountedPaneTabIds.map((tabId) => { <WorkspacePanelHost
const tabDescriptor = tabDescriptorMap.get(tabId); paneId={pane.id}
if (!tabDescriptor) { tabs={paneTabs}
return null; activeTabId={activeTabDescriptor?.tabId ?? null}
} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
return ( isWorkspaceFocused={isWorkspaceFocused}
<MountedTabSlot isPaneFocused={isFocused}
key={tabId} onFocusPane={stableOnFocusPane}
tabDescriptor={tabDescriptor} buildPaneContentModel={buildPaneContentModel}
isVisible={tabId === activeTabDescriptor?.tabId} />
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
);
})}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} /> <SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View> </View>
</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) => ({ 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: { group: {
flex: 1, flex: 1,
minWidth: 0, 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 * The extra 2px is optical: text ink ends inside its layout box, while the
* header icons' strokes extend to theirs. * 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. */ /** Left padding for a row in either workspace tree. */
export function treeRowPaddingLeft(depth: number): number { export function treeRowPaddingLeft(depth: number): number {
@@ -49,7 +50,7 @@ export const workspaceTreeRowStyles = StyleSheet.create((theme: Theme) => ({
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING, paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING, paddingRight: WORKSPACE_PANE_TRAILING_GLYPH_RAIL,
}, },
active: { active: {
backgroundColor: theme.colors.surfaceSidebarHover, backgroundColor: theme.colors.surfaceSidebarHover,

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