Compare commits

...
55 Commits
Author SHA1 Message Date
Mohamed Boudra 6da9fa43fa chore(release): cut 0.6.0 2026-08-25 15:58:11 +02:00
Mohamed Boudra cd8c868b32 docs: add 0.6.0 changelog 2026-08-25 15:50:49 +02:00
Mohamed Boudra 6f6466d4cb Wait for authoritative provider readiness before prompting (#3821)
* fix(server): trust provider connection lifecycle events

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

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

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

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

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

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

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

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

* test(ci): follow shared e2e daemon launcher

---------

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

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

* refactor(settings): unify discarded-value warnings

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

* refactor(settings): validate persisted settings declaratively

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

* fix(settings): preserve explicit services preferences

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

* refactor(settings): derive migration persistence types

Let the schema prove normalized settings coverage and carry its persisted shape through migrations.
2026-08-24 12:10:13 +02:00
Mohamed Boudra 88f410567d Revert "fix(server): honor Claude bypass permission mode (#3732)"
This reverts commit f998ee1691.
2026-08-24 11:05:19 +02:00
wongangandMohamed Boudra f998ee1691 fix(server): honor Claude bypass permission mode (#3732)
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-24 10:49:12 +02:00
Julien Eid 7a47ec0ee9 Fix crash on Android tablet workspace (#3760)
Fixes #3750
2026-08-24 09:21:25 +02:00
paseo-ai[bot] 4f796181bf fix: update lockfile signatures and Nix hash [skip ci] 2026-08-23 17:46:14 +00:00
Mohamed Boudra f517493591 chore(release): cut 0.5.1 2026-08-23 19:37:03 +02:00
Mohamed Boudra fb74f766c8 docs(changelog): prepare 0.5.1 stable release 2026-08-23 19:32:35 +02:00
Mohamed Boudra e268d1a894 fix(app): stabilize native composer resizing
JavaScript height state competed with native intrinsic layout across draft and agent ownership transitions. Let native inputs own sizing and scrolling while preserving DOM measurement on web.
2026-08-23 18:55:38 +02:00
paseo-ai[bot] b5f5832214 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-23 13:51:42 +00:00
Mohamed Boudra b8a31034ca chore(release): cut 0.5.0 2026-08-23 15:42:11 +02:00
Mohamed Boudra 68275f1e0f docs(changelog): prepare 0.5.0 stable release 2026-08-23 15:38:25 +02:00
Mohamed Boudra 3979371901 fix(server): recover already-settled interrupts (#3742)
* fix(server): recover already-settled interrupts

Provider state can drift from Paseo’s tracked run. Treat an authoritative already-idle interrupt as settled and retire the captured run token so retrying can start new work without weakening ambiguous-cancellation protection.

* fix(codex): fence interruption during turn startup

Reserve foreground-start ownership before asynchronous provider setup. An interrupt can now cancel preflight before turn/start or wait for an issued start to become interruptible, preventing acknowledged cancellation from leaving unmanaged work.
2026-08-23 14:59:36 +02:00
Dan Berindei 71e375c2bc fix(server): stop quota fetcher from writing provider credentials (#3442) 2026-08-23 14:24:18 +02:00
Mohamed Boudra 35cde075ae fix(app): restore composer height lifecycle (#3740)
Native Fabric can omit the initial content-size callback and retain stale size after deletion. Keep the common composer surface free of platform branches while native and web adapters own their measurement mechanisms.
2026-08-23 13:57:17 +02:00
Mohamed Boudra 2124115c8e Open the tab chooser beside the pointer (#3735)
* feat(app): open the tab chooser beside the pointer

Keep keyboard-created and empty-pane New tabs as persistent launcher surfaces, while giving pointer users an anchored chooser at the pane-local plus button. Both presentations resolve the same launch catalogue so future ordering and customization cannot drift.

* test(app): follow the anchored tab chooser

The browser helpers still searched inside the pane for persistent-launcher controls after clicking the portalled menu trigger. Exercise the menu through its own roles and stable catalogue IDs.
2026-08-23 13:08:30 +02:00
Mohamed Boudra 4094eeb566 fix(app): preserve timeline retry backoff (#3736)
Equivalent visibility publications were acting as implicit retry commands, so unrelated tab and layout changes could restart failed timeline work. Keep visibility identity stable and reserve immediate retries for explicit user action.
2026-08-23 13:04:14 +02:00
Mohamed Boudra 8d92e1cc17 fix(pairing): warn that pairing links grant daemon access (#3734)
A pairing offer remains valid across daemon restarts, so remove the incorrect rotation guidance and warn users where the durable credential is displayed.
2026-08-23 12:32:21 +02:00
5123b23f41 fix(app): stop a string SVG transform from crashing the app (#3711)
* fix(app): stop a string SVG transform from crashing the app

A root <Svg> is a real React Native view, so react-native-svg rewrites a
*string* `transform` into RN style syntax in `extractTransformSvgView`,
using a PEG parser whose grammar only accepts SVG transform functions.
Upstream lets that parser's SyntaxError escape, so any string it does not
understand takes the whole app down during render:

  SyntaxError: Expected transform functions but "n" found.
  "found": "n", "location": { "start": { "offset": 0 } }

`none` reproduces that message character for character; so does any other
CSS value (`rotate(-90deg)`, `translate(10px, 0)`). Every sibling
extractor in that file already catches the same error and degrades, so
the asymmetry is the bug: a string that is merely ignored on a <Path>
crashes on an <Svg>.

We feed that path untrusted input. The daemon scans a project for
favicon.svg / icon.svg / logo.svg (packages/server/src/utils/project-icon.ts)
and ships it to the client, where ProjectIconImage hands it to SvgCss.
Running the library's own XML parser over `<svg style="transform:none">`
yields exactly `transform: "none"` on the root, which is why the crash
starts once a host connects and the project icons render, with no
terminal involved.

Note this is *not* reachable from an RN-style array transform: the
extractor returns arrays untouched and only parses strings, so
`style={{ transform: [{ rotate: "-90deg" }] }}` (the context window meter
and the checks ring) was never a factor.

Patch the three builds a consumer can resolve - Metro takes the package's
`react-native` field (src/*.ts), Node and Vitest take main/module - and
register react-native-svg with the postinstall patch runner so the patch
actually applies. The new test pins all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDwfsMWoSFfPm2cFXe5noQ

* test(app): cover parsed root SVG transforms

Exercise the dependency patch with root props produced by the library's XML and CSS parsers, including embedded style rules that can arrive through project icons. Keep dependency-patch documentation focused on the reusable install and resolution constraints.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-23 11:05:25 +02:00
c3594b2079 fix(app): keep pinned project actions reachable (#3722)
* fix(app): keep pinned project actions reachable

Preserve an empty project shell after its final workspace is hoisted into the pinned section so project-scoped workspace creation remains available.

Co-authored-by: Agent Op <agentop718@gmail.com>

* test(app): verify pinned project workspace context

---------

Co-authored-by: Agent Op <agentop718@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-23 10:44:23 +02:00
Mohamed Boudra dc9fb9aa1b fix(app): make compact git actions menu-only
Keep compact toolbars narrow and require users to choose potentially consequential Git and workspace actions from the menu.
2026-08-23 09:39:42 +02:00
paseo-ai[bot] 7c430777bf fix: update lockfile signatures and Nix hash [skip ci] 2026-08-22 21:45:00 +00:00
Mohamed Boudra acad9f6881 chore(release): cut 0.5.0-beta.5 2026-08-22 23:35:40 +02:00
Mohamed Boudra fc214b20f1 test(app): align new tab launcher expectations 2026-08-22 23:32:05 +02:00
Mohamed Boudra c998b24c5f docs: add 0.5.0-beta.5 changelog 2026-08-22 23:25:10 +02:00
Mohamed Boudra 28df3f259a fix(app): align terminal profiles header 2026-08-22 22:34:05 +02:00
Mohamed Boudra cc2012be7c fix(app): prioritize side panel launcher actions
Lead with supporting workspace tools when the launcher is in the Side panel, while ordinary panes continue to lead with agent creation.
2026-08-22 22:24:35 +02:00
Mohamed Boudra b139046a3e fix(app): show the New agent shortcut
The command center resolved New agent through the New tab binding, so it advertised Cmd+T despite creating an agent. Resolve the target-agent binding instead and cover the mapping.
2026-08-22 22:17:13 +02:00
Mohamed Boudra 482bbb2910 fix(app): order new tab launcher actions by workflow 2026-08-22 22:16:48 +02:00
Mohamed Boudra 4a5a8d469c fix(app): keep compact model options scrollable
The compact model sheet had no sheet-aware scroll owner when profiles filled its viewport, leaving the controls below them unreachable. Keep the root provider view inside Gorhom's scroll container so native sheet gestures work on both platforms.
2026-08-22 22:06:27 +02:00
Mohamed Boudra c60fa098ac Give every pane a browser-style New tab (#3715)
* feat(app): replace empty panes with New tabs

Give every retained pane an independently identified New tab, replace it in place when a destination is chosen, and preserve split close and move invariants. Keep existing workspace startup and compact explorer routing explicit, with focused unit and browser regression coverage.

* test(app): count content tabs in subagent integration

* fix(app): preserve workspace shortcut ownership
2026-08-22 21:30:15 +02:00
Mohamed Boudra 33cf6cf337 perf(app): defer replica persistence until user inactivity
Whole-replica capture and serialization blocked the JS thread during navigation and streaming. Defer that work until the user has been inactive while allowing long-running streams to persist without becoming the idle signal.
2026-08-22 20:57:13 +02:00
Mohamed Boudra 65fbe38dd3 Restore compact and stable chat timelines (#3716)
Restore compact message and footer spacing, reserve independent composer-pill clearance for transcript content and floating controls, and keep visible web tool-call expansion from shifting the reading position.
2026-08-22 20:50:20 +02:00
Mohamed Boudra a69dca2907 Expand workspace panel actions and keyboard navigation (#3685)
* fix(app): keep file navigation in place

Retargeting a File tab allocated a new tab identity, causing the tab track to unmount and remount during navigation. Preserve the identity for File-to-File changes while retaining new instances for cross-kind replacements.

* feat(app): expand workspace panel actions

Keep the Command Center aligned with workspace tab and pane primitives. Add explicit panel placement, focused open behavior, keyboard navigation for empty panes, and a Changes pill that toggles the visible Side panel without closing its tab.
2026-08-22 14:15:23 +02:00
Mohamed Boudra 3029b5991a Surface workspace changes from agent panes (#3682)
* feat(app): refine workspace pane actions

Prioritize Changes when available, keep Files useful before a selection, and reserve desktop diff clicks for selection so comments require the explicit affordance. Move the Changes shortcut to Shift+G to avoid consuming a likely copy variant.

* feat(app): improve access to workspace changes
2026-08-22 12:20:21 +02:00
Mohamed Boudra b1455fb4b3 docs(hub): document zero-friction onboarding (#3657)
Lead with the browser-to-CLI onboarding journey, document the generated starter bundle, and align guided setup with available Hub connections and daemon-reported runtime defaults.
2026-08-22 12:15:58 +02:00
Mohamed Boudra b4e0ff7685 fix(app): restore native composer auto-growth (#3681)
Native multiline inputs were always scrollable, which kept iOS content-size measurement pinned to one line. Keep native sizing intrinsic below the cap and enable internal scrolling only after the composer reaches it.
2026-08-22 11:36:22 +02:00
Mohamed Boudra 4c6b79dc44 feat(app): refine agent profile creation (#3680)
Keep profiles as the richer primitive while restoring the familiar star cue and clearer guidance for agent selection. Account for borders in shared field geometry so controls preserve their declared heights across form consumers.
2026-08-22 11:16:28 +02:00
Mohamed Boudra a687c42f33 Keep released agent SDK versions reproducible (#3678)
Pin the validated provider and protocol SDK dependency pair so fresh installs cannot silently float after a Paseo release. Refresh the Nix npm dependency hash for the resulting lockfile.
2026-08-22 11:10:20 +02:00
Mohamed Boudra 7beff9cb0b fix(hub): make guided setup choices actionable (#3677)
Show a concise login success state, scope starter choices to available Hub connections, wait for daemon capabilities, and avoid partial starter bundles when setup cannot proceed.
2026-08-22 10:37:19 +02:00
Mohamed Boudra c763b80824 fix(files): keep large source previews responsive (#3665)
Large read-only files bypassed the editor's virtualization and eagerly mounted every highlighted token, which could stall the renderer for seconds and consume gigabytes of memory. Route source previews through bounded platform renderers and reject reads above their display budgets before accumulation.
2026-08-22 10:13:31 +02:00
Mohamed Boudra 8905ad4160 fix(relay): validate handshake keys (#3669) 2026-08-22 02:32:13 +02:00
BrianAguilarWascoandMohamed Boudra 60a5344cc6 fix(server): stop short start gates from undercutting provider startup budgets (#3621)
* fix(server): stop short start gates from undercutting provider startup budgets

Agent creation still failed on contended cold starts after #3578, because two
shorter gates on the same path fire before the event-stream readiness budget can
apply. A traced creation reached session.create twelve seconds in, past the 10s
session.create gate, and the agent was then killed by a bare "timeout" at fifteen
seconds from AGENT_RUN_START_TIMEOUT_MS while the SSE connect was still running.

A run-start bound of 15s wraps a path that is deliberately allowed 30s, so the
outer gate always wins and the inner budget is unreachable.

Give session.create the shared OPENCODE_SERVER_STARTUP_TIMEOUT_MS budget, since
creating the first session for a directory is part of OpenCode coming up, and
derive its error message from the constant instead of hardcoding "10s". Raise
AGENT_RUN_START_TIMEOUT_MS to 60s with a comment recording that it must stay
above the slowest provider startup budget; it stays a literal because that module
is provider-agnostic.

Related to #3571.

* test(server): drive the run start budget through a real AgentManager

The run start boundary tests replaced AgentManager.waitForAgentRunStart with a
bare timer, so they never exercised the production run-state transition or the
agent_state subscription the real wait depends on. That path could regress while
the tests stayed green.

Build the scenario on a real AgentManager with a provider session that holds its
turn start open for a configurable span, so pendingRun.started, the lifecycle
change to "running", and the agent_state notification are what the wait actually
observes. Budgets stay independent literals and the settled-before/after
assertions are unchanged.

* fix(server): describe agent run start timeouts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-22 02:06:16 +02:00
Mohamed Boudra 8f0c35e924 feat(hub): guide starter setup (#3651)
* feat(hub): guide setup with provider IDs

* fix(hub): preserve login setup state

* feat(hub): select starter agent runtime

Use the connected daemon's provider snapshot so generated Hub bundles only
contain complete provider, model, and mode combinations.

* fix(hub): stage starter runtime choices

Keep providers with valid modes selectable when the daemon has no default mode, so users explicitly choose the complete runtime.

* fix(hub): scope runtime choices to workspace

Provider capabilities can vary by repository, so request the snapshot for the bundle directory that guided setup will deploy.
2026-08-21 23:11:55 +02:00
347 changed files with 16642 additions and 7517 deletions
+86 -76
View File
@@ -1,98 +1,108 @@
# Changelog
## 0.5.0-beta.4 - 2026-08-21
## 0.6.0 - 2026-08-25
### Changed
- Restored the dedicated Explorer sidebar with Files and Changes by default, replacing the empty user-directed Side panel introduced in 0.5.0 ([#3826](https://github.com/getpaseo/paseo/pull/3826))
- Changed content opened to the side to use an ordinary closable workspace pane instead of the Explorer sidebar ([#3826](https://github.com/getpaseo/paseo/pull/3826))
- Restored the resizable combined Explorer dock on tablets ([#3826](https://github.com/getpaseo/paseo/pull/3826))
### Added
- Added per-action settings for opening files, changes, subagents, and pull requests in the main panel or on the side ([#3826](https://github.com/getpaseo/paseo/pull/3826))
- Added New tab and drag support for compatible files, diffs, agents, terminals, pull requests, and plugin panels in Explorer ([#3826](https://github.com/getpaseo/paseo/pull/3826))
- Added standalone Diff tabs while keeping an optional persisted inline-diff mode in Changes ([#3826](https://github.com/getpaseo/paseo/pull/3826))
- Added custom Windows and Linux window controls ([#3826](https://github.com/getpaseo/paseo/pull/3826))
### Fixed
- Fixed OpenCode turns failing when the first event-stream connection stalls during startup ([#3814](https://github.com/getpaseo/paseo/pull/3814))
- Fixed OpenCode prompts starting before the provider reports a ready connection ([#3821](https://github.com/getpaseo/paseo/pull/3821))
## 0.5.2 - 2026-08-24
### Fixed
- Fixed workspaces crashing on Android tablets and foldables in non-compact layouts ([#3760](https://github.com/getpaseo/paseo/pull/3760) by [@jeid64](https://github.com/jeid64))
- Fixed app settings resetting when builds sharing a profile recognize different settings ([#3787](https://github.com/getpaseo/paseo/pull/3787))
## 0.5.1 - 2026-08-23
### Fixed
- Fixed multiline composers stopping resizing after workspace and agent transitions on iOS and Android ([#3740](https://github.com/getpaseo/paseo/pull/3740))
## 0.5.0 - 2026-08-23
### Added
- Added experimental trusted local plugins ([#3222](https://github.com/getpaseo/paseo/pull/3222), [#3446](https://github.com/getpaseo/paseo/pull/3446), [#3465](https://github.com/getpaseo/paseo/pull/3465))
- Added plugin-contributed application themes to Settings → Appearance ([#3602](https://github.com/getpaseo/paseo/pull/3602) by [@ragokan](https://github.com/ragokan))
- Added MiniMax Code to the one-click ACP provider catalog ([#3457](https://github.com/getpaseo/paseo/pull/3457) by [@hetaoBackend](https://github.com/hetaoBackend))
- Added a user-directed Side panel with independent tabs ([#3287](https://github.com/getpaseo/paseo/pull/3287), [#3605](https://github.com/getpaseo/paseo/pull/3605))
- Added browser-style New tabs with an anchored pane-local chooser ([#3715](https://github.com/getpaseo/paseo/pull/3715), [#3735](https://github.com/getpaseo/paseo/pull/3735))
- Added active-turn steering for Claude, Codex, and OpenCode ([#3394](https://github.com/getpaseo/paseo/pull/3394), [#3580](https://github.com/getpaseo/paseo/pull/3580) by [@mcowger](https://github.com/mcowger))
- Added workspace labels for sidebar organization and filtering ([#3510](https://github.com/getpaseo/paseo/pull/3510) by [@cleiter](https://github.com/cleiter))
- Added live workspace change counts above active agent composers ([#3682](https://github.com/getpaseo/paseo/pull/3682))
- Added workspace panel, tab, and pane actions to the Command Center ([#3685](https://github.com/getpaseo/paseo/pull/3685))
- Added `paseo project` to create, list, rename, and delete projects from the terminal ([#3460](https://github.com/getpaseo/paseo/pull/3460))
- Added `paseo reload` to apply runtime-safe `config.json` changes without restarting the daemon ([#3365](https://github.com/getpaseo/paseo/pull/3365))
- Added Paseo skill management to Host → Agents, including remote hosts ([#3451](https://github.com/getpaseo/paseo/pull/3451))
- Added project filtering to the sidebar display menu ([#3563](https://github.com/getpaseo/paseo/pull/3563) by [@cleiter](https://github.com/cleiter))
- Added active-turn steering for OpenCode ([#3580](https://github.com/getpaseo/paseo/pull/3580) by [@mcowger](https://github.com/mcowger))
- Added separate Content text sizing for chat, the composer, Markdown, and review prose ([#3637](https://github.com/getpaseo/paseo/pull/3637))
- Added live sheets for summarized tool-call groups on compact layouts ([#3619](https://github.com/getpaseo/paseo/pull/3619))
- Added optional branch and project names on workspace rows ([#3445](https://github.com/getpaseo/paseo/pull/3445))
- Added drag-and-drop reordering for pinned workspaces ([#3341](https://github.com/getpaseo/paseo/pull/3341))
- Added one-click agent profile creation from the model chooser ([#3533](https://github.com/getpaseo/paseo/pull/3533))
- Added Android Studio to Open in editor ([#3531](https://github.com/getpaseo/paseo/pull/3531), [#3614](https://github.com/getpaseo/paseo/pull/3614) by [@caikovsky](https://github.com/caikovsky))
- Added MiniMax Code to the one-click ACP provider catalog ([#3457](https://github.com/getpaseo/paseo/pull/3457) by [@hetaoBackend](https://github.com/hetaoBackend))
- Added Nix syntax highlighting ([#3110](https://github.com/getpaseo/paseo/pull/3110) by [@Strainy](https://github.com/Strainy))
- Added Svelte syntax highlighting ([#3487](https://github.com/getpaseo/paseo/pull/3487), [#3534](https://github.com/getpaseo/paseo/pull/3534) by [@fiorelorenzo](https://github.com/fiorelorenzo))
- Added guided Hub setup that connects a daemon and deploys a compatible starter workflow ([#3651](https://github.com/getpaseo/paseo/pull/3651), [#3657](https://github.com/getpaseo/paseo/pull/3657), [#3677](https://github.com/getpaseo/paseo/pull/3677))
### Improved
- Changed the Side panel into a user-directed surface with independent tabs and configurable supporting-tab routing ([#3605](https://github.com/getpaseo/paseo/pull/3605))
- Changed Default send in Settings to a dropdown menu ([#3644](https://github.com/getpaseo/paseo/pull/3644))
- Reduced Android workspace-switch stalls after using several long chats ([#3610](https://github.com/getpaseo/paseo/pull/3610))
- Restored cached projects, workspaces, agents, and timelines immediately while hosts reconnect ([#3259](https://github.com/getpaseo/paseo/pull/3259), [#3329](https://github.com/getpaseo/paseo/pull/3329))
- Reduced workspace-switch stalls on desktop and Android with long chats ([#3447](https://github.com/getpaseo/paseo/pull/3447), [#3610](https://github.com/getpaseo/paseo/pull/3610))
- Kept composer typing within the frame budget on web and desktop ([#3450](https://github.com/getpaseo/paseo/pull/3450))
- Kept large diffs responsive while expanding, scrolling, and commenting ([#3422](https://github.com/getpaseo/paseo/pull/3422))
- Kept large read-only source previews responsive with bounded rendering ([#3665](https://github.com/getpaseo/paseo/pull/3665))
- Removed complete-transcript disk rewrites from long-running agent timelines ([#3647](https://github.com/getpaseo/paseo/pull/3647))
- Added outcome summaries and failure-first grouping to pull request checks ([#3483](https://github.com/getpaseo/paseo/pull/3483))
- Moved subagent and task trackers into pills above the composer ([#3482](https://github.com/getpaseo/paseo/pull/3482))
- Simplified mobile agent configuration into one options sheet ([#3424](https://github.com/getpaseo/paseo/pull/3424))
- Opened summarized tool-call groups in live sheets on compact layouts ([#3619](https://github.com/getpaseo/paseo/pull/3619))
- Added separate Content text sizing for chat, the composer, Markdown, and review prose ([#3637](https://github.com/getpaseo/paseo/pull/3637))
- Updated Pi usage and context meters during active turns ([#3532](https://github.com/getpaseo/paseo/pull/3532))
- Changed Archive finished to archive every finished subagent in the track ([#3368](https://github.com/getpaseo/paseo/pull/3368))
- Changed automatic Setup tabs to appear only after workspace setup fails ([#3634](https://github.com/getpaseo/paseo/pull/3634))
- Changed Default send in Settings to a dropdown menu ([#3644](https://github.com/getpaseo/paseo/pull/3644))
- Added password-equivalent warnings to pairing links in the desktop app and CLI ([#3734](https://github.com/getpaseo/paseo/pull/3734))
- Corrected Russian UI translations and terminology ([#3586](https://github.com/getpaseo/paseo/pull/3586) by [@timz](https://github.com/timz))
### Fixed
- Fixed modified tab indicators overlapping truncated tab titles
- Fixed composer steers remaining unread while Claude or Codex waited on a permission ([#3585](https://github.com/getpaseo/paseo/pull/3585) by [@cleiter](https://github.com/cleiter))
- Fixed Changes opening an empty comparison after checkout state changes ([#3636](https://github.com/getpaseo/paseo/pull/3636))
- Fixed Pi chats ending in an error state during a successful automatic retry ([#3639](https://github.com/getpaseo/paseo/pull/3639))
- Fixed Claude usage appearing unavailable when macOS Keychain contains multiple credential items ([#3597](https://github.com/getpaseo/paseo/pull/3597) by [@t-benoit](https://github.com/t-benoit))
- Fixed active Codex agents failing to open when their native thread was archived outside Paseo ([#3334](https://github.com/getpaseo/paseo/pull/3334))
- Fixed OpenCode turns failing when plugin-heavy startup takes more than ten seconds ([#3578](https://github.com/getpaseo/paseo/pull/3578) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
- Fixed worktree creation rejecting Git-valid branch names containing uppercase letters, underscores, or dots ([#3591](https://github.com/getpaseo/paseo/pull/3591))
- Fixed Android Studio showing a generic terminal icon in the desktop open-target menu ([#3614](https://github.com/getpaseo/paseo/pull/3614) by [@caikovsky](https://github.com/caikovsky))
## 0.5.0-beta.3 - 2026-08-20
### Fixed
- Fixed the composer retaining sent text on iOS and Android ([#3564](https://github.com/getpaseo/paseo/pull/3564))
- Fixed pull request checkout in fork workflows where the target repository is configured as `upstream` ([#2997](https://github.com/getpaseo/paseo/pull/2997) by [@mcowger](https://github.com/mcowger))
## 0.5.0-beta.2 - 2026-08-19
### Added
- Added Android Studio to Open in editor ([#3531](https://github.com/getpaseo/paseo/pull/3531) by [@caikovsky](https://github.com/caikovsky))
### Fixed
- Fixed `npm install @getpaseo/cli@beta` failing with a 404 because the plugin SDK it depends on was never published
- Renamed the plugin SDK package to `@getpaseo/plugin`, with `@paseo/plugin` imports still resolving for existing plugins
- Fixed the old docked file explorer sidebar staying open next to the explorer pane after upgrading to 0.5.0-beta.1, with no way to dismiss it on Windows and Linux
- Fixed new agents, terminals, and browser tabs opening in a background pane instead of the focused one
## 0.5.0-beta.1 - 2026-08-18
### Added
- Add experimental support for plugins ([See docs](/docs/plugins), [#3222](https://github.com/getpaseo/paseo/pull/3222), [#3446](https://github.com/getpaseo/paseo/pull/3446), [#3465](https://github.com/getpaseo/paseo/pull/3465))
- Added workspace labels to organize the sidebar ([#3510](https://github.com/getpaseo/paseo/pull/3510) by [@cleiter](https://github.com/cleiter))
- Added steering for Codex and Claude — send into a running turn instead of interrupting it ([#3394](https://github.com/getpaseo/paseo/pull/3394))
- Reworked the explorer into a persistent pane with its own tabs, so files, changes, and the pull request stay open next to the chat ([#3287](https://github.com/getpaseo/paseo/pull/3287))
- Changed Cmd/Ctrl+T to open a menu so you can choose what the new tab opens
- Added `paseo project` to create, list, rename, and delete projects from the terminal ([#3460](https://github.com/getpaseo/paseo/pull/3460))
- Added `paseo reload` to apply `config.json` changes without restarting the daemon ([#3365](https://github.com/getpaseo/paseo/pull/3365))
- Added Paseo skill management to Host → Agents, including remote hosts ([#3451](https://github.com/getpaseo/paseo/pull/3451))
- Added optional branch and project names on workspace rows ([#3445](https://github.com/getpaseo/paseo/pull/3445))
- Added drag-and-drop reordering for pinned workspaces ([#3341](https://github.com/getpaseo/paseo/pull/3341))
- Added Nix and Svelte syntax highlighting ([#3110](https://github.com/getpaseo/paseo/pull/3110) by [@Strainy](https://github.com/Strainy), [#3487](https://github.com/getpaseo/paseo/pull/3487) by [@fiorelorenzo](https://github.com/fiorelorenzo))
### Improved
- Improved checks summary to the pull request pane, grouped by status with failures first ([#3483](https://github.com/getpaseo/paseo/pull/3483))
- Projects, workspaces, and agents show up instantly when the app opens ([#3259](https://github.com/getpaseo/paseo/pull/3259), [#3329](https://github.com/getpaseo/paseo/pull/3329))
- Offline hosts keep showing their projects and workspaces while they reconnect ([#3259](https://github.com/getpaseo/paseo/pull/3259))
- Large diffs stay responsive while expanding, scrolling, and commenting ([#3422](https://github.com/getpaseo/paseo/pull/3422))
- Switching workspaces with keyboard shortcuts no longer lags ([#3447](https://github.com/getpaseo/paseo/pull/3447))
- Typing in the composer no longer lags on web and desktop ([#3450](https://github.com/getpaseo/paseo/pull/3450))
- Moved the subagents and tasks trackers into pills above the composer ([#3482](https://github.com/getpaseo/paseo/pull/3482))
- Simplified mobile agent configuration into one options sheet ([#3424](https://github.com/getpaseo/paseo/pull/3424))
- Archive finished now archives every finished subagent in the track ([#3368](https://github.com/getpaseo/paseo/pull/3368))
- The text size setting now scales the whole interface
### Fixed
- Fixed local plugin install on the packaged desktop app: esbuild can spawn from `app.asar.unpacked`, and the plugin subprocess loads `@paseo/plugin/server` instead of the React client barrel
- Fixed CJK IME composition being cancelled in text fields and the mobile terminal ([#3517](https://github.com/getpaseo/paseo/pull/3517), [#2811](https://github.com/getpaseo/paseo/pull/2811) by [@northsea4](https://github.com/northsea4), [#3462](https://github.com/getpaseo/paseo/pull/3462) by [@chulmin-dev](https://github.com/chulmin-dev), [#3343](https://github.com/getpaseo/paseo/pull/3343) by [@jimersylee](https://github.com/jimersylee), [#3391](https://github.com/getpaseo/paseo/pull/3391) by [@ZacharyZcR](https://github.com/ZacharyZcR), [@ljy900825-dev](https://github.com/ljy900825-dev))
- Fixed OpenCode turns failing when its event stream dropped ([#3395](https://github.com/getpaseo/paseo/pull/3395))
- Fixed CJK IME composition being cancelled in text fields and mobile terminals ([#2811](https://github.com/getpaseo/paseo/pull/2811), [#3343](https://github.com/getpaseo/paseo/pull/3343), [#3391](https://github.com/getpaseo/paseo/pull/3391), [#3462](https://github.com/getpaseo/paseo/pull/3462), [#3517](https://github.com/getpaseo/paseo/pull/3517) by [@northsea4](https://github.com/northsea4), [@jimersylee](https://github.com/jimersylee), [@ZacharyZcR](https://github.com/ZacharyZcR), [@chulmin-dev](https://github.com/chulmin-dev))
- Fixed OpenCode turns failing when their event stream dropped ([#3395](https://github.com/getpaseo/paseo/pull/3395))
- Fixed OpenCode agents timing out during slow or plugin-heavy startup ([#3578](https://github.com/getpaseo/paseo/pull/3578), [#3621](https://github.com/getpaseo/paseo/pull/3621) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
- Fixed an unrelated merged pull request archiving a workspace ([#3425](https://github.com/getpaseo/paseo/pull/3425))
- Fixed Annotate element and Screenshot element doing nothing on loaded desktop browser pages ([#3187](https://github.com/getpaseo/paseo/pull/3187) by [@dgk-dev](https://github.com/dgk-dev))
- Fixed repeated copy and fork footers after heartbeat runs ([#3484](https://github.com/getpaseo/paseo/pull/3484))
- Fixed Cursor plan usage on hosts signed in only through `cursor-agent` ([#3486](https://github.com/getpaseo/paseo/pull/3486) by [@Lite-G](https://github.com/Lite-G))
- Fixed resumed subagents splitting into separate rows
- Fixed a detected pull request revealing the explorer pane and stealing focus, which then made new agents, terminals, and browser tabs open inside the explorer pane instead of the main one
- Fixed workspace pull request status being lost on reload
- Fixed hidden terminal tabs stopping output in the focused workspace
- Fixed mobile composers retaining sent text after submission ([#3564](https://github.com/getpaseo/paseo/pull/3564))
- Fixed multiline composers retaining stale heights across workspace and draft lifecycles ([#3681](https://github.com/getpaseo/paseo/pull/3681), [#3740](https://github.com/getpaseo/paseo/pull/3740))
- Fixed pull request checkout when the target repository is configured as `upstream` ([#2997](https://github.com/getpaseo/paseo/pull/2997) by [@mcowger](https://github.com/mcowger))
- Fixed composer steers remaining unread while Claude or Codex waited on a permission ([#3585](https://github.com/getpaseo/paseo/pull/3585) by [@cleiter](https://github.com/cleiter))
- Fixed Changes opening an empty comparison after checkout state changes ([#3636](https://github.com/getpaseo/paseo/pull/3636))
- Fixed Pi chats ending in an error state during a successful automatic retry ([#3639](https://github.com/getpaseo/paseo/pull/3639))
- Fixed Claude usage appearing unavailable when macOS Keychain contains multiple credential items ([#3597](https://github.com/getpaseo/paseo/pull/3597) by [@t-benoit](https://github.com/t-benoit))
- Fixed provider usage checks overwriting Claude or Codex credential files ([#3442](https://github.com/getpaseo/paseo/pull/3442) by [@danberindei](https://github.com/danberindei))
- Fixed active Codex agents failing to open when their native thread was archived outside Paseo ([#3334](https://github.com/getpaseo/paseo/pull/3334))
- Fixed agents remaining impossible to stop after the provider had already settled their turn ([#3742](https://github.com/getpaseo/paseo/pull/3742))
- Fixed worktree creation rejecting Git-valid branch names containing uppercase letters, underscores, or dots ([#3591](https://github.com/getpaseo/paseo/pull/3591))
- Fixed project icons showing stale images in New Workspace ([#3600](https://github.com/getpaseo/paseo/pull/3600))
- Fixed malformed SVG project icons crashing the iOS app ([#3711](https://github.com/getpaseo/paseo/pull/3711) by [@omercnet](https://github.com/omercnet))
- Fixed pinning a project's only workspace hiding its New workspace action ([#3722](https://github.com/getpaseo/paseo/pull/3722) by [@yugui923](https://github.com/yugui923))
- Fixed workspace layout changes bypassing timeline retry backoff ([#3736](https://github.com/getpaseo/paseo/pull/3736))
- Fixed fresh installs resolving provider SDK versions that were not validated with the release ([#3678](https://github.com/getpaseo/paseo/pull/3678))
## 0.4.0 - 2026-08-13
+1 -1
View File
@@ -52,7 +52,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
| [docs/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference |
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and routing preferences |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
+10 -1
View File
@@ -35,7 +35,16 @@ be in flight.
### Cancellation
Cancellation changes lifecycle state only after the provider acknowledges the interrupt or emits a terminal turn event. If the interrupt is rejected or times out, the agent remains `running` with its active foreground turn intact. Follow-up actions such as replacement, reload, rewind, and Stop must report that failure instead of accepting work they cannot perform. Synthesizing a local cancellation without provider acknowledgment creates a split-brain session: Paseo accepts a new prompt while the provider still owns the previous foreground turn.
Provider interruption is idempotent at the `AgentSession` boundary. It resolves when the prior
foreground turn can no longer run, including when the provider reports that it is already idle. It
rejects only when the provider may still own the turn. Provider adapters translate native errors
into that contract; lifecycle callers do not interpret provider-specific errors.
After an acknowledged interrupt, the manager settles the captured run even when no terminal event
arrives or the run was still waiting for its provider turn id. The captured run token prevents an
older cancellation from settling a newer turn. If interruption is rejected or times out, the agent
keeps its active foreground turn and replacement, reload, rewind, and Stop report the failure.
Accepting new work after an ambiguous interruption would create a split-brain session.
## Relationships
+6
View File
@@ -62,6 +62,10 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
Header and toolbar controls use `interactionHighlight` for hovered, pressed, open, and selected
backgrounds. It is a translucent semantic fill so the same control works over the main surface and
the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control also fades its content.
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
@@ -155,6 +159,8 @@ On a narrow desktop route, app navigation yields to the rendered content topolog
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
---
+23 -2
View File
@@ -242,13 +242,13 @@ focused invalidation capture; React Native's generated stacks make that mode hig
Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
For the desktop Side panel toggle, run the app against the root checkout's daemon and use:
For the desktop Explorer sidebar toggle, run the app against the root checkout's daemon and use:
```bash
npm run profile:explorer-toggle --workspace=@getpaseo/app
```
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Side panel pane,
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Explorer pane,
records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
@@ -278,6 +278,17 @@ That report includes input and composer height changes plus React work grouped i
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
boundaries are nested. A printable key after an empty newline should not change either height.
### Preview Windows and Linux window controls on macOS
Desktop development can replace native macOS traffic lights with Paseo's custom controls:
```bash
PASEO_DESKTOP_WINDOW_CONTROLS=windows npm run dev:desktop
PASEO_DESKTOP_WINDOW_CONTROLS=linux npm run dev:desktop
```
The override is rejected in packaged builds. Restart the desktop process when changing it.
### Desktop macOS compositor watchdog
macOS display sleep can leave Chromium's GPU-process display link — the vsync
@@ -502,6 +513,16 @@ For tighter loops, you can rebuild a single workspace:
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
- Changed app build dependencies: `npm run build:app-deps`.
## Dependency patches
`patches/*.patch` are applied by `scripts/postinstall-patches.mjs` on every install. A patch only
runs when its package is actually present, so add the package to that script's `patchedPackages`
list when you introduce a new patch — otherwise the file sits in `patches/` and never applies.
Regenerate a patch with `npx patch-package <package>` after editing `node_modules/<package>`, and
patch every build the consumers use: Metro resolves the `react-native` field of a package
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
## ACP provider catalog versions
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
+74
View File
@@ -0,0 +1,74 @@
# Explorer sidebar and side pane
The Explorer sidebar and the side pane share panel implementations, but they have different shell
contracts.
| Surface | Purpose | Lifecycle |
| ---------------- | ---------------------------- | ------------------------------------------ |
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
| Side pane | Ordinary workspace content | Created and closed like any workspace pane |
## Panel host contract
Every desktop panel registers its supported `PaneHost` values and presentation. Launchers derive
fixed-target labels and icons from that registration, filter by host, and never substitute one
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
a compatible pane.
Files and Changes are the Explorer defaults and its singleton navigation views. Other compatible
tabs, including agents, terminals, files, and diffs, can move between Explorer and main panes.
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
## Explorer sidebar
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
On desktop, the shell is rendered outside the workspace split canvas so it divides the full
workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
never read or modify that width.
The persisted layout still contains the Explorer pane so tabs survive reloads. The renderer removes
that pane from the workspace split tree and docks it separately. Persisted identifiers retain the
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and
toggles the singleton Files and Changes views. Individual tab menus close instances or move
compatible tabs to main. Explorer tabs can be reordered, but the dock cannot be split. Selecting
an Explorer tab does not change workspace focus.
Cmd+E selects Changes for Git workspaces and Files otherwise. Compact layouts use the combined
full-screen Explorer overlay and close it after a file opens. Wide native layouts without pane
splits use the same combined content in a resizable inline dock; opening a file leaves that dock
visible. Both presentations keep their selection in the panel store and reuse the layout store's
per-workspace Explorer width. They do not create a second Explorer lifecycle.
## Side pane
`packages/app/src/workspace-tabs/open-beside.ts` owns content opened beside the user's work. The
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
right split around the workspace root; later side opens reuse it.
Closing the pane or moving away its final tab removes it normally and clears the remembered id. A
later side open creates a new pane. There is no hidden side-pane lifecycle.
Placement intent still controls existing tabs:
| Mode | New target | Existing target |
| --------- | --------------------------- | --------------------------------- |
| `pane` | opens in the requested pane | moves to the requested pane |
| `prefer` | opens in the requested pane | stays where the user placed it |
| `focused` | opens in the focused pane | focuses it where it already lives |
| `ambient` | opens in a compatible pane | focuses it where it already lives |
Explicit **Open to Side** uses `pane`. Implicit opens use `prefer`, so a preference affects only a
new target and never yanks an existing tab out of a user-selected pane.
## Routing preferences
Desktop **Settings → Layout → Open in side pane** has independent switches for Explorer Files,
Explorer Changes, chat files, diff files, subagents, pull requests, and Changes links. All switches
default off. Mobile ignores them.
Panels request an implicit open through the narrow `openPreferredTarget(target, source)` pane
contract. Entry points outside panels use `openPreferredWorkspaceTarget`. Do not branch on a
specific shell inside a panel.
+2 -1
View File
@@ -19,7 +19,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Side panel** — The companion surface beside the user's work, holding Changes, Files, the pull request, and tabs an agent opens alongside its output. UI: "Side panel". Code: `packages/app/src/workspace-tabs/side-panel.ts`; the persisted pane id and layout key keep the pre-rename `explorer` spelling. See [side-panel.md](side-panel.md). Forbidden: "Explorer" as the name of the surface — **File Explorer** is only the Files view inside it.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
+4
View File
@@ -165,6 +165,10 @@ RPC. Snapshot DTOs are deeply readonly and frozen at runtime so plugin code cann
app state or a memoized selection. Panels use one persisted
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
Panels declare `locations: ["workspace", "explorer"]` to opt into Explorer hosting; omission means
workspace only. Location controls hosting, not context. An agent panel target keeps its `agentId`
when moved between hosts. Explorer configuration can create workspace-context panels and remove
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the
+4
View File
@@ -147,6 +147,10 @@ Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
### Usage fetchers are read-only on credentials
A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI.
---
## ACP Provider Checklist
-94
View File
@@ -1,94 +0,0 @@
# Side panel
The companion surface beside the user's work: Changes, Files, the pull request, and
anything an agent asks to show alongside its output.
Everything that opens, reveals, hides, or asks about it goes through
`packages/app/src/workspace-tabs/side-panel.ts`. Nothing else branches on which
surface the current layout uses.
## Three renderings, two facts
| Layout | Rendering |
| ---------------------- | ----------------------------------------------- |
| compact | slide-over overlay, owned by the panel store |
| non-compact + splits | dedicated right pane, owned by the layout store |
| non-compact, no splits | ordinary tabs in the focused pane |
The third case is the one that bites. A native tablet renders one pane at a time and
the tab row only lists the focused pane's tabs, so anything routed into a separate
side panel pane there disappears with no way back.
## Placement
Where a tab lands is an intent the caller declares, not something the store infers
from a `paneId` that happens to be in scope. `WorkspaceTabPlacement`
(`packages/app/src/stores/workspace-layout-actions.ts`) has four modes, and the
difference between them only shows up when the tab is already open somewhere:
| Mode | New tab | Tab already open |
| --------- | ----------------- | ---------------------------- |
| `pane` | opens in the pane | **moves** to the pane |
| `prefer` | opens in the pane | stays where the user left it |
| `focused` | focused pane | focuses it in place |
| `ambient` | focused pane | focuses it in place |
`pane` is a pane-local affordance — that pane's `+` menu, its empty-state launcher.
The user is placing the tab and expects it to arrive.
`prefer` is an implicit supporting open — an agent's file link or a failed workspace
setup. It has an opinion about new tabs only. Yanking a tab out of the pane the user
deliberately moved it to is the failure this mode exists to prevent.
`ambient` differs from `focused` in one rule: reconciliation opens tabs with nobody
behind the click, and the side panel can hold focus from an earlier reveal, so an
ambient open of something the user works _in_ (an agent, a terminal, a browser) is
pushed out of the side panel. A `focused` open honours the focused pane whatever it
is, because the user is standing in it.
Deduplication lives in `openTabInLayoutFocused`, so it obeys the placement intent for
every caller. Do not re-place a tab after opening it.
## Lifecycle
The side panel lives in the ordinary split tree, so it is a pane like any other until
it empties or the user dismisses it:
- **Close pane** on the side panel **hides** it; its tabs are waiting on the next reveal.
- **Close pane** on any other pane **removes** it.
- Closing the Side panel's final tab hides the pane. Revealing it returns the empty launcher.
This is close-only: claiming or dragging that tab to another pane leaves the empty Side panel
visible.
- Nothing takes the last visible pane away. `closePane` will not remove it, and
neither `closePane` nor `hideSidePanel` will hide it — including the header toggle
reaching `hideSidePanel` after every other pane is gone. The gate is one check in
`setPaneHiddenInLayout`; do not add a second one upstream.
`closePane` in the layout store owns that distinction. React renders the button and
does not decide what it means.
Dragging a pane's last tab into another pane collapses the source pane — except the
side panel, which the user summons and expects to find again, split size and all.
## What the side panel never does
It does not seed itself. Revealing it opens an empty pane with the launcher; the user
picks what goes in. Detecting a pull request makes the launcher's card available and
opens nothing. A failed workspace setup adds a background tab to the main pane.
Running and successful setup never seed tabs.
## Routing preference
**Settings → General → Open supporting tabs in the Side panel**, on by default,
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions
— the header toggle, a pane's `+` menu, the empty-state launcher — are never subject
to it.
## The name
The product surface is the **Side panel**. **File Explorer** is still the name of the
Files view inside it. Persisted identifiers keep their pre-rename spelling: the pane
id is the literal string `"explorer"` (`SIDE_PANEL_PANE_ID`) and the persisted layout
key is `explorerPaneIdByWorkspace`. The persisted state schema is strict, so renaming
either would fail every saved blob and wipe the layout it describes.
+3
View File
@@ -189,6 +189,9 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
- The app Playwright harness boots on Windows as well as POSIX. Spawn Node entrypoints through `process.execPath`, not `npx` or `node_modules/.bin` shims: Node refuses to spawn `.cmd` or `.bat` without `shell: true`, and shell mode concatenates argv without escaping and sends kill signals to `cmd.exe` instead of the real child.
- Teardown kills the process tree, because a Windows signal reaches only the direct child and leaves forked workers holding the listening port.
- The `asdf`-backed local Elixir relay stays POSIX-only, so the `relay-deployment` Playwright project is unavailable on Windows.
## Pull-request test routing
+1 -1
View File
@@ -49,7 +49,7 @@ A failed catch-up or subscription reconcile retries on its own, doubling from 1s
A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active
writer, say — into a request and a log line every second on an idle app. The delay resets on success,
reconnect, delivery-mode change, and visibility change, so recovery is still immediate once the
condition clears.
condition clears. Republishing the same visibility set is a no-op and never bypasses backoff.
Background retries are silent. The retry the user presses in the sync-error callout is a fallible
user action and owns its pending state: `retrying` is a status the sync model publishes, not a React
+1 -1
View File
@@ -1 +1 @@
sha256-TNwqDZ9gJP613iYmDzVfK5vfye5NBloKN5Jt9t7yhx8=
sha256-TRZej2L43C3go4NWe496Dqs/4A+0GivCRtGzt3pX2dw=
+27 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.5.0-beta.4",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.5.0-beta.4",
"version": "0.6.0",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -36140,7 +36140,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.5.0-beta.4",
"version": "0.6.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37192,12 +37192,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.5.0-beta.4",
"version": "0.6.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.5.0-beta.4",
"@getpaseo/protocol": "0.5.0-beta.4",
"@getpaseo/server": "0.5.0-beta.4",
"@getpaseo/client": "0.6.0",
"@getpaseo/protocol": "0.6.0",
"@getpaseo/server": "0.6.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37444,10 +37444,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.5.0-beta.4",
"version": "0.6.0",
"dependencies": {
"@getpaseo/protocol": "0.5.0-beta.4",
"@getpaseo/relay": "0.5.0-beta.4",
"@getpaseo/protocol": "0.6.0",
"@getpaseo/relay": "0.6.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37458,7 +37458,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.5.0-beta.4",
"version": "0.6.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37701,7 +37701,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.5.0-beta.4",
"version": "0.6.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38597,7 +38597,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.5.0-beta.4",
"version": "0.6.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38626,12 +38626,12 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.5.0-beta.4",
"version": "0.6.0",
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.5.0-beta.4",
"@getpaseo/client": "0.6.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38640,14 +38640,14 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.5.0-beta.4",
"@getpaseo/client": "0.6.0",
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.5.0-beta.4",
"version": "0.6.0",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38660,7 +38660,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.5.0-beta.4",
"version": "0.6.0",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38878,19 +38878,19 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.5.0-beta.4",
"version": "0.6.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/claude-agent-sdk": "0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.5.0-beta.4",
"@getpaseo/highlight": "0.5.0-beta.4",
"@getpaseo/plugin": "0.5.0-beta.4",
"@getpaseo/protocol": "0.5.0-beta.4",
"@getpaseo/relay": "0.5.0-beta.4",
"@getpaseo/client": "0.6.0",
"@getpaseo/highlight": "0.6.0",
"@getpaseo/plugin": "0.6.0",
"@getpaseo/protocol": "0.6.0",
"@getpaseo/relay": "0.6.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
@@ -39940,7 +39940,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.5.0-beta.4",
"version": "0.6.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.5.0-beta.4",
"version": "0.6.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -442,21 +442,40 @@ function formatFrames(frames: TurnFrame[], centers: number | number[]): string {
.join("\n");
}
function submittedRowContentTop(frame: TurnFrame | undefined): number | undefined {
const viewportTop = frame?.userRow.rect?.top;
const scrollTop = frame?.scroll.scrollTop;
return viewportTop == null || scrollTop == null ? undefined : viewportTop + scrollTop;
}
function expectRowContinuity(frames: TurnFrame[]): void {
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
const baseline = frames[first]?.userRow.rect?.top;
const baselineFrame = frames[first];
const baselineContentTop = submittedRowContentTop(baselineFrame);
const disappeared = frames.findIndex(
(frame, index) => index > first && !hasPaintedLayout(frame.userRow),
);
const reappeared = frames.findIndex(
(frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow),
);
const shifted = frames.findIndex(
(frame, index) =>
const contentShifted = frames.findIndex((frame, index) => {
const contentTop = submittedRowContentTop(frame);
return (
index > first &&
hasPaintedLayout(frame.userRow) &&
baseline !== undefined &&
Math.abs((frame.userRow.rect?.top ?? baseline) - baseline) > 1,
baselineContentTop !== undefined &&
contentTop !== undefined &&
Math.abs(contentTop - baselineContentTop) > 1
);
});
const viewportChanged = frames.findIndex(
(frame, index) =>
index > first &&
[
geometryChanged(frame.scroll.rect?.top, baselineFrame?.scroll.rect?.top),
geometryChanged(frame.scroll.rect?.height, baselineFrame?.scroll.rect?.height),
geometryChanged(frame.scroll.clientHeight, baselineFrame?.scroll.clientHeight),
].some(Boolean),
);
const attachmentMissing = frames.findIndex(
(frame, index) => index >= first && !hasPaintedLayout(frame.attachment),
@@ -468,16 +487,18 @@ function expectRowContinuity(frames: TurnFrame[]): void {
`submitted row disappeared for ${Math.max(0, reappeared - disappeared)} recorded frames (${Math.max(0, (frames[reappeared]?.at ?? frames.at(-1)?.at ?? 0) - frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`,
]
: []),
...(shifted >= 0
...(contentShifted >= 0
? [
`submitted row moved from ${baseline?.toFixed(1)} to ${frames[shifted].userRow.rect?.top.toFixed(1)}`,
`submitted row moved in scroll content from ${baselineContentTop?.toFixed(1)} to ${submittedRowContentTop(frames[contentShifted])?.toFixed(1)}`,
]
: []),
...(viewportChanged >= 0 ? ["scroll viewport geometry changed"] : []),
...(attachmentMissing >= 0 ? ["submitted image attachment left the painted layout"] : []),
];
let failure = Math.max(0, first);
if (attachmentMissing >= 0) failure = attachmentMissing;
if (shifted >= 0) failure = shifted;
if (viewportChanged >= 0) failure = viewportChanged;
if (contentShifted >= 0) failure = contentShifted;
if (disappeared >= 0) failure = disappeared;
expect(
violations,
+118 -213
View File
@@ -30,11 +30,7 @@ const cleanupTasks: CleanupTask[] = [];
const APP_SETTINGS_KEY = "@paseo:app-settings";
function changesTree(page: Page) {
return page.getByTestId("changes-tree-rail-tree");
}
function changesContent(page: Page) {
return page.getByTestId("changes-tree-rail-content");
return page.getByTestId("changes-file-tree").filter({ visible: true });
}
async function readFileIfPresent(filePath: string): Promise<string | null> {
@@ -264,7 +260,8 @@ test("Changes opens the populated committed comparison for a clean checkout", as
await openWorkspaceChangesSurface(page, workspace);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
});
@@ -272,19 +269,19 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const mode = panel.getByTestId("changes-diff-status-trigger");
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const mode = tree.getByTestId("changes-diff-status-trigger");
await expect(mode).toContainText("Uncommitted");
await mode.click();
await page.getByTestId("changes-diff-mode-committed").click();
await expect(mode).toContainText("Committed");
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toBeVisible();
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toBeVisible();
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
execFileSync("git", ["commit", "-m", "Commit working changes"], { cwd: workspace.repoPath });
await expect(mode).toContainText("Committed");
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, {
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, {
timeout: 30_000,
});
@@ -297,7 +294,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
await openWorkspaceChangesSurface(page, workspace);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const mode = panel.getByTestId("changes-diff-status-trigger");
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const mode = tree.getByTestId("changes-diff-status-trigger");
await mode.click();
await page.getByTestId("changes-diff-mode-committed").click();
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
@@ -305,8 +303,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
await mode.click();
await page.getByTestId("changes-diff-mode-uncommitted").click();
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible();
const seeCommitted = panel.getByRole("button", { name: "See committed changes" });
await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible();
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
await expect(seeCommitted).toBeVisible();
await seeCommitted.click();
await expect(mode).toContainText("Committed");
@@ -321,7 +319,7 @@ test("changes file actions open below the right-click without a reserved kebab",
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
const deletedFileName = page.getByText("zz-deleted.ts", { exact: true });
const deletedFileName = page.getByTestId("diff-file-1-name");
await expect(deletedFileName).toHaveCSS("user-select", "none");
await deletedFileName.dblclick();
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
@@ -432,7 +430,6 @@ test("changes context menus duplicate files and folders", async ({ page }) => {
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
.toBe(AFTER);
await page.getByTestId("changes-toggle-tree").click();
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
await page.getByTestId("diff-folder-src-duplicate").click();
await expect
@@ -447,8 +444,6 @@ test("changes tree aligns every file status after its diff stat", async ({ page
});
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-tree").click();
const tree = changesTree(page);
const modifiedRow = tree.getByTestId("diff-tree-file-0");
const deletedRow = tree.getByTestId("diff-tree-file-1");
@@ -471,7 +466,6 @@ test("changes context menu recursively collapses descendant folders", async ({ p
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-tree").click();
const tree = changesTree(page);
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
@@ -516,7 +510,6 @@ test("changes context menus expose folder revert and restore a file after confir
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-tree").click();
const tree = changesTree(page);
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
const folderRevert = page.getByTestId("diff-folder-src-revert");
@@ -649,6 +642,11 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(visiblePanel).toBeVisible();
await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
@@ -658,34 +656,30 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
// Collapse-all and the layout switch are overflow-menu items, not toolbar icons.
const diffOptions = visiblePanel.getByRole("button", { name: "Diff options" });
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
await diffOptions.click();
// A workspace tab has nowhere to open itself, so the menu withholds that item.
await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0);
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
await page.getByTestId("changes-toggle-collapse-all").click();
await diffOptions.click();
await expect(page.getByTestId("changes-toggle-collapse-all")).toContainText("Expand all files");
await page.getByTestId("changes-toggle-collapse-all").click();
const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all");
await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files");
await collapseFiles.click();
await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files");
await collapseFiles.click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
const layoutItem = page.getByTestId("changes-toggle-layout");
await diffOptions.click();
await expect(layoutItem).toContainText("Side-by-side diff");
// Leading icon only: the layout is still unified, so no trailing check.
await expect(layoutItem.locator("svg")).toHaveCount(1);
await layoutItem.click();
await diffOptions.click();
await expect(layoutItem.locator("svg")).toHaveCount(2);
const layout = visiblePanel.getByTestId("changes-toggle-layout");
await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff");
await layout.click();
await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff");
await expect(page.getByText("Hide whitespace", { exact: true })).toBeVisible();
await expect(page.getByText("Wrap long lines", { exact: true })).toBeVisible();
await expect(page.getByTestId("changes-refresh")).toHaveText("Refresh");
await page.getByText("Wrap long lines", { exact: true }).click();
await diffOptions.click();
await expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace");
await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace");
const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines");
await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines");
await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute(
"aria-label",
"Refresh",
);
await wrapLines.click();
await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines");
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
@@ -701,168 +695,53 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
});
test("desktop Changes toggles a navigation tree beside the expanded diff document", async ({
page,
}, testInfo) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expectFlatFileList(page);
await expect(page.getByTestId("changes-layout-unified")).toHaveCount(0);
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
// The tree toggle is the one icon action that stays outside the overflow menu.
await expect(page.getByTestId("changes-toggle-tree")).toBeVisible();
const [changesToolbarBox, treeToggleBox, optionsBox, treeGlyphBox, optionsGlyphBox] =
await Promise.all([
page.getByTestId("changes-header").boundingBox(),
page.getByTestId("changes-toggle-tree").boundingBox(),
page.getByRole("button", { name: "Diff options" }).boundingBox(),
page.getByTestId("changes-toggle-tree").locator("svg").boundingBox(),
page.getByRole("button", { name: "Diff options" }).locator("svg").boundingBox(),
]);
if (!changesToolbarBox || !treeToggleBox || !optionsBox || !treeGlyphBox || !optionsGlyphBox) {
throw new Error("Changes toolbar geometry could not be measured");
}
expect(changesToolbarBox.height).toBe(36);
expect(treeToggleBox.width).toBe(24);
expect(treeToggleBox.height).toBe(24);
expect(optionsBox.width).toBe(24);
expect(optionsBox.height).toBe(24);
expect(treeGlyphBox.width).toBe(14);
expect(treeGlyphBox.height).toBe(14);
expect(optionsGlyphBox.width).toBe(14);
expect(optionsGlyphBox.height).toBe(14);
const diffOptions = page.getByRole("button", { name: "Diff options" });
const diffOptionsRestBackground = await diffOptions.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await diffOptions.hover();
const diffOptionsHoverBackground = await diffOptions.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
expect(diffOptionsHoverBackground).not.toBe(diffOptionsRestBackground);
await page.getByTestId("changes-toggle-tree").hover();
expect(
await page
.getByTestId("changes-toggle-tree")
.evaluate((element) => getComputedStyle(element).backgroundColor),
).toBe(diffOptionsHoverBackground);
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
await expect(page.getByTestId("changes-toggle-layout")).toHaveCount(0);
await page.getByTestId("changes-options-menu").click();
await expect(page.getByTestId("changes-options-menu-content")).toBeVisible();
await expect(page.getByTestId("changes-toggle-collapse-all")).toContainText("Collapse all files");
await expect(page.getByTestId("changes-toggle-layout")).toContainText("Side-by-side diff");
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Hide whitespace");
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Wrap long lines");
await expect(page.getByTestId("changes-refresh")).toHaveText("Refresh");
await page.getByTestId("changes-toggle-whitespace").click();
await page.getByTestId("changes-options-menu").click();
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Show whitespace");
await page.keyboard.press("Escape");
await scrollToLowerUnwrappedDiffRows(page);
await page.getByTestId("changes-toggle-tree").click();
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "true");
expect(
await page
.getByTestId("changes-toggle-tree")
.evaluate((element) => getComputedStyle(element).backgroundColor),
).toBe(diffOptionsHoverBackground);
const tree = changesTree(page);
const content = changesContent(page);
await expect(tree.getByTestId("diff-folder-src")).toBeVisible();
await expect(tree.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS(
"user-select",
"none",
);
await expect(tree.getByTestId("diff-tree-file-0")).toBeVisible();
await expect(tree.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1);
const folderToggleBounds = await tree.getByTestId("diff-folder-src-toggle").boundingBox();
const folderChevronBounds = await tree
.getByTestId("diff-folder-src-toggle")
.locator("svg")
.boundingBox();
expect(folderToggleBounds).not.toBeNull();
expect(folderChevronBounds).not.toBeNull();
expect(folderChevronBounds!.y + folderChevronBounds!.height / 2).toBeCloseTo(
folderToggleBounds!.y + folderToggleBounds!.height / 2,
0,
);
const folderLabelBounds = await tree
.getByTestId("diff-folder-src")
.getByText("src", { exact: true })
.boundingBox();
const fileLabelBounds = await tree
.getByTestId("diff-tree-file-0")
.getByText("use-mounted-tab-set.ts", { exact: true })
.boundingBox();
expect(folderLabelBounds).not.toBeNull();
expect(fileLabelBounds).not.toBeNull();
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(12, 0);
const folderToggle = tree.getByTestId("diff-folder-src-toggle");
await folderToggle.click();
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
await folderToggle.click();
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
await expect(tree.getByTestId("diff-tree-file-0")).toBeVisible();
const fileToggle = tree.getByTestId("diff-tree-file-0-toggle");
await fileToggle.click({ button: "right" });
await expect(fileToggle).toHaveAttribute("aria-selected", "true");
await expect(folderToggle).toHaveAttribute("aria-selected", "false");
await expect(page.getByTestId("diff-tree-file-0-context-menu")).toBeVisible();
await page.keyboard.press("Escape");
await fileToggle.click();
await expect(content.getByTestId("diff-file-0-body")).toBeVisible();
const contentBounds = await content.boundingBox();
const focusedFileBounds = await content.getByTestId("diff-file-0").boundingBox();
expect(contentBounds).not.toBeNull();
expect(focusedFileBounds).not.toBeNull();
expect(focusedFileBounds!.y).toBeGreaterThanOrEqual(contentBounds!.y);
expect(focusedFileBounds!.y).toBeLessThan(contentBounds!.y + 80);
await tree.getByTestId("diff-folder-src-toggle").click();
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
await page.getByTestId("changes-toggle-tree").click();
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "false");
await expectFlatFileList(page);
const screenshot = testInfo.outputPath("changes-toolbar.png");
await page.screenshot({ path: screenshot });
await testInfo.attach("changes-toolbar", { path: screenshot, contentType: "image/png" });
});
test("compact Changes keeps its options control touch-sized", async ({ page }) => {
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.setViewportSize({ width: 480, height: 900 });
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
const [optionsBox, glyphBox] = await Promise.all([
const compactChangesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
if (!(await compactChangesTab.isVisible())) {
await page.getByTestId("workspace-explorer-toggle").first().click();
}
await expect(compactChangesTab).toBeVisible();
await compactChangesTab.click();
const compactExplorer = page.getByTestId("explorer-content-area").filter({ visible: true });
await expect(compactExplorer.getByTestId("changes-header")).toBeVisible();
const actions = compactExplorer.getByTestId("changes-actions-menu-trigger");
const options = compactExplorer.getByRole("button", { name: "Diff options" });
const [actionsBox, optionsBox, glyphBox] = await Promise.all([
actions.boundingBox(),
options.boundingBox(),
options.locator("svg").boundingBox(),
]);
if (!optionsBox || !glyphBox) {
throw new Error("Compact Changes options geometry could not be measured");
if (!actionsBox || !optionsBox || !glyphBox) {
throw new Error("Compact Changes toolbar geometry could not be measured");
}
expect(actionsBox.width).toBe(48);
expect(actionsBox.height).toBe(28);
expect(optionsBox.width).toBe(32);
expect(optionsBox.height).toBe(32);
expect(glyphBox.width).toBe(18);
expect(glyphBox.height).toBe(18);
await expect(actions).not.toContainText("Commit");
await expect(actions.locator("svg")).toHaveCount(2);
await actions.click();
await expect(page.getByTestId("changes-primary-cta-menu")).toBeVisible();
await expect(page.getByTestId("changes-menu-commit")).toContainText("Commit");
await page.keyboard.press("Escape");
await options.click();
const wrapLines = page.getByText("Wrap long lines", { exact: true });
await expect(wrapLines).toBeVisible();
await wrapLines.click();
await options.click();
await expect(page.getByTestId("changes-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 }) => {
@@ -872,7 +751,9 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
const canvas = page.getByTestId("git-diff-canvas");
const root = page.getByTestId("git-diff-canvas-root");
const handle = page.getByTestId("workspace-split-resize-handle").getByRole("separator");
const handle = page
.getByTestId("workspace-explorer-sidebar-resize-handle")
.getByRole("separator");
await expect(handle).toBeVisible();
await expect
.poll(async () => {
@@ -893,11 +774,11 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
};
}),
]);
if (!handleBounds) throw new Error("Workspace split resize handle has no bounds");
if (!handleBounds) throw new Error("Explorer sidebar resize handle has no bounds");
await page.mouse.move(handleBounds.x + handleBounds.width / 2, handleBounds.y + 120);
await page.mouse.down();
await page.mouse.move(handleBounds.x - 120, handleBounds.y + 120);
await page.mouse.move(handleBounds.x + 120, handleBounds.y + 120);
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
const duringDrag = await Promise.all([
@@ -996,6 +877,23 @@ test("canvas diff creates, edits, and deletes an inline review without DOM code
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
});
test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
const focusedBackground = await changesTab.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await startReviewOnFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
await expect
.poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe(focusedBackground);
});
test("split canvas creates a review on the changed side and keeps it in that column", async ({
page,
}) => {
@@ -1046,6 +944,7 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
if (!bounds) throw new Error("Diff overlay scrollbar thumb has no bounds");
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.down();
await expect(grab).toHaveCSS("cursor", "grabbing");
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 180);
await page.mouse.up();
@@ -1095,7 +994,7 @@ test("canvas diff copies a dragged character selection without opening a review"
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("the first click after a canvas selection only dismisses the selection", async ({ page }) => {
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
@@ -1105,7 +1004,7 @@ test("the first click after a canvas selection only dismisses the selection", as
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await clickFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("canvas diff replaces a selection with forward and backward drags", async ({
@@ -1285,24 +1184,20 @@ async function setOpenChangesPresentation(
page: Page,
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
): Promise<void> {
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
await options.click();
const layoutItem = page.getByTestId("changes-toggle-layout");
const currentLayout = (await layoutItem.locator("svg").count()) === 2 ? "split" : "unified";
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
const layoutItem = diffPanel.getByTestId("changes-toggle-layout");
const currentLayout =
(await layoutItem.getAttribute("aria-label")) === "Switch to unified diff"
? "split"
: "unified";
if (currentLayout !== requestedPresentation.layout) {
await layoutItem.click();
} else {
await page.keyboard.press("Escape");
}
await options.click();
const wrapItem = page.getByTestId("changes-toggle-wrap-lines");
const currentWrapLines = (await wrapItem.locator("svg").count()) === 2;
const wrapItem = diffPanel.getByTestId("changes-toggle-wrap-lines");
const currentWrapLines = (await wrapItem.getAttribute("aria-label")) === "Scroll long lines";
if (currentWrapLines !== requestedPresentation.wrapLines) {
await wrapItem.click();
} else {
await page.keyboard.press("Escape");
}
}
@@ -1337,12 +1232,6 @@ async function releaseBrowserFontLoads(page: Page): Promise<void> {
});
}
async function expectFlatFileList(page: Page): Promise<void> {
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
await expect(page.getByTestId("diff-file-0")).toContainText("src");
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
const canvas = page.getByTestId("git-diff-canvas");
await expect
@@ -1463,6 +1352,7 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-explorer-toggle").first().click();
await openChangesInVisibleExplorer(page);
await expectExpandedMountedTabDiff(page);
}
@@ -1479,13 +1369,27 @@ async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspa
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
await expect(page.getByText("selection.ts")).toBeVisible({ timeout: 30_000 });
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(diffPanel.getByTestId("diff-file-0-name")).toHaveText("selection.ts", {
timeout: 30_000,
});
await expectExpandedMountedTabDiff(page);
}
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await openChangesPanel(page);
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
const explorer = page.getByTestId("workspace-explorer-sidebar");
await expect(explorer).toBeVisible({ timeout: 30_000 });
const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first();
await changesTab.click();
const changedFile = explorer
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
.filter({ visible: true })
.first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await changedFile.click();
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
@@ -1537,7 +1441,8 @@ async function startReviewOnFirstChangedLine(
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
const lineHeight = Math.round(fontSize * 1.5);
const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x;
await page.mouse.click(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
await page.mouse.move(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
await page.getByRole("button", { name: "Add review comment" }).click();
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
}
@@ -0,0 +1,27 @@
import { expect, test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`;
test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-explorer-" });
try {
await page.setViewportSize({ width: 1400, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await page.keyboard.press(CHANGES_SHORTCUT);
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible({
timeout: 30_000,
});
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { Locator } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
const COMMIT_SUBJECT = "Show commit timestamps";
@@ -15,7 +15,7 @@ test("commit history explains when the workspace has no commits ahead of its bas
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
await workspace.navigateTo();
await openChangesPanel(page);
await openChangesTreePanel(page);
const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click();
@@ -35,7 +35,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
await page.setViewportSize({ width: 1400, height: 900 });
await workspace.navigateTo();
await openChangesPanel(page);
await openChangesTreePanel(page);
const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click();
@@ -60,8 +60,8 @@ test("commit history shows dates and shares diff layout preferences", async ({
throw new Error("Commit-diff toolbar geometry could not be measured");
}
expect(commitToolbarBox.height).toBe(36);
expect(layoutToggleBox.width).toBe(24);
expect(layoutToggleBox.height).toBe(24);
expect(layoutToggleBox.width).toBe(20);
expect(layoutToggleBox.height).toBe(20);
expect(layoutToggleGlyphBox.width).toBe(14);
expect(layoutToggleGlyphBox.height).toBe(14);
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
@@ -0,0 +1,127 @@
import { rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openFilesPanel } from "../support/helpers/workspace-tabs";
const APP_SETTINGS_KEY = "@paseo:app-settings";
function visibleMainPane(page: Page) {
return page.getByTestId("workspace-pane-main").filter({ visible: true });
}
async function seedChangedAgent(repoPrefix: string) {
const workspace = await seedMockAgentWorkspace({
repoPrefix,
title: "Composer diff stat",
repo: { withRemote: true },
});
try {
await rm(path.join(workspace.cwd, "remote.git"), { recursive: true });
await writeFile(
path.join(workspace.cwd, "README.md"),
"# Temp Repo\nexport const one = 1;\nexport const two = 2;\n",
);
await workspace.client.checkoutRefresh(workspace.cwd);
await expect
.poll(async () => {
const workspaces = await workspace.client.fetchWorkspaces();
return (
workspaces.entries.find((entry) => entry.id === workspace.workspaceId)?.diffStat ?? null
);
})
.toEqual({ additions: 2, deletions: 0 });
return workspace;
} catch (error) {
await workspace.cleanup();
throw error;
}
}
test("composer diff stat opens Changes in the configured side pane", async ({ page }) => {
await page.addInitScript((settingsKey) => {
localStorage.setItem(settingsKey, JSON.stringify({ openInSidePane: { changesLinks: true } }));
}, APP_SETTINGS_KEY);
const workspace = await seedChangedAgent("composer-diff-stat-side-");
try {
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const pill = page.getByTestId("composer-diff-stat-pill");
await expect(pill).toBeVisible({ timeout: 30_000 });
await expect(pill).toContainText("+2");
await expect(pill).toContainText("-0");
await pill.click();
const sidePane = page
.locator('[data-testid^="workspace-pane-"]')
.filter({ visible: true })
.filter({ has: page.getByTestId("working-diff-panel") });
await expect(sidePane.getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(sidePane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
await expect(visibleMainPane(page).getByTestId("working-diff-panel")).toHaveCount(0);
await test.step("Explorer navigation does not replace the side pane", async () => {
await openFilesPanel(page);
await expect(page.getByTestId("workspace-explorer-sidebar")).toContainText("Files");
await pill.click();
await expect(sidePane.getByTestId("working-diff-panel")).toBeVisible();
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(1);
});
} finally {
await workspace.cleanup();
}
});
test("composer diff stat opens the compact explorer instead of a Changes tab", async ({ page }) => {
const workspace = await seedChangedAgent("composer-diff-stat-compact-");
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await page.getByTestId("composer-diff-stat-pill").click();
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("composer diff stat opens Changes in the focused pane by default", async ({ page }) => {
const workspace = await seedChangedAgent("composer-diff-stat-tab-");
try {
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await page.getByTestId("composer-diff-stat-pill").click();
const mainPane = visibleMainPane(page);
await expect(mainPane.getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(mainPane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
await expect(
page.locator('[data-testid^="workspace-pane-"]').filter({ visible: true }),
).toHaveCount(1);
} finally {
await workspace.cleanup();
}
});
@@ -1,7 +1,8 @@
import { expect, test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
test.describe("Direct terminal shortcut pane placement", () => {
test("opens a terminal in the focused pane", async ({ page }) => {
@@ -11,21 +12,31 @@ test.describe("Direct terminal shortcut pane placement", () => {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
const focusedPane = page
.getByTestId("workspace-tabs-row")
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
.first();
const primaryPane = page
.getByTestId("workspace-tabs-row")
.filter({ hasNot: page.getByTestId("workspace-tab-working_diff") })
.first();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const focusedPaneChild = page
.getByTestId("split-group-child")
.filter({ has: page.getByTestId("workspace-new-tab-panel") });
const originalPaneChild = page
.getByTestId("split-group-child")
.filter({ hasNot: page.getByTestId("workspace-new-tab-panel") });
await expect(focusedPaneChild).toBeVisible();
const focusedPaneId = await focusedPaneChild
.locator('[data-testid^="workspace-pane-"]')
.getAttribute("data-testid");
const originalPaneId = await originalPaneChild
.locator('[data-testid^="workspace-pane-"]')
.getAttribute("data-testid");
expect(focusedPaneId).not.toBeNull();
expect(originalPaneId).not.toBeNull();
const focusedPane = page.getByTestId(focusedPaneId!);
const originalPane = page.getByTestId(originalPaneId!);
await pressDirectNewTabShortcut(page, "t");
await expect(focusedPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(1, {
timeout: 30_000,
});
await expect(primaryPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
await expect(originalPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
} finally {
await workspace.cleanup();
}
@@ -0,0 +1,65 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
function visibleNewTabPanel(page: Page): Locator {
return page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
}
async function expectLauncherSelection(launcher: Locator, name: string): Promise<void> {
await expect(launcher.getByRole("button", { name, exact: true })).toBeFocused();
}
async function openExplorerWithKeyboard(page: Page): Promise<Locator> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+E`);
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
await expect(explorer).toBeVisible({ timeout: 30_000 });
return explorer;
}
test.describe("New tab keyboard launcher", () => {
test("Cmd+E reveals the repository Changes tree", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-keyboard-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
const explorer = await openExplorerWithKeyboard(page);
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
} finally {
await workspace.cleanup();
}
});
test("a new split pane focuses its launcher and supports arrow navigation", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-split-keyboard-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const launcher = visibleNewTabPanel(page);
await expect(launcher).toBeVisible({ timeout: 30_000 });
await expectLauncherSelection(launcher, "Agent");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Terminal");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Diff");
await page.keyboard.press("Enter");
await expect(
page.getByTestId("workspace-tab-working_diff").filter({ visible: true }),
).toBeVisible({ timeout: 30_000 });
} finally {
await workspace.cleanup();
}
});
});
@@ -2,14 +2,16 @@
// 1) with the explorer pane focused, a newly appearing agent must auto-open into the
// MAIN pane in the background — the explorer pane is a background surface and must
// never swallow entity tabs or lose the user's focus, and
// 2) splitting a tab out of a pane leaves that pane empty, and the app must stay
// interactive in the [agent | empty main | explorer] state (the reporter hit an
// app-wide lockup here with no way to close the empty pane).
// 2) splitting a tab out of a pane leaves a New tab behind, and the app must stay
// interactive in the [agent | New | explorer] state.
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import {
openChangesTreePanel,
waitForWorkspaceTabsVisible,
} from "../support/helpers/workspace-tabs";
function visible(page: Page, testId: string): Locator {
return page.getByTestId(testId).filter({ visible: true });
@@ -23,23 +25,18 @@ function draftTabChip(page: Page): Locator {
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
}
/** The explorer pane is the only tab row carrying the Changes tab. */
/** The Explorer has its own fixed tab rail, separate from workspace pane rows. */
function explorerTabRow(page: Page): Locator {
return visible(page, "workspace-tabs-row").filter({
has: page.getByTestId("workspace-tab-working_diff"),
});
return visible(page, "explorer-sidebar-tab-rail");
}
function mainTabRow(page: Page): Locator {
return visible(page, "workspace-tabs-row").filter({
hasNot: page.getByTestId("workspace-tab-working_diff"),
});
return visible(page, "workspace-tabs-row");
}
async function focusExplorerPane(page: Page): Promise<void> {
await openChangesPanel(page);
const changesTab = visible(page, "workspace-tab-working_diff").first();
await expect(changesTab).toHaveAttribute("aria-selected", "true");
async function selectExplorerChanges(page: Page): Promise<void> {
await openChangesTreePanel(page);
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
}
async function closeSeededDraftInMainPane(page: Page): Promise<void> {
@@ -53,19 +50,19 @@ async function closeSeededDraftInMainPane(page: Page): Promise<void> {
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
}
function emptyPaneChild(page: Page): Locator {
function newTabPaneChild(page: Page): Locator {
return page
.getByTestId("split-group-child")
.filter({ has: page.getByTestId("workspace-pane-empty-state") })
.filter({ has: page.getByTestId("workspace-new-tab-panel") })
.first();
}
async function emptyPaneBox(page: Page) {
const paneChild = emptyPaneChild(page);
const paneChild = newTabPaneChild(page);
await expect(paneChild).toBeVisible({ timeout: 15_000 });
const box = await paneChild.boundingBox();
expect(box).not.toBeNull();
if (!box) throw new Error("Empty pane has no bounding box");
if (!box) throw new Error("New tab pane has no bounding box");
return box;
}
@@ -97,7 +94,7 @@ async function dragChipTo(
}
test.describe("explorer pane tab placement", () => {
test("agents open in the main pane and empty-pane splits keep the app usable", async ({
test("agents open in the main pane and New-tab splits keep the app usable", async ({
page,
}, testInfo) => {
const consoleErrors: string[] = [];
@@ -116,8 +113,8 @@ test.describe("explorer pane tab placement", () => {
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
});
await test.step("focus the explorer pane", async () => {
await focusExplorerPane(page);
await test.step("select Changes in Explorer", async () => {
await selectExplorerChanges(page);
});
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
@@ -141,11 +138,8 @@ test.describe("explorer pane tab placement", () => {
await expect(
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toHaveCount(0);
// The background open must not steal the explorer pane's focus.
await expect(visible(page, "workspace-tab-working_diff").first()).toHaveAttribute(
"aria-selected",
"true",
);
// The background open must not change the selected Explorer view.
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
});
await test.step("close the draft; the agent is the main pane's only tab", async () => {
@@ -171,9 +165,10 @@ test.describe("explorer pane tab placement", () => {
body: await page.screenshot(),
contentType: "image/png",
});
// The split must have taken: agent pane + empty main + explorer = 3 tab rows.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
await expect(page.getByTestId("workspace-pane-empty-state")).toBeVisible();
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
await expect(explorerTabRow(page)).toHaveCount(1);
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
});
await test.step("app must stay interactive after the split", async () => {
@@ -186,17 +181,12 @@ test.describe("explorer pane tab placement", () => {
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
// Clicking inside the empty pane (focuses it) must not lock anything. Aim
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
// above the vertically centred launcher, which would open a tab instead.
const emptyPane = emptyPaneChild(page);
const emptyPane = newTabPaneChild(page);
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
// The empty pane's + menu must open and dismiss cleanly.
const emptyPaneTrigger = emptyPane.getByTestId("workspace-new-tab-menu-trigger").first();
await emptyPaneTrigger.click({ timeout: 5_000 });
await page.keyboard.press("Escape");
// A second drag must work: center-drop the agent tab back into the empty pane.
// A second drag must work: center-drop the agent tab back into the New pane.
const target = await emptyPaneBox(page);
await dragChipTo(page, chip, {
x: target.x + target.width / 2,
@@ -207,8 +197,8 @@ test.describe("explorer pane tab placement", () => {
contentType: "image/png",
});
// The agent chip must have moved into the previously empty pane and stay clickable.
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(0, {
// The agent chip must have replaced New in that pane and stay clickable.
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
timeout: 10_000,
});
await agentTabChip(page, agentId)
@@ -0,0 +1,48 @@
import { expect, test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
ensureExplorerSidebar,
openFilesPanel,
waitForWorkspaceTabsVisible,
} from "../support/helpers/workspace-tabs";
function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
}
test.describe("Explorer sidebar", () => {
test("starts with Files and Changes, switches views, and toggles without changing main", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-sidebar-defaults-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
const mainTabsBefore = await page
.getByTestId("workspace-pane-main")
.locator('[data-testid^="workspace-tab-"]')
.count();
const explorer = await ensureExplorerSidebar(page);
await expect(explorer.getByTestId("explorer-sidebar-tab-files")).toBeVisible();
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(0);
await openFilesPanel(page);
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
await explorer.getByTestId("explorer-sidebar-tab-changes_tree").click();
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
await page.getByTestId("workspace-explorer-toggle").first().click();
await expect(explorerSidebar(page)).toHaveCount(0);
await expect(
page.getByTestId("workspace-pane-main").locator('[data-testid^="workspace-tab-"]'),
).toHaveCount(mainTabsBefore);
} finally {
await workspace.cleanup();
}
});
});
@@ -19,14 +19,8 @@ function readPersistedPanelState(page: Page): Promise<string | null> {
return page.evaluate(() => localStorage.getItem("panel-state"));
}
/** Only the docked sidebar ever rendered `explorer-header` on a wide layout. */
function dockedSidebar(page: Page): Locator {
return visible(page, "explorer-header");
}
/** The desktop companion surface is a hideable side panel. */
function sidePanel(page: Page): Locator {
return visible(page, "workspace-side-panel");
function explorerSidebar(page: Page): Locator {
return visible(page, "workspace-explorer-sidebar");
}
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
@@ -65,18 +59,18 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await test.step("no docked sidebar, on load or after toggling", async () => {
await expect(dockedSidebar(page)).toHaveCount(0);
await test.step("the retired flag does not reveal Explorer", async () => {
await expect(explorerSidebar(page)).toHaveCount(0);
const toggle = visible(page, "workspace-explorer-toggle").first();
await toggle.click();
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
await expect(dockedSidebar(page)).toHaveCount(0);
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
await expect(
explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"),
).toBeVisible();
await toggle.click();
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
await expect(dockedSidebar(page)).toHaveCount(0);
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
});
await test.step("the retired keys are migrated out of storage", async () => {
@@ -107,22 +101,22 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await test.step("open the explorer so both sides are showing", async () => {
await toggle.click();
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
await expect(agentList).toHaveCount(1);
});
await test.step("the shortcut collapses both", async () => {
await page.keyboard.press(`${modifier}+.`);
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
await expect(agentList).toHaveCount(0);
});
await test.step("the shortcut restores both", async () => {
await page.keyboard.press(`${modifier}+.`);
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
});
} finally {
await workspace.cleanup();
+90 -4
View File
@@ -9,6 +9,8 @@ import {
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const APP_SETTINGS_KEY = "@paseo:app-settings";
const RED_PIXEL = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
"base64",
@@ -90,6 +92,87 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-source-lockfile-",
title: "Large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
const lockfile = `${'{"packages":['}${Array.from({ length: 42_000 }, (_, index) => `{"name":"package-${index}","version":"1.0.0"}`).join(",")}]}`;
await writeFile(path.join(session.cwd, "package-lock.json"), lockfile, "utf8");
try {
await openAgentRoute(page, session);
await openWorkspaceFile(page, "package-lock.json");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(editor(page)).toContainText('"package-0"');
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
} finally {
await session.cleanup();
}
});
test("keeps the app interactive around a plain 11 MB source", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-source-plain-",
title: "Plain large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
await writeFile(
path.join(session.cwd, "plain.txt"),
"plain source\n".repeat(1_050_000),
"utf8",
);
try {
await openAgentRoute(page, session);
await openWorkspaceFile(page, "plain.txt");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(editor(page)).toContainText("plain source");
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
await expect(page.getByTestId("message-input-root")).toBeVisible();
await page.getByTestId("workspace-tab-file_plain.txt").first().click();
await expect(page.getByTestId("file-source-editor")).toBeVisible();
} finally {
await session.cleanup();
}
});
test("refuses a file above the display budget and keeps its tab recoverable", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-source-unsupported-",
title: "Unsupported large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
await writeFile(
path.join(session.cwd, "too-large.txt"),
Buffer.alloc(51 * 1024 * 1024),
"utf8",
);
try {
await openAgentRoute(page, session);
await openWorkspaceFile(page, "too-large.txt");
await expect(page.getByTestId("file-source-too-large")).toContainText(
"This file is too large to display",
);
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
await expect(page.getByTestId("message-input-root")).toBeVisible();
await page.getByTestId("workspace-tab-file_too-large.txt").first().click();
await expect(page.getByTestId("file-source-too-large")).toBeVisible();
} finally {
await session.cleanup();
}
});
test("opens an assistant file link at its referenced line", async ({ page }) => {
const target = "target.ts:42";
const session = await seedAgentWithFileLink({
@@ -137,6 +220,12 @@ test.describe("CodeMirror workspace file editing", () => {
});
test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
await page.addInitScript((settingsKey) => {
localStorage.setItem(
settingsKey,
JSON.stringify({ openInSidePane: { explorerFiles: true } }),
);
}, APP_SETTINGS_KEY);
const target = "target.ts:42";
const session = await seedAgentWithFileLink({
target,
@@ -413,10 +502,7 @@ test.describe("CodeMirror workspace file editing", () => {
.filter({ visible: true })
.first()
.click({ button: "right" });
await page
.getByTestId("workspace-tab-context-file_draft.ts-close")
.filter({ visible: true })
.click();
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
expect(closePrompt).toContain("Closing it will discard the draft.");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
@@ -39,6 +39,12 @@ async function assertHeldFileTransition(
finalText: string,
gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
) {
await page.evaluate(() => {
const originalChip = document.querySelector('[data-testid="workspace-tab-file_src/a.ts"]');
(
window as typeof window & { __fileTransitionOriginalChip?: Element | null }
).__fileTransitionOriginalChip = originalChip;
});
await page.evaluate((tabTestId) => {
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
const sample = () => {
@@ -69,6 +75,19 @@ async function assertHeldFileTransition(
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
await expect(page.getByText("export const sourceA = true;")).toHaveCount(0);
const chipIdentity = await page.evaluate((tabTestId) => {
const windowWithOriginal = window as typeof window & {
__fileTransitionOriginalChip?: Element | null;
};
const originalChip = windowWithOriginal.__fileTransitionOriginalChip ?? null;
const currentChip = document.querySelector(`[data-testid="${tabTestId}"]`);
return {
originalStillConnected: originalChip?.isConnected ?? false,
sameNode: originalChip === currentChip,
};
}, `workspace-tab-file_${targetPath}`);
expect(chipIdentity).toEqual({ originalStillConnected: true, sameNode: true });
const heldTimeline = await page.evaluate(
() =>
(
@@ -1,115 +0,0 @@
// The file panel's tree rail had no way to close: the changes panel's toolbar
// carries a folder-tree toggle, the file toolbar carried nothing, and the rail
// was permanent on desktop.
import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
let workspace: SeededWorkspace;
test.beforeAll(async () => {
workspace = await seedWorkspace({
repoPrefix: "file-tree-rail-toggle-",
repo: { files: [{ path: "docs/guide.md", content: "# Guide\n" }] },
});
});
test.afterAll(async () => {
await workspace?.cleanup();
});
test.describe("File panel tree rail", () => {
test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openFileFromExplorer(page, "docs");
await openFileFromExplorer(page, "guide.md");
const rail = page.getByTestId("file-tree-rail-tree").filter({ visible: true });
const toggle = page.getByTestId("file-toggle-tree").filter({ visible: true }).first();
const editorToolbar = page.getByTestId("file-panel-bar").filter({ visible: true });
const filesToolbar = page.getByTestId("files-pane-header").filter({ visible: true });
const refresh = page.getByRole("button", { name: "Refresh files" }).filter({ visible: true });
const hiddenFiles = page
.getByRole("button", { name: "Hide hidden files" })
.filter({ visible: true });
const shoot = async (name: string) => {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path });
await testInfo.attach(name, { path, contentType: "image/png" });
};
await expect(rail).toBeVisible({ timeout: 30_000 });
await expect(toggle).toHaveAttribute("aria-selected", "true");
const [
editorToolbarBox,
filesToolbarBox,
treeToggleBox,
refreshBox,
treeGlyphBox,
refreshGlyphBox,
] = await Promise.all([
editorToolbar.boundingBox(),
filesToolbar.boundingBox(),
toggle.boundingBox(),
refresh.boundingBox(),
toggle.locator("svg").boundingBox(),
refresh.locator("svg").boundingBox(),
]);
if (
!editorToolbarBox ||
!filesToolbarBox ||
!treeToggleBox ||
!refreshBox ||
!treeGlyphBox ||
!refreshGlyphBox
) {
throw new Error("Pane-content toolbar geometry could not be measured");
}
expect(editorToolbarBox.height).toBe(36);
expect(filesToolbarBox.height).toBe(36);
expect(editorToolbarBox.y + editorToolbarBox.height).toBe(
filesToolbarBox.y + filesToolbarBox.height,
);
expect(treeToggleBox.width).toBe(24);
expect(treeToggleBox.height).toBe(24);
expect(refreshBox.width).toBe(24);
expect(refreshBox.height).toBe(24);
expect(treeGlyphBox.width).toBe(14);
expect(treeGlyphBox.height).toBe(14);
expect(refreshGlyphBox.width).toBe(14);
expect(refreshGlyphBox.height).toBe(14);
const restBackground = await refresh.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
const treeToggleSelectedBackground = await toggle.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await refresh.hover();
const refreshHoverBackground = await refresh.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
expect(refreshHoverBackground).not.toBe(restBackground);
expect(refreshHoverBackground).toBe(treeToggleSelectedBackground);
await hiddenFiles.click();
const hiddenFilesSelected = page
.getByRole("button", { name: "Show hidden files" })
.filter({ visible: true });
expect(
await hiddenFilesSelected.evaluate((element) => getComputedStyle(element).backgroundColor),
).toBe(refreshHoverBackground);
await shoot("tree-open");
await toggle.click();
await expect(toggle).toHaveAttribute("aria-selected", "false");
await expect(rail).toHaveCount(0);
await shoot("tree-closed");
await toggle.click();
await expect(toggle).toHaveAttribute("aria-selected", "true");
await expect(rail).toBeVisible();
await shoot("tree-reopened");
});
});
@@ -1,60 +0,0 @@
import { test, expect } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
expectExplorerNodeRetained,
hideExplorerSplit,
measureExplorerSplit,
openExplorerSplit,
resizeExplorerSplit,
stampExplorerNode,
} from "../support/helpers/split-pane";
import {
createAgentTabFromMenu,
waitForWorkspaceTabsVisible,
} from "../support/helpers/workspace-tabs";
test.describe("Hidden split pane retention", () => {
test("releases all layout space without unmounting and restores its dragged width", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "hidden-split-pane-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await createAgentTabFromMenu(page);
await openExplorerSplit(page);
// Drag to a non-default width first: restoring the default would prove nothing.
await resizeExplorerSplit(page, 137);
await stampExplorerNode(page);
const open = await measureExplorerSplit(page);
expect(open.explorerWidth).toBeGreaterThan(250);
expect(open.separatorCount).toBe(1);
await hideExplorerSplit(page);
const hidden = await measureExplorerSplit(page);
expect(hidden.explorerWidth).toBe(0);
// The sibling must absorb the whole row, not just collapse the hidden pane.
expect(hidden.visibleWidth).toBeCloseTo(hidden.containerWidth, 5);
expect(hidden.occupiedWidth).toBeCloseTo(hidden.containerWidth, 5);
expect(hidden.separatorCount).toBe(0);
// Same DOM node, so a teardown-and-rebuild cannot pass as retention.
await expectExplorerNodeRetained(page);
await openExplorerSplit(page);
await expectExplorerNodeRetained(page);
const reopened = await measureExplorerSplit(page);
expect(reopened.explorerWidth).toBeCloseTo(open.explorerWidth, 5);
expect(reopened.separatorCount).toBe(1);
await testInfo.attach("split-pane-geometry", {
body: JSON.stringify({ open, hidden, reopened }, null, 2),
contentType: "application/json",
});
} finally {
await workspace.cleanup();
}
});
});
+106 -55
View File
@@ -1,16 +1,17 @@
import { test, expect } from "../support/fixtures";
import type { Locator } from "@playwright/test";
import {
gotoWorkspace,
assertNewChatTileVisible,
assertNewTabMenuTriggerVisible,
assertSingleNewTabButton,
openNewTabMenuWithShortcut,
pressDirectNewTabShortcut,
clickNewChat,
clickNewTerminal,
countTabsOfKind,
waitForTabBar,
pressNewTabShortcut,
pressDirectNewTabShortcut,
getTabTestIds,
waitForTabWithTitle,
measureTileTransition,
@@ -45,6 +46,16 @@ const EMPTY_PROMPT_PROFILE: TerminalProfile = {
args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"],
};
async function tabTestIds(tabs: Locator): Promise<(string | null)[]> {
return tabs.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid")),
);
}
function tabIdentityKey(snapshot: Array<{ id: string }>): string {
return JSON.stringify(snapshot.map(({ id }) => id));
}
test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
const created = await workspace.client.createWorkspace({
@@ -66,14 +77,12 @@ test.afterAll(async () => {
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Tab creation", () => {
test("Cmd+T keeps opening the New tab menu after workspace switches", async ({ page }) => {
test("Cmd+T keeps creating a New tab after workspace switches", async ({ page }) => {
if (!secondWorkspaceId) {
throw new Error("Secondary workspace was not created");
}
const serverId = getServerId();
const newTabMenuItem = page.getByRole("menuitem", { name: /^Agent/ });
const switchWorkspaceRow = async (workspaceId: string) => {
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
await expect(row).toBeVisible({ timeout: 30_000 });
@@ -92,11 +101,12 @@ test.describe("Tab creation", () => {
for (let i = 0; i < 8; i++) {
await switchWorkspaceRow(sequence[i % sequence.length]);
await pressNewTabShortcut(page);
await expect(newTabMenuItem, `New tab menu did not open after switch ${i}`).toBeVisible({
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
`New tab did not open after switch ${i}`,
).toBeVisible({
timeout: 5_000,
});
await page.keyboard.press("Escape");
await expect(newTabMenuItem).toBeHidden({ timeout: 5_000 });
}
});
@@ -104,72 +114,91 @@ test.describe("Tab creation", () => {
await gotoWorkspace(page, workspace.workspaceId);
const workspaceUrl = page.url();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
const newTabCountBefore = await countTabsOfKind(page, "new_tab");
await page.keyboard.press(`${modifier}+Comma`);
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible();
await page.keyboard.press(`${modifier}+t`);
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
await page.goto(workspaceUrl);
await assertNewTabMenuTriggerVisible(page);
const countBefore = await countTabsOfKind(page, "draft");
await pressDirectNewTabShortcut(page, "a");
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore + 1);
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore);
});
test("Cmd+T opens the New tab menu without creating an agent", async ({ page }) => {
test("Cmd+T creates a New tab without creating an agent", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "draft");
await openNewTabMenuWithShortcut(page);
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore);
await expect(page.getByRole("button", { name: "New tab", expanded: true })).toBeVisible();
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
).toBeVisible();
});
test("opening two new tabs creates two draft tabs", async ({ page }) => {
test("clicking + opens its menu without creating a New tab", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "new_tab");
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await expect(
page.getByTestId("workspace-new-tab-menu").filter({ visible: true }),
).toBeVisible();
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(countBefore);
});
test("opening two New tabs creates two independent tab identities", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "draft");
const newTabs = page
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
const countBefore = await newTabs.count();
await pressDirectNewTabShortcut(page, "a");
await expect
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
.toBe(countBefore + 1);
const countAfterFirst = await countTabsOfKind(page, "draft");
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 1);
const firstIds = await tabTestIds(newTabs);
await pressDirectNewTabShortcut(page, "a");
await expect
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
.toBe(countAfterFirst + 1);
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 2);
const ids = await tabTestIds(newTabs);
expect(new Set(ids).size).toBe(ids.length);
expect(ids.filter((id) => !firstIds.includes(id))).toHaveLength(1);
});
test("New tab menu exposes shortcuts and supports arrow navigation", async ({ page }) => {
test("New tab exposes shortcuts and supports arrow navigation after refocus", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await openNewTabMenuWithShortcut(page);
const agent = page.getByRole("menuitem", { name: /^Agent/ });
const terminal = page.getByRole("menuitem", { name: /^Terminal/ });
const changes = page.getByRole("menuitem", { name: /Changes/ });
const files = page.getByRole("menuitem", { name: /Files/ });
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
const agent = panel.getByRole("button", { name: /^Agent/ });
const terminal = panel.getByRole("button", { name: /^Terminal/ });
const diff = panel.getByRole("button", { name: /Diff/ });
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*C`));
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
await expect(diff).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
await expect(agent).toBeFocused();
await page.keyboard.press("ArrowDown");
await expect(terminal).toBeFocused();
await page.keyboard.press("ArrowUp");
await expect(agent).toBeFocused();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "New tab" })).toBeFocused();
await expect(page.getByRole("button", { name: "New tab" })).toHaveAttribute(
"aria-expanded",
"false",
);
await pressDirectNewTabShortcut(page, "e");
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
await expect(
page.getByTestId("file-explorer-tree-scroll").filter({ visible: true }),
).toBeVisible();
await page.locator("body").click({ position: { x: 1, y: 1 } });
await panel.click({ position: { x: 20, y: 20 } });
await expect(agent).toBeFocused();
});
test("clicking new agent tab creates a draft tab", async ({ page }) => {
@@ -205,8 +234,12 @@ test.describe("Tab creation", () => {
try {
await gotoWorkspace(page, workspace.workspaceId);
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
await page.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name }).click();
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await page
.getByTestId("workspace-new-tab-menu")
.filter({ visible: true })
.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name })
.click();
await expectTerminalOutputContains(page, "prompt-args: 0");
} finally {
@@ -214,6 +247,20 @@ test.describe("Tab creation", () => {
}
});
test("terminal profiles are grouped with a settings action", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
await expect(menu.getByText("Terminal profiles", { exact: true })).toBeVisible();
const editProfiles = menu.getByTestId("workspace-new-tab-menu-edit-terminal-profiles");
await expect(editProfiles).toHaveAccessibleName("Edit profiles");
await editProfiles.click();
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
});
test("tab bar shows action buttons per pane", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await assertSingleNewTabButton(page);
@@ -226,15 +273,13 @@ test.describe("Tab creation", () => {
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
const newTabButtonInScroll = await scrollArea
.getByTestId("workspace-new-tab-menu-trigger")
.count();
const newTabButtonInScroll = await scrollArea.getByTestId("workspace-new-tab-button").count();
const scrollShades = await tabRow
.locator('[data-testid^="workspace-tabs-scroll-shade-"]')
.count();
expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true);
await expect(tabRow.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible();
await expect(tabRow.getByTestId("workspace-new-tab-button")).toBeVisible();
});
});
@@ -333,28 +378,34 @@ test.describe("Terminal title propagation", () => {
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Tab transitions (no flash)", () => {
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
test("New agent tab transition has no blank intermediate tab state", async ({
page,
withWorkspace,
}) => {
const isolatedWorkspace = await withWorkspace({ prefix: "launcher-no-flash-" });
await isolatedWorkspace.navigateTo();
await pressNewTabShortcut(page);
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
).toBeVisible();
// Sample tabs at high frequency across the transition
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page));
// Sample the single New → Agent replacement, not the separate action that
// creates the New tab in the first place.
const snapshots = await sampleTabsDuringTransition(page, async () => {
await page.getByTestId("workspace-new-tab-agent").filter({ visible: true }).first().click();
});
// Every snapshot should have at least one tab — no blank/zero-tab frames
for (const snapshot of snapshots) {
expect(snapshot.length).toBeGreaterThanOrEqual(1);
}
// Tab count should never spike excessively (no duplicate flash from add-then-remove).
// When running in-suite, previous tests may have created tabs on the shared workspace,
// so we allow +2 tolerance for accumulated state and React render batching.
// Replacement is atomic: the set of identities changes once, while its size stays fixed.
const counts = snapshots.map((snapshot) => snapshot.length);
const maxCount = Math.max(...counts);
const initialCount = counts[0] ?? 0;
expect(maxCount).toBeLessThanOrEqual(initialCount + 2);
expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot))).size).toBeLessThanOrEqual(
2,
);
expect(counts.every((count) => count === initialCount)).toBe(true);
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
});
@@ -0,0 +1,49 @@
import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
test("a workspace with an existing agent never presents New during startup", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "new-tab-startup-agent-" });
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Existing startup agent",
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: "ready",
});
try {
await page.addInitScript(() => {
Reflect.set(globalThis, "__paseoSawVisibleNewTab", false);
function recordVisibleNewTab() {
const panel = document.querySelector('[data-testid="workspace-new-tab-panel"]');
if (panel?.getClientRects().length) {
Reflect.set(globalThis, "__paseoSawVisibleNewTab", true);
}
}
window.addEventListener("DOMContentLoaded", () => {
new MutationObserver(recordVisibleNewTab).observe(document.documentElement, {
childList: true,
subtree: true,
});
recordVisibleNewTab();
});
});
await gotoWorkspace(page, workspace.workspaceId);
await expect(
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toHaveCount(
0,
);
expect(await page.evaluate(() => Reflect.get(globalThis, "__paseoSawVisibleNewTab"))).toBe(
false,
);
} finally {
await workspace.cleanup();
}
});
-217
View File
@@ -1,217 +0,0 @@
import { expect, type Locator, type Page, type TestInfo } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { ensureSidePanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path });
await testInfo.attach(name, { path, contentType: "image/png" });
}
function sidePanel(page: Page): Locator {
return page.getByTestId("workspace-side-panel").filter({ visible: true });
}
function mainPane(page: Page): Locator {
return page.getByTestId("workspace-pane-main").filter({ visible: true });
}
function emptyState(scope: Locator): Locator {
return scope.getByTestId("workspace-pane-empty-state");
}
function sidePanelToggle(page: Page): Locator {
return page.getByTestId("workspace-explorer-toggle").first();
}
function closePaneButton(page: Page): Locator {
return page.getByRole("button", { name: "Close pane" });
}
/** Drags the pane's only tab into the left edge of its own pane, splitting it out. */
async function splitTabOutOfMainPane(page: Page, chip: Locator): Promise<void> {
const paneBox = await mainPane(page).boundingBox();
const chipBox = await chip.boundingBox();
expect(paneBox).not.toBeNull();
expect(chipBox).not.toBeNull();
if (!paneBox || !chipBox) throw new Error("Unable to measure the main pane");
await page.mouse.move(chipBox.x + chipBox.width / 2, chipBox.y + chipBox.height / 2);
await page.mouse.down();
await page.mouse.move(chipBox.x + chipBox.width / 2 + 12, chipBox.y + chipBox.height / 2 + 4);
await page.mouse.move(paneBox.x + paneBox.width * 0.06, paneBox.y + paneBox.height * 0.6, {
steps: 20,
});
await page.mouse.up();
}
test.describe("Side panel", () => {
test("reveals empty, launches a view, and hides back out of the way", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "side-panel-reveal-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await expect(sidePanel(page)).toHaveCount(0);
await test.step("revealing the Side panel seeds nothing", async () => {
await sidePanelToggle(page).click();
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
await expect(emptyState(sidePanel(page))).toBeVisible();
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
await capture(page, testInfo, "01-side-panel-reveals-empty");
});
await test.step("Changes opens in the Side panel that offered it", async () => {
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
await capture(page, testInfo, "02-changes-opens-in-side-panel");
});
await test.step("a loaded pane offers no one-tap close", async () => {
await expect(closePaneButton(page)).toHaveCount(0);
});
await test.step("closing its final tab hides the Side panel, and it reopens empty", async () => {
const changesTab = sidePanel(page).getByTestId("workspace-tab-working_diff");
await changesTab.hover();
await page
.locator('[data-testid^="workspace-working-diff-close-"]')
.filter({ visible: true })
.click();
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
await capture(page, testInfo, "03-side-panel-hidden-after-final-tab-close");
await sidePanelToggle(page).click();
await expect(sidePanel(page)).toBeVisible({ timeout: 15_000 });
await expect(emptyState(sidePanel(page))).toBeVisible();
await capture(page, testInfo, "04-side-panel-revealed-empty");
});
} finally {
await workspace.cleanup();
}
});
test("an emptied ordinary pane is removed rather than hidden", async ({ page }, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "side-panel-close-main-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await ensureSidePanel(page);
// Dragging the pane's only tab into its own left edge splits that tab into a
// new pane and leaves the one it came from empty.
const draftTab = page
.locator('[data-testid^="workspace-tab-draft_"]')
.filter({ visible: true })
.first();
await expect(draftTab).toBeVisible({ timeout: 30_000 });
await splitTabOutOfMainPane(page, draftTab);
const draftPane = page
.locator('[data-testid^="workspace-pane-pane_"]')
.filter({ visible: true })
.first();
await expect(draftPane).toBeVisible({ timeout: 15_000 });
const emptyMain = emptyState(mainPane(page));
await expect(emptyMain).toBeVisible({ timeout: 15_000 });
await capture(page, testInfo, "06-empty-main-pane-launcher");
await emptyMain.getByRole("button", { name: "Close pane" }).click();
// Gone for good, unlike the Side panel beside it, which is still only hidden away.
await expect(mainPane(page)).toHaveCount(0, { timeout: 30_000 });
await expect(sidePanel(page)).toBeVisible();
await expect(draftPane.getByTestId("workspace-pane-empty-state")).toHaveCount(0);
await capture(page, testInfo, "07-empty-main-pane-removed");
} finally {
await workspace.cleanup();
}
});
test("the last pane on screen offers no Close pane", async ({ page }, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "side-panel-final-pane-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
// Park a tab in the Side panel, then put the panel away. The workspace still
// holds that tab, so emptying Main does not look like an empty workspace and
// nothing reseeds a draft into it.
await ensureSidePanel(page);
await emptyState(sidePanel(page)).getByRole("button", { name: "Files" }).click();
await expect(sidePanel(page).getByTestId("workspace-tab-files")).toBeVisible({
timeout: 30_000,
});
await sidePanelToggle(page).click();
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
const draftTab = page
.locator('[data-testid^="workspace-tab-draft_"]')
.filter({ visible: true })
.first();
await draftTab.hover();
await page
.locator('[data-testid^="workspace-draft-close-"]')
.filter({ visible: true })
.first()
.click();
// Main is now empty and the only pane the user can see. Taking it away would
// leave nowhere to look, so the launcher appears without a Close pane.
const lastPane = emptyState(mainPane(page));
await expect(lastPane).toBeVisible({ timeout: 15_000 });
await expect(lastPane.getByRole("button", { name: "Files" })).toBeVisible();
await expect(lastPane.getByRole("button", { name: "Close pane" })).toHaveCount(0);
await expect(closePaneButton(page)).toHaveCount(0);
await capture(page, testInfo, "08-final-visible-pane-has-no-close");
} finally {
await workspace.cleanup();
}
});
test("a pane's own menu creates an independent view when one is open elsewhere", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "side-panel-claim-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await ensureSidePanel(page);
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await capture(page, testInfo, "08-changes-in-side-panel");
await mainPane(page).getByTestId("workspace-new-tab-menu-trigger").click();
await page
.getByTestId("workspace-new-tab-menu-changes")
.filter({ visible: true })
.first()
.click();
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 15_000,
});
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(2);
await capture(page, testInfo, "09-independent-changes-in-both-panes");
} finally {
await workspace.cleanup();
}
});
});
@@ -29,10 +29,10 @@ test("sidebar and workspace pane borders highlight on hover", async ({ page, wit
await expectBorderHighlight(page, "left-sidebar-resize-handle", sidebarHandle, "1px");
await page.getByTestId("workspace-explorer-toggle").first().click();
const splitHandle = page.getByTestId("workspace-split-resize-handle");
const splitHandle = page.getByTestId("workspace-explorer-sidebar-resize-handle");
await expectBorderHighlight(
page,
"workspace-split-resize-handle",
"workspace-explorer-sidebar-resize-handle",
splitHandle.getByRole("separator"),
"3px",
);
@@ -1,6 +1,10 @@
import { test, expect, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import {
expectNewWorkspaceProjectSelected,
openNewWorkspaceComposer,
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
@@ -142,6 +146,25 @@ async function installPinRpcGate(
}
test.describe("Pin workspace shortcut", () => {
test("keeps project creation reachable after pinning its only workspace", async ({ page }) => {
const workspace = await seedWorkspace({
repoPrefix: "pin-project-creation-",
title: "Pinned workspace",
});
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
await openNewWorkspaceComposer(page, workspace);
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
} finally {
await workspace.cleanup();
}
});
test("pins the active workspace while its project section is collapsed", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-collapsed-" });
@@ -319,7 +319,7 @@ test.describe("Half-screen desktop layout", () => {
await expect(closedIcon).toBeVisible();
const closedBounds = await closedIcon.boundingBox();
expect(closedBounds).not.toBeNull();
expect(closedBounds?.x).toBeCloseTo(12, 0);
expect(closedBounds?.x).toBeCloseTo(9, 0);
expect(closedBounds?.y).toBe(openBounds?.y);
});
@@ -342,16 +342,19 @@ test.describe("Half-screen desktop layout", () => {
await openFilesPanel(page);
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
await expect(explorerToggle).toHaveAccessibleName("Close side panel");
await expect(
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
).toBeVisible();
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
await expect(page.getByTestId("explorer-sidebar-tab-rail")).toBeVisible();
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(1);
await explorerToggle.click();
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
0,
);
await expect(explorerToggle).toHaveAccessibleName("Open side panel");
await expect(
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
).toHaveCount(0);
await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
} finally {
await workspace.cleanup();
@@ -81,7 +81,7 @@ async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness
beforeXterm.rows,
);
// Split the pane downward. This focuses the new empty pane, so the terminal
// Split the pane downward. This focuses the new tab pane, so the terminal
// pane is unfocused at the exact moment its container shrinks.
await runWorkspaceActionFromCommandCenter(page, "Split pane down");
@@ -59,8 +59,12 @@ async function createTerminalViaMenu(page: Page): Promise<void> {
// Workspaces always render a hidden explorer companion pane alongside the
// main pane, so an unscoped testid locator matches both; scope to the
// visible one.
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
await page.getByTestId("workspace-new-tab-menu-terminal").click();
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await page
.getByTestId("workspace-new-tab-menu-terminal")
.filter({ visible: true })
.first()
.click();
}
async function listTerminalIds(harness: TerminalE2EHarness): Promise<string[]> {
@@ -128,7 +132,7 @@ test.describe("terminal PTY size claim under lost window focus", () => {
await page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId));
await expect(
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }),
page.getByTestId("workspace-new-tab-button").filter({ visible: true }),
).toBeVisible({
timeout: 30_000,
});
@@ -133,7 +133,7 @@ test.describe("Workspace agent title handoff", () => {
.toBe(promptTitle);
await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 });
await agentTab.click({ button: "right" });
await expect(page.getByTestId(`workspace-tab-context-agent_${agentId}-rename`)).toBeVisible({
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible({
timeout: 10_000,
});
await page.keyboard.press("Escape");
@@ -1,6 +1,5 @@
import { randomUUID } from "node:crypto";
import { execSync, spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import type { ChildProcess } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -23,6 +22,7 @@ import {
submitNewWorkspaceEmpty,
} from "../support/helpers/new-workspace";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { killProcessTree, spawnTsx } from "../support/helpers/spawn-node";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
@@ -195,9 +195,11 @@ async function getAvailablePort(): Promise<number> {
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
const startedAt = Date.now();
let lastConnectionError: unknown = null;
while (Date.now() - startedAt < 20_000) {
if (child.exitCode !== null) {
throw new Error(`Restart test daemon exited before listening (exit ${child.exitCode}).`);
while (Date.now() - startedAt < 90_000) {
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(
`Restart test daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).`,
);
}
try {
await new Promise<void>((resolve, reject) => {
@@ -226,21 +228,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
);
}
async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}, 5000);
try {
await once(child, "exit");
} finally {
clearTimeout(timeout);
}
}
async function startRestartDaemon(input: {
paseoHome: string;
origin: string;
@@ -251,8 +238,7 @@ async function startRestartDaemon(input: {
}
const serverDir = path.resolve(__dirname, "../../../server");
const tsxBin = execSync("which tsx").toString().trim();
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
const child = spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], {
cwd: serverDir,
env: withDisabledE2ESpeechEnv({
...process.env,
@@ -276,7 +262,7 @@ async function startRestartDaemon(input: {
try {
await waitForServer(port, child);
} catch (error) {
await stopProcess(child);
await killProcessTree(child);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error },
@@ -285,7 +271,7 @@ async function startRestartDaemon(input: {
return {
port,
close: () => stopProcess(child),
close: () => killProcessTree(child),
};
}
@@ -370,22 +356,6 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
return decodeWorkspaceIdFromPathSegment(match[1]);
}
async function expectWorkspaceRowHasOnlyIndicator(
page: Page,
input: { serverId: string; workspaceId: string; indicator: string },
) {
const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
for (const indicator of ["attention", "done", "failed", "loading", "needs_input", "running"]) {
const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`);
if (indicator === input.indicator) {
await expect(locator).toBeVisible({ timeout: 30_000 });
} else {
await expect(locator).toHaveCount(0);
}
}
}
async function expectWorkspaceRowDoesNotShowIndicator(
page: Page,
input: { serverId: string; workspaceId: string; indicator: string },
@@ -458,11 +428,6 @@ test.describe("Workspace model restart regressions", () => {
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
await waitForSidebarHydration(page);
await expectWorkspaceRowHasOnlyIndicator(page, {
serverId,
workspaceId: seeded.workspaceA,
indicator: "running",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
serverId,
workspaceId: seeded.workspaceB,
@@ -65,10 +65,10 @@ test.describe("Workspace pane mounting", () => {
.first()
.elementHandle();
expect(originalComposer).not.toBeNull();
const emptyPanesBeforeSplit = await page.getByTestId("workspace-pane-empty-state").count();
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
emptyPanesBeforeSplit + 1,
{ timeout: 30_000 },
);
@@ -70,9 +70,9 @@ test.describe("Workspace terminal tab rename", () => {
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
const copyTerminalId = page.getByTestId(
`workspace-tab-context-terminal_${terminalId}-copy-terminal-id`,
);
const copyTerminalId = page.getByRole("menuitem", {
name: /^Copy terminal id/,
});
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
await copyTerminalId.click();
@@ -99,10 +99,7 @@ test.describe("Workspace terminal tab rename", () => {
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
timeout: 10_000,
});
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
const renameItem = page.getByRole("menuitem", { name: "Rename", exact: true });
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
+6 -23
View File
@@ -1,5 +1,5 @@
import { spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { killProcessTree } from "./helpers/spawn-node";
import { existsSync } from "node:fs";
import path from "node:path";
import net from "node:net";
@@ -148,28 +148,11 @@ export async function warmMetro(port: number): Promise<void> {
}
}
async function stopProcess(child: ChildProcess | null): Promise<void> {
if (!child || child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
once(child, "exit"),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
resolve();
}, 5_000);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
const appDir = path.resolve(__dirname, "../..");
const child = spawn("npx", ["expo", "start", "--web", "--port", String(port)], {
const expoCli = require.resolve("expo/bin/cli");
// Spawns Node directly to bypass Windows .cmd shim execution restrictions without shell: true.
const child = spawn(process.execPath, [expoCli, "start", "--web", "--port", String(port)], {
cwd: appDir,
env: {
...process.env,
@@ -216,11 +199,11 @@ export default async function globalSetup() {
console.log(`[e2e] Metro warmed on port ${metroPort}`);
return async () => {
await stopProcess(metroProcess);
await killProcessTree(metroProcess);
console.log("[e2e] Metro stopped");
};
} catch (error) {
await stopProcess(metroProcess);
await killProcessTree(metroProcess);
throw error;
}
}
@@ -275,7 +275,7 @@ async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promi
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
}
if (draft.notes !== undefined) {
await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes);
await modal.getByRole("textbox", { name: "When to use", exact: true }).fill(draft.notes);
}
}
@@ -329,11 +329,18 @@ export async function expectAgentProfileForm(
const modal = editModal(page);
await expect(modal).toBeVisible({ timeout: 30_000 });
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
const notesHint = modal.getByTestId("agent-profile-notes-field-hint");
await expect(notesHint).toHaveText(
"Helps agents choose this profile when starting another agent.",
);
expect(await notesHint.evaluate((element) => getComputedStyle(element).webkitLineClamp)).not.toBe(
"1",
);
for (const [field, value] of Object.entries(expected)) {
if (field === "notes") {
await expect(
modal.getByRole("textbox", { name: "Notes for agents", exact: true }),
).toHaveValue(value);
await expect(modal.getByRole("textbox", { name: "When to use", exact: true })).toHaveValue(
value,
);
continue;
}
await expect(
@@ -1,27 +1,27 @@
import { expect, type Page } from "@playwright/test";
import { escapeRegex } from "./regex";
import { openChangesPanel as openWorkspaceChangesPanel } from "./workspace-tabs";
import { openChangesTreePanel } from "./workspace-tabs";
// The branch switcher lives in the Changes tab, not in the workspace header. It renders as a button whose
// accessible name carries the current branch ("Current branch: <name>. Press to
// switch branch."). Matching on the accessible name keeps these helpers tied to
// what a screen reader user hears, and it proves the panel resolved a real
// checkout directory from the opaque workspace id. Scoping to the changes header
// keeps the matcher unambiguous even when the header is shared with diff actions.
// checkout directory from the opaque workspace id. Scoping to the repository
// header keeps the matcher aligned with the row that owns repository identity.
function branchSwitcherTrigger(page: Page, branchName: string) {
return page
.getByTestId("changes-header")
.getByTestId("changes-repository-header")
.getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) })
.filter({ visible: true })
.first();
}
// Opens the fixed pane and lands on the Changes tab, where the switcher and diff live.
// Opens the fixed Explorer Changes view, where the switcher lives.
export async function openChangesPanel(page: Page): Promise<void> {
await openWorkspaceChangesPanel(page);
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
timeout: 30_000,
});
await openChangesTreePanel(page);
await expect(
page.getByTestId("changes-repository-header").filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
}
export async function expectWorkspaceBranch(page: Page, branchName: string): Promise<void> {
@@ -1,6 +1,6 @@
import { fork, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import path from "node:path";
import { killProcessTree } from "./spawn-node";
export interface OutdatedDaemon {
endpoint: string;
@@ -55,14 +55,10 @@ export async function startOutdatedDaemon(options?: {
endpoint: ready.endpoint,
label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host",
serverId: ready.serverId,
async close() {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
await once(child, "exit");
},
close: () => killProcessTree(child),
};
} catch (error) {
child.kill("SIGTERM");
await killProcessTree(child);
throw error;
}
}
+17 -3
View File
@@ -1,4 +1,4 @@
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -19,11 +19,19 @@ async function createFakeEditorBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
let realGhPath = "";
try {
realGhPath = execSync("which gh").toString().trim();
const locator = process.platform === "win32" ? "where.exe" : "which";
const candidates = execFileSync(locator, ["gh"], { encoding: "utf8" })
.split(/\r?\n/u)
.map((candidate) => candidate.trim())
.filter(Boolean);
realGhPath =
candidates.find(
(candidate) =>
process.platform !== "win32" || !/\.(?:cmd|bat)$/iu.test(path.extname(candidate)),
) ?? "";
} catch {
// The local GitHub fixture remains usable without a system gh binary.
}
const fakeEditorSource = `#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
@@ -41,6 +49,9 @@ if (recordPath) {
const editorPath = path.join(binDir, editorCommand);
await writeFile(editorPath, fakeEditorSource);
await chmod(editorPath, 0o755);
if (process.platform === "win32") {
await writeFile(`${editorPath}.cmd`, `@node "%~dp0${editorCommand}" %*\r\n`);
}
}
const fakeGhPath = path.join(binDir, "gh");
@@ -106,6 +117,9 @@ process.exit(result.status ?? 1);
`;
await writeFile(fakeGhPath, fakeGhSource);
await chmod(fakeGhPath, 0o755);
if (process.platform === "win32") {
await writeFile(`${fakeGhPath}.cmd`, '@node "%~dp0gh" %*\r\n');
}
return binDir;
}
@@ -1,16 +1,10 @@
import { once } from "node:events";
import {
spawn,
execFileSync,
execSync,
type ChildProcess,
type SpawnOptions,
} from "node:child_process";
import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { withDisabledE2ESpeechEnv } from "./speech-env";
import { killProcessTree, spawnTsx } from "./spawn-node";
export interface IsolatedHostDaemon {
serverId: string;
@@ -48,12 +42,14 @@ async function getAvailablePort(): Promise<number> {
}
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
const deadline = Date.now() + 20_000;
const deadline = Date.now() + 90_000;
let lastError: unknown = null;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`Isolated host daemon exited before listening (exit ${child.exitCode})`);
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(
`Isolated host daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)})`,
);
}
try {
await new Promise<void>((resolve, reject) => {
@@ -81,19 +77,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
);
}
async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}, 5_000);
try {
await once(child, "exit");
} finally {
clearTimeout(timeout);
}
}
export async function startIsolatedHostDaemon(
serverId: string,
options: IsolatedHostDaemonOptions = {},
@@ -114,11 +97,17 @@ export async function startIsolatedHostDaemon(
path.join(publishedPackageRoot, "package.json"),
`${JSON.stringify({ private: true })}\n`,
);
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
try {
const npmCli = process.env.npm_execpath;
if (!npmCli || path.basename(npmCli).toLowerCase() !== "npm-cli.js") {
throw new Error(
"Published-version E2E requires npm_execpath from npm. Start it through `npm run test:e2e`.",
);
}
execFileSync(
npmCommand,
process.execPath,
[
npmCli,
"install",
"--no-audit",
"--no-fund",
@@ -128,6 +117,9 @@ export async function startIsolatedHostDaemon(
{ cwd: publishedPackageRoot, stdio: "ignore" },
);
} catch (error) {
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
await rm(publishedPackageRoot, { recursive: true, force: true });
throw error;
}
@@ -155,7 +147,6 @@ export async function startIsolatedHostDaemon(
const serverDir = publishedPackageRoot
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
: path.resolve(__dirname, "../../../../server");
const tsxBin = execSync("which tsx").toString().trim();
const spawnDaemon = async (): Promise<ChildProcess> => {
const spawnOptions: SpawnOptions = {
cwd: serverDir,
@@ -175,7 +166,7 @@ export async function startIsolatedHostDaemon(
};
const child = publishedPackageRoot
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
: spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], spawnOptions);
: spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => {
@@ -187,7 +178,7 @@ export async function startIsolatedHostDaemon(
await waitForServer(port, child);
return child;
} catch (error) {
await stopProcess(child);
await killProcessTree(child);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error },
@@ -216,13 +207,13 @@ export async function startIsolatedHostDaemon(
getPid: () => child.pid,
restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await stopProcess(child);
await killProcessTree(child);
child = await spawnDaemon();
},
close: async () => {
if (closed) return;
closed = true;
await stopProcess(child);
await killProcessTree(child);
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
+10 -13
View File
@@ -62,7 +62,7 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
// ─── Tab actions ───────────────────────────────────────────────────────────
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open the focused pane's New tab menu. */
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to create a New tab in the focused pane. */
export async function pressNewTabShortcut(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+t`);
@@ -70,7 +70,7 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
export async function openNewTabMenuWithShortcut(page: Page): Promise<void> {
await pressNewTabShortcut(page);
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toBeVisible();
await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toBeVisible();
}
export async function pressDirectNewTabShortcut(page: Page, key: string): Promise<void> {
@@ -83,30 +83,27 @@ export async function pressDirectNewTabShortcut(page: Page, key: string): Promis
/** Assert the inline plus button is visible in the tab bar. */
export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
).toBeVisible();
}
/** Assert the new-tab dropdown trigger is visible in the tab bar. */
/** Assert the New tab button is visible in the tab bar. */
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
).toBeVisible();
}
// ─── Tab creation actions ─────────────────────────────────────────────────
/** Open the new-tab menu and click "New agent" to create a draft/chat tab. */
/** Choose Agent from the pane-local `+` menu. */
export async function clickNewChat(page: Page): Promise<void> {
await createAgentTabFromMenu(page);
}
/** Open the new-tab menu and click "New terminal". */
/** Choose Terminal from the pane-local `+` menu. */
export async function clickNewTerminal(page: Page): Promise<void> {
const trigger = page
.getByTestId("workspace-new-tab-menu-trigger")
.filter({ visible: true })
.first();
const trigger = page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
const item = page
@@ -137,7 +134,7 @@ export async function waitForTabWithTitle(
/** Assert the inline plus button is visible in the tab bar. */
export async function assertSingleNewTabButton(page: Page): Promise<void> {
const buttons = page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true });
const buttons = page.getByTestId("workspace-new-tab-button").filter({ visible: true });
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1);
}
@@ -224,7 +221,7 @@ export async function expectTabTitleFits(
}
export function terminalSurfaceLocator(page: Page) {
return page.locator('[data-testid="terminal-surface"]').first();
return page.locator('[data-testid="terminal-surface"]').filter({ visible: true }).first();
}
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
@@ -1,8 +1,8 @@
import { spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { existsSync } from "node:fs";
import net from "node:net";
import path from "node:path";
import { killProcessTree } from "./spawn-node";
export interface LocalElixirRelay {
endpoint: string;
@@ -27,19 +27,6 @@ async function availablePort(): Promise<number> {
});
}
async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const force = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}, 5_000);
try {
await once(child, "exit");
} finally {
clearTimeout(force);
}
}
async function waitUntilReady(
port: number,
child: ChildProcess,
@@ -48,9 +35,9 @@ async function waitUntilReady(
const deadline = Date.now() + 60_000;
let lastError: unknown;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(
`Elixir relay exited before becoming ready (exit ${child.exitCode}).\n${recentOutput()}`,
`Elixir relay exited before becoming ready (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).\n${recentOutput()}`,
);
}
try {
@@ -70,6 +57,12 @@ async function waitUntilReady(
}
export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
if (process.platform === "win32") {
throw new Error(
"The local Elixir relay requires asdf, which is not available on Windows; the relay-deployment Playwright project is POSIX-only.",
);
}
const relayRoot =
process.env.PASEO_RELAY_CHECKOUT ?? path.resolve(__dirname, "../../../../../..", "paseo-relay");
if (!existsSync(path.join(relayRoot, "mix.exs"))) {
@@ -107,14 +100,14 @@ export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
try {
await waitUntilReady(port, child, () => output.join("\n"));
} catch (error) {
await stopProcess(child);
await killProcessTree(child);
throw error;
}
};
const stop = async () => {
if (!child) return;
await stopProcess(child);
await killProcessTree(child);
child = null;
};
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
export interface MockAgentOptions {
repoPrefix: string;
title: string;
repo?: Parameters<typeof seedWorkspace>[0]["repo"];
port?: number;
initialPrompt?: string;
model?: string;
@@ -29,7 +30,11 @@ export interface MockAgentOptions {
export async function seedMockAgentWorkspace(
options: MockAgentOptions,
): Promise<MockAgentWorkspace> {
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: options.port });
const workspace = await seedWorkspace({
repoPrefix: options.repoPrefix,
repo: options.repo,
port: options.port,
});
try {
const agent = await workspace.client.createAgent({
provider: "mock",
@@ -113,6 +113,11 @@ export async function enableRelayAndExpectOffer(page: Page): Promise<void> {
export async function expectPairingOffer(page: Page): Promise<void> {
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
await expect(
page.getByRole("alert").filter({
hasText: "Treat this pairing link like a password. Anyone with it can access this daemon.",
}),
).toBeVisible();
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0);
}
@@ -12,6 +12,7 @@ export interface SeedWorkspaceDescriptor {
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
diffStat: { additions: number; deletions: number } | null;
labels?: string[];
}
@@ -0,0 +1,84 @@
import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import { once } from "node:events";
/**
* Runs a repo-local TypeScript entrypoint through Node's own tsx loader instead
* of the `node_modules/.bin/tsx` shim.
*
* The shim is a platform trap, not a convenience: on Windows it is `tsx.cmd`,
* and Node refuses to spawn `.cmd`/`.bat` without `shell: true` (EINVAL, from
* the CVE-2024-27980 mitigation). Opting into `shell: true` to get around that
* trades one bug for three — argv is concatenated unescaped (DEP0190), a path
* containing spaces splits, and the kill signal lands on `cmd.exe` while the
* real child keeps the port. Spawning `process.execPath` has none of those
* properties and is byte-identical on POSIX, so there is no platform branch.
*/
export function spawnTsx(entrypoint: string, args: string[], options: SpawnOptions): ChildProcess {
if (entrypoint.startsWith("-")) {
throw new Error(`TypeScript entrypoint must be a path, received ${entrypoint}`);
}
return spawn(process.execPath, ["--import", "tsx", "--", entrypoint, ...args], options);
}
/**
* Terminates a child and everything it forked, then waits for the exit.
*
* `child.kill()` on Windows maps to `TerminateProcess` against the direct child
* only. Metro and the daemon supervisor both fork workers, so the signal leaves
* grandchildren holding the listening port and the next run fails to bind.
* `taskkill /T` walks the tree; POSIX keeps the SIGTERM-then-SIGKILL ladder.
*/
export async function killProcessTree(child: ChildProcess | null): Promise<void> {
if (!child || hasExited(child)) return;
const exited = once(child, "exit").then(() => undefined);
if (process.platform === "win32") {
const pid = child.pid;
if (pid === undefined) {
throw new Error("Cannot terminate a Windows process tree without a PID");
}
// Register the exit listener before taskkill to avoid missing a fast exit.
// Bound taskkill itself so a stuck system utility cannot hang teardown.
const completed = Promise.withResolvers<Error | null>();
execFile("taskkill", ["/pid", String(pid), "/T", "/F"], { timeout: 5_000 }, (error) =>
completed.resolve(error),
);
const taskkillError = await completed.promise;
if (taskkillError && !hasExited(child)) {
try {
child.kill("SIGKILL");
} catch {
// The child can exit between the state check and the direct kill.
}
await waitForExitOrTimeout(exited, 5_000);
throw new Error(`Failed to terminate process tree for PID ${pid}`, { cause: taskkillError });
}
if (!hasExited(child) && !(await waitForExitOrTimeout(exited, 5_000))) {
throw new Error(`Process tree for PID ${pid} did not exit after taskkill`);
}
return;
}
child.kill("SIGTERM");
if (await waitForExitOrTimeout(exited, 5_000)) return;
child.kill("SIGKILL");
if (!(await waitForExitOrTimeout(exited, 5_000))) {
throw new Error(`Process ${String(child.pid)} did not exit after SIGKILL`);
}
}
function hasExited(child: ChildProcess): boolean {
return child.exitCode !== null || child.signalCode !== null;
}
async function waitForExitOrTimeout(exited: Promise<void>, timeoutMs: number): Promise<boolean> {
const timedOut = Promise.withResolvers<boolean>();
const timeout = setTimeout(() => timedOut.resolve(false), timeoutMs);
try {
return await Promise.race([exited.then(() => true), timedOut.promise]);
} finally {
clearTimeout(timeout);
}
}
@@ -1,84 +0,0 @@
import { expect, type Locator, type Page } from "@playwright/test";
export interface SplitPaneGeometry {
containerWidth: number;
explorerWidth: number;
visibleWidth: number;
separatorCount: number;
occupiedWidth: number;
}
function splitSeparator(page: Page): Locator {
return page.getByRole("separator", { name: "" });
}
export async function openExplorerSplit(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open side panel" }).click();
await expect(splitSeparator(page)).toHaveCount(1, { timeout: 30_000 });
}
export async function hideExplorerSplit(page: Page): Promise<void> {
await page.getByRole("button", { name: "Close side panel" }).click();
await expect(splitSeparator(page)).toHaveCount(0, { timeout: 30_000 });
}
export async function resizeExplorerSplit(page: Page, deltaX: number): Promise<void> {
const separator = splitSeparator(page);
await expect(separator).toHaveCount(1);
const box = await separator.boundingBox();
expect(box).not.toBeNull();
if (!box) throw new Error("Split separator has no bounding box");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + deltaX, box.y + box.height / 2, { steps: 10 });
await page.mouse.up();
}
export async function stampExplorerNode(page: Page): Promise<void> {
await page
.getByTestId("split-group-child")
.last()
.evaluate((node) => {
Reflect.set(window, "__splitPaneExplorerNode", node);
node.setAttribute("data-split-pane-identity", "retained");
});
}
export async function expectExplorerNodeRetained(page: Page): Promise<void> {
const stampedNode = page.locator('[data-split-pane-identity="retained"]');
await expect(stampedNode).toHaveCount(1);
expect(
await stampedNode.evaluate((node) => Reflect.get(window, "__splitPaneExplorerNode") === node),
).toBe(true);
}
export async function measureExplorerSplit(page: Page): Promise<SplitPaneGeometry> {
const explorerChild = page.locator('[data-split-pane-identity="retained"]');
await expect(explorerChild).toHaveCount(1);
return explorerChild.evaluate((explorerNode) => {
const visibleNode = Array.from(explorerNode.parentElement?.children ?? [])
.filter(
(candidate) =>
candidate !== explorerNode &&
(candidate as HTMLElement).dataset.testid === "split-group-child",
)
.sort(
(left, right) => right.getBoundingClientRect().width - left.getBoundingClientRect().width,
)[0];
if (!(visibleNode instanceof HTMLElement)) throw new Error("Visible split child not found");
const visible = visibleNode.getBoundingClientRect();
const explorer = explorerNode.getBoundingClientRect();
const container = explorerNode.parentElement?.getBoundingClientRect();
if (!container) throw new Error("Split child has no container");
const separatorCount =
explorerNode.parentElement?.querySelectorAll('[role="separator"]').length ?? 0;
return {
containerWidth: container.width,
explorerWidth: explorer.width,
visibleWidth: visible.width,
separatorCount,
occupiedWidth: explorer.width + visible.width,
};
});
}
@@ -42,8 +42,8 @@ export async function expectFailedSetupTabSeededInMainPane(
await expect(page.getByTestId(tabId).filter({ visible: true }).first()).toBeVisible({
timeout: 30_000,
});
const panel = await ensureSidePanel(page);
await expect(panel.getByTestId(tabId)).toHaveCount(0);
const explorer = await ensureExplorerSidebar(page);
await expect(explorer.getByTestId(tabId)).toHaveCount(0);
}
export async function closeSetupTab(page: Page, workspaceId: string): Promise<void> {
@@ -57,8 +57,8 @@ function visibleTestId(page: Page, testId: string) {
return page.getByTestId(testId).filter({ visible: true });
}
function sidePanel(page: Page) {
return visibleTestId(page, "workspace-side-panel").first();
function explorerSidebar(page: Page) {
return visibleTestId(page, "workspace-explorer-sidebar").first();
}
async function selectWorkspaceTab(tab: Locator): Promise<void> {
@@ -70,50 +70,54 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
await expect(tab).toHaveAttribute("aria-selected", "true");
}
/** Reveal the Side panel. It opens empty; the caller decides what goes in it. */
export async function ensureSidePanel(page: Page): Promise<Locator> {
/** Reveal the Explorer sidebar without changing its selected view. */
export async function ensureExplorerSidebar(page: Page): Promise<Locator> {
const toggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(toggle).toBeVisible({ timeout: 30_000 });
const panel = sidePanel(page);
if ((await panel.count()) === 0) {
const explorer = explorerSidebar(page);
if ((await explorer.count()) === 0) {
await toggle.click();
}
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
await expect(explorer).toBeVisible({ timeout: 30_000 });
return explorer;
}
/**
* Reveals the Side panel and brings one of its views up in it. Goes through the
* pane's own `+` menu, which is there whether the pane is empty or already loaded.
*/
async function openSidePanelView(
/** Reveals the Explorer sidebar and selects one of its fixed navigation views. */
async function openExplorerView(
page: Page,
view: { tabTestId: string; menuTestId: string; contentTestId: string; timeout?: number },
view: { tabTestId: string; contentTestId: string; timeout?: number },
): Promise<void> {
const panel = await ensureSidePanel(page);
const tab = panel.getByTestId(view.tabTestId);
if ((await tab.count()) === 0) {
await panel.getByTestId("workspace-new-tab-menu-trigger").click();
await visibleTestId(page, view.menuTestId).first().click();
}
await selectWorkspaceTab(tab);
const explorer = await ensureExplorerSidebar(page);
const tab = explorer.getByTestId(view.tabTestId);
await tab.click();
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
timeout: view.timeout ?? 30_000,
});
}
export async function openChangesTreePanel(page: Page): Promise<void> {
await openExplorerView(page, {
tabTestId: "explorer-sidebar-tab-changes_tree",
contentTestId: "changes-tree-panel",
});
}
export async function openChangesPanel(page: Page): Promise<void> {
await openSidePanelView(page, {
tabTestId: "workspace-tab-working_diff",
menuTestId: "workspace-new-tab-menu-changes",
contentTestId: "working-diff-panel",
await openChangesTreePanel(page);
const changedFile = page
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
.filter({ visible: true })
.first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await changedFile.click();
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
timeout: 30_000,
});
}
export async function openFilesPanel(page: Page): Promise<void> {
await openSidePanelView(page, {
tabTestId: "workspace-tab-files",
menuTestId: "workspace-new-tab-menu-files",
await openExplorerView(page, {
tabTestId: "explorer-sidebar-tab-files",
contentTestId: "file-explorer-tree-scroll",
});
}
@@ -125,26 +129,24 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
return;
}
await openSidePanelView(page, {
tabTestId: "workspace-tab-pull_request",
menuTestId: "workspace-new-tab-menu-pull-request",
contentTestId: "pr-pane",
timeout: 15_000,
});
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
await trigger.click();
await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
}
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
timeout: 30_000,
});
await expect(visibleTestId(page, "workspace-new-tab-menu-trigger").first()).toBeVisible({
await expect(visibleTestId(page, "workspace-new-tab-button").first()).toBeVisible({
timeout: 30_000,
});
}
/** Open the `+` menu in the tab row and pick "New agent". */
/** Open the pane-local `+` menu and pick Agent. */
export async function createAgentTabFromMenu(page: Page): Promise<void> {
const trigger = visibleTestId(page, "workspace-new-tab-menu-trigger").first();
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
const item = visibleTestId(page, "workspace-new-tab-menu-agent").first();
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.5.0-beta.4",
"version": "0.6.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -30,6 +30,7 @@
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
"profile:composer-typing": "node ./scripts/profile-composer-typing.mjs",
"profile:explorer-toggle": "node ./scripts/profile-explorer-toggle.mjs",
"profile:side-pane": "playwright test --config playwright.profile.config.ts side-pane-performance.spec.ts",
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
@@ -0,0 +1,388 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
const APP_SETTINGS_KEY = "@paseo:app-settings";
export interface ProfileTarget {
serverId: string;
workspaceId: string;
}
export interface RenderSummary {
id: string;
renders: number;
mounts: number;
updates: number;
actualDurationMs: number;
maximumActualDurationMs: number;
}
export interface InteractionMeasurement {
name: string;
latencyMs: number;
commits: number;
domMutations: number;
addedElements: number;
removedElements: number;
addedTestIds: string[];
removedTestIds: string[];
trackedNodes: Array<{
selector: string;
existedBefore: boolean;
connected: boolean;
sameNode: boolean;
}>;
renders: RenderSummary[];
reasons: Record<string, Record<string, number>>;
heapAfterGcBytes?: number;
}
interface BrowserMeasurementState {
startedAt: number;
selectors: string[];
originals: Array<Element | null>;
observer?: MutationObserver;
domMutations: number;
addedElements: number;
removedElements: number;
addedTestIds: Set<string>;
removedTestIds: Set<string>;
}
declare global {
interface Window {
__PASEO_SIDE_PANE_MEASUREMENT__?: BrowserMeasurementState;
}
}
function parseCliJson<T>(output: string): T {
const arrayStart = output.indexOf("[\n");
const objectStart = output.indexOf("{\n");
const start = arrayStart >= 0 ? arrayStart : objectStart;
if (start < 0) throw new Error(`Could not find JSON in CLI output: ${output}`);
return JSON.parse(output.slice(start)) as T;
}
export function resolveProfileTarget(): ProfileTarget {
const repoRoot = resolve(process.cwd(), "../..");
const serverId = (
process.env.PASEO_PROFILE_SERVER_ID ??
readFileSync(resolve(repoRoot, ".dev/paseo-home/server-id"), "utf8")
).trim();
const requestedName = process.env.PASEO_PROFILE_WORKSPACE_NAME?.trim() || "Paseo";
const requestedWorkspaceId = process.env.PASEO_PROFILE_WORKSPACE_ID?.trim();
if (requestedWorkspaceId) return { serverId, workspaceId: requestedWorkspaceId };
const output = execFileSync("npm", ["run", "cli", "--", "workspace", "ls", "--json"], {
cwd: repoRoot,
encoding: "utf8",
env: { ...process.env, FORCE_COLOR: "0" },
});
const workspaces =
parseCliJson<Array<{ workspaceId: string; name: string; cwd: string }>>(output);
const workspace = workspaces.find(
(candidate) => candidate.name === requestedName && candidate.cwd === repoRoot,
);
if (!workspace) {
throw new Error(`No Paseo workspace named ${requestedName} points at ${repoRoot}`);
}
return { serverId, workspaceId: workspace.workspaceId };
}
export async function openProfileWorkspace(page: Page, target: ProfileTarget): Promise<void> {
await page.addInitScript(() => {
const preserveRenderProfile = (original: History["pushState"]) =>
function preserveProfileParam(
this: History,
state: unknown,
unused: string,
url?: string | URL | null,
) {
if (url == null) return Reflect.apply(original, this, [state, unused, url]);
const nextUrl = new URL(String(url), location.href);
nextUrl.searchParams.set("renderProfile", "1");
return Reflect.apply(original, this, [
state,
unused,
`${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`,
]);
};
history.pushState = preserveRenderProfile(history.pushState);
history.replaceState = preserveRenderProfile(history.replaceState);
});
await page.goto("/?renderProfile=1", { waitUntil: "domcontentloaded" });
const workspaceRow = page.getByTestId(
`sidebar-workspace-row-${target.serverId}:${target.workspaceId}`,
);
await expect(workspaceRow).toBeVisible({ timeout: 60_000 });
await workspaceRow.click();
await expect(
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
).toBeVisible({
timeout: 60_000,
});
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/workspace/${target.workspaceId}\\?renderProfile=1$`));
}
export async function enableChangesOpenInSidePane(page: Page): Promise<void> {
await page.evaluate((settingsKey) => {
const persisted = JSON.parse(localStorage.getItem(settingsKey) ?? "{}") as Record<
string,
unknown
>;
const currentPreferences =
typeof persisted.openInSidePane === "object" && persisted.openInSidePane !== null
? (persisted.openInSidePane as Record<string, unknown>)
: {};
localStorage.setItem(
settingsKey,
JSON.stringify({
...persisted,
openInSidePane: { ...currentPreferences, explorerChanges: true },
}),
);
}, APP_SETTINGS_KEY);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
).toBeVisible({
timeout: 60_000,
});
}
export async function openChangesExplorer(page: Page): Promise<void> {
const explorer = page.getByTestId("workspace-explorer-sidebar");
if ((await explorer.count()) === 0) {
await page.keyboard.press("Meta+e");
}
await expect(explorer).toBeVisible({ timeout: 30_000 });
const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first();
await expect(changesTab).toBeVisible();
if ((await changesTab.getAttribute("aria-selected")) !== "true") {
await changesTab.click();
}
await expect(page.getByTestId("changes-tree-panel")).toBeVisible({ timeout: 30_000 });
await expect(changedFileRows(page).nth(1)).toBeVisible({ timeout: 30_000 });
}
export function changedFileRows(page: Page): Locator {
return page
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
.filter({ visible: true });
}
export async function selectChangedFile(page: Page, index: number): Promise<void> {
const row = changedFileRows(page).nth(index);
await expect(row).toBeVisible();
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
}
export async function expectWorkingDiffVisible(page: Page): Promise<void> {
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
timeout: 60_000,
});
await expect(page.getByTestId("git-diff-canvas").filter({ visible: true })).toBeVisible({
timeout: 60_000,
});
}
export async function closeWorkingDiffPane(page: Page): Promise<void> {
const tab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true }).first();
await expect(tab).toBeVisible();
await tab.hover();
await page
.locator('[data-testid^="workspace-working-diff-close-"]')
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toHaveCount(0);
}
export async function measureHeapAfterGc(page: Page): Promise<number> {
await page.requestGC();
return page.evaluate(() => {
const memory = performance as Performance & {
memory?: { usedJSHeapSize: number };
};
return memory.memory?.usedJSHeapSize ?? 0;
});
}
async function waitForReactIdle(page: Page, quietMs = 250): Promise<void> {
let previousCount = -1;
let unchangedSince = Date.now();
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
const count = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE__?.length ?? 0);
if (count !== previousCount) {
previousCount = count;
unchangedSince = Date.now();
} else if (Date.now() - unchangedSince >= quietMs) {
return;
}
await page.waitForTimeout(25);
}
throw new Error("React profiler did not become idle");
}
function summarizeRenders(
samples: Array<{
id: string;
phase: "mount" | "update" | "nested-update";
actualDuration: number;
}>,
): RenderSummary[] {
const summaries = new Map<string, RenderSummary>();
for (const sample of samples) {
const summary = summaries.get(sample.id) ?? {
id: sample.id,
renders: 0,
mounts: 0,
updates: 0,
actualDurationMs: 0,
maximumActualDurationMs: 0,
};
summary.renders += 1;
summary.mounts += sample.phase === "mount" ? 1 : 0;
summary.updates += sample.phase === "mount" ? 0 : 1;
summary.actualDurationMs += sample.actualDuration;
summary.maximumActualDurationMs = Math.max(
summary.maximumActualDurationMs,
sample.actualDuration,
);
summaries.set(sample.id, summary);
}
for (const summary of summaries.values()) {
summary.actualDurationMs = Math.round(summary.actualDurationMs * 100) / 100;
summary.maximumActualDurationMs = Math.round(summary.maximumActualDurationMs * 100) / 100;
}
return [...summaries.values()].sort(
(left, right) => right.actualDurationMs - left.actualDurationMs,
);
}
async function beginMeasurement(page: Page, selectors: string[]): Promise<void> {
await waitForReactIdle(page);
await page.evaluate((trackedSelectors) => {
globalThis.__PASEO_RESET_RENDER_PROFILE__?.();
const collectTestIds = (node: Node, output: Set<string>) => {
if (!(node instanceof Element)) return;
const ownTestId = node.getAttribute("data-testid");
if (ownTestId) output.add(ownTestId);
for (const element of node.querySelectorAll("[data-testid]")) {
const testId = element.getAttribute("data-testid");
if (testId) output.add(testId);
}
};
const state: BrowserMeasurementState = {
startedAt: performance.now(),
selectors: trackedSelectors,
originals: trackedSelectors.map((selector) => document.querySelector(selector)),
domMutations: 0,
addedElements: 0,
removedElements: 0,
addedTestIds: new Set(),
removedTestIds: new Set(),
};
state.observer = new MutationObserver((records) => {
state.domMutations += records.length;
for (const record of records) {
for (const node of record.addedNodes) {
state.addedElements +=
node instanceof Element ? 1 + node.querySelectorAll("*").length : 0;
collectTestIds(node, state.addedTestIds);
}
for (const node of record.removedNodes) {
state.removedElements +=
node instanceof Element ? 1 + node.querySelectorAll("*").length : 0;
collectTestIds(node, state.removedTestIds);
}
}
});
state.observer.observe(document.body, { childList: true, subtree: true });
window.__PASEO_SIDE_PANE_MEASUREMENT__ = state;
}, selectors);
}
async function finishMeasurement(page: Page, name: string): Promise<InteractionMeasurement> {
await page.evaluate(
() =>
new Promise<void>((resolveFrame) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame()));
}),
);
await waitForReactIdle(page);
const raw = await page.evaluate((measurementName) => {
const state = window.__PASEO_SIDE_PANE_MEASUREMENT__;
if (!state) throw new Error("Side-pane performance measurement was not started");
state.observer?.disconnect();
const samples = globalThis.__PASEO_RENDER_PROFILE__ ?? [];
return {
name: measurementName,
latencyMs: Math.round((performance.now() - state.startedAt) * 100) / 100,
commits: new Set(samples.map((sample) => sample.commitTime)).size,
domMutations: state.domMutations,
addedElements: state.addedElements,
removedElements: state.removedElements,
addedTestIds: [...state.addedTestIds].sort(),
removedTestIds: [...state.removedTestIds].sort(),
trackedNodes: state.selectors.map((selector, index) => {
const original = state.originals[index] ?? null;
const current = document.querySelector(selector);
return {
selector,
existedBefore: original !== null,
connected: original?.isConnected ?? false,
sameNode: original !== null && original === current,
};
}),
samples,
reasons: globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {},
};
}, name);
return {
...raw,
renders: summarizeRenders(raw.samples),
};
}
export async function measureInteraction(
page: Page,
input: {
name: string;
trackedSelectors: string[];
interact: () => Promise<void>;
expectComplete: () => Promise<void>;
},
): Promise<InteractionMeasurement> {
await beginMeasurement(page, input.trackedSelectors);
await input.interact();
await input.expectComplete();
return finishMeasurement(page, input.name);
}
export function expectTrackedNodePreserved(
measurement: InteractionMeasurement,
selector: string,
): void {
const tracked = measurement.trackedNodes.find((candidate) => candidate.selector === selector);
expect.soft(tracked).toEqual({
selector,
existedBefore: true,
connected: true,
sameNode: true,
});
}
export function expectProfilerDidNotMount(
measurement: InteractionMeasurement,
idPrefix: string,
): void {
const summaries = measurement.renders.filter((summary) => summary.id.startsWith(idPrefix));
expect.soft(summaries.length).toBeGreaterThan(0);
expect.soft(summaries.every((summary) => summary.mounts === 0)).toBe(true);
}
@@ -0,0 +1,119 @@
import { test, expect } from "@playwright/test";
import {
changedFileRows,
closeWorkingDiffPane,
enableChangesOpenInSidePane,
expectProfilerDidNotMount,
expectTrackedNodePreserved,
expectWorkingDiffVisible,
measureInteraction,
measureHeapAfterGc,
openChangesExplorer,
openProfileWorkspace,
resolveProfileTarget,
selectChangedFile,
type InteractionMeasurement,
} from "./helpers/side-pane-performance";
const MAIN_PANE = '[data-testid="workspace-pane-main"]';
const WORKING_DIFF = '[data-testid="working-diff-panel"]';
const DIFF_CANVAS = '[data-testid="git-diff-canvas-root"]';
const DIFF_TAB = '[data-testid="workspace-tab-working_diff"]';
test("side-pane diff transitions preserve mounted work and report their real React cost", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const target = resolveProfileTarget();
const reports: InteractionMeasurement[] = [];
await test.step("open the real main workspace with Changes routed beside it", async () => {
await openProfileWorkspace(page, target);
await enableChangesOpenInSidePane(page);
await openChangesExplorer(page);
expect(await changedFileRows(page).count()).toBeGreaterThanOrEqual(2);
});
await test.step("creating the side pane keeps the main pane mounted", async () => {
const report = await measureInteraction(page, {
name: "create-side-pane",
trackedSelectors: [MAIN_PANE],
interact: () => selectChangedFile(page, 0),
expectComplete: () => expectWorkingDiffVisible(page),
});
reports.push(report);
await testInfo.attach(report.name, {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
expectTrackedNodePreserved(report, MAIN_PANE);
});
await test.step("changing the selected diff file updates the existing diff in place", async () => {
const report = await measureInteraction(page, {
name: "change-diff-focus",
trackedSelectors: [MAIN_PANE, WORKING_DIFF, DIFF_CANVAS, DIFF_TAB],
interact: () => selectChangedFile(page, 1),
expectComplete: () => expectWorkingDiffVisible(page),
});
reports.push(report);
await testInfo.attach(report.name, {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
expectTrackedNodePreserved(report, MAIN_PANE);
expectTrackedNodePreserved(report, WORKING_DIFF);
expectTrackedNodePreserved(report, DIFF_CANVAS);
expectTrackedNodePreserved(report, DIFF_TAB);
expectProfilerDidNotMount(report, "WorkingDiffPanel:");
});
await test.step("repeated cold reopenings measure retained workspace work", async () => {
for (let index = 0; index < 5; index += 1) {
await closeWorkingDiffPane(page);
const report = await measureInteraction(page, {
name: `reopen-side-pane-${index + 1}`,
trackedSelectors: [MAIN_PANE],
interact: () => selectChangedFile(page, index % 2),
expectComplete: () => expectWorkingDiffVisible(page),
});
report.heapAfterGcBytes = await measureHeapAfterGc(page);
reports.push(report);
await testInfo.attach(report.name, {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
expectTrackedNodePreserved(report, MAIN_PANE);
expect(
report.renders.some(
(summary) => summary.id.startsWith("WorkingDiffPanel:") && summary.mounts > 0,
),
).toBe(true);
}
});
const artifact = {
appUrl: testInfo.project.use.baseURL,
target,
reports,
};
await testInfo.attach("side-pane-performance", {
body: JSON.stringify(artifact, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Side pane: ${JSON.stringify(
reports.map((report) => ({
name: report.name,
latencyMs: report.latencyMs,
commits: report.commits,
domMutations: report.domMutations,
addedElements: report.addedElements,
removedElements: report.removedElements,
heapAfterGcBytes: report.heapAfterGcBytes,
trackedNodes: report.trackedNodes,
renders: report.renders.slice(0, 8),
})),
)}`,
);
});
+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",
},
});
@@ -1,7 +1,6 @@
import { withUnistyles } from "react-native-unistyles";
import {
BookOpen,
Bot,
Boxes,
Brain,
Bug,
@@ -27,6 +26,7 @@ import {
Server,
Shield,
Sparkles,
Star,
Terminal,
TestTube,
Wrench,
@@ -42,7 +42,7 @@ import {
} from "./profile-appearance";
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
const ThemedDefaultIcon = withUnistyles(Bot);
const ThemedDefaultIcon = withUnistyles(Star);
/**
* `withUnistyles` has to wrap each icon once at module scope, so the registry
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import { layoutStream } from "./layout";
import type { StreamStrategy } from "./strategy";
function strategyFor(platform: "web" | "native"): StreamStrategy {
const inverted = platform === "native";
const neighborStep = inverted ? 1 : -1;
const getHistoryBoundaryIndex = (items: StreamItem[]) => {
if (items.length === 0) return null;
return inverted ? 0 : items.length - 1;
};
const getLiveHeadBoundaryIndex = (items: StreamItem[]) => {
if (items.length === 0) return null;
return inverted ? items.length - 1 : 0;
};
return {
orderTail: (items) => (inverted ? [...items].toReversed() : items),
orderHead: (items) => (inverted ? [...items].toReversed() : items),
getNeighborIndex: (index, relation) =>
relation === "above" ? index + neighborStep : index - neighborStep,
getNeighborItem: (items, index, relation) => {
const neighborIndex = relation === "above" ? index + neighborStep : index - neighborStep;
return items[neighborIndex];
},
getHistoryLiveBoundaryIndex: getHistoryBoundaryIndex,
getLiveHeadHistoryBoundaryIndex: getLiveHeadBoundaryIndex,
getLatestItemIndex: getHistoryBoundaryIndex,
getFrameChildOrder: () => (inverted ? "footer-then-content" : "content-then-footer"),
} as StreamStrategy;
}
function userMessage(id: string): Extract<StreamItem, { kind: "user_message" }> {
return {
kind: "user_message",
id,
text: id,
timestamp: new Date("2026-08-22T10:00:00.000Z"),
};
}
function assistantMessage(id: string): Extract<StreamItem, { kind: "assistant_message" }> {
return {
kind: "assistant_message",
id,
text: id,
timestamp: new Date("2026-08-22T10:00:01.000Z"),
};
}
function assistantBlock(
id: string,
blockIndex: number,
): Extract<StreamItem, { kind: "assistant_message" }> {
return {
...assistantMessage(id),
blockGroupId: "response",
blockIndex,
};
}
function timingFor(assistantId: string): Map<string, TurnTiming> {
return new Map([
[
assistantId,
{
startedAt: new Date("2026-08-22T10:00:00.000Z"),
completedAt: new Date("2026-08-22T10:00:02.000Z"),
durationMs: 2_000,
},
],
]);
}
function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean }) {
const strategy = strategyFor(input.platform);
const assistant = assistantMessage("assistant");
return {
assistant,
layout: layoutStream({
strategy,
isTurnActive: input.isTurnActive,
history: strategy.orderTail([userMessage("user"), assistant]),
liveHead: [],
timingByAssistantId: timingFor(assistant.id),
}),
};
}
describe("turn footer spacing", () => {
it.each(["web", "native"] as const)(
"compacts the last assistant against the completed footer on %s",
(platform) => {
const { assistant, layout } = layoutFor({ platform, isTurnActive: false });
const assistantLayout = layout.history.find((item) => item.item.id === assistant.id);
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
expect(assistantLayout?.assistantSpacing).toBe("compactBottom");
},
);
it.each(["web", "native"] as const)(
"keeps the same compact edge while the footer is running on %s",
(platform) => {
const { assistant, layout } = layoutFor({ platform, isTurnActive: true });
const assistantLayout = layout.history.find((item) => item.item.id === assistant.id);
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(assistantLayout?.assistantSpacing).toBe("compactBottom");
},
);
it.each([
["web", false],
["web", true],
["native", false],
["native", true],
] as const)(
"compacts both edges of the final split paragraph on %s when active=%s",
(platform, isTurnActive) => {
const strategy = strategyFor(platform);
const first = assistantBlock("first", 0);
const last = assistantBlock("last", 1);
const layout = layoutStream({
strategy,
isTurnActive,
history: strategy.orderTail([userMessage("user"), first]),
liveHead: strategy.orderHead([last]),
timingByAssistantId: timingFor(last.id),
});
const firstLayout = layout.history.find((item) => item.item.id === first.id);
const lastLayout = layout.liveHead.find((item) => item.item.id === last.id);
expect(firstLayout?.assistantSpacing).toBe("compactBottom");
expect(lastLayout?.assistantSpacing).toBe("compactBoth");
},
);
});
+1 -1
View File
@@ -330,7 +330,7 @@ describe("layoutStream", () => {
});
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom");
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactTop");
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactBoth");
});
it.each(["web", "android"] as const)(
+11 -5
View File
@@ -49,6 +49,7 @@ interface LayoutSegmentInput {
items: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
hasAuxiliaryFooter: boolean;
frameOrder: StreamFrameChildOrder;
boundaryIndex: number | null;
boundaryAboveItem: StreamItem | null;
@@ -249,11 +250,6 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryBelowItem,
});
const assistantSpacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
});
const completedFooter = resolveCompletedFooter({
strategy: input.strategy,
items: input.items,
@@ -265,6 +261,12 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
const assistantSpacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
});
return {
item,
@@ -294,6 +296,7 @@ const historyLayoutCache = new WeakMap<StreamItem[], Map<string, StreamLayoutIte
export function layoutStream(input: StreamLayoutInput): StreamLayout {
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
const hasAuxiliaryFooter = input.isTurnActive || auxiliaryTurnFooter !== null;
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
const liveHeadBoundaryIndex = input.strategy.getLiveHeadHistoryBoundaryIndex(input.liveHead);
const historyBoundaryItem =
@@ -313,6 +316,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
liveHeadBoundaryItem?.kind ?? "null",
liveHeadBoundaryItem?.turnId ?? "null",
auxiliaryTurnFooter?.itemId ?? "null",
hasAuxiliaryFooter ? "footer" : "no-footer",
].join(":");
let byKey = historyLayoutCache.get(input.history);
if (!byKey) {
@@ -328,6 +332,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
items: input.history,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder,
boundaryIndex: historyBoundaryIndex,
boundaryAboveItem: null,
@@ -347,6 +352,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
items: input.liveHead,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder,
boundaryIndex: liveHeadBoundaryIndex,
boundaryAboveItem: historyBoundaryItem,
@@ -74,6 +74,18 @@ describe("getAssistantBlockSpacing", () => {
expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default");
});
it("compacts the bottom edge when the turn footer follows the assistant block", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
expect(
getAssistantBlockSpacing({
item: a,
aboveItem: null,
belowItem: null,
hasFooterBelow: true,
}),
).toBe("compactBottom");
});
it("returns compactTop when the item above is in the same block group", () => {
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
+7 -1
View File
@@ -17,12 +17,15 @@ export function getAssistantBlockSpacing(params: {
item: StreamItem;
aboveItem: StreamItem | null | undefined;
belowItem: StreamItem | null | undefined;
hasFooterBelow?: boolean;
}): "default" | "compactTop" | "compactBottom" | "compactBoth" {
if (params.item.kind !== "assistant_message") {
return "default";
}
const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem });
const compactBottom = isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
const compactBottom =
params.hasFooterBelow ||
isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
if (compactTop && compactBottom) return "compactBoth";
if (compactTop) return "compactTop";
if (compactBottom) return "compactBottom";
@@ -44,6 +47,9 @@ export function getGapBetweenStreamItems(
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
return SPACING[1];
}
if (item.kind === "user_message" && belowItem.kind === "assistant_message") {
return 0;
}
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return 0;
}
+12 -6
View File
@@ -15,7 +15,10 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
import { estimateStreamItemHeight } from "./web-virtualization";
import {
estimateStreamItemHeight,
shouldAdjustScrollForVirtualRowResize,
} from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy } from "./strategy";
import {
@@ -370,14 +373,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
overscan: 8,
});
useEffect(() => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
if (historyStartPrependAnchorActiveRef.current) {
return false;
}
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
const viewportHeight = instance.scrollRect?.height ?? 0;
const scrollOffset = instance.scrollOffset ?? 0;
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
return shouldAdjustScrollForVirtualRowResize({
isHistoryStartPrependActive: historyStartPrependAnchorActiveRef.current,
rowStart: item.start,
scrollOffset,
remainingDistanceFromBottom: remainingDistance,
bottomThreshold: AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
});
};
return () => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
@@ -1,108 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("react-native", () => ({
Platform: {
OS: "web",
select: (options: { web?: unknown; default?: unknown }) => options.web ?? options.default,
},
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => (
<div data-testid={testID}>{children}</div>
),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function"
? (factory as (theme: Record<string, unknown>) => unknown)({
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 },
colors: {
foregroundMuted: "#aaa",
palette: { amber: { 500: "#f0b429", 700: "#b7791f" } },
},
})
: factory,
},
useUnistyles: () => ({ rt: { breakpoint: "md" } }),
withUnistyles: <T,>(component: T) => component,
}));
vi.mock("@/components/message", () => ({
AssistantTurnFooter: () => null,
LiveElapsed: () => <span data-testid="running-turn-timestamp" />,
STREAM_METADATA_FONT_SIZE: 11,
}));
vi.mock("@/components/assistant-fork-menu", () => ({
AssistantForkMenu: () => <button data-testid="running-turn-fork" type="button" />,
}));
vi.mock("@/components/synced-loader", () => ({
SyncedLoader: () => <span data-testid="running-turn-loader" />,
}));
vi.mock("@/components/retained-panel", () => ({
useRetainedPanelActive: () => true,
}));
import { TurnFooter } from "./turn-footer";
const unusedRunningTurnStrategy = null as unknown as React.ComponentProps<
typeof TurnFooter
>["strategy"];
describe("TurnFooter", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
beforeEach(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
});
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
});
it("places the running-turn fork between the loader and timestamp", () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<TurnFooter
isRunning
inFlightTurnStartedAt={new Date("2026-08-01T10:00:00.000Z")}
host={null}
strategy={unusedRunningTurnStrategy}
supportsTimelineCursor
onForkInFlightTurn={vi.fn()}
/>,
);
});
const footer = container.querySelector('[data-testid="turn-working-indicator"]');
const controls = Array.from(footer?.querySelectorAll("[data-testid]") ?? []).map((node) =>
node.getAttribute("data-testid"),
);
expect(controls).toEqual([
"running-turn-loader",
"running-turn-fork",
"running-turn-timestamp",
]);
});
});
@@ -23,7 +23,7 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[6];
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[8];
export type TurnContentStrategy = StreamStrategy;
export type AssistantTurnForkHandler = (input: {
@@ -220,7 +220,7 @@ const stylesheet = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
},
turnFooterRow: {
marginTop: theme.spacing[4],
marginTop: theme.spacing[2] + 5,
},
turnFooterSlot: {
flexDirection: "row",
+18 -5
View File
@@ -27,7 +27,7 @@ import { useMutation } from "@tanstack/react-query";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, ChevronDown, X } from "lucide-react-native";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openSidePanelView } from "@/workspace-tabs/side-panel";
import { openExplorerSidebarView } from "@/workspace-tabs/explorer-sidebar";
import {
AssistantMessage,
SpeakMessage,
@@ -260,6 +260,8 @@ export interface AgentStreamViewProps {
isAuthoritativeHistoryReady?: boolean;
/** Tail space required by a transparent overlay rendered at the bottom edge. */
bottomOverlayTailClearance?: number;
/** Bottom offset required for controls floating above that overlay. */
bottomOverlayControlClearance?: number;
toast?: ToastApi | null;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
readOnly?: boolean;
@@ -295,6 +297,10 @@ function useRetainedValue<T>(value: T, active: boolean): T {
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
function resolveBottomOverlayControlOffset(clearance: number | undefined): number {
return Math.max(16, clearance ?? 0);
}
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView(
{
@@ -309,6 +315,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
bottomOverlayTailClearance = 0,
bottomOverlayControlClearance,
toast,
onOpenWorkspaceFile,
readOnly = false,
@@ -450,7 +457,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setCurrentPath: false,
});
openSidePanelView({
openExplorerSidebarView({
isCompact: isMobile,
workspaceKey: buildWorkspaceTabPersistenceKey({
serverId: resolvedServerId,
@@ -467,7 +474,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const handleToolCallOpenFile = useStableEvent((filePath: string) => {
handleInlinePathPress({ raw: filePath, path: filePath }, "side");
handleInlinePathPress({ raw: filePath, path: filePath }, "preferred");
});
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
@@ -934,6 +941,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const scrollToBottomContainerStyle = useMemo(
() => [
stylesheet.scrollToBottomContainer,
{ bottom: resolveBottomOverlayControlOffset(bottomOverlayControlClearance) },
],
[bottomOverlayControlClearance],
);
const listEmptyComponent = useMemo(
() =>
renderListEmptyComponent({
@@ -1065,7 +1079,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
onJumpToPrompt={chatOutline.jumpToPrompt}
/>
{(!isNearBottom || isTimelineDetached) && (
<View style={stylesheet.scrollToBottomContainer} pointerEvents="box-none">
<View style={scrollToBottomContainerStyle} pointerEvents="box-none">
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
<Pressable
style={stylesheet.scrollToBottomButton}
@@ -1574,7 +1588,6 @@ const stylesheet = StyleSheet.create((theme) => ({
},
scrollToBottomContainer: {
position: "absolute",
bottom: 16,
left: 0,
right: 0,
alignItems: "center",
@@ -11,6 +11,7 @@ import {
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
shouldAdjustScrollForVirtualRowResize,
splitWebVirtualizedHistory,
type IndexedStreamItem,
} from "./web-virtualization";
@@ -168,6 +169,55 @@ describe("estimateStreamItemHeight", () => {
});
});
describe("virtual row resize anchoring", () => {
it("does not move the viewport when a visible row expands while detached", () => {
expect(
shouldAdjustScrollForVirtualRowResize({
isHistoryStartPrependActive: false,
rowStart: 1200,
scrollOffset: 1000,
remainingDistanceFromBottom: 5000,
bottomThreshold: 64,
}),
).toBe(false);
});
it("keeps the reading position when a row above the viewport changes size", () => {
expect(
shouldAdjustScrollForVirtualRowResize({
isHistoryStartPrependActive: false,
rowStart: 800,
scrollOffset: 1000,
remainingDistanceFromBottom: 5000,
bottomThreshold: 64,
}),
).toBe(true);
});
it("leaves history prepend and bottom following to their existing anchors", () => {
const baseInput = {
rowStart: 800,
scrollOffset: 1000,
remainingDistanceFromBottom: 5000,
bottomThreshold: 64,
};
expect(
shouldAdjustScrollForVirtualRowResize({
...baseInput,
isHistoryStartPrependActive: true,
}),
).toBe(false);
expect(
shouldAdjustScrollForVirtualRowResize({
...baseInput,
isHistoryStartPrependActive: false,
remainingDistanceFromBottom: 64,
}),
).toBe(false);
});
});
describe("web virtualization test overrides", () => {
it("uses defaults unless explicit positive integer overrides are present", () => {
const globalWithOverrides = globalThis as typeof globalThis & {
@@ -10,6 +10,21 @@ export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
export function shouldAdjustScrollForVirtualRowResize(input: {
isHistoryStartPrependActive: boolean;
rowStart: number;
scrollOffset: number;
remainingDistanceFromBottom: number;
bottomThreshold: number;
}): boolean {
if (input.isHistoryStartPrependActive) {
return false;
}
return (
input.remainingDistanceFromBottom > input.bottomThreshold && input.rowStart < input.scrollOffset
);
}
type BottomAnchorE2ETestGlobals = typeof globalThis & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
};
+26 -9
View File
@@ -36,6 +36,7 @@ import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { LeftSidebar } from "@/components/left-sidebar";
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
@@ -76,7 +77,7 @@ import { AgentNavigationListener } from "@/desktop/agent-navigation";
import { LegacyAgentSkillsMigration } from "@/agent-skills/legacy-migration";
import { legacyFavoriteProfileMigration } from "@/agent-profiles/migration";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { updateDesktopWindowChrome } from "@/desktop/electron/window";
import { getDesktopHost } from "@/desktop/host";
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
@@ -86,6 +87,7 @@ import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-ac
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
import { KeyboardShiftProvider } from "@/hooks/use-keyboard-shift-style";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
@@ -473,6 +475,10 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
}, [settings.theme, updateSettings]);
const isCompactLayout = useIsCompactFormFactor();
const explorerSidebarPresentation = resolveExplorerSidebarPresentation({
isCompact: isCompactLayout,
});
const usesCompactExplorerHost = explorerSidebarPresentation !== "pane";
useCompactWebViewportZoomLock(isCompactLayout);
const pathname = usePathname();
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
@@ -545,8 +551,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
{sidebarChrome}
</WindowChromeRegion>
) : null}
{isCompactLayout ? (
<CompactExplorerSidebarHost enabled={chromeEnabled}>
{usesCompactExplorerHost ? (
<CompactExplorerSidebarHost
enabled={chromeEnabled}
presentation={explorerSidebarPresentation === "dock" ? "dock" : "overlay"}
>
<WindowChromeRegion corners={chromeEnabled ? "both" : appChromeLayout.contentCorners}>
<View style={flexStyle}>{children}</View>
</WindowChromeRegion>
@@ -574,6 +583,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
</WindowChromeSafeArea>
</WindowChromeRegion>
) : null}
<DesktopWindowControls />
<FloatingPanelPortalHost />
{isCompactLayout ? sidebarChrome : null}
<DownloadToast />
@@ -665,18 +675,16 @@ function DesktopWindowControlsSync() {
const { isLoading } = useAppSettings();
const { theme } = useUnistyles();
const surface0 = theme.colors.surface0;
const foreground = theme.colors.foreground;
useEffect(() => {
if (isLoading || isNative) return;
void updateDesktopWindowControls({
void updateDesktopWindowChrome({
backgroundColor: surface0,
foregroundColor: foreground,
trafficLightOffsetY: -5,
trafficLightOffsetY: -4,
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [isLoading, surface0, foreground]);
}, [isLoading, surface0]);
return null;
}
@@ -953,9 +961,18 @@ function RootProviders({ children }: { children: ReactNode }) {
);
}
function recordUserActivity(): void {
getHostRuntimeStore().recordUserActivity();
}
function RootAppTree() {
return (
<GestureHandlerRootView style={flexStyle}>
<GestureHandlerRootView
style={flexStyle}
onTouchStart={recordUserActivity}
onTouchEnd={recordUserActivity}
onTouchCancel={recordUserActivity}
>
<View style={layoutStyles.surfaceFill}>
<RootProviders>
<RuntimeProviders>
@@ -176,7 +176,7 @@ describe("useFileLink", () => {
lineStart: undefined,
lineEnd: undefined,
},
disposition: "side",
disposition: "preferred",
},
]);
});
@@ -117,7 +117,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
});
const onPress = useStableEvent(() => {
open(stableSource, "side");
open(stableSource, "preferred");
});
const target = useMemo(() => {
@@ -36,10 +36,40 @@ function source(gitActions: GitActions): {
newBrowser: "New browser",
splitRight: "Split pane right",
splitDown: "Split pane down",
changes: "Changes",
files: "Files",
pullRequest: "Pull request",
openPanel: (name, placement) => `Open ${name} ${placement}`,
previousTab: "Previous tab",
nextTab: "Next tab",
closeCurrentTab: "Close current tab",
renameTab: "Rename",
reloadAgent: "Reload agent",
copyResumeCommand: "Copy resume command",
copyAgentId: "Copy agent id",
copyTerminalId: "Copy terminal id",
copyFilePath: "Copy file path",
closeTabsLeft: "Close tabs left",
closeTabsRight: "Close tabs right",
closeOtherTabs: "Close other tabs",
focusPaneLeft: "Focus pane left",
focusPaneRight: "Focus pane right",
focusPaneUp: "Focus pane up",
focusPaneDown: "Focus pane down",
moveTabLeft: "Move tab left",
moveTabRight: "Move tab right",
moveTabUp: "Move tab up",
moveTabDown: "Move tab down",
closePane: "Close pane",
toggleFocusMode: "Toggle focus mode",
toggleExplorerSidebar: "Toggle side panel",
},
icons: {},
shortcuts: {},
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true },
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
activeTabKind: null,
activeTabIndex: -1,
activeTabCount: 0,
dispatch: (action) => dispatched.push(action),
runGitAction: (action) => runGitActions.push(action),
},
@@ -88,23 +118,43 @@ describe("workspace command center contributions", () => {
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map(({ id, rank, visibility }) => ({ id, rank, visibility }))).toEqual([
{ id: "tab:new-agent", rank: 0, visibility: "always" },
{ id: "git:commit", rank: 1, visibility: "always" },
{ id: "tab:new-terminal", rank: 2, visibility: "query" },
{ id: "tab:new-browser", rank: 3, visibility: "query" },
{ id: "pane:split-right", rank: 4, visibility: "query" },
{ id: "pane:split-down", rank: 5, visibility: "query" },
expect(
contributions
.filter((item) =>
[
"tab:new-agent",
"git:commit",
"tab:new-terminal",
"tab:new-browser",
"pane:split-right",
"pane:split-down",
].includes(item.id),
)
.map(({ id, visibility }) => ({ id, visibility })),
).toEqual([
{ id: "tab:new-agent", visibility: "always" },
{ id: "git:commit", visibility: "always" },
{ id: "tab:new-terminal", visibility: "query" },
{ id: "tab:new-browser", visibility: "query" },
{ id: "pane:split-right", visibility: "query" },
{ id: "pane:split-down", visibility: "query" },
]);
});
it("omits browser and split actions when their existing capabilities are unavailable", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.capabilities = { canSplitPanes: false, canOpenBrowserTabs: false };
fixture.value.capabilities = {
canSplitPanes: false,
canOpenBrowserTabs: false,
isGit: false,
};
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map((item) => item.id)).toEqual(["tab:new-agent", "tab:new-terminal"]);
expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(false);
expect(contributions.some((item) => item.id.startsWith("pane:"))).toBe(false);
});
it("dispatches every tab and pane command to the workspace scope", () => {
@@ -113,13 +163,8 @@ describe("workspace command center contributions", () => {
for (const contribution of contributions) contribution.run();
expect(fixture.dispatched).toEqual([
{ id: "workspace.agent.new", scope: "workspace" },
{ id: "workspace.terminal.new", scope: "workspace" },
{ id: "workspace.browser.new", scope: "workspace" },
{ id: "workspace.pane.split.right", scope: "workspace" },
{ id: "workspace.pane.split.down", scope: "workspace" },
]);
expect(fixture.dispatched.length).toBeGreaterThan(5);
expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
});
it("keeps workspace creation commands available outside Git", () => {
@@ -127,13 +172,11 @@ describe("workspace command center contributions", () => {
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map((item) => item.id)).toEqual([
"tab:new-agent",
"tab:new-terminal",
"tab:new-browser",
"pane:split-right",
"pane:split-down",
]);
expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(true);
expect(contributions.some((item) => item.id === "pane:split-right")).toBe(true);
expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
});
});
@@ -1,5 +1,10 @@
import type { GitAction, GitActions } from "@/git/policy";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type {
WorkspacePanelPlacement,
WorkspacePanelTarget,
} from "@/keyboard/keyboard-action-dispatcher";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { ShortcutKey } from "@/utils/format-shortcut";
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
@@ -10,6 +15,33 @@ export interface WorkspaceCommandCenterLabels {
newBrowser: string;
splitRight: string;
splitDown: string;
changes: string;
files: string;
pullRequest: string;
openPanel(name: string, placement: WorkspacePanelPlacement): string;
previousTab: string;
nextTab: string;
closeCurrentTab: string;
renameTab: string;
reloadAgent: string;
copyResumeCommand: string;
copyAgentId: string;
copyTerminalId: string;
copyFilePath: string;
closeTabsLeft: string;
closeTabsRight: string;
closeOtherTabs: string;
focusPaneLeft: string;
focusPaneRight: string;
focusPaneUp: string;
focusPaneDown: string;
moveTabLeft: string;
moveTabRight: string;
moveTabUp: string;
moveTabDown: string;
closePane: string;
toggleFocusMode: string;
toggleExplorerSidebar: string;
}
export interface WorkspaceCommandCenterIcons {
@@ -18,6 +50,19 @@ export interface WorkspaceCommandCenterIcons {
newBrowser?: CommandCenterIcon;
splitRight?: CommandCenterIcon;
splitDown?: CommandCenterIcon;
changes?: CommandCenterIcon;
files?: CommandCenterIcon;
pullRequest?: CommandCenterIcon;
previousTab?: CommandCenterIcon;
nextTab?: CommandCenterIcon;
close?: CommandCenterIcon;
rename?: CommandCenterIcon;
reload?: CommandCenterIcon;
copy?: CommandCenterIcon;
focusPane?: CommandCenterIcon;
moveTab?: CommandCenterIcon;
focusMode?: CommandCenterIcon;
explorerSidebar?: CommandCenterIcon;
git?(action: GitAction): CommandCenterIcon | undefined;
}
@@ -27,6 +72,12 @@ export interface WorkspaceCommandCenterShortcuts {
splitRight?: ShortcutKey[][];
splitDown?: ShortcutKey[][];
archiveWorkspace?: ShortcutKey[][];
previousTab?: ShortcutKey[][];
nextTab?: ShortcutKey[][];
closeCurrentTab?: ShortcutKey[][];
closePane?: ShortcutKey[][];
toggleFocusMode?: ShortcutKey[][];
toggleExplorerSidebar?: ShortcutKey[][];
}
export interface WorkspaceCommandCenterSource {
@@ -37,7 +88,11 @@ export interface WorkspaceCommandCenterSource {
capabilities: {
canSplitPanes: boolean;
canOpenBrowserTabs: boolean;
isGit: boolean;
};
activeTabKind: WorkspaceTabTarget["kind"] | null;
activeTabIndex: number;
activeTabCount: number;
dispatch(action: KeyboardActionDefinition): void;
runGitAction(action: GitAction): void;
}
@@ -96,7 +151,315 @@ function buildWorkspaceAction(input: {
};
}
export function buildWorkspaceCommandCenterContributions(
type QueryActionInput = Omit<Parameters<typeof buildWorkspaceAction>[0], "source" | "visibility">;
function buildQueryAction(
source: WorkspaceCommandCenterSource,
input: QueryActionInput,
): CommandCenterContribution {
return buildWorkspaceAction({ ...input, source, visibility: "query" });
}
function buildPanelContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [];
const panelTargets: Array<{
target: WorkspacePanelTarget;
name: string;
icon?: CommandCenterIcon;
}> = [
{ target: "changes", name: source.labels.changes, icon: source.icons.changes },
{ target: "files", name: source.labels.files, icon: source.icons.files },
{
target: "pull-request",
name: source.labels.pullRequest,
icon: source.icons.pullRequest,
},
];
for (const [index, panel] of panelTargets.entries()) {
if (panel.target !== "files" && !source.capabilities.isGit) continue;
contributions.push(
buildQueryAction(source, {
id: `tab:open:${panel.target}`,
rank: 4 + index,
title: source.labels.openPanel(panel.name, "supporting"),
keywords: ["open", panel.target, "tab", "supporting"],
icon: panel.icon,
action: {
id: "workspace.tab.open",
scope: "workspace",
target: panel.target,
placement: "supporting",
},
}),
);
if (!source.capabilities.canSplitPanes) continue;
if (panel.target !== "pull-request") continue;
const placements = ["side-pane", "focused-pane"] as const;
for (const [placementIndex, placement] of placements.entries()) {
contributions.push(
buildQueryAction(source, {
id: `tab:open:${panel.target}:${placement}`,
rank: 10 + index * 2 + placementIndex,
title: source.labels.openPanel(panel.name, placement),
keywords: ["open", panel.target, "tab", placement, "pane"],
icon: panel.icon,
action: {
id: "workspace.tab.open",
scope: "workspace",
target: panel.target,
placement,
},
}),
);
}
}
return contributions;
}
function buildActiveTabContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
if (source.activeTabCount === 0) return [];
const contributions: CommandCenterContribution[] = [
buildQueryAction(source, {
id: "tab:previous",
rank: 30,
title: source.labels.previousTab,
keywords: ["tab", "previous", "switch", "navigate"],
icon: source.icons.previousTab,
shortcutKeys: source.shortcuts.previousTab,
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: -1 },
}),
buildQueryAction(source, {
id: "tab:next",
rank: 31,
title: source.labels.nextTab,
keywords: ["tab", "next", "switch", "navigate"],
icon: source.icons.nextTab,
shortcutKeys: source.shortcuts.nextTab,
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: 1 },
}),
buildQueryAction(source, {
id: "tab:close-current",
rank: 32,
title: source.labels.closeCurrentTab,
keywords: ["tab", "close", "current"],
icon: source.icons.close,
shortcutKeys: source.shortcuts.closeCurrentTab,
action: { id: "workspace.tab.close-current", scope: "workspace" },
}),
];
if (source.activeTabKind === "agent" || source.activeTabKind === "terminal") {
contributions.push(
buildQueryAction(source, {
id: "tab:rename-current",
rank: 33,
title: source.labels.renameTab,
keywords: ["tab", "rename", "current"],
icon: source.icons.rename,
action: { id: "workspace.tab.rename-current", scope: "workspace" },
}),
buildQueryAction(source, {
id: "tab:copy-id",
rank: 34,
title:
source.activeTabKind === "agent"
? source.labels.copyAgentId
: source.labels.copyTerminalId,
keywords: ["tab", "copy", "id"],
icon: source.icons.copy,
action: { id: "workspace.tab.copy-id", scope: "workspace" },
}),
);
}
if (source.activeTabKind === "agent") {
contributions.push(
buildQueryAction(source, {
id: "tab:reload-current",
rank: 35,
title: source.labels.reloadAgent,
keywords: ["tab", "agent", "reload"],
icon: source.icons.reload,
action: { id: "workspace.tab.reload-current", scope: "workspace" },
}),
buildQueryAction(source, {
id: "tab:copy-resume-command",
rank: 36,
title: source.labels.copyResumeCommand,
keywords: ["tab", "agent", "copy", "resume", "command"],
icon: source.icons.copy,
action: { id: "workspace.tab.copy-resume-command", scope: "workspace" },
}),
);
}
if (source.activeTabKind === "file") {
contributions.push(
buildQueryAction(source, {
id: "tab:copy-file-path",
rank: 37,
title: source.labels.copyFilePath,
keywords: ["tab", "file", "copy", "path"],
icon: source.icons.copy,
action: { id: "workspace.tab.copy-file-path", scope: "workspace" },
}),
);
}
if (source.activeTabIndex > 0) {
contributions.push(
buildQueryAction(source, {
id: "tab:close-left",
rank: 38,
title: source.labels.closeTabsLeft,
keywords: ["tab", "close", "left"],
icon: source.icons.close,
action: { id: "workspace.tab.close-left", scope: "workspace" },
}),
);
}
if (source.activeTabIndex >= 0 && source.activeTabIndex < source.activeTabCount - 1) {
contributions.push(
buildQueryAction(source, {
id: "tab:close-right",
rank: 39,
title: source.labels.closeTabsRight,
keywords: ["tab", "close", "right"],
icon: source.icons.close,
action: { id: "workspace.tab.close-right", scope: "workspace" },
}),
);
}
if (source.activeTabCount > 1) {
contributions.push(
buildQueryAction(source, {
id: "tab:close-others",
rank: 40,
title: source.labels.closeOtherTabs,
keywords: ["tab", "close", "others"],
icon: source.icons.close,
action: { id: "workspace.tab.close-others", scope: "workspace" },
}),
);
}
return contributions;
}
function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCenterContribution[] {
const paneActions: Array<{
id: string;
rank: number;
title: string;
keywords: string[];
action: KeyboardActionDefinition;
icon?: CommandCenterIcon;
shortcutKeys?: ShortcutKey[][];
}> = [
{
id: "pane:split-right",
rank: 50,
title: source.labels.splitRight,
keywords: ["split", "pane", "vertical"],
icon: source.icons.splitRight,
shortcutKeys: source.shortcuts.splitRight,
action: { id: "workspace.pane.split.right", scope: "workspace" },
},
{
id: "pane:split-down",
rank: 51,
title: source.labels.splitDown,
keywords: ["split", "pane", "horizontal"],
icon: source.icons.splitDown,
shortcutKeys: source.shortcuts.splitDown,
action: { id: "workspace.pane.split.down", scope: "workspace" },
},
{
id: "pane:focus-left",
rank: 52,
title: source.labels.focusPaneLeft,
keywords: ["pane", "focus", "left"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.left", scope: "workspace" },
},
{
id: "pane:focus-right",
rank: 53,
title: source.labels.focusPaneRight,
keywords: ["pane", "focus", "right"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.right", scope: "workspace" },
},
{
id: "pane:focus-up",
rank: 54,
title: source.labels.focusPaneUp,
keywords: ["pane", "focus", "up"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.up", scope: "workspace" },
},
{
id: "pane:focus-down",
rank: 55,
title: source.labels.focusPaneDown,
keywords: ["pane", "focus", "down"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.down", scope: "workspace" },
},
{
id: "pane:move-tab-left",
rank: 56,
title: source.labels.moveTabLeft,
keywords: ["pane", "tab", "move", "left"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.left", scope: "workspace" },
},
{
id: "pane:move-tab-right",
rank: 57,
title: source.labels.moveTabRight,
keywords: ["pane", "tab", "move", "right"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.right", scope: "workspace" },
},
{
id: "pane:move-tab-up",
rank: 58,
title: source.labels.moveTabUp,
keywords: ["pane", "tab", "move", "up"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.up", scope: "workspace" },
},
{
id: "pane:move-tab-down",
rank: 59,
title: source.labels.moveTabDown,
keywords: ["pane", "tab", "move", "down"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.down", scope: "workspace" },
},
{
id: "pane:close",
rank: 60,
title: source.labels.closePane,
keywords: ["pane", "close"],
icon: source.icons.close,
shortcutKeys: source.shortcuts.closePane,
action: { id: "workspace.pane.close", scope: "workspace" },
},
{
id: "pane:focus-mode-toggle",
rank: 62,
title: source.labels.toggleFocusMode,
keywords: ["pane", "focus", "mode", "toggle"],
icon: source.icons.focusMode,
shortcutKeys: source.shortcuts.toggleFocusMode,
action: { id: "workspace.focus.toggle", scope: "workspace" },
},
];
return paneActions.map((action) => buildQueryAction(source, action));
}
function buildCreationContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [
@@ -115,8 +478,7 @@ export function buildWorkspaceCommandCenterContributions(
const primary = source.gitActions.primary;
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
contributions.push(
buildWorkspaceAction({
source,
buildQueryAction(source, {
id: "tab:new-terminal",
rank: 2,
title: source.labels.newTerminal,
@@ -124,52 +486,45 @@ export function buildWorkspaceCommandCenterContributions(
icon: source.icons.newTerminal,
shortcutKeys: source.shortcuts.newTerminal,
action: { id: "workspace.terminal.new", scope: "workspace" },
visibility: "query",
}),
);
if (source.capabilities.canOpenBrowserTabs) {
contributions.push(
buildWorkspaceAction({
source,
buildQueryAction(source, {
id: "tab:new-browser",
rank: 3,
title: source.labels.newBrowser,
keywords: ["browser", "web", "preview"],
icon: source.icons.newBrowser,
action: { id: "workspace.browser.new", scope: "workspace" },
visibility: "query",
}),
);
}
if (source.capabilities.canSplitPanes) {
contributions.push(
buildWorkspaceAction({
source,
id: "pane:split-right",
rank: 4,
title: source.labels.splitRight,
keywords: ["split", "pane", "vertical"],
icon: source.icons.splitRight,
shortcutKeys: source.shortcuts.splitRight,
action: { id: "workspace.pane.split.right", scope: "workspace" },
visibility: "query",
}),
buildWorkspaceAction({
source,
id: "pane:split-down",
rank: 5,
title: source.labels.splitDown,
keywords: ["split", "pane", "horizontal"],
icon: source.icons.splitDown,
shortcutKeys: source.shortcuts.splitDown,
action: { id: "workspace.pane.split.down", scope: "workspace" },
visibility: "query",
}),
);
}
return contributions;
}
export function buildWorkspaceCommandCenterContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
const contributions = [
...buildCreationContributions(source),
...buildPanelContributions(source),
...buildActiveTabContributions(source),
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
buildQueryAction(source, {
id: "explorer-sidebar:toggle",
rank: 70,
title: source.labels.toggleExplorerSidebar,
keywords: ["explorer", "sidebar", "toggle", "show", "hide"],
icon: source.icons.explorerSidebar,
shortcutKeys: source.shortcuts.toggleExplorerSidebar,
action: { id: "sidebar.toggle.right", scope: "workspace" },
}),
];
const primary = source.gitActions.primary;
for (const [index, action] of source.gitActions.secondary.entries()) {
if (action.id === primary?.id) continue;
contributions.push(buildGitContribution(source, action, 10 + index, "query"));
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
}
return contributions;
}
@@ -131,6 +131,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
openWorkspaceFileFromExplorer({
filePath: path,
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
closeExplorerAfterOpen: true,
showMobileAgent: usePanelStore.getState().showMobileAgent,
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
useWorkspaceLayoutStore.getState().openTab({
@@ -1,18 +1,40 @@
import { useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { Columns2, Globe, Rows2, SquarePen, SquareTerminal } from "lucide-react-native";
import {
ArrowDownToLine,
ArrowLeft,
ArrowRight,
Columns2,
Copy,
Files,
Focus,
GitCompareArrows,
GitPullRequest,
Globe,
Move,
PanelRight,
Pencil,
RotateCw,
Rows2,
SquarePen,
SquareTerminal,
X,
} from "lucide-react-native";
import { getIsElectron } from "@/constants/platform";
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
import { GIT_ACTION_ICONS } from "@/git/action-icons";
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import {
resolveShortcutKeysForAction,
type ShortcutOverrides,
} from "@/keyboard/keyboard-shortcuts";
import { type ShortcutOverrides } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import {
collectAllTabs,
findPaneById,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getCommandCenterIcon } from "./icon";
@@ -22,6 +44,7 @@ import {
buildWorkspaceCommandCenterContributions,
type WorkspaceCommandCenterShortcuts,
} from "./workspace-contributions";
import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts";
const WORKSPACE_COMMAND_CENTER_ICONS = {
newAgent: getCommandCenterIcon(SquarePen),
@@ -29,8 +52,27 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
newBrowser: getCommandCenterIcon(Globe),
splitRight: getCommandCenterIcon(Columns2),
splitDown: getCommandCenterIcon(Rows2),
changes: getCommandCenterIcon(GitCompareArrows),
files: getCommandCenterIcon(Files),
pullRequest: getCommandCenterIcon(GitPullRequest),
previousTab: getCommandCenterIcon(ArrowLeft),
nextTab: getCommandCenterIcon(ArrowRight),
close: getCommandCenterIcon(X),
rename: getCommandCenterIcon(Pencil),
reload: getCommandCenterIcon(RotateCw),
copy: getCommandCenterIcon(Copy),
focusPane: getCommandCenterIcon(Focus),
moveTab: getCommandCenterIcon(Move),
focusMode: getCommandCenterIcon(ArrowDownToLine),
explorerSidebar: getCommandCenterIcon(PanelRight),
};
const OPEN_PANEL_LABEL_KEYS = {
supporting: "shell.commandCenter.open",
"side-pane": "shell.commandCenter.openInSidePane",
"focused-pane": "shell.commandCenter.openInFocusedPane",
} as const;
function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined {
if (!element) return undefined;
function StaticIcon() {
@@ -41,17 +83,7 @@ function staticIcon(element: ReactElement | undefined): CommandCenterIcon | unde
function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceCommandCenterShortcuts {
const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() };
return {
newAgent: resolveShortcutKeysForAction("workspace-tab-new", overrides, platform) ?? undefined,
newTerminal:
resolveShortcutKeysForAction("workspace-terminal-new", overrides, platform) ?? undefined,
splitRight:
resolveShortcutKeysForAction("workspace-pane-split-right", overrides, platform) ?? undefined,
splitDown:
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
archiveWorkspace:
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
};
return resolveWorkspaceCommandCenterShortcuts({ overrides, platform });
}
export function useWorkspaceCommandCenterActions(): void {
@@ -60,10 +92,22 @@ export function useWorkspaceCommandCenterActions(): void {
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;
const workspaceKey =
serverId && workspaceId ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId }) : null;
const layout = useWorkspaceLayoutStore((state) =>
workspaceKey ? (state.layoutByWorkspace[workspaceKey] ?? null) : null,
);
const focusedPane = layout ? findPaneById(layout.root, layout.focusedPaneId) : null;
const focusedTabs = layout
? collectAllTabs(layout.root).filter((tab) => focusedPane?.tabIds.includes(tab.tabId))
: [];
const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId);
const activeTabKind =
activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null;
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const isCompact = useIsCompactFormFactor();
const { overrides } = useKeyboardShortcutOverrides();
const { gitActions } = useGitActions({
const { gitActions, isGit } = useGitActions({
serverId: serverId ?? "",
cwd: cwd ?? "",
icons: GIT_ACTION_ICONS,
@@ -81,6 +125,33 @@ export function useWorkspaceCommandCenterActions(): void {
newBrowser: t("workspace.tabs.actions.newBrowser"),
splitRight: t("workspace.tabs.actions.splitRight"),
splitDown: t("workspace.tabs.actions.splitDown"),
changes: t("workspace.tabs.actions.changes"),
files: t("workspace.tabs.actions.files"),
pullRequest: t("workspace.tabs.actions.pullRequest"),
openPanel: (name, placement) => t(OPEN_PANEL_LABEL_KEYS[placement], { name }),
previousTab: t("settings.shortcuts.help.previousTab"),
nextTab: t("settings.shortcuts.help.nextTab"),
closeCurrentTab: t("settings.shortcuts.help.closeCurrentTab"),
renameTab: t("workspace.tabs.menu.rename"),
reloadAgent: t("workspace.tabs.menu.reloadAgent"),
copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"),
copyAgentId: t("workspace.tabs.menu.copyAgentId"),
copyTerminalId: t("workspace.tabs.menu.copyTerminalId"),
copyFilePath: t("workspace.tabs.menu.copyFilePath"),
closeTabsLeft: t("workspace.tabs.menu.closeLeft"),
closeTabsRight: t("workspace.tabs.menu.closeRight"),
closeOtherTabs: t("workspace.tabs.menu.closeOthers"),
focusPaneLeft: t("settings.shortcuts.help.focusPaneLeft"),
focusPaneRight: t("settings.shortcuts.help.focusPaneRight"),
focusPaneUp: t("settings.shortcuts.help.focusPaneUp"),
focusPaneDown: t("settings.shortcuts.help.focusPaneDown"),
moveTabLeft: t("settings.shortcuts.help.moveTabLeft"),
moveTabRight: t("settings.shortcuts.help.moveTabRight"),
moveTabUp: t("settings.shortcuts.help.moveTabUp"),
moveTabDown: t("settings.shortcuts.help.moveTabDown"),
closePane: t("settings.shortcuts.help.closePane"),
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
toggleExplorerSidebar: t("workspace.tabs.explorerSidebar.toggle"),
},
icons: {
...WORKSPACE_COMMAND_CENTER_ICONS,
@@ -90,14 +161,29 @@ export function useWorkspaceCommandCenterActions(): void {
capabilities: {
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
canOpenBrowserTabs: getIsElectron(),
isGit,
},
activeTabKind,
activeTabIndex,
activeTabCount: focusedTabs.length,
dispatch: (action) => {
clearCommandCenterFocusRestoreElement();
keyboardActionDispatcher.dispatch(action);
},
runGitAction,
}),
[gitActions, isCompact, keyboardActionDispatcher, overrides, runGitAction, t],
[
activeTabIndex,
activeTabKind,
focusedTabs.length,
gitActions,
isCompact,
isGit,
keyboardActionDispatcher,
overrides,
runGitAction,
t,
],
);
useCommandCenterActions({
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts";
describe("resolveWorkspaceCommandCenterShortcuts", () => {
it("assigns the New agent command its own shortcut", () => {
expect(
resolveWorkspaceCommandCenterShortcuts({
overrides: {},
platform: { isMac: true, isDesktop: true },
}).newAgent,
).toEqual([["mod", "shift", "A"]]);
});
});
@@ -0,0 +1,37 @@
import {
resolveShortcutKeysForAction,
type ShortcutOverrides,
} from "@/keyboard/keyboard-shortcuts";
import type { WorkspaceCommandCenterShortcuts } from "./workspace-contributions";
interface ResolveWorkspaceCommandCenterShortcutsInput {
overrides: ShortcutOverrides;
platform: { isMac: boolean; isDesktop: boolean };
}
export function resolveWorkspaceCommandCenterShortcuts({
overrides,
platform,
}: ResolveWorkspaceCommandCenterShortcutsInput): WorkspaceCommandCenterShortcuts {
return {
newAgent:
resolveShortcutKeysForAction("workspace-tab-target-agent", overrides, platform) ?? undefined,
newTerminal:
resolveShortcutKeysForAction("workspace-terminal-new", overrides, platform) ?? undefined,
splitRight:
resolveShortcutKeysForAction("workspace-pane-split-right", overrides, platform) ?? undefined,
splitDown:
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
archiveWorkspace:
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
previousTab:
resolveShortcutKeysForAction("workspace-tab-prev", overrides, platform) ?? undefined,
nextTab: resolveShortcutKeysForAction("workspace-tab-next", overrides, platform) ?? undefined,
closeCurrentTab:
resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined,
closePane:
resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined,
toggleExplorerSidebar:
resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined,
};
}
@@ -1,7 +1,7 @@
import { useCallback, useMemo, useRef } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { Text, View } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native";
import { GitBranch } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme";
@@ -10,6 +10,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
import { ToolbarLabelSelectTrigger } from "@/components/ui/toolbar-label-trigger";
interface BranchSwitcherProps {
currentBranchName: string | null;
@@ -23,9 +24,7 @@ interface BranchSwitcherProps {
const foregroundMutedIconColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedChevronDown = withUnistyles(ChevronDown);
export function BranchSwitcher({
currentBranchName,
@@ -56,14 +55,6 @@ export function BranchSwitcher({
const handleOpen = useCallback(() => setIsOpen(true), [setIsOpen]);
const triggerStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
(Boolean(hovered) || pressed) && styles.triggerHovered,
],
[],
);
const branchLeadingSlot = useMemo(
() => <ThemedGitBranch size={14} uniProps={foregroundMutedIconColorMapping} />,
[],
@@ -90,21 +81,16 @@ export function BranchSwitcher({
<View ref={anchorRef} collapsable={false} style={styles.anchor}>
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
<ToolbarLabelSelectTrigger
testID={testID}
label={currentBranchName}
open={isOpen}
onPress={handleOpen}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("branchSwitcher.currentBranch", {
branchName: currentBranchName,
})}
>
<ThemedGitBranch size={12} uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.branchLabel} numberOfLines={1}>
{currentBranchName}
</Text>
<ThemedChevronDown size={12} uniProps={foregroundMutedIconColorMapping} />
</Pressable>
/>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{t("branchSwitcher.triggerTooltip")}</Text>
@@ -136,25 +122,6 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1,
minWidth: 0,
},
trigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
minWidth: 0,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
borderRadius: theme.borderRadius.md,
flexShrink: 1,
},
triggerHovered: {
backgroundColor: theme.colors.surface1,
},
branchLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
flexShrink: 1,
},
tooltipText: {
color: theme.colors.popoverForeground,
fontSize: theme.fontSize.sm,
@@ -1,9 +1,12 @@
import { type ReactNode, useCallback, useEffect, useMemo, useRef } from "react";
import { View } from "react-native";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View, type LayoutChangeEvent } from "react-native";
import { GestureDetector } from "react-native-gesture-handler";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { CompactExplorerSidebar } from "@/components/compact-explorer-sidebar";
import {
CompactExplorerSidebar,
NativeExplorerSidebarDock,
} from "@/components/compact-explorer-sidebar";
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
@@ -12,6 +15,7 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { isWeb } from "@/constants/platform";
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
import {
resolveCompactExplorerSidebarHostModel,
type CompactExplorerSidebarHostModel,
@@ -94,10 +98,16 @@ function useActiveCompactExplorerSidebarModel(
interface CompactExplorerSidebarHostProps {
children: ReactNode;
enabled: boolean;
presentation: "overlay" | "dock";
}
export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) {
export function CompactExplorerSidebarHost({
children,
enabled,
presentation,
}: CompactExplorerSidebarHostProps) {
const model = useActiveCompactExplorerSidebarModel(enabled);
const [containerWidth, setContainerWidth] = useState(0);
const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
@@ -127,14 +137,54 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
openWorkspaceFileFromExplorer({
filePath,
persistenceKey: model.persistenceKey,
closeExplorerAfterOpen: presentation === "overlay",
showMobileAgent,
openWorkspaceTabInFocusedPane,
focusWorkspaceTab,
});
},
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, showMobileAgent],
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, presentation, showMobileAgent],
);
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = event.nativeEvent.layout.width;
setContainerWidth((current) => (current === nextWidth ? current : nextWidth));
}, []);
const explorer =
enabled && model ? (
<DiffDocumentWorkspaceCacheProvider key={model.persistenceKey}>
{presentation === "dock" ? (
<NativeExplorerSidebarDock
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
persistenceKey={model.persistenceKey}
containerWidth={containerWidth}
onOpenFile={handleOpenFile}
/>
) : (
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
onOpenFile={handleOpenFile}
/>
)}
</DiffDocumentWorkspaceCacheProvider>
) : null;
if (presentation === "dock") {
return (
<View style={styles.row} onLayout={handleContainerLayout}>
<View style={styles.fill}>{children}</View>
{explorer}
</View>
);
}
return (
<>
<CompactExplorerOpenGestureSurface
@@ -143,15 +193,7 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
>
{children}
</CompactExplorerOpenGestureSurface>
{enabled && model ? (
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
onOpenFile={handleOpenFile}
/>
) : null}
{explorer}
</>
);
}
@@ -160,4 +202,8 @@ const styles = {
fill: {
flex: 1,
},
row: {
flex: 1,
flexDirection: "row",
},
} as const;
@@ -1,5 +1,8 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View, Text, Pressable } from "react-native";
import { Gesture } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
@@ -29,6 +32,13 @@ import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability";
import { PullRequestContent } from "@/panels/pull-request";
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout";
import { resolveExplorerSidebarWidth } from "@/components/explorer-sidebar-layout";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -139,6 +149,118 @@ export function CompactExplorerSidebar({
);
}
interface NativeExplorerSidebarDockProps extends ExplorerSidebarProps {
persistenceKey: string;
containerWidth: number;
}
export function NativeExplorerSidebarDock({
serverId,
workspaceId,
workspaceRoot,
isGit,
onOpenFile,
persistenceKey,
containerWidth,
}: NativeExplorerSidebarDockProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const storedWidth = useWorkspaceLayoutStore(
(state) => state.explorerSidebarWidthByWorkspace[persistenceKey],
);
const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar);
const visibleWidth = resolveExplorerSidebarWidth({
requestedWidth: storedWidth,
containerWidth,
});
const resizeWidth = useSharedValue(visibleWidth);
const startWidthRef = useRef(visibleWidth);
const [resizePressed, setResizePressed] = useState(false);
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
serverId,
workspaceRoot,
isGit,
});
useEffect(() => {
resizeWidth.value = visibleWidth;
}, [resizeWidth, visibleWidth]);
const showResizeGrip = useCallback(() => setResizePressed(true), []);
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
const commitWidth = useCallback(
(width: number) => resizeExplorerSidebar(persistenceKey, width),
[persistenceKey, resizeExplorerSidebar],
);
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.enabled(true)
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onBegin(() => scheduleOnRN(showResizeGrip))
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
.onStart((event) => {
startWidthRef.current = visibleWidth + event.translationX;
resizeWidth.value = visibleWidth;
})
.onUpdate((event) => {
resizeWidth.value = resolveExplorerSidebarWidth({
requestedWidth: startWidthRef.current - event.translationX,
containerWidth,
});
})
.onEnd(() => runOnJS(commitWidth)(resizeWidth.value))
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
[commitWidth, containerWidth, hideResizeGrip, resizeWidth, showResizeGrip, visibleWidth],
);
const animatedWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value }));
const dockStyle = useMemo(
() => [
styles.nativeDock,
{
display: isOpen ? ("flex" as const) : ("none" as const),
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
backgroundColor: theme.colors.surfaceSidebar,
},
animatedWidthStyle,
],
[animatedWidthStyle, insets.top, isOpen, theme.colors.surfaceSidebar],
);
const dockContentStyle = useMemo(
() => [styles.nativeDockContent, { borderLeftColor: theme.colors.border }],
[theme.colors.border],
);
return (
<RetainedPanelActivity active={isOpen}>
<Animated.View style={dockStyle} testID="native-explorer-sidebar-dock">
<View style={dockContentStyle}>
<SidebarResizeHandle
edge="left"
gesture={resizeGesture}
pressed={resizePressed}
testID="native-explorer-sidebar-resize-handle"
/>
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={showMobileAgent}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</View>
</Animated.View>
</RetainedPanelActivity>
);
}
interface ExplorerTabButtonProps {
tab: ExplorerTab;
active: boolean;
@@ -230,7 +352,7 @@ function ExplorerSidebarContent({
<ExplorerTabButton
tab="changes"
active={resolvedTab === "changes"}
label={t("workspace.tabs.sidePanel.changes")}
label={t("workspace.tabs.explorerSidebar.changes")}
onTabPress={onTabPress}
testID="explorer-tab-changes"
/>
@@ -238,7 +360,7 @@ function ExplorerSidebarContent({
<ExplorerTabButton
tab="files"
active={resolvedTab === "files"}
label={t("workspace.tabs.sidePanel.files")}
label={t("workspace.tabs.explorerSidebar.files")}
onTabPress={onTabPress}
testID="explorer-tab-files"
/>
@@ -268,7 +390,7 @@ function ExplorerSidebarContent({
nativeID="explorer-close"
accessible
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.sidePanel.close")}
accessibilityLabel={t("workspace.tabs.explorerSidebar.close")}
hitSlop={8}
>
{({ hovered, pressed }) => (
@@ -335,7 +457,6 @@ function ChangedFilesPane({
);
return (
<ChangesSurface
host="explorer"
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
@@ -370,6 +491,18 @@ function FilesPane({
const PrTabContent = PullRequestContent;
const styles = StyleSheet.create((theme) => ({
nativeDock: {
position: "relative",
height: "100%",
minHeight: 0,
overflow: "hidden",
},
nativeDockContent: {
position: "relative",
flex: 1,
minHeight: 0,
borderLeftWidth: 1,
},
sidebarContent: {
flex: 1,
minHeight: 0,
@@ -17,15 +17,20 @@ import { isNative } from "@/constants/platform";
* On macOS, Electron handles edge resize natively.
*/
export const titlebarDragSurfaceStyle: React.CSSProperties = {
cursor: "default",
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "drag",
};
const DRAG_OVERLAY_STYLE: React.CSSProperties = {
...titlebarDragSurfaceStyle,
top: 0,
left: 0,
display: "block",
position: "absolute",
width: "100%",
height: "100%",
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "drag",
};
const TOP_RESIZER_STYLE: React.CSSProperties = {
@@ -0,0 +1,208 @@
import { useCallback, useMemo } from "react";
import { Pressable, View } from "react-native";
import Svg, { Line, Rect } from "react-native-svg";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
closeDesktopWindow,
minimizeDesktopWindow,
toggleDesktopMaximize,
} from "@/desktop/electron/window";
import {
getDesktopWindowControlsPresentation,
type DesktopWindowControlsPresentation,
} from "@/desktop/window-chrome-presentation";
import { useCustomDesktopWindowControls } from "@/utils/desktop-window";
import type { Theme } from "@/styles/theme";
type WindowControlKind = "minimize" | "maximize" | "restore" | "close";
function WindowControlGlyph({
kind,
color,
size,
strokeWidth,
}: {
kind: WindowControlKind;
color: string;
size: number;
strokeWidth: number;
}) {
if (kind === "minimize") {
return (
<Svg width={size} height={size} viewBox="0 0 16 16" style={styles.glyph}>
<Line x1={3} y1={8} x2={13} y2={8} stroke={color} strokeWidth={strokeWidth} />
</Svg>
);
}
if (kind === "restore") {
return (
<Svg width={size} height={size} viewBox="0 0 16 16" style={styles.glyph}>
<Rect
x={5.5}
y={3.5}
width={7}
height={7}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
/>
<Line x1={3.5} y1={5.5} x2={3.5} y2={12.5} stroke={color} strokeWidth={strokeWidth} />
<Line x1={3} y1={12.5} x2={10.5} y2={12.5} stroke={color} strokeWidth={strokeWidth} />
</Svg>
);
}
if (kind === "close") {
return (
<Svg width={size} height={size} viewBox="0 0 16 16" style={styles.glyph}>
<Line x1={4} y1={4} x2={12} y2={12} stroke={color} strokeWidth={strokeWidth} />
<Line x1={12} y1={4} x2={4} y2={12} stroke={color} strokeWidth={strokeWidth} />
</Svg>
);
}
return (
<Svg width={size} height={size} viewBox="0 0 16 16" style={styles.glyph}>
<Rect
x={3.5}
y={3.5}
width={9}
height={9}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
/>
</Svg>
);
}
const ThemedWindowControlGlyph = withUnistyles(WindowControlGlyph);
const mutedGlyphMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const whiteGlyphMapping = () => ({ color: "#ffffff" });
function WindowControl({
kind,
label,
onPress,
presentation,
}: {
kind: WindowControlKind;
label: string;
onPress: () => void;
presentation: DesktopWindowControlsPresentation;
}) {
const glyph = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) => {
const highlighted = hovered || pressed;
const closeHighlighted = kind === "close" && highlighted;
return (
<View
style={[
styles.controlSurface,
presentation.hoverShape === "circle"
? styles.circularControlSurface
: styles.fullControlSurface,
highlighted ? styles.controlHovered : null,
closeHighlighted ? styles.closeHovered : null,
]}
>
<ThemedWindowControlGlyph
kind={kind}
size={presentation.glyphSize}
strokeWidth={presentation.glyphStrokeWidth}
uniProps={closeHighlighted ? whiteGlyphMapping : mutedGlyphMapping}
/>
</View>
);
},
[kind, presentation],
);
const controlStyle = useMemo(
() => [styles.control, { width: presentation.controlWidth }],
[presentation.controlWidth],
);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onPress}
style={controlStyle}
>
{glyph}
</Pressable>
);
}
export function DesktopWindowControls() {
const { t } = useTranslation();
const { visible, mode, isMaximized } = useCustomDesktopWindowControls();
const minimize = useCallback(() => void minimizeDesktopWindow(), []);
const toggleMaximize = useCallback(() => void toggleDesktopMaximize(), []);
const close = useCallback(() => void closeDesktopWindow(), []);
if (!visible || !mode) return null;
const presentation = getDesktopWindowControlsPresentation(mode);
return (
<View
style={[styles.controls, { height: presentation.controlHeight }]}
testID="desktop-window-controls"
>
<WindowControl
kind="minimize"
label={t("desktop.windowControls.minimize")}
onPress={minimize}
presentation={presentation}
/>
<WindowControl
kind={isMaximized ? "restore" : "maximize"}
label={t(
isMaximized ? "desktop.windowControls.restore" : "desktop.windowControls.maximize",
)}
onPress={toggleMaximize}
presentation={presentation}
/>
<WindowControl
kind="close"
label={t("desktop.windowControls.close")}
onPress={close}
presentation={presentation}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
controls: {
position: "absolute",
top: 0,
right: 0,
zIndex: 3000,
flexDirection: "row",
},
control: {
height: "100%",
alignItems: "center",
justifyContent: "center",
},
controlSurface: {
alignItems: "center",
justifyContent: "center",
},
fullControlSurface: {
width: "100%",
height: "100%",
},
circularControlSurface: {
width: 24,
height: 24,
borderRadius: 12,
},
glyph: {
flexShrink: 0,
},
controlHovered: {
backgroundColor: theme.colors.interactionHighlight,
},
closeHovered: {
backgroundColor: "rgba(232, 17, 35, 0.9)",
},
}));
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import {
resolveExplorerSidebarDockSizes,
resolveExplorerSidebarWidth,
} from "@/components/explorer-sidebar-layout";
describe("Explorer sidebar layout", () => {
it("keeps the sidebar width fixed when the workspace body changes size", () => {
const narrow = resolveExplorerSidebarDockSizes({ requestedWidth: 320, containerWidth: 1200 });
const wide = resolveExplorerSidebarDockSizes({ requestedWidth: 320, containerWidth: 1520 });
expect(narrow[1] * 1200).toBeCloseTo(320);
expect(wide[1] * 1520).toBeCloseTo(320);
});
it("has no fixed maximum while preserving room for the workspace body", () => {
expect(resolveExplorerSidebarWidth({ requestedWidth: 100, containerWidth: 1200 })).toBe(240);
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1600 })).toBe(900);
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(800);
expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(350);
});
});
@@ -0,0 +1,27 @@
const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 320;
const MIN_EXPLORER_SIDEBAR_WIDTH = 240;
const MIN_WORKSPACE_BODY_WIDTH = 400;
export function resolveExplorerSidebarWidth(input: {
requestedWidth?: number;
containerWidth: number;
}): number {
const requestedWidth = input.requestedWidth ?? DEFAULT_EXPLORER_SIDEBAR_WIDTH;
const maximumVisibleWidth =
input.containerWidth > 0
? Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, input.containerWidth - MIN_WORKSPACE_BODY_WIDTH)
: requestedWidth;
return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maximumVisibleWidth, requestedWidth));
}
export function resolveExplorerSidebarDockSizes(input: {
requestedWidth?: number;
containerWidth: number;
}): number[] {
if (input.containerWidth <= 0) {
return [1, 0];
}
const width = resolveExplorerSidebarWidth(input);
const ratio = width / input.containerWidth;
return [1 - ratio, ratio];
}
@@ -1,6 +1,7 @@
import { Fragment, useMemo, type ReactElement, type ReactNode } from "react";
import { Fragment, useMemo, type ReactElement } from "react";
import { withUnistyles } from "react-native-unistyles";
import {
ArrowRightToLine,
Copy,
CopyPlus,
Download,
@@ -25,8 +26,11 @@ import {
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
type FileActionGroup = "create" | "open" | "reference" | "manage" | "destructive";
interface FileAction {
key: string;
group: FileActionGroup;
label: string;
icon: LucideIcon;
onSelect: () => void;
@@ -35,10 +39,19 @@ interface FileAction {
testID?: string;
}
function optionalFileAction(
available: boolean,
onSelect: (() => void) | undefined,
action: Omit<FileAction, "onSelect">,
): FileAction | null {
return available && onSelect ? { ...action, onSelect } : null;
}
interface FileActionsContextMenuContentProps {
fileKind: "file" | "directory";
fileExists?: boolean;
onOpenFile?: () => void;
onOpenToSide?: () => void;
onCopyPath?: () => void;
onCopyRelativePath?: () => void;
onReveal?: () => void;
@@ -52,8 +65,6 @@ interface FileActionsContextMenuContentProps {
onDuplicate?: () => void;
onRevert?: () => void;
onDelete?: () => void;
/** Optional metadata block rendered above the actions (e.g. size/modified). */
header?: ReactNode;
testIDPrefix?: string;
}
@@ -65,6 +76,7 @@ export function FileActionsContextMenuContent({
fileKind,
fileExists = true,
onOpenFile,
onOpenToSide,
onCopyPath,
onCopyRelativePath,
onReveal,
@@ -78,7 +90,6 @@ export function FileActionsContextMenuContent({
onDuplicate,
onRevert,
onDelete,
header,
testIDPrefix,
}: FileActionsContextMenuContentProps): ReactElement | null {
const { t } = useTranslation();
@@ -88,6 +99,7 @@ export function FileActionsContextMenuContent({
onNewFile
? {
key: "new-file",
group: "create",
label: t("workspace.fileActions.newFile"),
icon: FilePlus,
onSelect: onNewFile,
@@ -96,6 +108,7 @@ export function FileActionsContextMenuContent({
onNewFolder
? {
key: "new-folder",
group: "create",
label: t("workspace.fileActions.newFolder"),
icon: FolderPlus,
onSelect: onNewFolder,
@@ -104,6 +117,7 @@ export function FileActionsContextMenuContent({
onCollapseFolder
? {
key: "collapse-folder",
group: "open",
label: t("workspace.fileActions.collapseFolder"),
icon: FolderMinus,
onSelect: onCollapseFolder,
@@ -112,30 +126,22 @@ export function FileActionsContextMenuContent({
availableFile && onOpenFile
? {
key: "open-file",
group: "open",
label: t("workspace.fileActions.openFile"),
icon: FileText,
onSelect: onOpenFile,
}
: null,
onRename
? {
key: "rename",
label: t("workspace.fileActions.rename"),
icon: Pencil,
onSelect: onRename,
}
: null,
onDuplicate
? {
key: "duplicate",
label: t("workspace.fileActions.duplicate"),
icon: CopyPlus,
onSelect: onDuplicate,
}
: null,
optionalFileAction(availableFile, onOpenToSide, {
key: "open-to-side",
group: "open",
label: t("workspace.fileActions.openToSide"),
icon: ArrowRightToLine,
}),
onCopyPath
? {
key: "copy-path",
group: "reference",
label: t("workspace.fileActions.copyPath"),
icon: Copy,
onSelect: onCopyPath,
@@ -144,6 +150,7 @@ export function FileActionsContextMenuContent({
onCopyRelativePath
? {
key: "copy-relative-path",
group: "reference",
label: t("workspace.fileActions.copyRelativePath"),
icon: Copy,
onSelect: onCopyRelativePath,
@@ -152,6 +159,7 @@ export function FileActionsContextMenuContent({
onReveal && revealTargetName
? {
key: "reveal",
group: "reference",
label: t("workspace.fileActions.revealIn", { target: revealTargetName }),
icon: FolderOpen,
onSelect: onReveal,
@@ -160,6 +168,7 @@ export function FileActionsContextMenuContent({
availableFile && onDownload
? {
key: "download",
group: "reference",
label: t("workspace.fileActions.download"),
icon: Download,
onSelect: onDownload,
@@ -168,14 +177,34 @@ export function FileActionsContextMenuContent({
availableFile && onAddToChat
? {
key: "add-to-chat",
group: "reference",
label: t("workspace.fileActions.addToChat"),
icon: MessageSquarePlus,
onSelect: onAddToChat,
}
: null,
onRename
? {
key: "rename",
group: "manage",
label: t("workspace.fileActions.rename"),
icon: Pencil,
onSelect: onRename,
}
: null,
onDuplicate
? {
key: "duplicate",
group: "manage",
label: t("workspace.fileActions.duplicate"),
icon: CopyPlus,
onSelect: onDuplicate,
}
: null,
onRevert
? {
key: "revert",
group: "destructive",
label: t("workspace.fileActions.revert"),
icon: Undo2,
onSelect: onRevert,
@@ -185,6 +214,7 @@ export function FileActionsContextMenuContent({
onDelete
? {
key: "delete",
group: "destructive",
label: t("workspace.fileActions.delete"),
icon: Trash2,
onSelect: onDelete,
@@ -195,9 +225,7 @@ export function FileActionsContextMenuContent({
const availableActions = specs.filter((action): action is FileAction => action !== null);
return availableActions.map((action, index) =>
Object.assign(action, {
separatorBefore: Boolean(
action.destructive && index > 0 && !availableActions[index - 1].destructive,
),
separatorBefore: index > 0 && action.group !== availableActions[index - 1]?.group,
testID: testIDPrefix ? `${testIDPrefix}-${action.key}` : undefined,
}),
);
@@ -214,6 +242,7 @@ export function FileActionsContextMenuContent({
onNewFile,
onNewFolder,
onOpenFile,
onOpenToSide,
onRename,
onReveal,
onRevert,
@@ -231,12 +260,6 @@ export function FileActionsContextMenuContent({
width={220}
testID={testIDPrefix ? `${testIDPrefix}-context-menu` : undefined}
>
{header ? (
<>
{header}
<ContextMenuSeparator />
</>
) : null}
{actions.map((action) => (
<Fragment key={action.key}>
{action.separatorBefore ? <ContextMenuSeparator /> : null}
@@ -41,7 +41,9 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
PaneContentToolbar,
paneContentToolbarIconSize,
paneContentToolbarIconButtonStyle,
paneContentToolbarTrailingPadding,
ToolbarButton,
ToolbarControls,
} from "@/components/ui/pane-content-toolbar";
import {
useOverlayFlatListScrollbar,
@@ -61,7 +63,6 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { isHiddenExplorerPath } from "@/file-explorer/visibility";
import {
@@ -100,16 +101,6 @@ function DirectoryChevronIcon({ loading, expanded }: { loading: boolean; expande
return <TreeChevron expanded={expanded} />;
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
interface TreeRowItemProps {
serverId: string;
workspaceId?: string | null;
@@ -126,6 +117,7 @@ interface TreeRowItemProps {
revealTargetName?: string;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
onOpenFileToSide?: (path: string) => void;
onNewEntry?: (parentPath: string, kind: "file" | "directory") => void;
onCollapseDirectory?: (path: string) => void;
onRenameEntry?: (entry: ExplorerEntry) => void;
@@ -141,13 +133,6 @@ function sortTriggerStyle({
return [styles.sortTrigger, (Boolean(hovered) || pressed) && styles.sortTriggerHovered];
}
function iconButtonStyle(
isCompact: boolean,
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
) {
return paneContentToolbarIconButtonStyle({ hovered, pressed }, false, isCompact);
}
type ExplorerListRow =
| { type: "entry"; row: ExplorerTreeRow }
| { type: "draft"; parentPath: string; kind: "file" | "directory"; depth: number }
@@ -256,6 +241,7 @@ function TreeRowItem({
revealTargetName,
onDownloadEntry,
onAddToChat,
onOpenFileToSide,
onNewEntry,
onCollapseDirectory,
onRenameEntry,
@@ -263,7 +249,6 @@ function TreeRowItem({
onDeleteEntry,
testID,
}: TreeRowItemProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const showNameHover = useCallback(() => setIsHovered(true), []);
const hideNameHover = useCallback(() => setIsHovered(false), []);
@@ -317,6 +302,10 @@ function TreeRowItem({
onAddToChat?.(entry.path);
}, [onAddToChat, entry.path]);
const handleOpenToSide = useCallback(() => {
onOpenFileToSide?.(entry.path);
}, [entry.path, onOpenFileToSide]);
const handleNewFile = useCallback(() => {
onNewEntry?.(entry.path, "file");
}, [onNewEntry, entry.path]);
@@ -341,30 +330,6 @@ function TreeRowItem({
onDeleteEntry?.(entry);
}, [onDeleteEntry, entry]);
const metaHeader = useMemo(
() => (
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
),
[entry.modifiedAt, entry.size, t],
);
return (
<ContextMenu>
<ContextMenuTrigger
@@ -407,13 +372,13 @@ function TreeRowItem({
revealTargetName={revealTargetName}
onDownload={handleDownload}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
onOpenToSide={!isDirectory && onOpenFileToSide ? handleOpenToSide : undefined}
onNewFile={onNewEntry ? handleNewFile : undefined}
onNewFolder={onNewEntry ? handleNewFolder : undefined}
onCollapseFolder={isDirectory && isExpanded ? handleCollapseDirectory : undefined}
onRename={onRenameEntry ? handleRename : undefined}
onDuplicate={onDuplicateEntry ? handleDuplicate : undefined}
onDelete={onDeleteEntry ? handleDelete : undefined}
header={metaHeader}
testIDPrefix={testID}
/>
</ContextMenu>
@@ -425,6 +390,7 @@ interface FileExplorerPaneProps {
workspaceId?: string | null;
workspaceRoot: string;
onOpenFile?: (filePath: string) => void;
onOpenFileToSide?: (filePath: string) => void;
onAddToChat?: (path: string) => void;
}
@@ -433,6 +399,7 @@ export function FileExplorerPane({
workspaceId,
workspaceRoot,
onOpenFile,
onOpenFileToSide,
onAddToChat,
}: FileExplorerPaneProps) {
const { t } = useTranslation();
@@ -996,6 +963,7 @@ export function FileExplorerPane({
revealTargetName={fileManagerTarget?.label}
onDownloadEntry={handleDownloadEntry}
onAddToChat={onAddToChat}
onOpenFileToSide={onOpenFileToSide}
onNewEntry={fsEntryOpsEnabled ? handleNewEntry : undefined}
onCollapseDirectory={handleCollapseDirectory}
onRenameEntry={fsEntryOpsEnabled ? handleRenameEntry : undefined}
@@ -1026,6 +994,7 @@ export function FileExplorerPane({
fileManagerTarget,
selectedEntryPath,
onAddToChat,
onOpenFileToSide,
serverId,
workspaceId,
],
@@ -1049,11 +1018,6 @@ export function FileExplorerPane({
});
}, [requestDirectoryListing]);
const toolbarIconButtonStyle = useCallback(
(state: PressableStateCallbackType) => iconButtonStyle(isCompact, state),
[isCompact],
);
if (!hasWorkspaceScope) {
return (
<View style={styles.centerState}>
@@ -1087,7 +1051,6 @@ export function FileExplorerPane({
handleBackFromError={handleBackFromError}
handleRetry={handleRetry}
sortTriggerStyle={sortTriggerStyle}
iconButtonStyle={toolbarIconButtonStyle}
/>
</View>
);
@@ -1162,7 +1125,6 @@ interface FileExplorerPaneContentProps {
handleBackFromError: () => void;
handleRetry: () => void;
sortTriggerStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
iconButtonStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
}
function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
@@ -1186,7 +1148,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
handleBackFromError,
handleRetry,
sortTriggerStyle: sortTriggerStyleProp,
iconButtonStyle: iconButtonStyleProp,
} = props;
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
@@ -1204,17 +1165,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
const emptyLabel = showHiddenFiles
? t("workspace.fileExplorer.empty.noFiles")
: t("workspace.fileExplorer.empty.noVisibleFiles");
const hiddenFilesToggleStyle = useCallback(
(state: PressableStateCallbackType) => [
iconButtonStyleProp(state),
!showHiddenFiles && styles.iconButtonActive,
],
[showHiddenFiles, iconButtonStyleProp],
);
const hiddenFilesToggleAccessibilityState = useMemo(
() => ({ selected: !showHiddenFiles }),
[showHiddenFiles],
);
if (error) {
return (
@@ -1245,7 +1195,10 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return (
<View style={[styles.treePane, styles.treePaneFill]}>
<PaneContentToolbar style={styles.paneHeader} testID="files-pane-header">
<PaneContentToolbar
style={[styles.paneHeader, { paddingRight: paneContentToolbarTrailingPadding(isCompact) }]}
testID="files-pane-header"
>
<Pressable
onPress={handleSortCycle}
style={sortTriggerStyleProp}
@@ -1256,86 +1209,82 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.headerActions}>
<ToolbarControls style={styles.headerActions}>
{onNewEntryAtRoot ? (
<>
<Pressable
onPress={handleNewFileAtRoot}
<ToolbarButton
label={t("workspace.fileActions.newFile")}
compact={isCompact}
hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={t("workspace.fileActions.newFile")}
testID="files-new-file"
onPress={handleNewFileAtRoot}
>
<FilePlus
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
</Pressable>
<Pressable
onPress={handleNewFolderAtRoot}
</ToolbarButton>
<ToolbarButton
label={t("workspace.fileActions.newFolder")}
compact={isCompact}
hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={t("workspace.fileActions.newFolder")}
testID="files-new-folder"
onPress={handleNewFolderAtRoot}
>
<FolderPlus
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
</Pressable>
</ToolbarButton>
</>
) : null}
<Pressable
onPress={handleToggleHiddenFiles}
<ToolbarButton
label={hiddenFilesToggleAccessibilityLabel}
selected={!showHiddenFiles}
compact={isCompact}
hitSlop={8}
style={hiddenFilesToggleStyle}
accessibilityRole="button"
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
accessibilityState={hiddenFilesToggleAccessibilityState}
testID="files-hidden-toggle"
onPress={handleToggleHiddenFiles}
>
{showHiddenFiles ? (
<Eye
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
) : (
<EyeOff
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
)}
</Pressable>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={
</ToolbarButton>
<ToolbarButton
label={
isRefreshFetching
? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh")
}
compact={isCompact}
disabled={isRefreshFetching}
hitSlop={8}
testID="files-refresh"
onPress={handleRefresh}
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
<LoadingSpinner
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
) : (
<RotateCw
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
color={theme.colors.foregroundExtraMuted}
/>
)}
</View>
</Pressable>
</View>
</ToolbarButton>
</ToolbarControls>
</PaneContentToolbar>
<ContextMenu>
<RootCreationContextTarget enabled={Boolean(onNewEntryAtRoot)}>
@@ -1479,6 +1428,7 @@ function TreeRowDispatcher({
revealTargetName,
onDownloadEntry,
onAddToChat,
onOpenFileToSide,
onNewEntry,
onCollapseDirectory,
onRenameEntry,
@@ -1500,6 +1450,7 @@ function TreeRowDispatcher({
revealTargetName?: string;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
onOpenFileToSide?: (path: string) => void;
onNewEntry?: (parentPath: string, kind: "file" | "directory") => void;
onCollapseDirectory?: (path: string) => void;
onRenameEntry?: (entry: ExplorerEntry) => void;
@@ -1530,6 +1481,7 @@ function TreeRowDispatcher({
revealTargetName={revealTargetName}
onDownloadEntry={onDownloadEntry}
onAddToChat={onAddToChat}
onOpenFileToSide={onOpenFileToSide}
onNewEntry={onNewEntry}
onCollapseDirectory={onCollapseDirectory}
onRenameEntry={onRenameEntry}
@@ -1662,7 +1614,6 @@ function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surfaceSidebar,
},
desktopSplit: {
flex: 1,
@@ -1699,7 +1650,7 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingRight: theme.spacing[3],
backgroundColor: theme.colors.surfaceSidebar,
},
sortTrigger: {
flexDirection: "row",
@@ -1712,7 +1663,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.base,
},
sortTriggerHovered: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surfaceSidebarHover,
},
sortTriggerText: {
fontSize: theme.fontSize.sm,
@@ -1721,7 +1672,6 @@ const styles = StyleSheet.create((theme) => ({
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
treeList: {
flex: 1,
@@ -1808,38 +1758,12 @@ const styles = StyleSheet.create((theme) => ({
draftPlaceholder: {
color: theme.colors.foregroundExtraMuted,
},
contextMetaBlock: {
paddingVertical: theme.spacing[1],
},
contextMetaRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
minHeight: 32,
paddingHorizontal: theme.spacing[3],
},
contextMetaLabel: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
flexShrink: 0,
},
contextMetaValue: {
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
flex: 1,
minWidth: 0,
textAlign: "right",
},
previewHeaderText: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
iconButtonActive: {
backgroundColor: theme.colors.surface2,
},
refreshIcon: {
width: 16,
height: 16,
@@ -5,6 +5,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { isWeb } from "@/constants/platform";
import {
iconButtonChromeFrameStyle,
iconButtonChromeStyle,
} from "@/components/ui/icon-button-chrome";
interface HeaderToggleButtonState {
hovered: boolean;
@@ -42,7 +46,17 @@ export function HeaderToggleButton({
? ({ "aria-expanded": expandedState } as Record<string, boolean>)
: null;
const combinedStyle = useMemo(() => [headerIconSlotStyle.slot, style], [style]);
const combinedStyle = useMemo(
() =>
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) =>
iconButtonChromeStyle({
size: "large",
state: { hovered: Boolean(hovered), pressed },
disabled: Boolean(disabled),
style,
}),
[disabled, style],
);
return (
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
@@ -68,16 +82,6 @@ export function HeaderToggleButton({
);
}
export const headerIconSlotStyle = StyleSheet.create((theme) => ({
slot: {
padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg,
},
}));
const styles = StyleSheet.create((theme) => ({
tooltipRow: {
flexDirection: "row",
@@ -90,3 +94,7 @@ const styles = StyleSheet.create((theme) => ({
},
shortcut: {},
}));
export const headerIconSlotStyle = {
slot: iconButtonChromeFrameStyle("large"),
};
@@ -10,6 +10,7 @@ import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome";
interface MenuHeaderProps {
title?: string;
@@ -26,7 +27,7 @@ interface SidebarMenuToggleProps {
const MOBILE_MENU_LINE_WIDTH = 16;
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
const MOBILE_MENU_LINE_HEIGHT = 2;
const MOBILE_MENU_LINE_HEIGHT = 1.5;
function MobileMenuIcon({ color }: { color: string }) {
const lineStyle = useMemo(() => [styles.mobileMenuLine, { backgroundColor: color }], [color]);
@@ -84,19 +85,21 @@ function SidebarMenuToggleButton({
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
accessibilityState={accessibilityState}
>
{({ hovered, pressed }) => {
let color = extraMutedIdleIcon
? theme.colors.foregroundExtraMuted
: theme.colors.foregroundMuted;
if (hovered || pressed) {
color = theme.colors.foreground;
}
return isMobile ? (
<MobileMenuIcon color={color} />
) : (
<PanelLeft size={theme.iconSize.md} color={color} />
);
}}
{isMobile ? (
<MobileMenuIcon
color={
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
}
/>
) : (
<PanelLeft
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
color={
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
}
/>
)}
</HeaderToggleButton>
);
}
+1 -1
View File
@@ -1462,7 +1462,7 @@ export const AssistantMessage = memo(function AssistantMessage({
const fileLinkActions = useAssistantFileLinkActions();
const handleMarkdownLinkPress = useStableEvent((url: string) => {
fileLinkActions.open({ href: url }, "side");
fileLinkActions.open({ href: url }, "preferred");
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false;
+14 -2
View File
@@ -13,7 +13,7 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
import { BottomSheetFlatList, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
@@ -1455,7 +1455,19 @@ function ModelBrowserContent({
return scrolling === "independent" ? (
<IndependentProviderList>{allProvidersContent}</IndependentProviderList>
) : (
allProvidersContent
<BottomSheetScrollView
style={styles.virtualizedModelList}
contentContainerStyle={[
styles.virtualizedModelListContent,
styles.virtualizedProviderListContent,
]}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
showsVerticalScrollIndicator={false}
testID="compact-provider-list"
>
{allProvidersContent}
</BottomSheetScrollView>
);
}
+128 -19
View File
@@ -1,14 +1,22 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { View, type PointerEvent as RNPointerEvent } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { startResizeHandleDrag, type ResizeHandleDrag } from "@/components/resize-handle-drag";
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout";
export interface ResizeHandleProps {
testID?: string;
direction: "horizontal" | "vertical";
hitAreaAlignment?: "center" | "end";
groupId: string;
index: number;
sizes: number[];
containerSize: number;
onPreviewResizeSplit: (groupId: string, sizes: number[]) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void;
}
@@ -30,14 +38,18 @@ function resetWindowHorizontalScroll() {
export function ResizeHandle({
testID,
direction,
hitAreaAlignment = "center",
groupId,
index,
sizes,
containerSize,
onPreviewResizeSplit,
onResizeSplit,
}: ResizeHandleProps) {
const { theme } = useUnistyles();
const finePointer = useHasFinePointer();
const pointerStatesRef = useRef(new Map<number, PointerState>());
const touchDragRef = useRef<ResizeHandleDrag | null>(null);
const cursorBeforeDragRef = useRef<string | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [active, setActive] = useState(false);
@@ -51,13 +63,6 @@ export function ResizeHandle({
return;
}
const containerElement = hitAreaElement.parentElement?.parentElement ?? null;
if (!containerElement) {
return;
}
const rect = containerElement.getBoundingClientRect();
const containerSize = direction === "horizontal" ? rect.width : rect.height;
if (containerSize <= 0) {
return;
}
@@ -140,9 +145,41 @@ export function ResizeHandle({
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
},
[direction, groupId, index, onPreviewResizeSplit, onResizeSplit, sizes],
[containerSize, direction, groupId, index, onPreviewResizeSplit, onResizeSplit, sizes],
);
const touchGesture = useMemo(() => {
const gesture = Gesture.Pan()
.runOnJS(true)
.onBegin(() => setDragging(true))
.onStart(() => {
touchDragRef.current = startResizeHandleDrag({
sizes,
index,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
});
})
.onUpdate((event) => {
if (containerSize <= 0) return;
const translation = direction === "horizontal" ? event.translationX : event.translationY;
touchDragRef.current?.move(translation / containerSize);
})
.onEnd(() => touchDragRef.current?.finish())
.onFinalize(() => {
touchDragRef.current = null;
setDragging(false);
});
return direction === "horizontal"
? gesture
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
: gesture
.activeOffsetY([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
.failOffsetX([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET]);
}, [containerSize, direction, groupId, index, onPreviewResizeSplit, onResizeSplit, sizes]);
const handlePointerEnter = useCallback(() => {
hoverTimerRef.current = setTimeout(() => {
setActive(true);
@@ -178,11 +215,36 @@ export function ResizeHandle({
styles.hitArea,
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
{
...(direction === "horizontal"
? { left: hitAreaAlignment === "end" ? 0 : -5 }
: { top: hitAreaAlignment === "end" ? 0 : -5 }),
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
touchAction: "none",
} as object,
],
[direction],
[direction, hitAreaAlignment],
);
const touchHitAreaStyle = useMemo(
() => [
styles.touchHitArea,
direction === "horizontal" ? styles.touchHitAreaHorizontal : styles.touchHitAreaVertical,
{
...(direction === "horizontal"
? { left: hitAreaAlignment === "end" ? 0 : -12 }
: { top: hitAreaAlignment === "end" ? 0 : -12 }),
touchAction: "none",
} as object,
],
[direction, hitAreaAlignment],
);
const touchGripStyle = useMemo(
() => [
styles.touchGrip,
direction === "horizontal" ? styles.touchGripHorizontal : styles.touchGripVertical,
highlighted ? styles.touchGripVisible : styles.touchGripHidden,
{ backgroundColor: theme.colors.foreground },
],
[direction, highlighted, theme.colors.foreground],
);
return (
@@ -194,14 +256,27 @@ export function ResizeHandle({
testID={testID ? `${testID}-highlight` : undefined}
/>
)}
<View
role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
style={hitAreaStyle}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
/>
{finePointer ? (
<View
role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
style={hitAreaStyle}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
/>
) : (
<GestureDetector gesture={touchGesture}>
<View
role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
collapsable={false}
style={touchHitAreaStyle}
>
<View pointerEvents="none" style={touchGripStyle} />
</View>
</GestureDetector>
)}
</View>
);
}
@@ -210,6 +285,7 @@ const styles = StyleSheet.create((_theme) => ({
handle: {
position: "relative",
flexShrink: 0,
zIndex: 10,
},
handleHorizontal: {
width: 1,
@@ -240,15 +316,48 @@ const styles = StyleSheet.create((_theme) => ({
zIndex: 10,
},
hitAreaHorizontal: {
left: -5,
top: 0,
bottom: 0,
width: 10,
},
hitAreaVertical: {
top: -5,
left: 0,
right: 0,
height: 10,
},
touchHitArea: {
position: "absolute",
zIndex: 10,
alignItems: "center",
justifyContent: "center",
},
touchHitAreaHorizontal: {
top: "50%",
width: 24,
height: 88,
transform: [{ translateY: -44 }],
},
touchHitAreaVertical: {
left: "50%",
width: 88,
height: 24,
transform: [{ translateX: -44 }],
},
touchGrip: {
borderRadius: 2,
},
touchGripHorizontal: {
width: 4,
height: 36,
},
touchGripVertical: {
width: 36,
height: 4,
},
touchGripHidden: {
opacity: 0.12,
},
touchGripVisible: {
opacity: 0.3,
},
}));
@@ -37,14 +37,7 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import {
ExternalLink,
GitPullRequest,
Settings,
MoreVertical,
Plus,
Trash2,
} from "lucide-react-native";
import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
@@ -159,6 +152,7 @@ import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import type { HostBadgeModel } from "@/hosts/appearance";
import { useHostBadges } from "@/hosts/use-host-badges";
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -166,7 +160,6 @@ const projectViewKeyExtractor = (project: SidebarProjectEntry) => project.viewKe
const WORKSPACE_STATUS_DOT_WIDTH = 14;
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedPlus = withUnistyles(Plus);
const ThemedMoreVertical = withUnistyles(MoreVertical);
@@ -179,26 +172,6 @@ const foregroundColorMapping = (theme: Theme) => ({
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const redColorMapping = (theme: Theme) => ({
color: theme.colors.statusDanger,
});
const greenColorMapping = (theme: Theme) => ({
color: theme.colors.statusSuccess,
});
const purpleColorMapping = (theme: Theme) => ({
color: theme.colors.statusMerged,
});
function getPrIconUniMapping(state: PrHint["state"]) {
switch (state) {
case "merged":
return purpleColorMapping;
case "open":
return greenColorMapping;
case "closed":
return redColorMapping;
}
}
function isWorkspaceSelected(input: {
selection: ActiveWorkspaceSelection | null;
@@ -352,7 +325,6 @@ export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp<ViewS
const textStyle = isHovered
? [prBadgeStyles.text, prBadgeStyles.textHovered]
: prBadgeStyles.text;
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
const presentation = getForgePresentation(normalizeForge(hint.forge));
return (
@@ -370,9 +342,9 @@ export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp<ViewS
style={pressableStyle}
>
{isHovered ? (
<ThemedExternalLink size={12} uniProps={iconUniProps} />
<ThemedExternalLink size={12} uniProps={foregroundColorMapping} />
) : (
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
<PullRequestStateIcon state={hint.state} size={12} />
)}
<Text style={textStyle} numberOfLines={1}>
{hint.number}
@@ -2,15 +2,7 @@ import { Fragment, useCallback, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
ExternalLink,
Folder,
GitBranch,
GitMerge,
GitPullRequest,
GitPullRequestClosed,
Globe,
} from "lucide-react-native";
import { ExternalLink, Folder, GitBranch, Globe } from "lucide-react-native";
import {
workspaceLabelKey,
type WorkspaceLabelDefinition,
@@ -23,6 +15,7 @@ import { getForgePresentation, normalizeForge } from "@/git/forge";
import { openExternalUrl } from "@/utils/open-external-url";
import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model";
import type { Theme } from "@/styles/theme";
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
import { CheckIndicator } from "./check-indicator";
import type { CheckSummary, CheckSummaryState } from "./check-summary";
import { selectMetaRowItems, type MetaRowItem } from "./meta-items";
@@ -44,9 +37,6 @@ const META_ICON_SIZE = HOST_BADGE_ICON_SIZE;
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedFolder = withUnistyles(Folder);
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed);
const ThemedGlobe = withUnistyles(Globe);
/** Stable identity so a row without labels doesn't re-select its items on every render. */
@@ -54,7 +44,6 @@ const EMPTY_LABELS: readonly WorkspaceLabelDefinition[] = [];
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMerged });
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
/**
@@ -212,7 +201,6 @@ function PullRequestItem({ hint }: { hint: PrHint }) {
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const Icon = isHovered ? ThemedExternalLink : PR_ICONS[hint.state];
return (
<Pressable
accessibilityRole="link"
@@ -227,10 +215,11 @@ function PullRequestItem({ hint }: { hint: PrHint }) {
onHoverOut={handleHoverOut}
style={pressableItemStyle}
>
<Icon
size={META_ICON_SIZE}
uniProps={isHovered ? foregroundMapping : PR_COLOR_MAPPINGS[hint.state]}
/>
{isHovered ? (
<ThemedExternalLink size={META_ICON_SIZE} uniProps={foregroundMapping} />
) : (
<PullRequestStateIcon state={hint.state} size={META_ICON_SIZE} />
)}
<Text style={isHovered ? styles.prTextHovered : styles.prText} numberOfLines={1}>
{hint.number}
{/* An open change request is the unremarkable case and says nothing extra; a merged
@@ -311,20 +300,6 @@ function ServiceItem({ summary }: { summary: WorkspaceServiceSummary }) {
const successMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
const PR_ICONS = {
open: ThemedGitPullRequest,
merged: ThemedGitMerge,
closed: ThemedGitPullRequestClosed,
} as const;
// Same three colours the hover card gives the same three states — a change request has one
// identity, and it shouldn't shift when you hover the row that names it.
const PR_COLOR_MAPPINGS = {
open: successMapping,
merged: mergedMapping,
closed: dangerMapping,
} as const;
const PR_STATE_LABEL_KEYS = {
merged: "workspace.git.pr.states.merged",
closed: "workspace.git.pr.states.closed",
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { resolveSplitContainerRoot } from "@/components/split-container-focus";
import {
resolveSplitContainerRoot,
splitNodeContainsPane,
} from "@/components/split-container-focus";
import type { SplitNode } from "@/stores/workspace-layout-store";
const pane = (id: string): SplitNode => ({
@@ -35,25 +38,11 @@ describe("split focus root", () => {
).toEqual({ root, usesFallbackStrip: false });
});
it("renders only a maximized pane without entering focus mode", () => {
it("finds the maximized pane without replacing the rendered split tree", () => {
expect(splitNodeContainsPane(root, "right")).toBe(true);
expect(splitNodeContainsPane(root, "missing")).toBe(false);
expect(
resolveSplitContainerRoot({
root,
focusedPaneId: "left",
focusModeEnabled: false,
maximizedPaneId: "right",
}),
).toEqual({ root: pane("right"), usesFallbackStrip: false });
});
it("keeps normal splits when the maximized pane is unavailable", () => {
expect(
resolveSplitContainerRoot({
root,
focusedPaneId: "left",
focusModeEnabled: false,
maximizedPaneId: "missing",
}),
resolveSplitContainerRoot({ root, focusedPaneId: "left", focusModeEnabled: false }),
).toEqual({ root, usesFallbackStrip: false });
});
});
@@ -4,9 +4,8 @@ export function resolveSplitContainerRoot(input: {
root: SplitNode;
focusedPaneId: string | null;
focusModeEnabled: boolean | undefined;
maximizedPaneId?: string | null;
}): { root: SplitNode; usesFallbackStrip: boolean } {
const isolatedPaneId = input.focusModeEnabled ? input.focusedPaneId : input.maximizedPaneId;
const isolatedPaneId = input.focusModeEnabled ? input.focusedPaneId : null;
if (!isolatedPaneId) return { root: input.root, usesFallbackStrip: false };
const isolatedPane = findPane(input.root, isolatedPaneId);
if (!isolatedPane || isolatedPane.hidden === true) {
@@ -15,6 +14,12 @@ export function resolveSplitContainerRoot(input: {
return { root: { kind: "pane", pane: isolatedPane }, usesFallbackStrip: false };
}
/** Whether a split subtree contains the pane currently projected full-size. */
export function splitNodeContainsPane(node: SplitNode, paneId: string): boolean {
if (node.kind === "pane") return node.pane.id === paneId;
return node.group.children.some((child) => splitNodeContainsPane(child, paneId));
}
function findPane(node: SplitNode, paneId: string): SplitPane | null {
if (node.kind === "pane") return node.pane.id === paneId ? node.pane : null;
for (const child of node.group.children) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
const BUILDS = {
// Metro resolves src; Node consumers resolve one of the compiled builds.
src: "react-native-svg/src/lib/extract/extractTransform.ts",
esm: "react-native-svg/lib/module/lib/extract/extractTransform",
cjs: "react-native-svg/lib/commonjs/lib/extract/extractTransform.js",
} as const;
const CSS_MODULE = "react-native-svg/src/css/index.tsx";
const XML_MODULE = "react-native-svg/src/xml.tsx";
type Extractor = (props: { transform?: unknown }) => unknown;
type XmlMiddleware = (root: unknown) => unknown;
interface ParsedRoot {
props: Record<string, unknown>;
}
type XmlParser = (source: string, middleware?: XmlMiddleware) => ParsedRoot | null;
async function loadBuild(extractSpecifier: string) {
const [cssModule, extractModule, xmlModule] = (await Promise.all([
import(/* @vite-ignore */ CSS_MODULE),
import(/* @vite-ignore */ extractSpecifier),
import(/* @vite-ignore */ XML_MODULE),
])) as [
{ inlineStyles?: XmlMiddleware; default?: { inlineStyles?: XmlMiddleware } },
{
extractTransformSvgView?: Extractor;
default?: { extractTransformSvgView?: Extractor };
},
{ parse?: XmlParser; default?: { parse?: XmlParser } },
];
const inlineStyles = cssModule.inlineStyles ?? cssModule.default?.inlineStyles;
const extract =
extractModule.extractTransformSvgView ?? extractModule.default?.extractTransformSvgView;
const parseXml = xmlModule.parse ?? xmlModule.default?.parse;
if (!inlineStyles || !extract || !parseXml)
throw new Error(`could not load react-native-svg build`);
return (xml: string) => {
const root = parseXml(xml, inlineStyles);
if (!root) throw new Error(`could not parse root SVG`);
const props = { ...root.props };
const style = props.style;
if (style && typeof style === "object" && "transform" in style) {
props.transform = style.transform;
}
return extract(props);
};
}
describe.each(Object.entries(BUILDS))("root SVG transforms (%s build)", (_name, specifier) => {
it("still converts a real SVG transform string", async () => {
const extractFromXml = await loadBuild(specifier);
expect(extractFromXml(`<svg transform="rotate(-90)"></svg>`)).toEqual([{ rotate: "-90deg" }]);
});
it.each([
["an inline CSS keyword", `<svg style="transform:none"></svg>`],
["an embedded CSS rule", `<svg><style>svg { transform: none; }</style></svg>`],
["a CSS angle unit", `<svg transform="rotate(-90deg)"></svg>`],
["a CSS length unit", `<svg transform="translate(10px, 0)"></svg>`],
])("drops %s from parsed root SVG props", async (_label, xml) => {
const extractFromXml = await loadBuild(specifier);
expect(() => extractFromXml(xml)).not.toThrow();
expect(extractFromXml(xml)).toBeUndefined();
});
});

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