Compare commits

..
Author SHA1 Message Date
Mohamed Boudra f660fcf2e5 chore(release): cut 0.5.0-beta.4 2026-08-21 16:35:11 +02:00
Mohamed Boudra 50480d8c9d docs: add 0.5.0-beta.4 changelog 2026-08-21 16:32:00 +02:00
Mohamed Boudra 75b0eaf8a8 test(app): use platform-aware editor chords in file-editing e2e
CodeMirror's mac keymap binds doc start/end to Cmd and gives Ctrl-a the
emacs line-start behaviour, so the Control chords in these specs did
nothing on macOS and only passed on Linux CI.
2026-08-21 15:20:15 +02:00
Mohamed Boudra 2caecdaae8 Use a dropdown for default send behavior (#3644)
* fix(app): make default send a dropdown

Keep the compact three-choice setting behind one anchored control while preserving the selected behavior and persistence flow.

* test(app): select default send behavior from its menu

The agent-message submission coverage configures send behavior through Settings, so it follows the dropdown interaction rather than the removed segmented buttons.
2026-08-21 15:10:14 +02:00
Mohamed Boudra 29883cb797 fix(app): lay out the tab modified dot inline
The dot was absolutely positioned at the chip's right edge, so it painted
on top of a truncated label. Render it as the last flex child of the tab
content row and widen a modified tab by the dot plus its gap so the label
keeps the room it had.
2026-08-21 15:04:14 +02:00
Mohamed Boudra 97bdbebd7f docs(hub): document semantic GitHub triggers (#3643) 2026-08-21 14:59:04 +02:00
Mohamed Boudra c527b203c4 test(app): stabilize consecutive turn e2e 2026-08-21 13:58:21 +02:00
Mohamed Boudra 602860625c Give readable content its own text size (#3637)
* feat(app): separate content typography from interface size

Readable prose benefits from its own scale without inflating navigation and controls. Preserve existing users’ rendered size during migration while giving fresh desktop installs a 15px content default.

* test(app): cover independent content typography

The appearance journey now verifies that changing interface size leaves content size intact. Timeline traversal yields when a test deliberately holds the requested page, so the helper no longer waits on its own gate.

* test(desktop): cover independent content typography

The desktop appearance regression follows the renamed Interface control and verifies it does not alter Content size.
2026-08-21 12:35:23 +02:00
Mohamed Boudra c032df5b3d Keep Pi chats active through retry recovery (#3639)
* fix(server): preserve Pi turns through retries

Pi reports each provider attempt with agent_end, while agent_settled marks the final user-turn boundary. Keeping modern streams active until settlement prevents a recovered attempt from leaving the chat in an error state.

* fix(server): show Pi provider retries

Automatic retries keep the turn alive, but users still need to understand the delay. Surface Pi's retry-start signal through the existing non-terminal error activity row without changing terminal lifecycle handling.

* refactor(server): clarify Pi retry boundaries

Use the server's object-parameter convention for the retry boundary helpers so their event, turn, and retry semantics stay explicit as the lifecycle evolves.
2026-08-21 12:16:19 +02:00
Okan YILDIRIM e1805ef542 feat(plugins): add theme contributions
Adds plugin-contributed light and dark app themes, including persistence, host-aware coalescing, fallback behavior, documentation, examples, and browser/iOS QA.
2026-08-21 11:48:12 +02:00
Mohamed Boudra a7f3bf7abb fix(app): open Changes on the populated comparison (#3636)
* fix(app): restore smart Changes comparison selection

The tab migration persisted the selected comparison, pinning Changes to an empty view after checkout state changed. Keep comparison overrides ephemeral per surface and expire them when dirtiness changes, while preserving only presentation state in tabs.

* chore(app): date Changes compatibility cleanup
2026-08-21 11:23:43 +02:00
Mohamed Boudra 04624cb419 fix(app): show Setup tabs only for failures (#3634)
* fix(app): only surface failed workspace setup

Running and successful setup snapshots were treated as reasons to seed a background Setup tab, adding noise to every workspace with setup commands. Seed only failed setup and classify that tab as main-workspace content so the failure stays visible without stealing focus.

* fix(app): surface failed setup once

Keep the failed-setup surfacing claim with the setup snapshot so closing its background tab remains respected after the workspace remounts. Explicitly prefer the main pane for a new failure tab without moving a user-placed existing tab.

* fix(app): keep setup tabs out of side panel

A preferred main-pane placement can fall back when users remove that pane. Keep that implicit fallback out of the focused Side panel when another workspace pane is available.
2026-08-21 11:17:04 +02:00
Matt CowgerandMohamed Boudra bc02cfd8cd feat(opencode): steer active turns without interrupting (#3580)
* feat(opencode): add active-turn steering via native V2 prompt

Add OpenCode support for active-turn steering (PR #3394) using the
native V2 session prompt with delivery: steer. Implements
steerActiveTurn, pending steer echo correlation for multiple steers,
prompted event handling, and definitive vs ambiguous error handling.
Updates docs and SDK to 1.18.18 for typed V2 access.

* fix(opencode): use V1 prompt for steer so model sees it

V2 prompt was admitted to a separate inbox not visible to the V1
processor, so "How old is bigbird?" was shown inline but never in
the LLM context. Switch steer to session/prompt_async (V1) which
queues while busy and is included in the next LLM call's history.
Update tests to expect V1 and keep the timeline grouping.

* fix(opencode): honor steering permission contract

Admit the steer before denying permissions that block its delivery, matching the provider steering contract. Remove the abandoned V2 path and SDK upgrade because active-turn delivery uses the existing prompt endpoint.

* chore(opencode): preserve SDK patch metadata

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-21 10:38:36 +02:00
Mohamed Boudra 32d6b18dbf Open summarized tool calls in mobile sheets (#3619)
* feat(app): open summarized tool calls in mobile sheets

Keep compact timelines readable by moving summarized tool-call groups into a live sheet while preserving the existing inline desktop interaction. Individual calls reuse the current detail sheet and stack above the group.

* fix(app): keep mobile tool call sheets current

Restore live group-sheet auto-follow, replace the mocked component coverage with real compact and desktop browser journeys, and remove the touched theme subscriptions.
2026-08-21 08:53:42 +02:00
DanielWalnutandMohamed Boudra cf4664ddf1 feat: add MiniMax Code ACP provider (#3457)
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-21 08:53:24 +02:00
Christoph LeiterandClaude Opus 5 c3d37eba4b feat(app): filter the sidebar by project (#3563)
The sidebar can be pinned to a set of hosts or to a set of labels, but
not to a set of projects. If you keep many projects on one host, the
project is the filter you want.

Add a Project page to the sidebar display menu, next to Host. It
multi-selects projects, and an empty selection means every project.

Apply the filter at two points. Project grouping reads the projects
array, but Status grouping builds its rows from the workspace entries.
A filter applied to only one of the two leaves the other mode
unfiltered.

Apply nothing when a stored selection names no visible project. The
project list is narrowed by the host filter, and it is empty before any
host connects, so dropping keys that do not match would destroy the
selection on every cold start.

Count the project filter in the sidebar header gate. Pinning every chat
in a project drops that project from the unpinned list. Filtering to
that project then removed the header, and the header carries the display
menu, which is the only way back to the Project page.

Generalise the label filter empty state to all sidebar filters, and move
its strings out of the labels namespace.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:51:35 +02:00
Tuzhibaev TimurandMohamed Boudra 33224ace47 Russian localization fixes by a native speaker (#3586)
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-20 23:22:19 +02:00
efa25ad3fe fix(server): show Claude usage again on macOS (#3597)
* fix(server): show Claude usage again on macOS

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): keep an incomplete Keychain item from hiding a working one

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(server): tighten Claude keychain fix

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-20 23:02:25 +02:00
BrianAguilarWasco 28a963795b fix(server): extend OpenCode event stream readiness timeout to match server startup budget
Closes #3571.
2026-08-20 22:56:18 +02:00
Mohamed Boudra 3f7188bc1c fix(codex): restore externally archived active threads (#3334)
Codex threads can be archived outside Paseo while their Paseo agent remains active. Repair that provider-state drift during interactive resume while preserving the existing read-only history path for Paseo-archived agents.
2026-08-21 04:39:26 +08:00
Caique Maurano b54c8e493a fix(desktop): show Android Studio app icon (#3614) 2026-08-20 22:26:38 +02:00
Mohamed Boudra 19a7d26349 feat(app): make the side panel user-directed (#3605)
* feat(app): let the file panel close its tree rail

The file panel's tree rail was permanent on desktop — the changes panel
had a folder-tree toggle in its toolbar, the file toolbar had nothing, so
the rail was there whether you wanted it or not.

The changes panel's private DesktopTreeToggle becomes a shared
TreeRailToggle so both toolbars render the same affordance; that also
drops the treeToggleStyle prop threaded through ChangesToolbarProps,
which recomputed exactly what the component already built internally.
The i18n keys move out of workspace.git.diff into workspace.tree, since a
shared component reaching into the git namespace would rot.

The two rails keep independent flags — the file rail in panel-store, the
changes rail in useChangesPreferences — so closing one leaves the other
alone. FileTreeToggle wires itself to the store rather than taking a
callback: the bar renders several components below FilePanel, where the
rail actually lives.

Both rails also get narrower (320 -> 260 default, 200 -> 180 minimum).
The persist version bump resets treeRailWidth on purpose: nearly every
existing install carries the old 320 default, so without the reset the
narrower rail would only ever reach new users. Anyone who dragged their
rail to a deliberate custom width loses it once.

Note that MIN_CONTENT_WIDTH still clamps the rail to
`containerWidth - 240`, so in a narrow pane the new default never
applies — that floor, not the default, is what sets the width there.

* feat(app): make the side panel an empty workspace surface

The fixed, hideable side panel keeps supporting context beside an agent without seeding content. Pane-local choices stay local, while implicit supporting opens follow a persisted routing preference.

* test(app): exercise side panel policy through its capability boundary

The browser rail journey already covers the real tree-toggle behavior. Side-panel policy tests now pass their platform capability through the public boundary instead of replacing a production module.

* test(app): expect an empty side panel after upgrade

* test(app): expect an empty first split pane

* test(app): account for the empty side panel in split retention

* feat(app): make workspace panel tabs independent

Give explicit New actions fresh tab instances, keep panel state opaque to tab management, preserve File navigation context, and stabilize uncached File transitions.

* fix(app): preserve file conflicts during preview refresh

Keep the current preview mounted for same-target reads and conversions so the editor retains its conflict observation state. Clear retained previews only when the file target changes.
2026-08-20 21:49:23 +02:00
Mohamed Boudra 2a0a0ff656 Keep mobile workspace switching responsive with long chats (#3610)
* perf(app): bound native workspace mount work

Native workspace switches retained inactive React trees and mounted large chat histories in aggressive list batches, making Hermes and Fabric work scale with prior usage.

Retain only the active native workspace, reveal a shared turn-aligned history window locally before daemon pagination, preserve desktop retention semantics, and pace native list mounting.

* test(app): cover bounded history journeys

Exercise local history reveal and hidden outline jumps through the production browser viewport while preserving held-page pagination and scroll anchoring.

* fix(app): reveal fetched outline targets

A bounded merged jump could load its target before the mounted history window, leaving the pending jump unable to scroll. Reveal fetched hidden targets through the existing history-window capability, then scroll once they mount.

* test(app): follow anchored history reveals

History-start helpers could stop at a transient edge before local window expansion restored the content anchor. Retry the real wheel or keyboard action after geometry settles until the timeline remains at history start.

* test(app): wait for history anchor release
2026-08-20 20:52:12 +02:00
Mohamed Boudra 5d7afd59ae website: keep Hub prerequisites focused 2026-08-20 14:47:55 +02:00
Mohamed Boudra 959da4acb8 website: simplify Hub getting started 2026-08-20 14:46:15 +02:00
Mohamed Boudra 9163aed3f1 Prepare Hub guided self-hosting docs for the next release (#3342)
* docs(hub): drop the manual auth secret from self-hosting setup

Hub generates its authentication secret on first start and stores it in the
database it is already using, so self-hosters no longer run openssl or carry
PASEO_HUB_AUTH_SECRET between restarts. The variable stays as an override for
deployments that must supply the secret from a platform secret store; Hub uses
it without persisting it, so removing it resumes the stored value.

Also corrects a claim that predates this change: rotating the secret does not
invalidate execution credentials already issued. Hub verifies those against
persisted hashes rather than the secret, so only browser sessions end.

Documentation companion to https://github.com/getpaseo/hub/pull/50.

* docs(hub): lead with the local guided setup

* docs(hub): document XDG data directory

* docs: make Hub self-hosting the quickstart
2026-08-20 14:16:01 +02:00
c6b04f302a fix(server): stop a steered prompt from deadlocking on a permission (#3585)
* fix(server): stop a steered prompt from deadlocking on a permission

Claude parks its turn inside the blocking canUseTool callback while a
plan approval or a tool prompt waits for an answer. The SDK stops to
read its input stream there, so a message sent at the open card is
queued and never read. The user must reject the card or interrupt the
agent by hand, and then send the message a second time.

Deny the pending permissions after the steer is admitted. The prompt is
already in the SDK input queue when the permission callback returns, so
Claude reads it in the same turn. Plan mode stays on and no turn
restarts.

One pass is not enough. A turn that made several tool calls opens its
next request 10 to 300 milliseconds after the previous one is answered,
so a single snapshot advances the batch by one and parks the turn again.
The Claude adapter already tracks the steers the SDK has not read, so
expose that as an optional AgentSession.hasUnreadSteer and answer each
new request while that method reports the prompt as unread. The release
stops the moment the prompt is read, so a permission the agent asks for
after it acts on the message still reaches the user.

Only the two entrypoints that carry a message from a person set the
flag. A scheduled prompt, a child-finished notification, and an
agent-to-agent prompt must not answer a card the user is looking at.

Denial left no timeline row, so a denied plan disappeared from the
transcript. Claude and Codex now push a plan_approval row with
approved: false. For Codex the row comes from resolvePlanPermission,
which is the path that dismissPendingPlanApprovals also uses.

In the app, queue mode held the message until the turn ended, and a
parked turn never ends. The composer now sends while a permission is
pending, and the button label agrees with the submit path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): keep a post-prompt permission out of the release

The release loop answered the pending requests before it asked whether
the agent had read the prompt. The agent can read the prompt during a
poll interval and then open a permission request because of it. The next
pass denied that request, so a decision that belongs to the user was
made for them.

The loop now asks first and answers second. The first pass stays
unconditional, because a provider that does not report unread steers
must still get one release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): make steer permission clearing provider-owned

Keep the daemon policy explicit while letting each provider queue the steer and release permissions that block it. This removes timing-based polling and preserves rejected plans as canonical timeline rows.

* fix(server): preserve omitted steer options

Only construct provider steer options when permission clearing is requested. System prompts otherwise keep the existing undefined options contract.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-20 14:02:00 +02:00
Mohamed Boudra 975dbd433b Fix project icon cache reuse in New Workspace (#3600)
* fix(app): reuse project icon cache in new workspace

New Workspace omitted each host placement's effective icon revision, so its icon queries could not share the sidebar's memory or persistent cache entries. Carry that revision through the target projection.

* refactor(app): centralize project icon targets

Project icon consumers constructed equivalent cache targets independently. One target factory now carries each host placement revision through the sidebar, New Workspace, Projects, and Project Settings.

* test(app): inline project icon fixture
2026-08-20 13:15:58 +02:00
Mohamed Boudra 4dbd26c88c fix(server): accept Git-valid worktree branch names (#3591)
Branch-off creation reused the filesystem slug validator even when callers supplied a separate branch name and worktree slug. Validate branch refs with Git at the worktree boundary so exact valid refs are preserved while path slugs remain restricted.
2026-08-20 09:44:29 +02:00
paseo-ai[bot] 2e18620a28 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-20 07:23:03 +00:00
Mohamed Boudra dd8efba552 chore(release): cut 0.5.0-beta.3 2026-08-20 09:12:30 +02:00
Mohamed Boudra 08556c2bda docs(changelog): add 0.5.0-beta.3 entry 2026-08-20 09:09:05 +02:00
Matt Cowger bed5d2b1be fix(server): add upstream remote fallback to GitHub PR checkout refs (#2997) 2026-08-20 02:54:30 +08:00
Mohamed Boudra 4391c47615 fix(app): clear composer text input upon sending on mobile (#3564)
* fix(app): clear mobile composer input on submit

Submitting a prompt on mobile failed to clear the native text input because EditingTextInput.replaceText previously only called setNativeProps({ text }), which is ignored on Fabric and PasteTextInput.

Fix this by:
- Delegating replaceText in EditingTextInputNative to inputRef.current.replaceText when available, clearing via clear() when text is empty, and falling back to setNativeProps.
- Patching @mattermost/react-native-paste-input to expose replaceText(text, selection) backed by native Commands.setTextAndSelection, updating clear() to use live event counts, and preventing useImperativeHandle closures over null refs during initial mount.
- Registering the patch in scripts/postinstall-patches.mjs and adding unit tests for native text input replacement and clearing.

* fix: update nix npm dependency hash
2026-08-19 18:53:26 +02:00
paseo-ai[bot] 1fbe5cab39 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-19 14:41:35 +00:00
Mohamed Boudra ccd991b3e3 chore(release): cut 0.5.0-beta.2 2026-08-19 16:30:43 +02:00
Mohamed Boudra 5fca5b7ccb chore: update lockfile metadata 2026-08-19 16:27:50 +02:00
Mohamed Boudra 9b7f12a6e9 docs(changelog): add 0.5.0-beta.2 entry 2026-08-19 16:24:51 +02:00
paseo-ai[bot] 1492bc659a fix: update lockfile signatures and Nix hash [skip ci] 2026-08-19 14:00:40 +00:00
Mohamed Boudra 345638dc7f fix(app): retire the docked explorer sidebar
Upgrading to 0.5.0-beta.1 left the old docked explorer sidebar showing
next to the new explorer pane, with no way to dismiss it.

The sidebar rendered off the persisted `desktop.fileExplorerOpen` flag.
#3287 rerouted the explorer toggle to the workspace-layout explorer pane,
so nothing on a wide layout ever wrote that flag false again, and no
migration cleared it. Assistant directory links and review attachments
could still set it. On Windows and Linux the sidebar's close button was
suppressed because the OS window controls own the top-right corner, so
there was no affordance at all.

The explorer is now one surface picked from two facts — is the layout
compact, and can the platform render split panes:

  compact                -> slide-over overlay (panel store)
  non-compact + splits   -> dedicated right pane (layout store)
  non-compact, no splits -> ordinary tabs in the focused pane

`workspace-tabs/explorer-surface.ts` owns that choice and every entry
point routes through it, so the toggle, Ctrl+E, Ctrl+., directory links,
review attachments and side file opens can no longer disagree about where
the explorer lives.

Pane splits stay gated on `isWeb`: they depend on dnd-kit and DOM-backed
accessibility helpers. Native tablets are non-compact without splits, so
they get explorer tabs rather than a second pane — deliberately trading
side-by-side agent and Changes for a surface that only renders what the
single-column tab row can actually reach.

Ctrl+. is handled by the workspace screen rather than the app shell. The
pathname alone cannot identify the active workspace: desktop cold-starts
at "/" and restores the remembered workspace from route params, so a key
derived from the pathname is null exactly when a workspace is mounted,
and the explorer reads as permanently closed.

`desktop.fileExplorerOpen` and `explorerWidth` are dropped by a migration
to panel-state v15 but stay declared in the persisted schema. That schema
is a strict object and the validated storage deletes the whole entry when
parsing fails, so removing the keys outright would wipe every upgrading
user's sidebar width, expanded paths and diff-collapse state.
2026-08-19 15:52:51 +02:00
Mohamed Boudra 929d2b717e fix(plugins): publish the plugin SDK under the @getpaseo scope
The daemon imports the plugin SDK at runtime, but the workspace was named
@paseo/plugin and marked private, so it was never published. 0.5.0-beta.1
shipped @getpaseo/server depending on a package that does not exist and
`npm install @getpaseo/cli@beta` failed with a 404 for every user.

Two things hid it. sync-workspace-versions only rewrites deps matching
@getpaseo/*, so the "*" range on @paseo/plugin survived untouched into the
published tarball. And `npm pack --dry-run`, which is all release:check and
CI run, lists files without ever resolving the dependency graph.

Renaming to @getpaseo/plugin rather than publishing the old name: @paseo is
not our scope on npm, so that name could never have been published at all.
The rename also brings the package under the version-sync filter, which is
what pins it correctly from here on.

Plugins scaffolded against the old name still import @paseo/plugin. Both
spellings resolve through plugin-sdk-specifiers.ts, tagged COMPAT with a
removal date, so existing plugins keep loading.

This does not repair 0.5.0-beta.1 — a published tarball's dependencies
cannot be changed. The package publishes with the next release.
2026-08-19 15:52:51 +02:00
Mohamed Boudra ce905d9483 fix(app): open new tabs in the focused pane
Preserve ambient placement for reconciliation and background opens while routing user-directed tab creation through the focused visible pane. Defer terminal reconciliation during creates so a push cannot preempt the intended pane placement.
2026-08-19 15:52:51 +02:00
Mohamed Boudra 133c38c5c3 docs(plugins): link contribution roadmap 2026-08-19 15:52:51 +02:00
Caique Maurano 97a6deaa39 feat(desktop): add Android Studio editor target (#3531) 2026-08-19 14:14:13 +02:00
Mohamed Boudra e12bdc2fc5 test(server): align directory search expectations 2026-08-19 13:06:06 +02:00
paseo-ai[bot] 41298bd910 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-19 10:49:47 +00:00
320 changed files with 13935 additions and 4644 deletions
+1 -1
View File
@@ -207,11 +207,11 @@
"packages/app/src/desktop/browser/pane/index.tsx",
"packages/app/src/desktop/browser/pane/index.web.tsx",
"packages/app/src/components/command-center.tsx",
"packages/app/src/components/compact-explorer-sidebar.tsx",
"packages/app/src/components/context-window-meter.tsx",
"packages/app/src/components/dictation-controls.tsx",
"packages/app/src/components/download-toast.tsx",
"packages/app/src/components/draggable-list.native.tsx",
"packages/app/src/components/explorer-sidebar.tsx",
"packages/app/src/components/headers/back-header.tsx",
"packages/app/src/components/headers/menu-header.tsx",
"packages/app/src/components/headers/screen-header.tsx",
+51
View File
@@ -1,5 +1,56 @@
# Changelog
## 0.5.0-beta.4 - 2026-08-21
### Added
- 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 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))
### 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))
- Changed automatic Setup tabs to appear only after workspace setup fails ([#3634](https://github.com/getpaseo/paseo/pull/3634))
- 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
+1
View File
@@ -52,6 +52,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
| [docs/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference |
| [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 |
+1 -1
View File
@@ -23,7 +23,7 @@ RUN set -eux; \
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@paseo/plugin --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/plugin --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
+3 -1
View File
@@ -103,7 +103,9 @@ recoverable from History. Opening one pins its tab and renders the archived-agen
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow.
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. A
provider session can be archived outside Paseo while its Paseo agent remains active. Interactive
resume repairs that drift through the provider's native unarchive hook; history resume does not.
Provider session connection owns every process it spawns until the session is registered with
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
+1 -1
View File
@@ -30,7 +30,7 @@ Before adding a new component, read `components/ui/`. The primitive usually exis
Hierarchy is conveyed through weight and color, not size. Most interface text is `fontSize.base`; compact metadata and hints use `fontSize.sm`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
The authored ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Base size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Code size remains independent.
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 15px on every platform. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
Weight has three tiers, applied by role:
+2 -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 explorer toggle, run the app against the root checkout's daemon and use:
For the desktop Side panel toggle, run the app against the root checkout's daemon and use:
```bash
npm run profile:explorer-toggle --workspace=@getpaseo/app
```
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the explorer pane,
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Side panel 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
+2 -1
View File
@@ -19,6 +19,7 @@ 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.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
@@ -43,7 +44,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
- **Composer track** — A contextual lane at the foot of a pane, by the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**, **Tasks track**. The subagents and tasks tracks are pills floating over the transcript, each opening a panel with its rows; the queue track is a lane inside the composer itself. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), pill and panel frame in `packages/app/src/composer/tracks.tsx`, `SubagentsTrack` (`packages/app/src/subagents/track.tsx`), `AgentTaskList` (`packages/app/src/composer/task-list/index.tsx`).
- **Steer** — A follow-up admitted into an agent's already-running turn instead of interrupting it. Interrupting the turn discards steers the provider has not read yet; there is no take-back short of that, and no provider offers one. The stop button and Escape both interrupt. A steer the provider never read keeps its transcript row, matching Codex. UI: "Send and steer". Code: `steerActiveTurn` (`packages/server/src/server/agent/agent-sdk-types.ts`). Don't confuse with **Queue track**, which holds messages client-side until the turn ends and never enters the running turn. Forbidden: "Delivery", "Queued prompt".
- **Steer** — A follow-up admitted into an agent's already-running turn instead of interrupting it. Interrupting the turn discards steers the provider has not read yet; there is no take-back short of that, and no provider offers one. The stop button and Escape both interrupt. A steer the provider never read keeps its transcript row, matching Codex. Sending while the agent waits on a permission denies that request first, so the message reaches the same turn instead of stalling behind it. UI: "Send and steer". Code: `steerActiveTurn` (`packages/server/src/server/agent/agent-sdk-types.ts`). Don't confuse with **Queue track**, which holds messages client-side until the turn ends and never enters the running turn. Forbidden: "Delivery", "Queued prompt".
- **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term.
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
- **Fork** — Starting a NEW agent session seeded with a copy of another session's curated chat history. The history rides along as a `chat_history` composer attachment on a draft; the fork does **not** resume or branch the provider-side session, and the source session keeps running untouched. Two targets, one surface, one RPC (`agent.fork_context`, gated on the `agentForkContext` host feature). UI: the fork menu lives in the assistant turn footer and offers "Fork in a new tab" / "Fork in a new workspace". On a **completed** turn it sits beside the copy button and pins a boundary to that turn, so the fork stops where the turn ended. On the **in-flight** turn it sits beside the progress loader and elapsed timer, and omits the boundary entirely — `selectForkContextRows` projects the whole timeline when no boundary is given, so the fork captures everything **up to now**, including the response still streaming. That omission is what makes mid-run forking work; pinning a boundary there would silently drop the live response. Forbidden on provider-subagent (read-only) panes. Code: `useForkAgent` (`packages/app/src/hooks/use-fork-agent.ts`), `selectForkContextRows` (`packages/server/src/server/agent/activity-curator.ts`). Don't confuse with: git fork (unrelated; Paseo has no such concept), **Worktree** (filesystem isolation, not history copying).
+35 -12
View File
@@ -1,7 +1,7 @@
# Local plugins
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
this first slice does not sandbox it.
@@ -53,7 +53,7 @@ my-plugin/
```
Paseo compiles TypeScript and TSX when loading the plugin, so these packages are development dependencies only.
The generated declaration file supplies `@paseo/plugin` and `@paseo/plugin/server` types until the
The generated declaration file supplies `@getpaseo/plugin` and `@getpaseo/plugin/server` types until the
SDK is distributed as a public package. Regenerate new plugins with the matching Paseo CLI when the
SDK contract changes.
@@ -96,15 +96,15 @@ code lives behind filename boundaries:
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
Shared files import contracts from `@paseo/plugin/server`. Client files import hooks from
`@paseo/plugin`. Plugin UI runs on desktop and mobile across multiple themes: color every
Shared files import contracts from `@getpaseo/plugin/server`. Client files import hooks from
`@getpaseo/plugin`. Plugin UI runs on desktop and mobile across multiple themes: color every
`Text` from `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from
`layout.compact`. See `public-docs/plugins/reference.md`.
| Module | Use it for |
| ---------------------- | -------------------------------------------------------- |
| `@paseo/plugin` | hooks and UI types |
| `@paseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
| Module | Use it for |
| ------------------------- | -------------------------------------------------------- |
| `@getpaseo/plugin` | hooks and UI types |
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
The compiler removes client registrations and imports from the server entry point, and server
registrations and imports from the client entry point. Importing a `*.server` module from a client
@@ -113,7 +113,7 @@ such as `StyleSheet.create` belong in `*.client.tsx`; placing them in `index.ts`
server bundle.
```ts
import type { PluginContext } from "@paseo/plugin";
import type { PluginContext } from "@getpaseo/plugin";
import { Greeting } from "./greeting.client";
import { createGreeting } from "./greeting.server";
import { greetRpc } from "./greeting.shared";
@@ -178,7 +178,7 @@ search picker, drafts, selected pill, and submission. The plugin returns complet
credentials and vendor API calls stay in the daemon handler.
```ts
import type { PluginContext } from "@paseo/plugin";
import type { PluginContext } from "@getpaseo/plugin";
import { search } from "./issues.server";
import { issues, searchIssues } from "./issues.shared";
@@ -194,5 +194,28 @@ on several hosts are not coalesced. The selected snapshot submits as a text atta
external-resource presentation, so it remains readable if the plugin is removed or an older peer
drops the optional presentation fields.
See `plugin-examples/local-plugin` for a native surface and `plugin-examples/linear` for a complete
attachment-source example.
## Contribute a theme
`addTheme` takes a small light or dark palette and a display name. Paseo expands it through the
same semantic builders as the built-in themes, so plugins do not depend on the complete app token
contract. Unistyles needs every theme name at `StyleSheet.configure` time, so
`packages/app/src/styles/theme.ts` reserves one light and one dark plugin slot. The appearance
provider rewrites the matching slot when the selection changes. See [unistyles.md](unistyles.md)
for the runtime-patching rules the appearance settings share.
`addTheme` is a client registration, so the compiler strips it from the backend bundle. A daemon
that predates it does not, and the plugin fails to start there. Daemons advertise
`features.pluginThemes` in `server_info`; the plugin theme catalog is the one place the app reads it, and
a host without it contributes no themes.
The selection persists as `theme: "plugin"` plus a `pluginThemeId` of `<pluginId>/theme/<themeId>`,
so equal themes on several hosts coalesce the way sidebar contributions do. Two hosts can answer
that id with different palettes, so picking a theme records its host through
`rememberPluginContributionHost` and resolution prefers it; a peer connecting or dropping then does
not repaint the app. Without a preference the sorted registry snapshot decides, so the result is
stable rather than arrival-ordered. The app resolves that id
against the installed catalog on every change; an id nothing contributes falls back to the default
preference instead of painting the reserved slot's placeholder colors.
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
attachment-source example, and `plugin-examples/catppuccin` for a theme.
+3 -1
View File
@@ -81,10 +81,12 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId` set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe.
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. The released OpenCode v1 adapter does not implement this operation.
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. A missing session reports `unavailable` and uses the normal interrupt fallback.
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`.
`SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text.
Rewind accepts the canonical wire `messageId` and resolves it to the provider identity before calling the adapter. A submitted prompt cannot be rewound until its provider echo supplies that identity.
Submitted user-message wire items carry the same Paseo ID in `messageId` and `clientMessageId`. Provider adapters attach `clientMessageId` only to the echo for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
+1 -1
View File
@@ -120,7 +120,7 @@ republish the packages:
```bash
PASEO_VERSION=$(node -p "require('./package.json').version")
for package in highlight relay protocol client server cli; do
for package in highlight relay protocol client plugin server cli; do
npm dist-tag add "@getpaseo/$package@$PASEO_VERSION" beta
done
```
+94
View File
@@ -0,0 +1,94 @@
# 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 -2
View File
@@ -335,15 +335,16 @@ If we ever need to avoid the transition entirely, store at least the theme prefe
## Runtime Theme Patching For User Preferences
Appearance settings (UI/mono font family, font sizes, syntax-highlight theme) are applied by patching every registered theme at runtime with `UnistylesRuntime.updateTheme(name, updater)` — not by threading preference reads through components. `applyAppearance` in `packages/app/src/screens/settings/appearance/apply-appearance.ts` runs from a `ProvidersWrapper` effect on settings load/change and loops all six theme keys, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
Appearance settings (theme, UI/mono font family, font sizes, syntax-highlight theme) are owned by `packages/app/src/appearance`. Its provider subscribes once and synchronizes Unistyles when settings or plugin contributions change. `applyAppearance` patches every key in `REGISTERED_THEMES`, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
This works without `useUnistyles()` because every consumer already reads these tokens through `StyleSheet.create((theme) => …)` (or the `withUnistyles`/`uniProps` path for the markdown renderer), so patching the theme repaints tracked views through the native ShadowRegistry with no React re-render.
Gotchas:
- **Patch all themes, not just the active one.** The active theme can change and adaptive mode can flip light/dark; patching every key keeps the active key current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. The effect depends on the settings values (not on `theme`), so it cannot loop.
- **The reserved plugin keys are the exception to that ordering.** A plugin-contributed theme is rebuilt from its palette, which discards the appearance patch, so the appearance provider writes the matching light or dark slot before applying font and syntax preferences. See [plugins.md](plugins.md).
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
- **The app shell uses one `AppearanceStyleBoundary`.** Runtime-patched numeric theme values are baked into Unistyles web classes rather than CSS variables, while parsed/memoized content also does not naturally re-run when appearance tokens change. The boundary sits below stable runtime providers in `app/_layout.tsx` and remounts the visual shell once. `applyAppearance` patches the active theme before inactive registry entries so its subscribers receive the committed values in the same update. Do not add local appearance keys or nested boundaries.
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
+1 -1
View File
@@ -1 +1 @@
sha256-yHpFbO9HYwuyioIOKzpSv/FZjMqne+LFUSq5eFgRBPI=
sha256-FhcGrCB3+Nsr/RCEQClXwTZW+jyRptNpNjIzAmnb3pI=
+31 -31
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.5.0-beta.1",
"version": "0.5.0-beta.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.5.0-beta.1",
"version": "0.5.0-beta.4",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -6247,6 +6247,10 @@
"resolved": "packages/highlight",
"link": true
},
"node_modules/@getpaseo/plugin": {
"resolved": "packages/plugin",
"link": true
},
"node_modules/@getpaseo/protocol": {
"resolved": "packages/protocol",
"link": true
@@ -9556,10 +9560,6 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@paseo/plugin": {
"resolved": "packages/plugin",
"link": true
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -36140,7 +36140,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.5.0-beta.1",
"version": "0.5.0-beta.4",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -36154,12 +36154,12 @@
"@getpaseo/client": "*",
"@getpaseo/expo-two-way-audio": "*",
"@getpaseo/highlight": "*",
"@getpaseo/plugin": "*",
"@getpaseo/protocol": "*",
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@marijn/find-cluster-break": "^1.0.2",
"@mattermost/react-native-paste-input": "2.0.1",
"@paseo/plugin": "*",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
@@ -37192,12 +37192,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.5.0-beta.1",
"version": "0.5.0-beta.4",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.5.0-beta.1",
"@getpaseo/protocol": "0.5.0-beta.1",
"@getpaseo/server": "0.5.0-beta.1",
"@getpaseo/client": "0.5.0-beta.4",
"@getpaseo/protocol": "0.5.0-beta.4",
"@getpaseo/server": "0.5.0-beta.4",
"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.1",
"version": "0.5.0-beta.4",
"dependencies": {
"@getpaseo/protocol": "0.5.0-beta.1",
"@getpaseo/relay": "0.5.0-beta.1",
"@getpaseo/protocol": "0.5.0-beta.4",
"@getpaseo/relay": "0.5.0-beta.4",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37458,7 +37458,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.5.0-beta.1",
"version": "0.5.0-beta.4",
"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.1",
"version": "0.5.0-beta.4",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38597,7 +38597,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.5.0-beta.1",
"version": "0.5.0-beta.4",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38625,13 +38625,13 @@
}
},
"packages/plugin": {
"name": "@paseo/plugin",
"version": "0.5.0-beta.1",
"name": "@getpaseo/plugin",
"version": "0.5.0-beta.4",
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "*",
"@getpaseo/client": "0.5.0-beta.4",
"@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": "*",
"@getpaseo/client": "0.5.0-beta.4",
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.5.0-beta.1",
"version": "0.5.0-beta.4",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38660,7 +38660,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.5.0-beta.1",
"version": "0.5.0-beta.4",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38878,20 +38878,20 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.5.0-beta.1",
"version": "0.5.0-beta.4",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.5.0-beta.1",
"@getpaseo/highlight": "0.5.0-beta.1",
"@getpaseo/protocol": "0.5.0-beta.1",
"@getpaseo/relay": "0.5.0-beta.1",
"@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",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
"@paseo/plugin": "*",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
@@ -39940,7 +39940,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.5.0-beta.1",
"version": "0.5.0-beta.4",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.5.0-beta.1",
"version": "0.5.0-beta.4",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -47,8 +47,8 @@
"build": "npm run build --workspaces --if-present",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
"build:plugin": "npm run build --workspace=@paseo/plugin",
"build:plugin:clean": "npm run build:clean --workspace=@paseo/plugin",
"build:plugin": "npm run build --workspace=@getpaseo/plugin",
"build:plugin:clean": "npm run build:clean --workspace=@getpaseo/plugin",
"build:relay": "npm run build --workspace=@getpaseo/relay",
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
@@ -102,11 +102,11 @@
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/plugin && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/plugin && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/plugin --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/plugin --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/plugin --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/plugin --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
"release:push": "node scripts/push-current-release-tag.mjs",
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:publish:beta && npm run release:push",
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:publish:beta && npm run release:push",
@@ -11,8 +11,8 @@ import {
} from "../support/helpers/settings";
const ACP_PROVIDER = {
id: "hermes",
name: "Hermes",
id: "minimax-code",
name: "MiniMax Code",
};
interface ProviderCatalogDaemonClient {
@@ -22,7 +22,7 @@ interface ProviderCatalogDaemonClient {
}
test.describe("ACP provider catalog", () => {
test("adds a catalog provider from settings", async ({ page }) => {
test("adds MiniMax Code from settings", async ({ page }) => {
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
clientIdPrefix: "provider-catalog-e2e",
});
@@ -726,7 +726,7 @@ async function recordDelayedRunningTransition(
gate.holdNextAgentStreamEvent("turn_started");
gate.holdNextAgentUpdate(agent.agentId, "running");
gate.holdNextServerMessage("send_agent_message_response");
gate.setAgentStreamItemSuppressed("assistant_message", true);
gate.setAgentStreamSuppressed(true);
await recordTurnFrames(page, prompt);
await installActivityContinuityOracle(page, prompt);
await submitMessage(page, prompt);
@@ -779,13 +779,13 @@ async function recordDelayedRunningTransition(
const checkpoints = await readActivityContinuityOracle(page);
expectActivityContinuity(checkpoints);
expect(elapsedBeforeAuthoritative).toBe(0);
gate.setAgentStreamItemSuppressed("assistant_message", false);
gate.setAgentStreamSuppressed(false);
return { frames, checkpoints };
} finally {
if (!turnReleased) gate.releaseHeldAgentStreamEvent("turn_started");
if (!snapshotReleased) gate.releaseHeldAgentUpdate(agent.agentId, "running");
if (!responseReleased) gate.releaseHeldServerMessage("send_agent_message_response");
gate.setAgentStreamItemSuppressed("assistant_message", false);
gate.setAgentStreamSuppressed(false);
}
}
@@ -814,6 +814,8 @@ test("keeps the first prompt of a new agent in place through authoritative hydra
await expectComposerVisible(page);
const prompt = "Delay synthetic user message by 300ms.";
gate.holdNextServerMessage("fetch_agent_timeline_response");
gate.setAgentStreamEventSuppressed("timeline", true);
await attachImageFromMenu(page, FIRST_PROMPT_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await recordTurnFrames(page, prompt);
@@ -821,7 +823,10 @@ test("keeps the first prompt of a new agent in place through authoritative hydra
const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }).first();
await expect(submittedRow).toBeVisible();
await gate.waitForServerMessage("fetch_agent_timeline_response");
await gate.waitForHeldServerMessage("fetch_agent_timeline_response");
gate.truncateHeldTimelineAfterLast("user_message");
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
await expect(submittedRow.getByTestId("rewind-menu-trigger")).toBeVisible();
await recordPaintsFor(page, 80);
expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page));
} finally {
@@ -315,10 +315,11 @@ async function configureInterruptInSettings(page: Page): Promise<void> {
async function selectSendBehaviorInSettings(
page: Page,
buttonName: string,
behaviorLabel: string,
stored: string,
): Promise<void> {
await page.getByRole("button", { name: buttonName, exact: true }).click();
await page.getByRole("button", { name: /^Default send: / }).click();
await page.getByRole("menuitem", { name: behaviorLabel, exact: true }).click();
await expect
.poll(async () => {
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
@@ -91,14 +91,16 @@ test.describe("Agent timeline pagination", () => {
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectTimelinePromptCentered(page, agent.newestPrompt);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await expectTimelinePromptNotMounted(page, agent.initialTailOldestPrompt);
await userScrollsTimelineToHistoryStart(page);
await expectTimelinePromptVisible(page, agent.initialTailOldestPrompt);
await history.expectRequestedPages(1);
history.releasePage(1);
await history.expectSettledWithRequestedPages(1);
await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt);
await userScrollsTimelineToHistoryStart(page);
await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt);
await history.expectRequestedPages(2);
} finally {
await agent.cleanup();
@@ -45,7 +45,7 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test
}
});
test("applies the base font size to settings text", async ({ page }) => {
test("applies the interface font size to settings text", async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiBaseFontSize: 21 }));
});
@@ -56,11 +56,14 @@ test("applies the base font size to settings text", async ({ page }) => {
const sectionTitle = page.getByText("Theme", { exact: true }).first();
await expect(sectionTitle).toHaveCSS("font-size", "18px");
const fontSizeInput = page.getByLabel("Base font size");
await expect(fontSizeInput).toHaveValue("21");
await fontSizeInput.fill("12");
await fontSizeInput.press("Tab");
const interfaceSizeInput = page.getByLabel("Interface font size");
const contentSizeInput = page.getByLabel("Content font size");
await expect(interfaceSizeInput).toHaveValue("21");
await expect(contentSizeInput).toHaveValue("21");
await interfaceSizeInput.fill("12");
await interfaceSizeInput.press("Tab");
await expect(fontSizeInput).toHaveValue("12");
await expect(interfaceSizeInput).toHaveValue("12");
await expect(contentSizeInput).toHaveValue("21");
await expect(sectionTitle).toHaveCSS("font-size", "10px");
});
+185 -1
View File
@@ -258,6 +258,61 @@ test.afterEach(async () => {
}
});
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
const workspace = await createWorkspaceWithCommittedDiff();
await openWorkspaceChangesSurface(page, workspace);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
});
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const mode = panel.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();
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, {
timeout: 30_000,
});
await writeFile(path.join(workspace.repoPath, "new-working-change.txt"), "uncommitted\n");
await expect(mode).toContainText("Uncommitted", { timeout: 30_000 });
});
test("an empty Changes comparison links to the populated comparison", async ({ page }) => {
const workspace = await createWorkspaceWithCommittedDiff();
await openWorkspaceChangesSurface(page, workspace);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const mode = panel.getByTestId("changes-diff-status-trigger");
await mode.click();
await page.getByTestId("changes-diff-mode-committed").click();
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
await mode.click();
await page.getByTestId("changes-diff-mode-uncommitted").click();
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible();
const seeCommitted = panel.getByRole("button", { name: "See committed changes" });
await expect(seeCommitted).toBeVisible();
await seeCommitted.click();
await expect(mode).toContainText("Committed");
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
});
test("changes file actions open below the right-click without a reserved kebab", async ({
page,
}) => {
@@ -648,7 +703,7 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
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);
@@ -658,6 +713,41 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen
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);
@@ -675,6 +765,12 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen
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();
@@ -735,7 +831,38 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
await page.getByTestId("changes-toggle-tree").click();
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "false");
await expectFlatFileList(page);
const screenshot = testInfo.outputPath("changes-toolbar.png");
await page.screenshot({ path: screenshot });
await testInfo.attach("changes-toolbar", { path: screenshot, contentType: "image/png" });
});
test("compact Changes keeps its options control touch-sized", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
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([
options.boundingBox(),
options.locator("svg").boundingBox(),
]);
if (!optionsBox || !glyphBox) {
throw new Error("Compact Changes options geometry could not be measured");
}
expect(optionsBox.width).toBe(32);
expect(optionsBox.height).toBe(32);
expect(glyphBox.width).toBe(18);
expect(glyphBox.height).toBe(18);
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");
});
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
@@ -875,6 +1002,7 @@ test("split canvas creates a review on the changed side and keeps it in that col
const workspace = await createWorkspaceWithMountedTabDiff();
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
await openWorkspaceChanges(page, workspace);
await setOpenChangesPresentation(page, { layout: "split", wrapLines: false });
await startReviewOnFirstChangedLine(page, "right");
const [editor, body] = await Promise.all([
page.getByTestId("inline-review-editor").boundingBox(),
@@ -1004,6 +1132,7 @@ test("canvas diff copies only the selected split side", async ({ context, page }
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
await openSelectionWorkspaceChanges(page, workspace);
await setOpenChangesPresentation(page, { layout: "split", wrapLines: false });
const before = await readSelectionPaintSamples(page, "right");
await dragAddedTextRange(page, {
lines,
@@ -1027,6 +1156,7 @@ test("canvas diff copies exact wrapped fragments", async ({ context, page }) =>
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await configureDiffPresentation(page, { layout: "unified", wrapLines: true });
await openSelectionWorkspaceChanges(page, workspace);
await setOpenChangesPresentation(page, { layout: "unified", wrapLines: true });
await dragAddedTextRange(page, {
lines: [content],
wrapped: true,
@@ -1151,6 +1281,31 @@ async function configureDiffPresentation(
);
}
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";
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;
if (currentWrapLines !== requestedPresentation.wrapLines) {
await wrapItem.click();
} else {
await page.keyboard.press("Escape");
}
}
async function holdBrowserFontLoads(page: Page): Promise<void> {
await page.addInitScript(() => {
const fontSet = document.fonts;
@@ -1265,6 +1420,28 @@ async function createWorkspaceWithMountedTabDiff(
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-committed-", {
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
});
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
});
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path });
await writeFile(path.join(repo.path, "committed-only.ts"), "export const committed = true;\n");
execFileSync("git", ["add", "committed-only.ts"], { cwd: repo.path });
execFileSync("git", ["commit", "-m", "Add committed-only file"], { cwd: repo.path });
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!created.workspace) throw new Error(created.error ?? "Failed to create committed workspace");
return { id: created.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithExactSelectionDiff(content: string): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-canvas-selection-", {
files: [{ path: "src/selection.ts", content: "" }],
@@ -1290,6 +1467,13 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
await expectExpandedMountedTabDiff(page);
}
async function openWorkspaceChangesSurface(page: Page, workspace: DirtyWorkspace): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
}
async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
@@ -119,6 +119,7 @@ test.describe("desktop chat outline", () => {
test("previews and jumps to the focused prompt from the keyboard", async ({ page }) => {
await focusChatOutlinePrompt(page, 1);
await expectChatOutlinePreview(page, agent.prompts[0]);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await pressEnterOnFocusedPrompt(page);
await expectTimelinePromptLandedBelowTop(page, agent.oldestPrompt);
@@ -51,6 +51,19 @@ test("commit history shows dates and shares diff layout preferences", async ({
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
const [commitToolbarBox, layoutToggleBox, layoutToggleGlyphBox] = await Promise.all([
panel.getByTestId("commit-diff-header").boundingBox(),
layoutToggle.boundingBox(),
layoutToggle.locator("svg").boundingBox(),
]);
if (!commitToolbarBox || !layoutToggleBox || !layoutToggleGlyphBox) {
throw new Error("Commit-diff toolbar geometry could not be measured");
}
expect(commitToolbarBox.height).toBe(36);
expect(layoutToggleBox.width).toBe(24);
expect(layoutToggleBox.height).toBe(24);
expect(layoutToggleGlyphBox.width).toBe(14);
expect(layoutToggleGlyphBox.height).toBe(14);
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
await expectCommitDiffHeaderGeometry(panel);
@@ -77,7 +77,7 @@ diffPerfDescribe("Diff canvas performance", () => {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByRole("button", { name: "Open explorer" }).click();
await openChangesPanel(page);
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
await expectSingleCanvasSurface(page);
@@ -0,0 +1,33 @@
import { expect, test } from "../support/fixtures";
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
test.describe("Direct terminal shortcut pane placement", () => {
test("opens a terminal in the focused pane", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "direct-terminal-shortcut-pane-" });
try {
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 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);
} finally {
await workspace.cleanup();
}
});
});
@@ -9,7 +9,7 @@ import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { ensureExplorerPane, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
function visible(page: Page, testId: string): Locator {
return page.getByTestId(testId).filter({ visible: true });
@@ -37,9 +37,8 @@ function mainTabRow(page: Page): Locator {
}
async function focusExplorerPane(page: Page): Promise<void> {
await ensureExplorerPane(page);
await openChangesPanel(page);
const changesTab = visible(page, "workspace-tab-working_diff").first();
await changesTab.click({ position: { x: 12, y: 13 } });
await expect(changesTab).toHaveAttribute("aria-selected", "true");
}
@@ -54,11 +53,15 @@ async function closeSeededDraftInMainPane(page: Page): Promise<void> {
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
}
async function emptyPaneBox(page: Page) {
const paneChild = page
function emptyPaneChild(page: Page): Locator {
return page
.getByTestId("split-group-child")
.filter({ hasText: "No tabs in this pane." })
.filter({ has: page.getByTestId("workspace-pane-empty-state") })
.first();
}
async function emptyPaneBox(page: Page) {
const paneChild = emptyPaneChild(page);
await expect(paneChild).toBeVisible({ timeout: 15_000 });
const box = await paneChild.boundingBox();
expect(box).not.toBeNull();
@@ -170,7 +173,7 @@ test.describe("explorer pane tab placement", () => {
});
// The split must have taken: agent pane + empty main + explorer = 3 tab rows.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
await expect(page.getByText("No tabs in this pane.")).toBeVisible();
await expect(page.getByTestId("workspace-pane-empty-state")).toBeVisible();
});
await test.step("app must stay interactive after the split", async () => {
@@ -183,12 +186,10 @@ test.describe("explorer pane tab placement", () => {
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
// Clicking inside the empty pane (focuses it) must not lock anything.
const emptyPane = page
.getByTestId("split-group-child")
.filter({ hasText: "No tabs in this pane." })
.first();
await emptyPane.click({ timeout: 5_000 });
// Clicking inside the empty pane (focuses it) must not lock anything. Aim
// above the vertically centred launcher, which would open a tab instead.
const emptyPane = emptyPaneChild(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();
@@ -207,7 +208,9 @@ test.describe("explorer pane tab placement", () => {
});
// The agent chip must have moved into the previously empty pane and stay clickable.
await expect(page.getByText("No tabs in this pane.")).toHaveCount(0, { timeout: 10_000 });
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(0, {
timeout: 10_000,
});
await agentTabChip(page, agentId)
.first()
.click({ position: { x: 12, y: 13 } });
@@ -0,0 +1,131 @@
// Reported on 0.5.0-beta.1 desktop: after upgrading, the old docked explorer
// sidebar and the new explorer pane were both visible, and on Windows the
// sidebar had no close button because the window controls own that corner.
//
// The sidebar rendered off a persisted `desktop.fileExplorerOpen` flag that no
// migration cleared and that the rerouted explorer toggle no longer wrote. The
// explorer is now one surface per layout, so that flag must not resurrect it.
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 { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
function visible(page: Page, testId: string): Locator {
return page.getByTestId(testId).filter({ visible: true });
}
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");
}
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
async function seedRetiredSidebarState(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem(
"panel-state",
JSON.stringify({
version: 14,
state: {
desktop: { agentListOpen: true, fileExplorerOpen: true, focusModeEnabled: false },
explorerTab: "changes",
explorerTabByCheckout: {},
expandedPathsByWorkspace: {},
diffCollapsedFoldersByWorkspace: {},
collapsedFilePathsByWorkspace: {},
sidebarWidth: 340,
explorerWidth: 400,
explorerSortOption: "name",
explorerShowHiddenFiles: true,
treeRailWidth: 320,
},
}),
);
});
}
test.describe("explorer surface after upgrading from the docked sidebar", () => {
test("a persisted fileExplorerOpen flag no longer renders a second explorer", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-surface-upgrade-" });
try {
await seedRetiredSidebarState(page);
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);
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 toggle.click();
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
await expect(dockedSidebar(page)).toHaveCount(0);
});
await test.step("the retired keys are migrated out of storage", async () => {
const persisted = await readPersistedPanelState(page);
expect(persisted).not.toBeNull();
expect(persisted).not.toContain("fileExplorerOpen");
expect(persisted).not.toContain("explorerWidth");
// Everything else the user had must survive the migration.
expect(persisted).toContain('"sidebarWidth":340');
});
} finally {
await workspace.cleanup();
}
});
test("toggle-both-sidebars closes and restores the agent list and the explorer", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-surface-both-" });
const modifier = process.platform === "darwin" ? "Meta" : "Control";
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
const agentList = visible(page, "sidebar-sessions");
const toggle = visible(page, "workspace-explorer-toggle").first();
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(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(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();
});
} finally {
await workspace.cleanup();
}
});
});
@@ -117,7 +117,7 @@ test.describe("CodeMirror workspace file editing", () => {
const sourceEditor = editor(page);
await sourceEditor.click();
await sourceEditor.press("Control+Home");
await sourceEditor.press("ControlOrMeta+Home");
await expect(page.getByLabel(/^Line 1, column \d+$/)).toBeVisible();
await page
@@ -241,13 +241,13 @@ test.describe("CodeMirror workspace file editing", () => {
const initialModeBox = await modeControl.boundingBox();
expect(initialModeBox).not.toBeNull();
const initialModeX = initialModeBox!.x;
await content.press("Control+End");
await content.press("ControlOrMeta+End");
await expect(page.getByLabel(/Line 12, column \d+/)).toBeVisible();
const movedModeBox = await modeControl.boundingBox();
expect(movedModeBox).not.toBeNull();
expect(movedModeBox!.x).toBe(initialModeX);
await content.press("Control+a");
await content.press("ControlOrMeta+a");
const selection = editorHost.locator(".cm-selectionBackground").first();
await expect(selection).toBeVisible();
await expect(selection).toHaveCSS("background-color", "rgba(255, 255, 255, 0.2)");
@@ -0,0 +1,128 @@
import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { delayFileReadResponse } from "../support/helpers/file-read-gate";
import {
expandFolder,
openFileExplorer,
openFileFromExplorer,
} from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
let workspace: SeededWorkspace;
test.beforeAll(async () => {
workspace = await seedWorkspace({
repoPrefix: "file-transition-",
repo: {
files: [
{ path: "src/a.ts", content: "export const sourceA = true;\n" },
{ path: "src/b.ts", content: "export const sourceB = true;\n" },
{ path: "docs/b.md", content: "# Markdown B\n" },
],
},
});
});
test.afterAll(async () => workspace?.cleanup());
async function openSourceA(page: import("@playwright/test").Page) {
await openFileExplorer(page);
await expandFolder(page, "src");
await openFileFromExplorer(page, "a.ts");
await expect(page.getByText("export const sourceA = true;")).toBeVisible({ timeout: 30_000 });
}
async function assertHeldFileTransition(
page: import("@playwright/test").Page,
targetPath: string,
finalText: string,
gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
) {
await page.evaluate((tabTestId) => {
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
const sample = () => {
const next = {
chip: Boolean(document.querySelector(`[data-testid="${tabTestId}"]`)),
loading: Boolean(document.querySelector('[data-testid="file-preview-loading"]')),
unavailable: Boolean(document.querySelector('[data-testid="file-preview-unsupported"]')),
};
if (JSON.stringify(timeline.at(-1)) !== JSON.stringify(next)) timeline.push(next);
};
sample();
const observer = new MutationObserver(sample);
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
(
window as typeof window & { __fileTransitionTimeline?: typeof timeline }
).__fileTransitionTimeline = timeline;
(
window as typeof window & { __fileTransitionObserver?: MutationObserver }
).__fileTransitionObserver = observer;
}, `workspace-tab-file_${targetPath}`);
await openFileFromExplorer(page, targetPath.split("/").pop() as string);
await gate.waitUntilHeld();
const targetChip = page.getByTestId(`workspace-tab-file_${targetPath}`).first();
await expect(targetChip).toBeVisible();
await expect(targetChip).toContainText(targetPath.split("/").pop() as string);
await expect(page.getByTestId("file-preview-loading")).toBeVisible();
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
await expect(page.getByText("export const sourceA = true;")).toHaveCount(0);
const heldTimeline = await page.evaluate(
() =>
(
window as typeof window & {
__fileTransitionTimeline?: Array<{
chip: boolean;
loading: boolean;
unavailable: boolean;
}>;
}
).__fileTransitionTimeline ?? [],
);
expect(heldTimeline).toContainEqual(expect.objectContaining({ chip: true, loading: true }));
expect(heldTimeline).not.toContainEqual(expect.objectContaining({ unavailable: true }));
const descriptorIndex = heldTimeline.findIndex((sample) => sample.chip);
expect(heldTimeline.slice(descriptorIndex)).not.toContainEqual(
expect.objectContaining({ chip: false }),
);
gate.release();
await expect(page.getByText(finalText)).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("file-preview-loading")).toHaveCount(0);
const timeline = await page.evaluate(() => {
const windowWithTimeline = window as typeof window & {
__fileTransitionTimeline?: Array<{ chip: boolean; loading: boolean; unavailable: boolean }>;
__fileTransitionObserver?: MutationObserver;
};
windowWithTimeline.__fileTransitionObserver?.disconnect();
return windowWithTimeline.__fileTransitionTimeline ?? [];
});
expect(
timeline.filter((sample, index) => sample.loading && !timeline[index - 1]?.loading),
).toHaveLength(1);
}
test.describe("File target transitions", () => {
test("keeps the Markdown descriptor and loading state stable through a real delayed read", async ({
page,
}) => {
const gate = await delayFileReadResponse(page, "docs/b.md");
await page.reload();
await gotoWorkspace(page, workspace.workspaceId);
await openSourceA(page);
await expandFolder(page, "docs");
await assertHeldFileTransition(page, "docs/b.md", "Markdown B", gate);
});
test("keeps the source descriptor and loading state stable through a real delayed read", async ({
page,
}) => {
const gate = await delayFileReadResponse(page, "src/b.ts");
await page.reload();
await gotoWorkspace(page, workspace.workspaceId);
await openSourceA(page);
await assertHeldFileTransition(page, "src/b.ts", "export const sourceB = true;", gate);
});
});
@@ -0,0 +1,115 @@
// 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");
});
});
@@ -0,0 +1,83 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import { submitMessage } from "../support/helpers/composer";
import { waitForPermissionPrompt } from "../support/helpers/permissions";
import { cleanupRewindFlow, type AgentHandle, launchAgent } from "../support/helpers/rewind-flow";
const scenarios = [
{
provider: "claude" as const,
providerConfig: { model: "haiku", modeId: "plan" },
planPrompt:
"Do not ask questions. Produce a concise plan to append the line 'Hello' to README.md, then present it for approval. Do not implement it.",
planText: "README.md",
steerPrompt: "Do not implement anything. Reply exactly CLAUDE_STEER_RECEIVED and stop.",
reply: "CLAUDE_STEER_RECEIVED",
},
{
provider: "codex" as const,
providerConfig: { featureValues: { plan_mode: true } },
planPrompt:
"Produce a concise implementation plan with exactly these steps: Inspect permission steering; Implement permission steering; Verify permission steering. Do not implement it.",
planText: "Inspect permission steering",
steerPrompt: "Do not produce another plan. Reply exactly CODEX_STEER_RECEIVED and stop.",
reply: "CODEX_STEER_RECEIVED",
},
];
test.describe("composer steer supersedes plan approval", () => {
for (const scenario of scenarios) {
test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({
page,
}, testInfo) => {
test.setTimeout(420_000);
const cwd = realpathSync(
mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: scenario.provider,
cwd,
mode: "full-access",
providerConfig: scenario.providerConfig,
});
await submitMessage(page, scenario.planPrompt);
await waitForPermissionPrompt(page, 180_000);
const pendingPlan = page.getByTestId("permission-plan-card");
await expect(pendingPlan).toContainText(scenario.planText);
const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`);
await page.screenshot({ path: pendingScreenshot });
await testInfo.attach(`${scenario.provider} pending plan`, {
path: pendingScreenshot,
contentType: "image/png",
});
await submitMessage(page, scenario.steerPrompt);
await expect(pendingPlan).toHaveCount(0, { timeout: 30_000 });
const rejectedPlan = page.getByTestId("timeline-plan-card");
await expect(rejectedPlan).toBeVisible({ timeout: 30_000 });
await expect(rejectedPlan).toContainText(scenario.planText);
await expect(
page.getByTestId("assistant-message").filter({ hasText: scenario.reply }),
).toBeVisible({
timeout: 180_000,
});
const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`);
await page.screenshot({ path: rejectedScreenshot });
await testInfo.attach(`${scenario.provider} rejected plan`, {
path: rejectedScreenshot,
contentType: "image/png",
});
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
}
});
@@ -0,0 +1,118 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { openSettingsSection } from "../support/helpers/settings";
const PLUGIN_ID = "plugin-theme-e2e";
// Catppuccin Mocha: base, text, surface0, surface1, mauve, subtext0, overlay0.
const PLUGIN_SOURCE = `export default function contribute(plugin) {
plugin.addTheme({
id: "mocha",
name: "Catppuccin Mocha",
appearance: "dark",
colors: {
background: "#1e1e2e",
foreground: "#cdd6f4",
raised: "#313244",
control: "#45475a",
border: "#45475a",
accent: "#cba6f7",
mutedForeground: "#a6adc8",
ring: "#6c7086",
},
});
plugin.addTheme({
id: "latte",
name: "Catppuccin Latte",
appearance: "light",
colors: {
background: "#eff1f5",
foreground: "#4c4f69",
raised: "#e6e9ef",
control: "#dce0e8",
border: "#ccd0da",
accent: "#8839ef",
mutedForeground: "#6c6f85",
ring: "#9ca0b0",
},
});
return () => {};
}`;
// settingsStyles.sectionHeaderTitle paints from foregroundMuted, so the section heading proves the
// contributed palette reached the semantic tokens rather than just the swatch.
const MOCHA_MUTED_FOREGROUND = "rgb(166, 173, 200)";
const LATTE_MUTED_FOREGROUND = "rgb(108, 111, 133)";
test("applies a contributed theme and falls back when its plugin is gone", async ({
page,
}, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(path.join(directory, "index.ts"), PLUGIN_SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.goto("/settings");
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await openSettingsSection(page, "appearance");
const sectionTitle = page.getByText("Theme", { exact: true }).first();
await page.getByLabel("Theme: System", { exact: true }).click();
const mochaItem = page.getByText("Catppuccin Mocha", { exact: true });
await expect(mochaItem).toBeVisible({ timeout: 30_000 });
await page.screenshot({
path: testInfo.outputPath("plugin-theme-picker.png"),
animations: "disabled",
fullPage: true,
});
await test.step("a contributed light theme uses the light palette", async () => {
await page.getByText("Catppuccin Latte", { exact: true }).click();
await expect(page.getByLabel("Theme: Catppuccin Latte", { exact: true })).toBeVisible();
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
await page.getByLabel("Theme: Catppuccin Latte", { exact: true }).click();
});
await mochaItem.click();
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible();
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
await page.screenshot({
path: testInfo.outputPath("plugin-theme-applied.png"),
fullPage: true,
});
await test.step("the selection survives a reload", async () => {
await page.reload();
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
});
await test.step("removing the plugin falls back to the default theme", async () => {
await client.removePlugin(PLUGIN_ID);
await expect(page.getByLabel("Theme: System", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(sectionTitle).not.toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
await page.screenshot({
path: testInfo.outputPath("plugin-theme-fallback.png"),
fullPage: true,
});
});
} finally {
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
await client
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
.catch(() => undefined);
await client.close().catch(() => undefined);
await rm(directory, { recursive: true, force: true });
}
});
@@ -31,7 +31,7 @@ function isSettledWorkspaceUrl(url: URL): boolean {
function pluginSource(): string {
return `import React, { useRef } from "react";
import { Text, View } from "react-native";
import { useAgent, useWorkspace } from "@paseo/plugin";
import { useAgent, useWorkspace } from "@getpaseo/plugin";
function WorkspacePanel({ workspaceId, host, layout }) {
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
@@ -70,9 +70,28 @@ test.describe("Settings toggle tab regression", () => {
await pressSettingsToggleShortcut(page);
await expect(page).toHaveURL(/\/settings\/general$/);
await page.getByRole("button", { name: "Queue", exact: true }).click();
const defaultSendTrigger = page.getByRole("button", {
name: "Default send: Steer",
exact: true,
});
await expect(defaultSendTrigger).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Queue", exact: true })).toHaveCount(0);
await defaultSendTrigger.click();
await expect(page.getByRole("menuitem", { name: "Steer", exact: true })).toHaveAttribute(
"aria-checked",
"true",
);
await page.getByRole("menuitem", { name: "Queue", exact: true }).click();
await expectSendBehavior(page, "queue");
await page.getByRole("button", { name: "Interrupt", exact: true }).click();
const queuedDefaultSendTrigger = page.getByRole("button", {
name: "Default send: Queue",
exact: true,
});
await expect(queuedDefaultSendTrigger).toBeVisible();
await queuedDefaultSendTrigger.click();
await page.getByRole("menuitem", { name: "Interrupt", exact: true }).click();
await expectSendBehavior(page, "interrupt");
await pressSettingsToggleShortcut(page);
+217
View File
@@ -0,0 +1,217 @@
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();
}
});
});
@@ -0,0 +1,127 @@
import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
closeSidebarDisplayPreferences,
openSidebarProjectFilter,
pinWorkspaceFromSidebar,
selectAllProjectsFilter,
selectSidebarStatusGrouping,
toggleProjectFilter,
} from "../support/helpers/sidebar";
test.describe("Sidebar project filter", () => {
test.describe.configure({ timeout: 180_000 });
test("pins the sidebar to one project across both grouping modes", async ({ page }) => {
// Two temp repos means two projects, which is also what makes the `Project ›` row appear.
const alpha = await seedWorkspace({ repoPrefix: "project-filter-alpha-", title: "Alpha work" });
const beta = await seedWorkspace({ repoPrefix: "project-filter-beta-", title: "Beta work" });
const serverId = getServerId();
const alphaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${alpha.workspaceId}`);
const betaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${beta.workspaceId}`);
const filterTrigger = page.getByTestId("sidebar-display-project-filter");
try {
await gotoAppShell(page);
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
await expect(betaRow).toBeVisible({ timeout: 30_000 });
await openSidebarProjectFilter(page);
await expect(page.getByTestId("sidebar-project-filter-all")).toBeVisible();
await toggleProjectFilter(page, alpha.projectKey);
await closeSidebarDisplayPreferences(page);
await expect(alphaRow).toBeVisible();
await expect(betaRow).toHaveCount(0, { timeout: 10_000 });
// The indicator reads the filter as it is applied, so it must be on here.
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(filterTrigger).toBeVisible();
await expect(filterTrigger.getByTestId("menu-sub-indicator")).toBeVisible();
await closeSidebarDisplayPreferences(page);
// Status grouping builds its rows from the workspace entries rather than the projects
// array, so a filter applied in only one of the two places passes every check above and
// silently fails right here.
await selectSidebarStatusGrouping(page);
await closeSidebarDisplayPreferences(page);
await expect(alphaRow).toBeVisible({ timeout: 15_000 });
await expect(betaRow).toHaveCount(0);
// The filter is view state and survives a cold load.
await page.reload();
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
await expect(betaRow).toHaveCount(0);
await openSidebarProjectFilter(page);
await selectAllProjectsFilter(page);
await closeSidebarDisplayPreferences(page);
await expect(alphaRow).toBeVisible();
await expect(betaRow).toBeVisible({ timeout: 15_000 });
} finally {
await beta.cleanup();
await alpha.cleanup();
}
});
test("keeps the display menu reachable when the pinned section swallows the filtered project", async ({
page,
}) => {
// Pinning hoists a chat out of its project, and a project whose chats are ALL hoisted is
// dropped from the project list entirely. Filter to that project and the list body has no
// project rows left — so the header, which carries the only route back to the filter page,
// has to survive on the strength of the filter alone.
const alpha = await seedWorkspace({
repoPrefix: "project-filter-pinned-",
title: "Pinned work",
});
const beta = await seedWorkspace({ repoPrefix: "project-filter-other-", title: "Other work" });
const serverId = getServerId();
const alphaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${alpha.workspaceId}`);
try {
await gotoAppShell(page);
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
await openSidebarProjectFilter(page);
await toggleProjectFilter(page, alpha.projectKey);
await closeSidebarDisplayPreferences(page);
await pinWorkspaceFromSidebar(page, alpha.workspaceId);
await expect(alphaRow).toBeVisible();
// The way out of the filter is still on screen.
await expect(page.getByTestId("sidebar-display-preferences-menu")).toBeVisible();
await openSidebarProjectFilter(page);
await selectAllProjectsFilter(page);
await closeSidebarDisplayPreferences(page);
await expect(
page.getByTestId(`sidebar-workspace-row-${serverId}:${beta.workspaceId}`),
).toBeVisible({ timeout: 15_000 });
} finally {
await beta.cleanup();
await alpha.cleanup();
}
});
test("hides the filter row when there is only one project", async ({ page }) => {
const only = await seedWorkspace({ repoPrefix: "project-filter-solo-", title: "Solo work" });
const serverId = getServerId();
try {
await gotoAppShell(page);
await expect(
page.getByTestId(`sidebar-workspace-row-${serverId}:${only.workspaceId}`),
).toBeVisible({ timeout: 30_000 });
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible();
await expect(page.getByTestId("sidebar-display-project-filter")).toHaveCount(0);
} finally {
await only.cleanup();
}
});
});
@@ -343,7 +343,7 @@ test.describe("Half-screen desktop layout", () => {
await openFilesPanel(page);
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
await expect(explorerToggle).toHaveAccessibleName("Close explorer");
await expect(explorerToggle).toHaveAccessibleName("Close side panel");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
@@ -351,7 +351,7 @@ test.describe("Half-screen desktop layout", () => {
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
0,
);
await expect(explorerToggle).toHaveAccessibleName("Open explorer");
await expect(explorerToggle).toHaveAccessibleName("Open side panel");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
} finally {
await workspace.cleanup();
@@ -0,0 +1,280 @@
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
type WebSocketMessage = string | Buffer;
const COMPACT_VIEWPORT = { width: 390, height: 520 };
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function getToolCallStatus(
message: WebSocketMessage,
agentId: string,
): { callId: string; status: string } | null {
const sessionMessage = parseSessionMessage(message);
if (sessionMessage?.type !== "agent_stream") {
return null;
}
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
if (payload?.agentId !== agentId) {
return null;
}
const event = payload.event as Record<string, unknown> | undefined;
const item = event?.item as Record<string, unknown> | undefined;
return event?.type === "timeline" &&
item?.type === "tool_call" &&
typeof item.callId === "string" &&
typeof item.status === "string"
? { callId: item.callId, status: item.status }
: null;
}
function cloneToolCallWithId(
message: WebSocketMessage,
callId: string,
sequenceOffset: number,
): string {
const raw = typeof message === "string" ? message : message.toString("utf8");
const envelope = JSON.parse(raw) as {
message?: { payload?: { seq?: number; event?: { item?: { callId?: string } } } };
};
const payload = envelope.message?.payload;
const item = payload?.event?.item;
if (!payload || !item?.callId || typeof payload.seq !== "number") {
throw new Error("Expected a tool-call session message");
}
item.callId = callId;
payload.seq += sequenceOffset;
return JSON.stringify(envelope);
}
function shiftTimelineSequence(
message: WebSocketMessage,
sequenceOffset: number,
): WebSocketMessage {
if (sequenceOffset === 0) {
return message;
}
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { message?: { payload?: { seq?: number } } };
const payload = envelope.message?.payload;
if (typeof payload?.seq !== "number") {
return message;
}
payload.seq += sequenceOffset;
return JSON.stringify(envelope);
} catch {
return message;
}
}
async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string) {
let firstCallId: string | null = null;
let firstCompletedMessage: WebSocketMessage | null = null;
let isHolding = false;
let sequenceOffset = 0;
const heldMessages: WebSocketMessage[] = [];
let sendToPage: ((message: WebSocketMessage) => void) | null = null;
let resolveFirstCompleted!: () => void;
const firstCompleted = new Promise<void>((resolve) => {
resolveFirstCompleted = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
sendToPage = (message) => ws.send(message);
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (isHolding) {
heldMessages.push(message);
return;
}
ws.send(shiftTimelineSequence(message, sequenceOffset));
const toolCall = getToolCallStatus(message, agentId);
firstCallId ??= toolCall?.callId ?? null;
if (toolCall?.callId === firstCallId && toolCall.status === "completed") {
firstCompletedMessage = message;
isHolding = true;
resolveFirstCompleted();
}
});
});
return {
waitForFirstCompleted: () => firstCompleted,
release(extraCompletedCalls = 0) {
isHolding = false;
if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) {
throw new Error("Expected the first completed tool call before release");
}
for (let index = 0; index < extraCompletedCalls; index += 1) {
sendToPage?.(
cloneToolCallWithId(
firstCompletedMessage as WebSocketMessage,
`${firstCallId}:${index}`,
index + 1,
),
);
}
sequenceOffset = extraCompletedCalls;
for (const message of heldMessages.splice(0)) {
sendToPage?.(shiftTimelineSequence(message, sequenceOffset));
}
},
};
}
async function configureOverviewToolCalls(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
);
});
}
async function createOverviewAgent(page: Page, title: string) {
await configureOverviewToolCalls(page);
return seedMockAgentWorkspace({
repoPrefix: "tool-call-overview-sheet-",
title,
model: "ten-second-stream",
});
}
async function openOverviewAgent(
page: Page,
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
): Promise<void> {
await openAgentRoute(page, {
workspaceId: agent.workspaceId,
agentId: agent.agentId,
});
await expectComposerVisible(page);
}
function hasScrolledToLatest(root: HTMLElement): boolean {
for (const node of root.querySelectorAll<HTMLElement>("*")) {
if (node.scrollHeight <= node.clientHeight) {
continue;
}
const maximumScrollTop = node.scrollHeight - node.clientHeight;
return maximumScrollTop > 0 && Math.abs(node.scrollTop - maximumScrollTop) <= 1;
}
return false;
}
function readScrollMetrics(root: HTMLElement) {
const badgeElements = root.querySelectorAll<HTMLElement>("[data-testid=tool-call-badge]");
const latestBadge = badgeElements.item(badgeElements.length - 1);
const scrollables = [];
for (const node of root.querySelectorAll<HTMLElement>("*")) {
if (node.scrollHeight <= node.clientHeight) {
continue;
}
scrollables.push({
className: node.className,
clientHeight: node.clientHeight,
rect: node.getBoundingClientRect().toJSON(),
scrollHeight: node.scrollHeight,
scrollTop: node.scrollTop,
tagName: node.tagName,
});
}
return {
latestBadgeRect: latestBadge?.getBoundingClientRect().toJSON(),
rootRect: root.getBoundingClientRect().toJSON(),
scrollables,
viewport: { height: window.innerHeight, width: window.innerWidth },
};
}
test.describe("compact overview tool calls", () => {
test.use({ viewport: COMPACT_VIEWPORT, hasTouch: true });
test("keeps a live group sheet current and stacks individual details above it", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const agent = await createOverviewAgent(page, "Compact overview tool calls");
try {
const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
await openOverviewAgent(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Exercise the overview tool-call sheet.");
await gate.waitForFirstCompleted();
const group = page.getByTestId("tool-call-group").first();
await expect(group).toBeVisible();
await group.click();
const sheet = page.getByTestId("tool-call-group-sheet");
const summary = page.getByTestId("tool-call-group-sheet-summary");
await expect(sheet).toBeVisible();
const initialSummary = await summary.innerText();
gate.release(10);
const badges = sheet.getByTestId("tool-call-badge");
await expect.poll(() => badges.count(), { timeout: 30_000 }).toBeGreaterThanOrEqual(11);
await expect(summary).not.toHaveText(initialSummary);
await expect.poll(() => sheet.evaluate(hasScrolledToLatest)).toBe(true);
const scrollMetrics = await sheet.evaluate(readScrollMetrics);
await testInfo.attach("compact-overview-scroll-metrics", {
body: JSON.stringify(scrollMetrics, null, 2),
contentType: "application/json",
});
await badges.last().click();
const detailClose = page.getByTestId("tool-call-sheet-close");
await expect(detailClose).toBeVisible();
await detailClose.click();
await expect(detailClose).toBeHidden();
await expect(sheet).toBeVisible();
await testInfo.attach("compact-overview-tool-call-group", {
body: await page.screenshot(),
contentType: "image/png",
});
await page.getByTestId("tool-call-group-sheet-close").click();
await expect(sheet).toBeHidden();
await expect(group).toBeVisible();
} finally {
await agent.cleanup();
}
});
});
test("keeps overview tool calls inline on desktop", async ({ page }) => {
test.setTimeout(120_000);
const agent = await createOverviewAgent(page, "Desktop overview tool calls");
try {
await openOverviewAgent(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
const group = page.getByTestId("tool-call-group").first();
await expect(group).toBeVisible();
await group.click();
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
await expect(group.getByTestId("tool-call-badge").first()).toBeVisible();
} finally {
await agent.cleanup();
}
});
@@ -266,7 +266,7 @@ test.describe("Workspace labels", () => {
// Clear restores the complete sidebar without leaving the filter page.
await page.getByTestId("sidebar-label-filter-clear").click();
await expect(page.getByTestId("sidebar-label-filter-empty-state")).toBeHidden();
await expect(page.getByTestId("sidebar-filter-empty-state")).toBeHidden();
await expect(page.getByTestId("sidebar-label-filter-clear")).toBeHidden();
await expect(labelledRow).toBeVisible();
await expect(unlabelledRow).toBeVisible();
@@ -65,12 +65,14 @@ test.describe("Workspace pane mounting", () => {
.first()
.elementHandle();
expect(originalComposer).not.toBeNull();
const emptyPanesBeforeSplit = await page.getByTestId("workspace-pane-empty-state").count();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
2,
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(
emptyPanesBeforeSplit + 1,
{ timeout: 30_000 },
);
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(1);
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
expect(originalStillConnected).toBe(true);
@@ -1,7 +1,10 @@
import { test, expect } from "../support/fixtures";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
closeSetupTab,
waitForWorkspaceTabsVisible,
expectFailedSetupTabSeededInMainPane,
expectSetupTabNotSeeded,
expectNoTerminalTabs,
clickFirstTerminalTab,
expectFirstTerminalTabContains,
@@ -19,6 +22,7 @@ import {
expectSetupPanel,
openHomeWithProject,
navigateToWorkspaceViaSidebar,
returnHomeFromWorkspace,
openWorkspaceScriptsMenu,
startWorkspaceScriptFromMenu,
closeWorkspaceScriptsMenu,
@@ -39,7 +43,7 @@ interface WorkspaceScriptStarter {
}
test.describe("Workspace setup streaming", () => {
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
test("does not seed the setup tab while workspace setup is running", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-open-", {
paseoConfig: {
@@ -60,7 +64,7 @@ test.describe("Workspace setup streaming", () => {
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await expectSetupPanel(page);
await expectSetupTabNotSeeded(page, workspace.id);
} finally {
await client.close();
await repo.cleanup();
@@ -99,6 +103,8 @@ test.describe("Workspace setup streaming", () => {
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await expectSetupTabNotSeeded(page, workspace.id);
await expectSetupPanel(page);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await expectComposerVisible(page, { timeout: 30_000 });
@@ -156,7 +162,7 @@ test.describe("Workspace setup streaming", () => {
}
});
test("streams a failed setup snapshot when setup fails", async () => {
test("seeds a failed setup tab once in the main pane", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-failure-", {
paseoConfig: {
@@ -173,7 +179,7 @@ test.describe("Workspace setup streaming", () => {
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
);
await createWorkspaceThroughDaemon(client, {
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "workspace-setup-failure",
});
@@ -182,6 +188,16 @@ test.describe("Workspace setup streaming", () => {
expect(failedPayload.detail.log).toContain("starting setup");
expect(failedPayload.detail.log).toContain("setup failed");
expect(failedPayload.error).toMatch(/failed/i);
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await waitForWorkspaceTabsVisible(page);
await expectFailedSetupTabSeededInMainPane(page, workspace.id);
await closeSetupTab(page, workspace.id);
await returnHomeFromWorkspace(page);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await expectSetupTabNotSeeded(page, workspace.id);
} finally {
await client.close();
await repo.cleanup();
@@ -0,0 +1,48 @@
import type { Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
function fileReadRequest(message: WebSocketMessage, path: string): boolean {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
const session =
envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as { type?: unknown; mode?: unknown; path?: unknown })
: (envelope as { type?: unknown; mode?: unknown; path?: unknown });
return (
session.type === "file_explorer_request" && session.mode === "file" && session.path === path
);
} catch {
return false;
}
}
export async function delayFileReadResponse(page: Page, path: string) {
let delayed: (() => void) | null = null;
let resolveDelayed: (() => void) | null = null;
const delayedResponse = new Promise<void>((resolve) => {
resolveDelayed = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
server.onMessage((message) => {
ws.send(message);
});
// Hold the outgoing JSON request. The corresponding body response travels as
// binary frames, so intercepting the response cannot identify this read.
ws.onMessage((message) => {
if (delayed || !fileReadRequest(message, path)) {
server.send(message);
return;
}
delayed = () => server.send(message);
resolveDelayed?.();
});
});
return {
waitUntilHeld: () => delayedResponse,
release: () => delayed?.(),
};
}
@@ -174,6 +174,27 @@ export async function openSidebarDisplayPage(page: Page, branchTestID: string):
await page.getByTestId(branchTestID).click();
}
// Project filters live a page below the display-preferences root, like the host filters.
export async function openSidebarProjectFilter(page: Page): Promise<void> {
await openSidebarDisplayPage(page, "sidebar-display-project-filter");
}
// The filter rows are keyed by the project's sidebar view key. A project seeded from its own temp
// repo has a `projectKey` unique on the host, so the view key IS that key — see
// `buildWorkspaceStructureProjects`.
export async function toggleProjectFilter(page: Page, projectViewKey: string): Promise<void> {
await page.getByTestId(`sidebar-project-filter-${projectViewKey}`).click();
}
export async function selectAllProjectsFilter(page: Page): Promise<void> {
await page.getByTestId("sidebar-project-filter-all").click();
}
export async function closeSidebarDisplayPreferences(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(page.getByTestId("sidebar-display-preferences-content")).toHaveCount(0);
}
export async function selectSidebarStatusGrouping(page: Page): Promise<void> {
await openSidebarDisplayPage(page, "sidebar-display-grouping");
await page.getByTestId("sidebar-grouping-status").click();
@@ -13,12 +13,12 @@ function splitSeparator(page: Page): Locator {
}
export async function openExplorerSplit(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open explorer" }).click();
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 explorer" }).click();
await page.getByRole("button", { name: "Close side panel" }).click();
await expect(splitSeparator(page)).toHaveCount(0, { timeout: 30_000 });
}
@@ -532,30 +532,42 @@ export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void
export async function userScrollsTimelineToHistoryStart(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.hover();
for (let step = 0; step < 60; step += 1) {
if ((await readTimelineViewport(page)).scrollTop <= HISTORY_START_THRESHOLD_PX) {
break;
}
await page.mouse.wheel(0, -1_000);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
}),
);
}
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
await returnTimelineToSettledHistoryStart(page, () => page.mouse.wheel(0, -1_000));
}
export async function userNavigatesTimelineToHistoryStartWithKeyboard(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.focus();
await page.keyboard.press("Home");
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
await returnTimelineToSettledHistoryStart(page, () => page.keyboard.press("Home"));
}
async function returnTimelineToSettledHistoryStart(
page: Page,
moveUp: () => Promise<unknown>,
): Promise<void> {
const loadingSpinner = page.getByTestId("load-older-history-spinner");
// A caller may only start a new history traversal after its prior page has settled.
await expect(loadingSpinner).toBeHidden();
let previous = await readTimelineViewport(page);
while (true) {
await moveUp();
await waitForTimelineGeometryToSettle(page);
let viewport = await readTimelineViewport(page);
// Tests that hold the response need to observe the requested page before releasing it.
if (await loadingSpinner.isVisible()) {
return;
}
if (viewport.scrollTop <= HISTORY_START_THRESHOLD_PX) {
return;
}
await expect(loadingSpinner).toBeHidden();
await waitForTimelineGeometryToSettle(page);
viewport = await readTimelineViewport(page);
expect(
viewport.scrollTop < previous.scrollTop || viewport.scrollHeight > previous.scrollHeight,
).toBe(true);
previous = viewport;
}
}
export async function scrollTimelineToNewestLoadedEdge(page: Page): Promise<void> {
@@ -276,6 +276,11 @@ export async function navigateToWorkspaceViaSidebar(
});
}
export async function returnHomeFromWorkspace(page: Page): Promise<void> {
await page.getByRole("button", { name: "Home" }).click();
await expect(page).not.toHaveURL(/\/workspace\//, { timeout: 30_000 });
}
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
await page.getByTestId("workspace-scripts-button").click();
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
@@ -13,14 +13,52 @@ export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
return ids;
}
function setupTabTestId(workspaceId: string): string {
return `workspace-tab-setup_${workspaceId}`;
}
async function waitForSetupToReachWorkspace(page: Page): Promise<void> {
const actionsButton = page.getByTestId("workspace-header-menu-trigger");
await expect(actionsButton).toBeVisible({ timeout: 30_000 });
await actionsButton.click();
await expect(page.getByTestId("workspace-header-show-setup")).toBeVisible({ timeout: 30_000 });
await page.keyboard.press("Escape");
}
export async function expectSetupTabNotSeeded(page: Page, workspaceId: string): Promise<void> {
await waitForSetupToReachWorkspace(page);
const tab = page.getByTestId(setupTabTestId(workspaceId));
for (let attempt = 0; attempt < 5; attempt += 1) {
await expect(tab).toHaveCount(0);
await page.waitForTimeout(100);
}
}
export async function expectFailedSetupTabSeededInMainPane(
page: Page,
workspaceId: string,
): Promise<void> {
const tabId = setupTabTestId(workspaceId);
await expect(page.getByTestId(tabId).filter({ visible: true }).first()).toBeVisible({
timeout: 30_000,
});
const panel = await ensureSidePanel(page);
await expect(panel.getByTestId(tabId)).toHaveCount(0);
}
export async function closeSetupTab(page: Page, workspaceId: string): Promise<void> {
const tabId = setupTabTestId(workspaceId);
await page.getByTestId(tabId).filter({ visible: true }).first().click({ button: "right" });
await page.getByTestId(`workspace-tab-context-setup_${workspaceId}-close`).click();
await expect(page.getByTestId(tabId)).toHaveCount(0);
}
function visibleTestId(page: Page, testId: string) {
return page.getByTestId(testId).filter({ visible: true });
}
function explorerPaneTabRow(page: Page) {
return visibleTestId(page, "workspace-tabs-row")
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
.first();
function sidePanel(page: Page) {
return visibleTestId(page, "workspace-side-panel").first();
}
async function selectWorkspaceTab(tab: Locator): Promise<void> {
@@ -32,35 +70,51 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
await expect(tab).toHaveAttribute("aria-selected", "true");
}
export async function ensureExplorerPane(page: Page) {
/** Reveal the Side panel. It opens empty; the caller decides what goes in it. */
export async function ensureSidePanel(page: Page): Promise<Locator> {
const toggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(toggle).toBeVisible({ timeout: 30_000 });
const tabRow = explorerPaneTabRow(page);
if ((await tabRow.count()) === 0) {
const panel = sidePanel(page);
if ((await panel.count()) === 0) {
await toggle.click();
}
await expect(tabRow).toBeVisible({ timeout: 30_000 });
return tabRow;
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
}
/**
* 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(
page: Page,
view: { tabTestId: string; menuTestId: 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);
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
timeout: view.timeout ?? 30_000,
});
}
export async function openChangesPanel(page: Page): Promise<void> {
const tabRow = await ensureExplorerPane(page);
await selectWorkspaceTab(tabRow.getByTestId("workspace-tab-working_diff"));
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
timeout: 30_000,
await openSidePanelView(page, {
tabTestId: "workspace-tab-working_diff",
menuTestId: "workspace-new-tab-menu-changes",
contentTestId: "working-diff-panel",
});
}
export async function openFilesPanel(page: Page): Promise<void> {
const tabRow = await ensureExplorerPane(page);
const filesTab = tabRow.getByTestId("workspace-tab-files");
if ((await filesTab.count()) === 0) {
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
await visibleTestId(page, "workspace-new-tab-menu-files").first().click();
}
await selectWorkspaceTab(filesTab);
await expect(visibleTestId(page, "file-explorer-tree-scroll").first()).toBeVisible({
timeout: 30_000,
await openSidePanelView(page, {
tabTestId: "workspace-tab-files",
menuTestId: "workspace-new-tab-menu-files",
contentTestId: "file-explorer-tree-scroll",
});
}
@@ -71,14 +125,12 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
return;
}
const tabRow = await ensureExplorerPane(page);
const pullRequestTab = tabRow.getByTestId("workspace-tab-pull_request");
if ((await pullRequestTab.count()) === 0) {
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
}
await selectWorkspaceTab(pullRequestTab);
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
await openSidePanelView(page, {
tabTestId: "workspace-tab-pull_request",
menuTestId: "workspace-new-tab-menu-pull-request",
contentTestId: "pr-pane",
timeout: 15_000,
});
}
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.5.0-beta.1",
"version": "0.5.0-beta.4",
"private": true,
"main": "index.ts",
"scripts": {
@@ -49,12 +49,12 @@
"@getpaseo/client": "*",
"@getpaseo/expo-two-way-audio": "*",
"@getpaseo/highlight": "*",
"@getpaseo/plugin": "*",
"@getpaseo/protocol": "*",
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@marijn/find-cluster-break": "^1.0.2",
"@mattermost/react-native-paste-input": "2.0.1",
"@paseo/plugin": "*",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
@@ -174,4 +174,151 @@ describe("useChatOutline", () => {
await waitFor(() => expect(onJumpError).toHaveBeenCalledOnce());
});
it("reveals an already-loaded older prompt before scrolling to it", async () => {
runtime.listAgentTimelinePrompts.mockResolvedValue({
epoch: "epoch-1",
prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }],
});
const scrollToMessage = vi.fn();
const revealLoadedItem = vi.fn(() => true);
const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
scrollToMessage,
};
const viewportRef = { current: viewport };
const tail = [
{
id: "older-prompt",
kind: "user_message" as const,
text: "prompt",
timestamp: new Date(1),
timelineCursor: { epoch: "epoch-1", seq: 1 },
},
];
const { result, rerender } = renderHook(
({ visibleItemIds }) =>
useChatOutline({
agentId: "agent-1",
serverId: "server-1",
timelineEpoch: "epoch-1",
tail,
head: [],
enabled: true,
viewportRef,
onJumpError: vi.fn(),
visibleItemIds,
revealLoadedItem,
}),
{ initialProps: { visibleItemIds: new Set<string>() } },
);
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
await act(async () => result.current.jumpToPrompt(1));
expect(revealLoadedItem).toHaveBeenCalledWith("older-prompt");
expect(scrollToMessage).not.toHaveBeenCalled();
rerender({ visibleItemIds: new Set(["older-prompt"]) });
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledWith("older-prompt"));
});
it("reveals a fetched prompt that lands outside the mounted history window", async () => {
runtime.listAgentTimelinePrompts.mockResolvedValue({
epoch: "epoch-1",
prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }],
});
const fetch = deferred<void>();
runtime.fetchAgentTimeline.mockReturnValue(fetch.promise);
const scrollToMessage = vi.fn();
const revealLoadedItem = vi.fn(() => true);
const viewportRef = {
current: {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
scrollToMessage,
},
};
const fetchedPrompt = {
id: "fetched-prompt",
kind: "user_message" as const,
text: "prompt",
timestamp: new Date(1),
timelineCursor: { epoch: "epoch-1", seq: 1 },
};
const { result, rerender } = renderHook(
({ tail, visibleItemIds }) =>
useChatOutline({
agentId: "agent-1",
serverId: "server-1",
timelineEpoch: "epoch-1",
tail,
head: [],
enabled: true,
viewportRef,
onJumpError: vi.fn(),
visibleItemIds,
revealLoadedItem,
}),
{
initialProps: {
tail: [] as (typeof fetchedPrompt)[],
visibleItemIds: new Set<string>(),
},
},
);
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
act(() => result.current.jumpToPrompt(1));
await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce());
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set<string>() });
await waitFor(() => expect(revealLoadedItem).toHaveBeenCalledWith(fetchedPrompt.id));
expect(scrollToMessage).not.toHaveBeenCalled();
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set([fetchedPrompt.id]) });
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledOnce());
expect(scrollToMessage).toHaveBeenCalledWith(fetchedPrompt.id);
await act(async () => fetch.resolve());
});
it("jumps directly to a loaded prompt in the live head", async () => {
runtime.listAgentTimelinePrompts.mockResolvedValue({
epoch: "epoch-1",
prompts: [{ seq: 2, timestamp: new Date(2).toISOString(), preview: "prompt" }],
});
const scrollToMessage = vi.fn();
const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
scrollToMessage,
};
const livePrompt = {
id: "live-prompt",
kind: "user_message" as const,
text: "prompt",
timestamp: new Date(2),
timelineCursor: { epoch: "epoch-1", seq: 2 },
};
const revealLoadedItem = vi.fn();
revealLoadedItem.mockReturnValue(false);
const { result } = renderHook(() =>
useChatOutline({
agentId: "agent-1",
serverId: "server-1",
timelineEpoch: "epoch-1",
tail: [],
head: [livePrompt],
enabled: true,
viewportRef: { current: viewport },
onJumpError: vi.fn(),
visibleItemIds: new Set([livePrompt.id]),
revealLoadedItem,
}),
);
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
await act(async () => result.current.jumpToPrompt(2));
expect(scrollToMessage).toHaveBeenCalledWith("live-prompt");
});
});
@@ -33,6 +33,8 @@ export interface UseChatOutlineInput {
enabled: boolean;
viewportRef: RefObject<StreamViewportHandle | null>;
onJumpError: () => void;
visibleItemIds?: ReadonlySet<string>;
revealLoadedItem?: (itemId: string) => boolean;
}
export interface ChatOutline {
@@ -51,6 +53,8 @@ export function useChatOutline({
enabled,
viewportRef,
onJumpError,
visibleItemIds,
revealLoadedItem,
}: UseChatOutlineInput): ChatOutline {
const [index, setIndex] = useState<AgentTimelinePromptIndexPayload | null>(null);
const [pendingJump, setPendingJump] = useState<PendingPromptJump | null>(null);
@@ -135,7 +139,12 @@ export function useChatOutline({
useEffect(() => {
if (pendingJump === null) return;
const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq);
if (target && !pendingJump.hasScrolled) {
if (target) {
if (pendingJump.hasScrolled) return;
if (visibleItemIds?.has(target.id) === false) {
revealLoadedItem?.(target.id);
return;
}
viewportRef.current?.scrollToMessage?.(target.id);
setPendingJump((current) => {
if (current?.requestId !== pendingJump.requestId) return current;
@@ -144,7 +153,7 @@ export function useChatOutline({
return;
}
if (pendingJump.fetchSettled) setPendingJump(null);
}, [loadedItems, pendingJump, viewportRef]);
}, [loadedItems, pendingJump, revealLoadedItem, viewportRef, visibleItemIds]);
const jumpToPrompt = useCallback(
(seq: number) => {
@@ -152,6 +161,11 @@ export function useChatOutline({
setPendingJump(null);
const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq);
if (loaded) {
if (revealLoadedItem?.(loaded.id)) {
const requestId = nextJumpRequestIdRef.current;
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
return;
}
viewportRef.current?.scrollToMessage?.(loaded.id);
return;
}
@@ -171,7 +185,7 @@ export function useChatOutline({
});
});
},
[agentId, index, loadedItems, onJumpError, serverId, viewportRef],
[agentId, index, loadedItems, onJumpError, revealLoadedItem, serverId, viewportRef],
);
return { prompts, activePrompt, jumpToPrompt, reportReadingPosition };
@@ -22,6 +22,22 @@ const visibleHistoryStart: HistoryStartPaginationInput = {
};
describe("history start pagination", () => {
it("treats locally revealed history as progress before remote pagination", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const localReveal = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
progressKey: "epoch-1:20:local-60",
});
expect(first.shouldLoad).toBe(true);
expect(localReveal.state).toEqual({
status: "settling",
loadedProgressKey: "epoch-1:20:local-60",
});
});
it("waits for user scroll intent before loading", () => {
const dormant = createHistoryStartPaginationState();
const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart);
@@ -0,0 +1,39 @@
import type { StreamItem } from "@/types/stream";
export const DEFAULT_MOUNTED_RECENT_STREAM_ITEMS = 20;
type MountedRecentStreamItemsE2ETestGlobals = typeof globalThis & {
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
};
function readPositiveIntegerOverride(value: unknown): number | null {
if (!Number.isFinite(value)) {
return null;
}
const normalized = Math.trunc(value as number);
return normalized > 0 ? normalized : null;
}
export function getMountedRecentStreamItems(): number {
const override = readPositiveIntegerOverride(
(globalThis as MountedRecentStreamItemsE2ETestGlobals)
.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS,
);
return override ?? DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
}
export function findMountedWindowStart(input: {
items: StreamItem[];
minMountedCount: number;
}): number {
const { items, minMountedCount } = input;
if (items.length <= minMountedCount) {
return 0;
}
let startIndex = Math.max(items.length - minMountedCount, 0);
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
startIndex -= 1;
}
return startIndex;
}
@@ -25,6 +25,56 @@ function assistantMessage(id: string, seed: number): StreamItem {
}
describe("buildAgentStreamRenderModel", () => {
it("projects a bounded recent turn-aligned history window on every platform", () => {
const tail = [
userMessage("u1", 1),
assistantMessage("a1", 2),
userMessage("u2", 3),
assistantMessage("a2", 4),
userMessage("u3", 5),
assistantMessage("a3", 6),
];
const model = buildAgentStreamRenderModel({
isTurnActive: false,
activeTurnStartedAt: null,
tail,
head: [],
platform: "native",
isMobileBreakpoint: false,
historyStart: 2,
});
expect(model.history.map((item) => item.id)).toEqual(["a3", "u3", "a2", "u2"]);
expect(model.segments.historyVirtualized).toHaveLength(0);
});
it("derives timing only for the rendered history window", () => {
const tail = [
userMessage("hidden-u", 1),
assistantMessage("hidden-a", 2),
userMessage("visible-u", 3),
assistantMessage("visible-a", 4),
];
const model = buildAgentStreamRenderModel({
isTurnActive: false,
activeTurnStartedAt: null,
tail,
head: [],
platform: "web",
isMobileBreakpoint: false,
historyStart: 2,
});
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
startedAt: tail[2]?.timestamp,
completedAt: tail[3]?.timestamp,
durationMs: 1000,
});
});
it("keeps head separate from committed history on desktop web", () => {
const tail: StreamItem[] = [];
for (let index = 0; index < 60; index += 1) {
+8 -9
View File
@@ -1,11 +1,8 @@
import type { ReactNode } from "react";
import { deriveStreamTurnTiming, type StreamTurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
} from "./web-virtualization";
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
import { getWebPartialVirtualizationThreshold } from "./web-virtualization";
import { orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy } from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
@@ -41,6 +38,7 @@ export interface BuildAgentStreamRenderModelInput {
head: StreamItem[];
platform: "web" | "native";
isMobileBreakpoint: boolean;
historyStart?: number;
}
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
@@ -91,7 +89,7 @@ function splitOrderedTail(params: {
platform === "web" &&
!isMobileBreakpoint &&
orderedTail.length > getWebPartialVirtualizationThreshold();
const cacheKey = `${platform}:${isMobileBreakpoint}:${getWebMountedRecentStreamItems()}:${shouldSplitHistory}`;
const cacheKey = `${platform}:${isMobileBreakpoint}:${getMountedRecentStreamItems()}:${shouldSplitHistory}`;
let cachedByKey = splitHistoryCache.get(orderedTail);
if (!cachedByKey) {
cachedByKey = new Map();
@@ -117,7 +115,7 @@ function splitOrderedTail(params: {
const mountedWindowStart = findMountedWindowStart({
items: orderedTail,
minMountedCount: getWebMountedRecentStreamItems(),
minMountedCount: getMountedRecentStreamItems(),
});
const split = {
history: orderedTail,
@@ -165,9 +163,10 @@ export function buildAgentStreamRenderModel(
isMobileBreakpoint: input.isMobileBreakpoint,
});
const orderingCacheKey = `${input.platform}:${input.isMobileBreakpoint}`;
const renderedTail = input.historyStart ? input.tail.slice(input.historyStart) : input.tail;
const orderedTail = getOrderedItems({
cache: orderedTailCache,
source: input.tail,
source: renderedTail,
cacheKey: orderingCacheKey,
order: (items) =>
orderTailForStreamRenderStrategy({
@@ -193,7 +192,7 @@ export function buildAgentStreamRenderModel(
const turnTiming = getTurnTiming({
isTurnActive: input.isTurnActive,
activeTurnStartedAt: input.activeTurnStartedAt,
tail: input.tail,
tail: renderedTail,
head: input.head,
});
@@ -617,10 +617,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}
initialNumToRender={40}
maxToRenderPerBatch={40}
updateCellsBatchingPeriod={0}
windowSize={21}
initialNumToRender={12}
windowSize={10}
removeClippedSubviews={false}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
@@ -0,0 +1,159 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { StreamItem } from "@/types/stream";
import { useStreamHistoryWindow } from "./use-stream-history-window";
function message(id: string, kind: "user_message" | "assistant_message"): StreamItem {
return { id, kind, text: id, timestamp: new Date() };
}
afterEach(() => {
Reflect.deleteProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS");
});
describe("useStreamHistoryWindow", () => {
it("mounts only the most recent 20 items by default", () => {
const items = Array.from({ length: 11 }, (_, turn) => [
message(`u${turn}`, "user_message"),
message(`a${turn}`, "assistant_message"),
]).flat();
const { result } = renderHook(() =>
useStreamHistoryWindow({ agentId: "agent-1", items, loadRemoteOlder: vi.fn() }),
);
expect(result.current.start).toBe(2);
});
it("reveals loaded history before requesting an older daemon page", async () => {
Object.defineProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS", {
value: 2,
configurable: true,
});
const loadRemoteOlder = vi.fn(async () => true);
const { result } = renderHook(() =>
useStreamHistoryWindow({
agentId: "agent-1",
items: [
message("u1", "user_message"),
message("a1", "assistant_message"),
message("u2", "user_message"),
message("a2", "assistant_message"),
],
loadRemoteOlder,
}),
);
expect(result.current.start).toBe(2);
await act(async () => result.current.loadOlder());
expect(result.current.start).toBe(0);
expect(loadRemoteOlder).not.toHaveBeenCalled();
await act(async () => result.current.loadOlder());
expect(loadRemoteOlder).toHaveBeenCalledOnce();
});
it("keeps its boundary at the same item when loaded history is prepended", () => {
Object.defineProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS", {
value: 2,
configurable: true,
});
const items = [
message("u1", "user_message"),
message("a1", "assistant_message"),
message("u2", "user_message"),
message("a2", "assistant_message"),
];
const { result, rerender } = renderHook(
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
{ initialProps: { history: items } },
);
expect(result.current.start).toBe(2);
rerender({
history: [message("u0", "user_message"), message("a0", "assistant_message"), ...items],
});
expect(result.current.start).toBe(4);
});
it("keeps live appends inside the rendered window", () => {
Object.defineProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS", {
value: 2,
configurable: true,
});
const items = [
message("u1", "user_message"),
message("a1", "assistant_message"),
message("u2", "user_message"),
message("a2", "assistant_message"),
];
const { result, rerender } = renderHook(
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
{ initialProps: { history: items } },
);
rerender({
history: [...items, message("u3", "user_message"), message("a3", "assistant_message")],
});
expect(result.current.start).toBe(2);
});
it("restarts at a recent window when an item replacement removes the boundary", () => {
Object.defineProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS", {
value: 2,
configurable: true,
});
const { result, rerender } = renderHook(
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
{
initialProps: {
history: [
message("u1", "user_message"),
message("a1", "assistant_message"),
message("u2", "user_message"),
message("a2", "assistant_message"),
],
},
},
);
rerender({
history: [
message("new-u1", "user_message"),
message("new-a1", "assistant_message"),
message("new-u2", "user_message"),
message("new-a2", "assistant_message"),
message("new-u3", "user_message"),
message("new-a3", "assistant_message"),
],
});
expect(result.current.start).toBe(4);
});
it("does not claim to reveal a missing or already-visible item", () => {
Object.defineProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS", {
value: 2,
configurable: true,
});
const { result } = renderHook(() =>
useStreamHistoryWindow({
agentId: "agent-1",
items: [
message("u1", "user_message"),
message("a1", "assistant_message"),
message("u2", "user_message"),
message("a2", "assistant_message"),
],
loadRemoteOlder: vi.fn(),
}),
);
expect(result.current.revealLoadedHistory("missing")).toBe(false);
expect(result.current.revealLoadedHistory("u2")).toBe(false);
});
});
@@ -0,0 +1,115 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { StreamItem } from "@/types/stream";
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
function findEarlierHistoryWindowStart(input: {
items: StreamItem[];
start: number;
itemId: string | undefined;
}): number {
const targetIndex = input.itemId ? input.items.findIndex((item) => item.id === input.itemId) : -1;
const targetStart = targetIndex >= 0 && targetIndex < input.start ? targetIndex : input.start;
if (!input.itemId) {
return findMountedWindowStart({
items: input.items.slice(0, targetStart),
minMountedCount: getMountedRecentStreamItems(),
});
}
return findMountedWindowStart({
items: input.items.slice(0, targetStart + 1),
minMountedCount: 1,
});
}
interface HistoryWindowBoundary {
agentId: string;
boundaryItemId: string | null;
initialized: boolean;
}
function reconcileHistoryWindow(input: {
current: HistoryWindowBoundary;
agentId: string;
boundaryIndex: number;
initialBoundaryItemId: string | null;
hasItems: boolean;
}): HistoryWindowBoundary {
const { current, agentId, boundaryIndex, initialBoundaryItemId, hasItems } = input;
if (current.agentId !== agentId) {
return { agentId, boundaryItemId: initialBoundaryItemId, initialized: hasItems };
}
if (!current.initialized && hasItems) {
return { ...current, boundaryItemId: initialBoundaryItemId, initialized: true };
}
if (current.boundaryItemId !== null && boundaryIndex < 0) {
return { ...current, boundaryItemId: initialBoundaryItemId };
}
return current;
}
export function useStreamHistoryWindow(input: {
agentId: string;
items: StreamItem[];
loadRemoteOlder: () => boolean | Promise<boolean>;
}) {
const { agentId, items, loadRemoteOlder } = input;
const initialStart = useMemo(
() => findMountedWindowStart({ items, minMountedCount: getMountedRecentStreamItems() }),
[items],
);
const initialBoundaryItemId = initialStart === 0 ? null : (items[initialStart]?.id ?? null);
const [window, setWindow] = useState(() => ({
agentId,
boundaryItemId: initialBoundaryItemId,
initialized: items.length > 0,
}));
const boundaryIndex =
window.agentId === agentId && window.boundaryItemId !== null
? items.findIndex((item) => item.id === window.boundaryItemId)
: -1;
let start = initialStart;
if (window.agentId === agentId && window.initialized) {
if (window.boundaryItemId === null) {
start = 0;
} else if (boundaryIndex >= 0) {
start = boundaryIndex;
}
}
useEffect(() => {
setWindow((current) =>
reconcileHistoryWindow({
current,
agentId,
boundaryIndex,
initialBoundaryItemId,
hasItems: items.length > 0,
}),
);
}, [agentId, boundaryIndex, initialBoundaryItemId, items.length]);
const revealLoadedHistory = useCallback(
(itemId?: string): boolean => {
const targetIndex = itemId ? items.findIndex((item) => item.id === itemId) : -1;
if (start === 0 || (itemId !== undefined && (targetIndex < 0 || targetIndex >= start))) {
return false;
}
const nextStart = findEarlierHistoryWindowStart({ items, start, itemId });
if (nextStart === start) {
return false;
}
setWindow({
agentId,
boundaryItemId: items[nextStart]?.id ?? null,
initialized: true,
});
return true;
},
[agentId, items, start],
);
const loadOlder = useCallback(async (): Promise<boolean> => {
return revealLoadedHistory() || (await loadRemoteOlder());
}, [loadRemoteOlder, revealLoadedHistory]);
return { start, hasLocalHistory: start > 0, revealLoadedHistory, loadOlder };
}
+44 -12
View File
@@ -26,7 +26,8 @@ import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, ChevronDown, X } from "lucide-react-native";
import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openSidePanelView } from "@/workspace-tabs/side-panel";
import {
AssistantMessage,
SpeakMessage,
@@ -104,6 +105,7 @@ import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useStreamHistoryWindow } from "./use-stream-history-window";
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
@@ -339,8 +341,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
new Set(),
);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
// Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? context.serverId ?? "";
@@ -377,7 +377,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentId,
toast,
});
const { isLoadingOlder, hasOlder, progressKey, loadOlder } = historyPagination
const {
isLoadingOlder: remoteIsLoadingOlder,
hasOlder: remoteHasOlder,
progressKey: remoteProgressKey,
loadOlder: loadRemoteOlder,
} = historyPagination
? {
isLoadingOlder: historyPagination.isLoadingOlder,
hasOlder: historyPagination.hasOlder,
@@ -445,15 +450,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setCurrentPath: false,
});
const checkout = {
serverId: resolvedServerId,
cwd: context.cwd,
isGit: context.projectPlacement?.checkout?.isGit ?? true,
};
setExplorerTabForCheckout({ ...checkout, tab: "files" });
openFileExplorerForCheckout({
openSidePanelView({
isCompact: isMobile,
checkout,
workspaceKey: buildWorkspaceTabPersistenceKey({
serverId: resolvedServerId,
workspaceId: context.workspaceId ?? "",
}),
checkout: {
serverId: resolvedServerId,
cwd: context.cwd,
isGit: context.projectPlacement?.checkout?.isGit ?? true,
},
view: "files",
});
},
);
@@ -518,6 +526,19 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
toolCallDetailLevel,
],
);
const {
start: historyWindowStart,
hasLocalHistory,
revealLoadedHistory,
loadOlder,
} = useStreamHistoryWindow({
agentId,
items: projectedToolCalls.tail,
loadRemoteOlder,
});
const isLoadingOlder = remoteIsLoadingOlder;
const hasOlder = hasLocalHistory || remoteHasOlder;
const progressKey = `${remoteProgressKey ?? "local"}:${historyWindowStart}`;
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
@@ -527,6 +548,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
head: projectedToolCalls.head,
platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile,
historyStart: historyWindowStart,
});
}, [
isMobile,
@@ -534,6 +556,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
projectedToolCalls.head,
projectedToolCalls.tail,
effectiveTurnPresentation.startedAt,
historyWindowStart,
]);
const streamLayout = useMemo(
() =>
@@ -555,6 +578,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const handleTimelineHistoryLoadError = useCallback(() => {
toast?.error(t("agentStream.historyLoadFailed"));
}, [t, toast]);
const visibleHistoryItemIds = useMemo(
() =>
new Set(
[...baseRenderModel.history, ...baseRenderModel.segments.liveHead].map((item) => item.id),
),
[baseRenderModel.history, baseRenderModel.segments.liveHead],
);
const chatOutline = useChatOutline({
agentId,
serverId: resolvedServerId,
@@ -564,6 +594,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
enabled: supportsChatOutline && chatOutlineEnabled,
viewportRef,
onJumpError: handleTimelineHistoryLoadError,
visibleItemIds: visibleHistoryItemIds,
revealLoadedItem: revealLoadedHistory,
});
useImperativeHandle(
@@ -1,13 +1,17 @@
import type { StreamItem } from "@/types/stream";
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-message-height-estimate";
import {
DEFAULT_MOUNTED_RECENT_STREAM_ITEMS,
findMountedWindowStart,
getMountedRecentStreamItems,
} from "./history-window";
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
type BottomAnchorE2ETestGlobals = typeof globalThis & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
};
function readPositiveIntegerOverride(value: unknown): number | null {
@@ -26,10 +30,7 @@ export function getWebPartialVirtualizationThreshold(): number {
}
export function getWebMountedRecentStreamItems(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS,
);
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
return getMountedRecentStreamItems();
}
export interface IndexedStreamItem {
@@ -63,21 +64,7 @@ export function estimateStreamItemHeight(item: StreamItem): number {
}
}
export function findMountedWindowStart(input: {
items: StreamItem[];
minMountedCount: number;
}): number {
const { items, minMountedCount } = input;
if (items.length <= minMountedCount) {
return 0;
}
let startIndex = Math.max(items.length - minMountedCount, 0);
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
startIndex -= 1;
}
return startIndex;
}
export { findMountedWindowStart };
export function splitWebVirtualizedHistory(input: {
entries: IndexedStreamItem[];
+26 -74
View File
@@ -20,7 +20,8 @@ import { AppState, useWindowDimensions, View } from "react-native";
import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AppearanceProvider } from "@/appearance/provider";
import { CommandCenter } from "@/command-center/command-center";
import { CommandCenterRootActions } from "@/command-center/root-registration";
import { CommandCenterProvider } from "@/command-center/provider";
@@ -110,14 +111,12 @@ import {
useHosts,
} from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { flushDraftPersistStorage } from "@/stores/draft-store";
import { getNextThemePreference, THEME_TO_UNISTYLES } from "@/styles/theme";
import { getNextThemePreference } from "@/styles/theme";
import { useSessionStore } from "@/stores/session-store";
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
import type { HostProfile } from "@/types/host-connection";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import {
useHasWindowChromeObstruction,
WindowChromeProvider,
@@ -463,15 +462,10 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const { settings, updateSettings } = useAppSettings();
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList);
const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList);
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const exitFocusMode = usePanelStore((state) => state.exitFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const isDesktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const explorerWidth = usePanelStore((state) => state.explorerWidth);
const { width: viewportWidth } = useWindowDimensions();
const cycleTheme = useCallback(() => {
@@ -487,25 +481,16 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const hasMountedDesktopSidebar = useLatchedBoolean(chromeEnabled);
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
const toggleDesktopSidebars = useCallback(() => {
const { desktop } = usePanelStore.getState();
toggleDesktopSidebarsWithCheckoutIntent({
isAgentListOpen: desktop.agentListOpen,
isFileExplorerOpen: desktop.fileExplorerOpen,
openAgentList: openDesktopAgentList,
closeAgentList: closeDesktopAgentList,
closeFileExplorer: closeDesktopFileExplorer,
toggleFocusedFileExplorer: () =>
keyboardActionDispatcher.dispatch({
id: "sidebar.toggle.right",
scope: "sidebar",
}),
});
}, [
closeDesktopAgentList,
closeDesktopFileExplorer,
keyboardActionDispatcher,
openDesktopAgentList,
]);
// The focused workspace owns its layout key, its checkout, and therefore the
// only correct answer to "is the explorer open". Let it decide when there is
// one: the pathname alone cannot identify the active workspace, because
// desktop cold-starts at "/" and restores the workspace from route params.
if (keyboardActionDispatcher.dispatch({ id: "sidebar.toggle.both", scope: "sidebar" })) {
return;
}
// Off a workspace route there is no explorer — only the agent list.
toggleAgentList();
}, [keyboardActionDispatcher, toggleAgentList]);
// TODO: stop matching pathname here as a branch. `chromeEnabled` should not
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with
// global concerns like keyboard shortcuts. Split those out so settings (and
@@ -527,9 +512,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const appContentMinimumWidth = resolveDesktopAppContentMinimum({
isSettingsRoute: pathname.includes("/settings"),
isWorkspaceExplorerOpen: isWorkspaceRoute && isDesktopFileExplorerOpen,
requestedExplorerWidth: explorerWidth,
viewportWidth,
});
const desktopSidebarMounted = hasMountedDesktopSidebar && !isWorkspaceFocusModeEnabled;
const desktopSidebarVisible = resolveDesktopSidebarVisibility({
@@ -664,59 +646,29 @@ function MobileGestureWrapper({
}
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const { upsertConnectionFromOfferUrl } = useHostMutations();
// Apply theme setting on mount and when it changes
useEffect(() => {
if (settingsLoading) return;
if (settings.theme === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[settings.theme]);
}
}, [settingsLoading, settings.theme]);
// Apply font / size / syntax appearance settings on mount and when they change.
// Sibling to the theme effect above; order is irrelevant because both patch all
// registered theme keys, so the active key is always current.
useEffect(() => {
if (settingsLoading) return;
applyAppearance({
uiFontFamily: settings.uiFontFamily,
monoFontFamily: settings.monoFontFamily,
uiBaseFontSize: settings.uiBaseFontSize,
codeFontSize: settings.codeFontSize,
syntaxTheme: settings.syntaxTheme,
});
}, [
settingsLoading,
settings.uiFontFamily,
settings.monoFontFamily,
settings.uiBaseFontSize,
settings.codeFontSize,
settings.syntaxTheme,
]);
return (
<VoiceProvider>
<DesktopWindowControlsSync enabled={!settingsLoading} />
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
<HostSessionManager />
<FaviconStatusSync />
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
</VoiceProvider>
<AppearanceProvider>
<VoiceProvider>
<DesktopWindowControlsSync />
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
<HostSessionManager />
<FaviconStatusSync />
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
</VoiceProvider>
</AppearanceProvider>
);
}
function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
function DesktopWindowControlsSync() {
const { isLoading } = useAppSettings();
const { theme } = useUnistyles();
const surface0 = theme.colors.surface0;
const foreground = theme.colors.foreground;
useEffect(() => {
if (!enabled || isNative) return;
if (isLoading || isNative) return;
void updateDesktopWindowControls({
backgroundColor: surface0,
foregroundColor: foreground,
@@ -724,7 +676,7 @@ function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [enabled, surface0, foreground]);
}, [isLoading, surface0, foreground]);
return null;
}
@@ -19,6 +19,7 @@ import {
getWorkspaceSelectionKey,
orderWorkspaceSelectionsForStableRender,
reconcileRetainedWorkspaceSelections,
resolveWorkspaceDeckRetentionLimit,
type RetainedWorkspaceSelection,
resolveWorkspaceDeckEntries,
shouldKeepWorkspaceDeckEntryMounted,
@@ -33,7 +34,7 @@ import {
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
} from "@/utils/host-route-browser";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { isWeb } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
import { RenderProfile } from "@/utils/render-profiler";
function getParamValue(value: string | string[] | undefined): string {
@@ -217,6 +218,7 @@ function WorkspaceDeck({
currentEntries: retainedSelections,
activeSelection,
now: reconciliationNow,
maxMountedWorkspaces: resolveWorkspaceDeckRetentionLimit({ isNative }),
}),
[activeSelection, reconciliationNow, retainedSelections],
);
@@ -253,6 +255,7 @@ function WorkspaceDeck({
currentEntries: current,
activeSelection,
now: Date.now(),
maxMountedWorkspaces: resolveWorkspaceDeckRetentionLimit({ isNative }),
}),
);
}, expirationDelay + 1);
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { darkHighlightColors, resolveSyntaxColors } from "@getpaseo/highlight";
import { DEFAULT_UI_FONT_STACK, REGISTERED_THEMES } from "@/styles/theme";
import { applyAppearance, type AppearanceInput } from "./apply-appearance";
import { applyAppearance, type AppearanceInput } from "./apply";
// Override the global react-native-unistyles mock (vitest.setup.ts) so that
// UnistylesRuntime.updateTheme is a spy that records (themeName, updater) calls.
@@ -27,6 +27,7 @@ interface FakeTheme {
fontFamily: { ui: string; mono: string };
fontSize: {
code: number;
content: number;
sm: number;
base: number;
lg: number;
@@ -45,6 +46,7 @@ function makeFakeTheme(): FakeTheme {
fontFamily: { ui: "seed-ui-stack", mono: "seed-mono-stack" },
fontSize: {
code: 12,
content: 15,
sm: 12,
base: 14,
lg: 16,
@@ -63,6 +65,7 @@ function makeInput(overrides: Partial<AppearanceInput> = {}): AppearanceInput {
uiFontFamily: "",
monoFontFamily: "",
uiBaseFontSize: 14,
contentFontSize: 15,
codeFontSize: 12,
syntaxTheme: "one",
...overrides,
@@ -131,6 +134,7 @@ describe("applyAppearance", () => {
const alreadyScaled = makeFakeTheme();
alreadyScaled.fontSize = {
code: 4,
content: 4,
sm: 4,
base: 4,
lg: 4,
@@ -155,6 +159,15 @@ describe("applyAppearance", () => {
expect(fontSize.code).toBe(10);
});
it("sets content size independently of the interface and code sizes", () => {
applyAppearance(makeInput({ uiBaseFontSize: 14, contentFontSize: 19, codeFontSize: 10 }));
const { fontSize } = runCapturedUpdater();
expect(fontSize.base).toBe(14);
expect(fontSize.content).toBe(19);
expect(fontSize.code).toBe(10);
});
it("sets fontSize.code to codeFontSize regardless of the UI font size", () => {
applyAppearance(makeInput({ uiBaseFontSize: 14, codeFontSize: 18 }));
@@ -15,6 +15,7 @@ export interface AppearanceInput {
uiFontFamily: string; // "" -> default stack
monoFontFamily: string; // "" -> default stack
uiBaseFontSize: number; // already clamped
contentFontSize: number; // already clamped
codeFontSize: number; // already clamped
syntaxTheme: SyntaxThemeId;
}
@@ -28,7 +29,11 @@ export interface AppearanceInput {
* `code` is set absolutely to `codeSize`, never scaled by the UI factor — a separate
* control on a separate semantic axis (mono/diff text).
*/
function scaleFontSize(uiBaseSize: number, codeSize: number): Theme["fontSize"] {
function scaleFontSize(
uiBaseSize: number,
contentSize: number,
codeSize: number,
): Theme["fontSize"] {
const r = uiBaseSize / FONT_SIZE.base;
return {
sm: Math.round(FONT_SIZE.sm * r),
@@ -38,6 +43,7 @@ function scaleFontSize(uiBaseSize: number, codeSize: number): Theme["fontSize"]
"2xl": Math.round(FONT_SIZE["2xl"] * r),
"3xl": Math.round(FONT_SIZE["3xl"] * r),
"4xl": Math.round(FONT_SIZE["4xl"] * r),
content: contentSize, // absolute, NOT scaled
code: codeSize, // absolute, NOT scaled
};
}
@@ -68,7 +74,11 @@ export function applyAppearance(input: AppearanceInput): void {
for (const key of themeKeys) {
UnistylesRuntime.updateTheme(key, (t) => {
const fontFamily = { ui, mono };
const fontSize = scaleFontSize(input.uiBaseFontSize, input.codeFontSize);
const fontSize = scaleFontSize(
input.uiBaseFontSize,
input.contentFontSize,
input.codeFontSize,
);
const lineHeight = { ...t.lineHeight, diff: diffLineHeight };
if (t.colorScheme === "light") {
return {
+97
View File
@@ -0,0 +1,97 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo } from "react";
import { UnistylesRuntime } from "react-native-unistyles";
import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings";
import {
rememberPluginThemeHost,
usePluginThemeCatalog,
type PluginThemeOption,
} from "@/plugins/themes";
import { PLUGIN_THEME_NAMES, PLUGIN_THEME_PREFERENCE, THEME_TO_UNISTYLES } from "@/styles/theme";
import { applyAppearance } from "./apply";
interface ContributedThemes {
options: PluginThemeOption[];
selected: PluginThemeOption | null;
select: (option: PluginThemeOption) => void;
}
interface ApplyThemeInput {
preference: AppSettings["theme"];
contributedTheme: PluginThemeOption | null;
}
const ContributedThemesContext = createContext<ContributedThemes | null>(null);
function applyTheme({ preference, contributedTheme }: ApplyThemeInput): void {
if (contributedTheme) {
const themeName = PLUGIN_THEME_NAMES[contributedTheme.theme.colorScheme];
UnistylesRuntime.updateTheme(themeName, () => contributedTheme.theme);
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(themeName);
return;
}
const builtInPreference =
preference === PLUGIN_THEME_PREFERENCE ? DEFAULT_THEME_PREFERENCE : preference;
if (builtInPreference === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
return;
}
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[builtInPreference]);
}
export function AppearanceProvider({ children }: { children: ReactNode }) {
const { settings, updateSettings, isLoading } = useAppSettings();
const options = usePluginThemeCatalog();
const selected = useMemo(() => {
if (settings.theme !== PLUGIN_THEME_PREFERENCE) return null;
return options.find((option) => option.id === settings.pluginThemeId) ?? null;
}, [options, settings.pluginThemeId, settings.theme]);
useEffect(() => {
if (isLoading) return;
applyTheme({ preference: settings.theme, contributedTheme: selected });
applyAppearance({
uiFontFamily: settings.uiFontFamily,
monoFontFamily: settings.monoFontFamily,
uiBaseFontSize: settings.uiBaseFontSize,
contentFontSize: settings.contentFontSize,
codeFontSize: settings.codeFontSize,
syntaxTheme: settings.syntaxTheme,
});
}, [
isLoading,
selected,
settings.theme,
settings.uiFontFamily,
settings.monoFontFamily,
settings.uiBaseFontSize,
settings.contentFontSize,
settings.codeFontSize,
settings.syntaxTheme,
]);
const select = useCallback(
(option: PluginThemeOption) => {
rememberPluginThemeHost(option);
void updateSettings({
theme: PLUGIN_THEME_PREFERENCE,
pluginThemeId: option.id,
});
},
[updateSettings],
);
const value = useMemo(() => ({ options, selected, select }), [options, selected, select]);
return (
<ContributedThemesContext.Provider value={value}>{children}</ContributedThemesContext.Provider>
);
}
export function useContributedThemes(): ContributedThemes {
const themes = useContext(ContributedThemesContext);
if (themes === null) throw new Error("useContributedThemes requires AppearanceProvider");
return themes;
}
@@ -51,6 +51,8 @@ export const ACP_PROVIDER_ICON_SVGS = {
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 40 40" fill="none">\n <path d="M25 15H35V16.75C35 29 30.5001 35 16.5001 35H15V25H16.5001C22.6251 25 25 22.875 25 16.75V15Z" fill="currentColor"/>\n <rect x="5" y="15" width="10" height="10" fill="currentColor"/>\n <rect x="15" y="5" width="10" height="10" fill="currentColor"/>\n</svg>\n',
kilo: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 40 40">\n <path fill="currentColor" d="M27.9998324,34.6666921h6.6666279v5.3333023h-8.3809037l-3.6190266-3.6190266v-8.3809037h5.3333023v6.6666279ZM39.9997627,26.2857884l-3.6190266-3.6190266h-8.3809037v5.3333023h6.6666279v6.6666279h5.3333023v-8.3809037ZM17.3332326,22.6667578h-5.3333023v5.3333023h5.3333023v-5.3333023ZM0,36.3809638l3.6190266,3.6190266h13.714206v-5.3333023H5.3333023v-11.9999302H0v13.714206ZM33.9600959,11.9999247V3.619021L30.3410693-.0000056h-7.6745392v5.3333023h5.9602634v6.6666279h-5.9602634v5.3333023h17.3332326v-5.3333023h-6.0396668ZM5.3333023,0H0v17.3332326h5.3333023v-5.9999651h6.6666279v5.9999651h5.3333023v-5.9999651l-5.3333023-5.3333023h-6.6666279V0ZM17.3332326,0h-5.3333023v5.9999651h5.3333023V0Z"/>\n</svg>\n',
kimi: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">\n <path fill="currentColor" d="M19.514 7.342C19.711 7.862 19.814 8.413 19.816 8.969C19.816 12.494 15.998 14.697 12.946 12.934C11.529 12.116 10.657 10.605 10.657 8.969C10.657 5.646 14.085 3.431 17.115 4.793C15.26 3.373 12.99 2.607 10.655 2.612C4.772 2.614 0.005 7.381 0 13.265C0.002 19.148 4.772 23.918 10.655 23.92C16.538 23.916 21.306 19.147 21.31 13.265C21.312 11.155 20.687 9.095 19.514 7.342ZM14.841 4.666C14.841 8.191 18.657 10.395 21.709 8.632C23.127 7.814 24 6.302 24 4.666C24 1.14 20.184-1.061 17.13 0.699C15.714 1.519 14.841 3.03 14.841 4.666"/>\n</svg>\n',
"minimax-code":
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">\n <path fill="currentColor" d="M2 3h3l3 5 3-5h3v10h-2V6.5l-4 6-4-6V13H2V3Z"/>\n</svg>\n',
"minion-code":
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">\n <rect fill="none" stroke="currentColor" stroke-width="1.1" x="4.5" y="2.5" width="7" height="10" rx="3.5"/>\n <line stroke="currentColor" stroke-width="1" x1="3" y1="5.5" x2="13" y2="5.5"/>\n <circle fill="none" stroke="currentColor" stroke-width="1" cx="6.5" cy="5.5" r="1.6"/>\n <circle fill="none" stroke="currentColor" stroke-width="1" cx="9.5" cy="5.5" r="1.6"/>\n <circle fill="currentColor" cx="6.8" cy="5.6" r="0.55"/>\n <circle fill="currentColor" cx="9.8" cy="5.6" r="0.55"/>\n <path fill="none" stroke="currentColor" stroke-width="0.6" stroke-linecap="round" d="M7 8.3 Q8 9 9 8.3"/>\n <path fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" d="M4.5 8 L3 9.5"/>\n <path fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" d="M11.5 8 L13 9.5"/>\n <line stroke="currentColor" stroke-width="0.7" x1="5" y1="9.5" x2="11" y2="9.5"/>\n <line stroke="currentColor" stroke-width="0.9" x1="6.5" y1="12.5" x2="6.5" y2="14"/>\n <line stroke="currentColor" stroke-width="0.9" x1="9.5" y1="12.5" x2="9.5" y2="14"/>\n <line stroke="currentColor" stroke-width="0.9" stroke-linecap="round" x1="6.5" y1="14" x2="5.5" y2="14"/>\n <line stroke="currentColor" stroke-width="0.9" stroke-linecap="round" x1="9.5" y1="14" x2="10.5" y2="14"/>\n <line stroke="currentColor" stroke-width="0.7" stroke-linecap="round" x1="8" y1="2.5" x2="7.3" y2="0.8"/>\n <line stroke="currentColor" stroke-width="0.7" stroke-linecap="round" x1="8" y1="2.5" x2="8" y2="0.5"/>\n <line stroke="currentColor" stroke-width="0.7" stroke-linecap="round" x1="8" y1="2.5" x2="8.7" y2="0.8"/>\n</svg>\n',
"mistral-vibe":
@@ -132,7 +132,13 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
filePath: path,
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
showMobileAgent: usePanelStore.getState().showMobileAgent,
openWorkspaceTabFocused: useWorkspaceLayoutStore.getState().openTabFocused,
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
useWorkspaceLayoutStore.getState().openTab({
workspaceKey,
target,
intent: "reveal",
placement,
}),
focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab,
});
},
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { darkTheme } from "@/styles/theme";
import { appearanceStyleBoundaryKey } from "./appearance-style-boundary";
describe("appearanceStyleBoundaryKey", () => {
it("changes when content size changes without any other appearance token changing", () => {
const contentOnlyChange = {
...darkTheme,
fontSize: { ...darkTheme.fontSize, content: darkTheme.fontSize.content + 1 },
};
expect(appearanceStyleBoundaryKey(contentOnlyChange)).not.toBe(
appearanceStyleBoundaryKey(darkTheme),
);
});
});
@@ -11,8 +11,8 @@ function AppearanceStyleBoundaryBase({ appearanceKey, children }: AppearanceStyl
return <Fragment key={appearanceKey}>{children}</Fragment>;
}
const appearanceStyleBoundaryMapping = (theme: Theme): Partial<AppearanceStyleBoundaryProps> => ({
appearanceKey: [
export function appearanceStyleBoundaryKey(theme: Theme): string {
return [
theme.fontFamily.ui,
theme.fontFamily.mono,
theme.fontSize.sm,
@@ -22,6 +22,7 @@ const appearanceStyleBoundaryMapping = (theme: Theme): Partial<AppearanceStyleBo
theme.fontSize["2xl"],
theme.fontSize["3xl"],
theme.fontSize["4xl"],
theme.fontSize.content,
theme.fontSize.code,
theme.lineHeight.diff,
theme.colors.foreground,
@@ -51,8 +52,12 @@ const appearanceStyleBoundaryMapping = (theme: Theme): Partial<AppearanceStyleBo
theme.colors.syntax.meta,
theme.colors.syntax.heading,
theme.colors.syntax.link,
].join("\u0000"),
});
].join("\u0000");
}
function appearanceStyleBoundaryMapping(theme: Theme): Partial<AppearanceStyleBoundaryProps> {
return { appearanceKey: appearanceStyleBoundaryKey(theme) };
}
const ThemedAppearanceStyleBoundary = withUnistyles(AppearanceStyleBoundaryBase);
@@ -3,11 +3,12 @@ import { View } 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/explorer-sidebar";
import { CompactExplorerSidebar } from "@/components/compact-explorer-sidebar";
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
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";
@@ -46,9 +47,7 @@ function useActiveCompactExplorerSidebarModel(
): CompactExplorerSidebarHostModel | null {
const selection = useActiveWorkspaceSelection();
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
const isExplorerOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: true }),
);
const isExplorerOpen = usePanelStore(selectIsCompactFileExplorerOpen);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const client = useHostRuntimeClient(selection?.serverId ?? "");
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
@@ -99,24 +98,26 @@ interface CompactExplorerSidebarHostProps {
export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) {
const model = useActiveCompactExplorerSidebarModel(enabled);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
const openWorkspaceTabInFocusedPane = useCallback(
(workspaceKey: string, target: WorkspaceTabTarget) =>
openTab({ workspaceKey, target, intent: "reveal" }),
[openTab],
);
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
const handleOpenExplorer = useCallback(() => {
if (!model?.workspaceRoot) {
return;
}
openFileExplorerForCheckout({
isCompact: true,
checkout: {
serverId: model.serverId,
cwd: model.workspaceRoot,
isGit: model.isGit,
},
openCompactFileExplorer({
serverId: model.serverId,
cwd: model.workspaceRoot,
isGit: model.isGit,
});
}, [model, openFileExplorerForCheckout]);
}, [model, openCompactFileExplorer]);
const handleOpenFile = useCallback(
(filePath: string) => {
@@ -127,11 +128,11 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
filePath,
persistenceKey: model.persistenceKey,
showMobileAgent,
openWorkspaceTabFocused,
openWorkspaceTabInFocusedPane,
focusWorkspaceTab,
});
},
[focusWorkspaceTab, model, openWorkspaceTabFocused, showMobileAgent],
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, showMobileAgent],
);
return (
@@ -1,20 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
View,
Text,
Pressable,
useWindowDimensions,
StyleSheet as RNStyleSheet,
} from "react-native";
import { useCallback, useMemo, useState } from "react";
import { View, Text, Pressable } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { formatPrTabLabel, PullRequestTabIcon } from "@/git/pull-request-panel";
import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store";
import {
usePanelStore,
selectIsCompactFileExplorerOpen,
type ExplorerTab,
} from "@/stores/panel-store";
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import {
@@ -23,19 +18,14 @@ import {
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
import { ChangesSurface } from "@/git/diff-pane";
import { changesStateSchema, defaultChangesState, type ChangesState } from "@/panels/changes/state";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { shouldUseCompactExplorerKeyboardPadding } from "@/hooks/keyboard-shift-policy";
import { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/utils/desktop-window";
import { WindowChromeSafeArea } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { RetainedPanel, RetainedPanelActivity } from "@/components/retained-panel";
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout";
import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability";
import { PullRequestContent } from "@/panels/pull-request";
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
@@ -81,7 +71,7 @@ export function CompactExplorerSidebar({
}: ExplorerSidebarProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: true }));
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
serverId,
@@ -149,123 +139,6 @@ export function CompactExplorerSidebar({
);
}
export function ExplorerSidebar({
serverId,
workspaceId,
workspaceRoot,
isGit,
onOpenFile,
}: ExplorerSidebarProps) {
const insets = useSafeAreaInsets();
const explorerWidth = usePanelStore((state) => state.explorerWidth);
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: false }));
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
serverId,
workspaceRoot,
isGit,
});
const { width: viewportWidth } = useWindowDimensions();
const visibleExplorerWidth = resolveDesktopExplorerWidth({
requestedWidth: explorerWidth,
viewportWidth,
});
const startWidthRef = useRef(visibleExplorerWidth);
const resizeWidth = useSharedValue(visibleExplorerWidth);
const [resizePressed, setResizePressed] = useState(false);
const showResizeGrip = useCallback(() => setResizePressed(true), []);
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
useEffect(() => {
resizeWidth.value = visibleExplorerWidth;
}, [resizeWidth, visibleExplorerWidth]);
const handleDesktopClose = useCallback(() => {
logExplorerSidebar("handleClose", {
reason: "desktop-close-button",
isOpen,
});
closeDesktopFileExplorer();
}, [closeDesktopFileExplorer, isOpen]);
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.enabled(true)
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onBegin(() => {
scheduleOnRN(showResizeGrip);
})
// See the left sidebar's gesture: horizontal intent only, with the start
// width anchored to the activation translation so the threshold is free.
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
.onStart((event) => {
startWidthRef.current = visibleExplorerWidth + event.translationX;
resizeWidth.value = visibleExplorerWidth;
})
.onUpdate((event) => {
const newWidth = startWidthRef.current - event.translationX;
resizeWidth.value = resolveDesktopExplorerWidth({
requestedWidth: newWidth,
viewportWidth,
});
})
.onEnd(() => {
runOnJS(setExplorerWidth)(resizeWidth.value);
})
.onFinalize(() => {
scheduleOnRN(hideResizeGrip);
}),
[
hideResizeGrip,
resizeWidth,
setExplorerWidth,
showResizeGrip,
viewportWidth,
visibleExplorerWidth,
],
);
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
}));
const desktopSidebarStyle = useMemo(
() => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }],
[resizeAnimatedStyle, insets.top],
);
if (!isOpen) {
return null;
}
return (
<Animated.View style={desktopSidebarStyle}>
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
<SidebarResizeHandle
edge="left"
gesture={resizeGesture}
pressed={resizePressed}
testID="explorer-sidebar-resize-handle"
/>
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleDesktopClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</View>
</Animated.View>
);
}
interface ExplorerTabButtonProps {
tab: ExplorerTab;
active: boolean;
@@ -319,7 +192,6 @@ function ExplorerSidebarContent({
}: SidebarContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right");
const { prPane, showPullRequest: showPrTab } = usePullRequestPanelAvailability({
serverId,
cwd: workspaceRoot,
@@ -358,7 +230,7 @@ function ExplorerSidebarContent({
<ExplorerTabButton
tab="changes"
active={resolvedTab === "changes"}
label={t("workspace.tabs.explorer.changes")}
label={t("workspace.tabs.sidePanel.changes")}
onTabPress={onTabPress}
testID="explorer-tab-changes"
/>
@@ -366,7 +238,7 @@ function ExplorerSidebarContent({
<ExplorerTabButton
tab="files"
active={resolvedTab === "files"}
label={t("workspace.tabs.explorer.files")}
label={t("workspace.tabs.sidePanel.files")}
onTabPress={onTabPress}
testID="explorer-tab-files"
/>
@@ -389,27 +261,23 @@ function ExplorerSidebarContent({
)}
</View>
<View style={styles.headerRightSection}>
{!hasRightWindowControls && (
<Pressable
onPress={onClose}
style={styles.closeButton}
testID="explorer-close"
nativeID="explorer-close"
accessible
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.explorer.close")}
hitSlop={8}
>
{({ hovered, pressed }) => (
<X
size={18}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)}
</Pressable>
)}
<Pressable
onPress={onClose}
style={styles.closeButton}
testID="explorer-close"
nativeID="explorer-close"
accessible
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.sidePanel.close")}
hitSlop={8}
>
{({ hovered, pressed }) => (
<X
size={18}
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</View>
</WindowChromeSafeArea>
@@ -462,6 +330,9 @@ function ChangedFilesPane({
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
>) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
const [changesState, setChangesState] = useState<ChangesState>(() =>
changesStateSchema.parse(defaultChangesState),
);
return (
<ChangesSurface
host="explorer"
@@ -469,8 +340,11 @@ function ChangedFilesPane({
workspaceId={workspaceId}
cwd={workspaceRoot}
enabled={isOpen}
modeScope="compact-explorer"
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
state={changesState}
onStateChange={setChangesState}
/>
);
}
@@ -495,21 +369,7 @@ function FilesPane({
const PrTabContent = PullRequestContent;
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
const explorerStaticStyles = RNStyleSheet.create({
desktopSidebar: {
position: "relative" as const,
},
});
const styles = StyleSheet.create((theme) => ({
desktopSidebarBorder: {
borderLeftWidth: 1,
borderLeftColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
},
sidebarContent: {
flex: 1,
minHeight: 0,
@@ -3,7 +3,6 @@ import {
canDesktopAppSidebarShare,
resolveDesktopAppChromeLayout,
resolveDesktopAppContentMinimum,
resolveDesktopExplorerWidth,
resolveDesktopSidebarVisibility,
resolveDesktopSidebarWidth,
} from "@/components/desktop-sidebar-layout";
@@ -79,18 +78,8 @@ describe("desktop sidebar layout", () => {
expect(resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 1440 })).toBe(600);
});
it("keeps a temporarily narrow explorer render-only", () => {
expect(resolveDesktopExplorerWidth({ requestedWidth: 400, viewportWidth: 751 })).toBe(351);
expect(resolveDesktopExplorerWidth({ requestedWidth: 400, viewportWidth: 1440 })).toBe(400);
});
it("yields app navigation when settings or Explorer need the shell width", () => {
const settingsMinimum = resolveDesktopAppContentMinimum({
isSettingsRoute: true,
isWorkspaceExplorerOpen: false,
requestedExplorerWidth: 400,
viewportWidth: 751,
});
it("yields app navigation when settings needs the shell width", () => {
const settingsMinimum = resolveDesktopAppContentMinimum({ isSettingsRoute: true });
expect(settingsMinimum).toBe(720);
expect(
canDesktopAppSidebarShare({
@@ -99,32 +88,16 @@ describe("desktop sidebar layout", () => {
viewportWidth: 751,
}),
).toBe(false);
});
const explorerMinimum = resolveDesktopAppContentMinimum({
isSettingsRoute: false,
isWorkspaceExplorerOpen: true,
requestedExplorerWidth: 400,
viewportWidth: 751,
});
expect(explorerMinimum).toBe(751);
it("imposes no content minimum outside settings", () => {
expect(resolveDesktopAppContentMinimum({ isSettingsRoute: false })).toBe(0);
expect(
canDesktopAppSidebarShare({
contentMinimumWidth: explorerMinimum,
contentMinimumWidth: resolveDesktopAppContentMinimum({ isSettingsRoute: false }),
requestedSidebarWidth: 320,
viewportWidth: 751,
}),
).toBe(false);
expect(
canDesktopAppSidebarShare({
contentMinimumWidth: resolveDesktopAppContentMinimum({
isSettingsRoute: false,
isWorkspaceExplorerOpen: true,
requestedExplorerWidth: 400,
viewportWidth: 1120,
}),
requestedSidebarWidth: 320,
viewportWidth: 1120,
}),
).toBe(true);
});
});
@@ -1,12 +1,7 @@
import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout";
import {
MAX_EXPLORER_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
MIN_EXPLORER_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
} from "@/stores/panel-store";
import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
export const MIN_DESKTOP_CENTER_WIDTH = 400;
const MIN_DESKTOP_CENTER_WIDTH = 400;
export function resolveDesktopSidebarVisibility(input: {
chromeEnabled: boolean;
@@ -67,32 +62,8 @@ export function resolveDesktopSidebarWidth(input: {
});
}
export function resolveDesktopExplorerWidth(input: {
requestedWidth: number;
viewportWidth: number;
}): number {
"worklet";
return resolveDesktopPanelWidth({
...input,
minimumWidth: MIN_EXPLORER_SIDEBAR_WIDTH,
maximumWidth: MAX_EXPLORER_SIDEBAR_WIDTH,
});
}
export function resolveDesktopAppContentMinimum(input: {
isSettingsRoute: boolean;
isWorkspaceExplorerOpen: boolean;
requestedExplorerWidth: number;
viewportWidth: number;
}): number {
const workspaceMinimum = input.isWorkspaceExplorerOpen
? MIN_DESKTOP_CENTER_WIDTH +
resolveDesktopExplorerWidth({
requestedWidth: input.requestedExplorerWidth,
viewportWidth: input.viewportWidth,
})
: 0;
return Math.max(input.isSettingsRoute ? SETTINGS_DESKTOP_SPLIT_MIN_WIDTH : 0, workspaceMinimum);
export function resolveDesktopAppContentMinimum(input: { isSettingsRoute: boolean }): number {
return input.isSettingsRoute ? SETTINGS_DESKTOP_SPLIT_MIN_WIDTH : 0;
}
export function canDesktopAppSidebarShare(input: {
@@ -24,7 +24,7 @@ import {
} from "react-native";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import * as Clipboard from "expo-clipboard";
import { ChevronDown, Eye, EyeOff, FilePlus, FolderPlus, RotateCw } from "lucide-react-native";
@@ -38,6 +38,11 @@ import {
WORKSPACE_TREE_LOADING_ICON_SIZE,
} from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
PaneContentToolbar,
paneContentToolbarIconSize,
paneContentToolbarIconButtonStyle,
} from "@/components/ui/pane-content-toolbar";
import {
useOverlayFlatListScrollbar,
type OverlayFlatListScrollbar,
@@ -136,8 +141,11 @@ function sortTriggerStyle({
return [styles.sortTrigger, (Boolean(hovered) || pressed) && styles.sortTriggerHovered];
}
function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.iconButton, (Boolean(hovered) || pressed) && styles.iconButtonHovered];
function iconButtonStyle(
isCompact: boolean,
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
) {
return paneContentToolbarIconButtonStyle({ hovered, pressed }, false, isCompact);
}
type ExplorerListRow =
@@ -1041,6 +1049,11 @@ export function FileExplorerPane({
});
}, [requestDirectoryListing]);
const toolbarIconButtonStyle = useCallback(
(state: PressableStateCallbackType) => iconButtonStyle(isCompact, state),
[isCompact],
);
if (!hasWorkspaceScope) {
return (
<View style={styles.centerState}>
@@ -1058,6 +1071,7 @@ export function FileExplorerPane({
>
<FileExplorerPaneContent
error={error}
isCompact={isCompact}
showInitialLoading={showInitialLoading}
showBackFromError={showBackFromError}
listRows={listRows}
@@ -1073,7 +1087,7 @@ export function FileExplorerPane({
handleBackFromError={handleBackFromError}
handleRetry={handleRetry}
sortTriggerStyle={sortTriggerStyle}
iconButtonStyle={iconButtonStyle}
iconButtonStyle={toolbarIconButtonStyle}
/>
</View>
);
@@ -1132,6 +1146,7 @@ function RootCreationContextTarget({
interface FileExplorerPaneContentProps {
error: string | null;
isCompact: boolean;
showInitialLoading: boolean;
showBackFromError: boolean;
listRows: ExplorerListRow[];
@@ -1155,6 +1170,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
const { t } = useTranslation();
const {
error,
isCompact,
showInitialLoading,
showBackFromError,
listRows,
@@ -1229,7 +1245,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return (
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<PaneContentToolbar style={styles.paneHeader} testID="files-pane-header">
<Pressable
onPress={handleSortCycle}
style={sortTriggerStyleProp}
@@ -1251,7 +1267,10 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
accessibilityLabel={t("workspace.fileActions.newFile")}
testID="files-new-file"
>
<FilePlus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<FilePlus
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
</Pressable>
<Pressable
onPress={handleNewFolderAtRoot}
@@ -1261,7 +1280,10 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
accessibilityLabel={t("workspace.fileActions.newFolder")}
testID="files-new-folder"
>
<FolderPlus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<FolderPlus
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
</Pressable>
</>
) : null}
@@ -1275,9 +1297,15 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
testID="files-hidden-toggle"
>
{showHiddenFiles ? (
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Eye
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
) : (
<EyeOff size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<EyeOff
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
)}
</Pressable>
<Pressable
@@ -1295,14 +1323,20 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<LoadingSpinner
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<RotateCw
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
)}
</View>
</Pressable>
</View>
</View>
</PaneContentToolbar>
<ContextMenu>
<RootCreationContextTarget enabled={Boolean(onNewEntryAtRoot)}>
{listRows.length === 0 ? (
@@ -1662,13 +1696,10 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
},
paneHeader: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingRight: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
sortTrigger: {
flexDirection: "row",
@@ -1806,16 +1837,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
iconButton: {
width: 22,
height: 22,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
iconButtonActive: {
backgroundColor: theme.colors.surface2,
},
+14 -7
View File
@@ -88,7 +88,8 @@ interface SidebarSharedProps {
projectIconTargets: SidebarProjectIconTarget[];
pinnedGroups: PinnedSidebarGroups;
projects: SidebarProjectEntry[];
hasProjectsBeforeLabelFilter: boolean;
hasProjectsBeforeFilter: boolean;
hasActiveProjectFilter: boolean;
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
isInitialLoad: boolean;
isRevalidating: boolean;
@@ -143,7 +144,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
const {
projects,
hasProjectsBeforeLabelFilter,
hasProjectsBeforeFilter,
resolvedProjectFilters,
workspaceEntriesByKey,
isInitialLoad,
isRevalidating,
@@ -251,7 +253,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
projectIconTargets,
pinnedGroups,
projects,
hasProjectsBeforeLabelFilter,
hasProjectsBeforeFilter,
hasActiveProjectFilter: resolvedProjectFilters.length > 0,
workspaceEntriesByKey,
isInitialLoad,
isRevalidating,
@@ -606,7 +609,8 @@ function MobileSidebar({
projectIconTargets,
pinnedGroups,
projects,
hasProjectsBeforeLabelFilter,
hasProjectsBeforeFilter,
hasActiveProjectFilter,
workspaceEntriesByKey,
isInitialLoad,
isRevalidating,
@@ -725,7 +729,8 @@ function MobileSidebar({
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeLabelFilter={hasProjectsBeforeLabelFilter}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
@@ -757,7 +762,8 @@ function DesktopSidebar({
projectIconTargets,
pinnedGroups,
projects,
hasProjectsBeforeLabelFilter,
hasProjectsBeforeFilter,
hasActiveProjectFilter,
workspaceEntriesByKey,
isInitialLoad,
isRevalidating,
@@ -930,7 +936,8 @@ function DesktopSidebar({
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeLabelFilter={hasProjectsBeforeLabelFilter}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
+7 -4
View File
@@ -354,9 +354,12 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
},
text: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontSize: theme.fontSize.content,
...(isWeb
? { lineHeight: Math.round(theme.fontSize.base * 1.4), overflowWrap: "anywhere" as const }
? {
lineHeight: Math.round(theme.fontSize.content * 1.4),
overflowWrap: "anywhere" as const,
}
: {}),
},
imagePreviewContainer: {
@@ -1958,8 +1961,8 @@ const speakMessageStylesheet = StyleSheet.create((theme) => ({
},
text: {
fontFamily: theme.fontFamily.ui,
fontSize: theme.fontSize.base,
lineHeight: Math.round(theme.fontSize.base * 1.4),
fontSize: theme.fontSize.content,
lineHeight: Math.round(theme.fontSize.content * 1.4),
color: theme.colors.foreground,
},
}));
@@ -118,7 +118,7 @@ import {
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility";
import {
SidebarLabelFilterEmptyState,
SidebarFilterEmptyState,
SidebarProjectEmptyState,
} from "@/components/sidebar/empty-states";
import { selectWorkspaceServiceSummary } from "@/components/sidebar/workspace-meta-row";
@@ -245,7 +245,9 @@ interface SidebarWorkspaceListProps {
projectIconTargets: SidebarProjectIconTarget[];
pinnedGroups: PinnedSidebarGroups;
projects: SidebarProjectEntry[];
hasProjectsBeforeLabelFilter: boolean;
hasProjectsBeforeFilter: boolean;
/** Whether a project filter is actually being applied — the resolved list, not the stored one. */
hasActiveProjectFilter: boolean;
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
collapsedProjectKeys: ReadonlySet<string>;
onToggleProjectCollapsed: (projectViewKey: string) => void;
@@ -1935,7 +1937,8 @@ export function SidebarWorkspaceList({
projectIconTargets,
pinnedGroups,
projects,
hasProjectsBeforeLabelFilter,
hasProjectsBeforeFilter,
hasActiveProjectFilter,
workspaceEntriesByKey,
collapsedProjectKeys,
onToggleProjectCollapsed,
@@ -1999,8 +2002,12 @@ export function SidebarWorkspaceList({
// A filter that matches nothing swaps the list's body and nothing above it. It used to replace
// this whole subtree, which unmounted the header — and the header is where the display menu's
// trigger lives, so filtering the last row away closed the menu you were filtering from.
const labelFilterEmpty =
hasActiveLabelFilter && hasProjectsBeforeLabelFilter && projects.length === 0;
//
// Only the label filter can get here. The project filter resolves against the projects it can
// see and falls back to "all projects" when nothing matches, so it either keeps at least one
// project or is not applied at all — it can narrow this list but never empty it.
const sidebarFilterEmpty =
hasActiveLabelFilter && hasProjectsBeforeFilter && projects.length === 0;
// Project mode is the one that keeps its project headers; every other grouping mode is a flat
// list of grouped rows, so a new mode lands in the grouped branch rather than silently in this
@@ -2019,7 +2026,7 @@ export function SidebarWorkspaceList({
onToggleWorkspacePin={onToggleWorkspacePin}
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
listHeaderComponent={listHeaderComponent}
labelFilterEmpty={labelFilterEmpty}
sidebarFilterEmpty={sidebarFilterEmpty}
parentGestureRef={parentGestureRef}
dragGestureHostPresented={dragGestureHostPresented}
/>
@@ -2036,7 +2043,8 @@ export function SidebarWorkspaceList({
onAddProject={onAddProject}
listFooterComponent={listFooterComponent}
listHeaderComponent={listHeaderComponent}
labelFilterEmpty={labelFilterEmpty}
sidebarFilterEmpty={sidebarFilterEmpty}
hasActiveProjectFilter={hasActiveProjectFilter}
parentGestureRef={parentGestureRef}
dragGestureHostPresented={dragGestureHostPresented}
pathname={pathname}
@@ -2069,7 +2077,7 @@ function SidebarGroupedModeList({
onToggleWorkspacePin,
onPinnedWorkspaceReorder,
listHeaderComponent,
labelFilterEmpty,
sidebarFilterEmpty,
parentGestureRef,
dragGestureHostPresented,
}: {
@@ -2084,7 +2092,7 @@ function SidebarGroupedModeList({
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
listHeaderComponent?: ReactElement | null;
labelFilterEmpty: boolean;
sidebarFilterEmpty: boolean;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostPresented?: boolean;
}) {
@@ -2111,7 +2119,7 @@ function SidebarGroupedModeList({
onToggleWorkspacePin={onToggleWorkspacePin}
onPinnedWorkspaceReorder={onPinnedWorkspaceReorder}
listHeaderComponent={listHeaderComponent}
labelFilterEmpty={labelFilterEmpty}
sidebarFilterEmpty={sidebarFilterEmpty}
parentGestureRef={parentGestureRef}
dragGestureHostPresented={dragGestureHostPresented}
/>
@@ -2130,7 +2138,8 @@ function ProjectModeList({
onAddProject,
listFooterComponent,
listHeaderComponent,
labelFilterEmpty,
sidebarFilterEmpty,
hasActiveProjectFilter,
parentGestureRef,
dragGestureHostPresented,
pathname,
@@ -2144,12 +2153,12 @@ function ProjectModeList({
| "workspaceGroups"
| "projectIconTargets"
| "groupMode"
| "hasProjectsBeforeLabelFilter"
| "hasProjectsBeforeFilter"
| "isRefreshing"
| "onRefresh"
> & {
/** Swaps the list body for the label filter's empty state. Never the header above it. */
labelFilterEmpty: boolean;
sidebarFilterEmpty: boolean;
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
pathname: string;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
@@ -2487,11 +2496,17 @@ function ProjectModeList({
) : null}
{/* The header carries the display menu, which is the only way back out of a filter, so it
stays for as long as a filter is what emptied the list. It is absent only when the
sidebar is genuinely empty, where a section heading would sit over nothing. */}
{unpinnedProjects.length > 0 || hasActiveHostFilter || labelFilterEmpty
sidebar is genuinely empty, where a section heading would sit over nothing.
Every filter that can empty this branch needs a term here: a project filter pinned to a
project whose chats are all pinned leaves `unpinnedProjects` empty, and without its term
the header would go with it, taking the only route back to the filter page. */}
{unpinnedProjects.length > 0 ||
hasActiveHostFilter ||
hasActiveProjectFilter ||
sidebarFilterEmpty
? listHeaderComponent
: null}
{labelFilterEmpty ? <SidebarLabelFilterEmptyState /> : projectBody}
{sidebarFilterEmpty ? <SidebarFilterEmptyState /> : projectBody}
{listFooterComponent}
</>
);
@@ -38,6 +38,12 @@ import {
import { HostStatusDot } from "@/components/host-status-dot";
import { isWeb } from "@/constants/platform";
import { useHosts } from "@/runtime/host-runtime";
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
import { ProjectIconView } from "@/components/project-icon-view";
import { useProjectIcons } from "@/projects/icons";
import { resolveSidebarProjectIconTargets } from "@/utils/sidebar-project-row-model";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { Theme } from "@/styles/theme";
import {
hasActiveSidebarLabelFilter,
@@ -159,6 +165,9 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
const { t } = useTranslation();
const preferences = useSidebarDisplayPreferences();
const hosts = useHosts();
// `allProjects`, never `projects`: the model's `projects` is already filtered, so a picker fed
// from it would lose the row that undoes the filter as soon as the filter narrowed to one.
const { allProjects, resolvedProjectFilters } = useSidebarModel();
const { labels } = useWorkspaceLabelProjection();
const [managerOpen, setManagerOpen] = useState(false);
const openManager = useCallback(() => setManagerOpen(true), []);
@@ -173,6 +182,8 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
);
const showHostFilter = hosts.length > 1;
// One project is the whole sidebar, so filtering to it is a no-op with a menu row attached.
const showProjectFilter = allProjects.length > 1;
// Nothing to filter by means no row at all. The active-filter half is not redundant: the merged
// catalog only counts hosts that are online, so a host dropping off would otherwise take away
// the only way back to a filter that is still hiding workspaces.
@@ -236,6 +247,19 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
content: <HostFilterPage preferences={preferences} hosts={hosts} />,
});
}
if (showProjectFilter) {
definitions.push({
id: "projectFilter",
title: t("sidebar.display.projectFilter.label"),
content: (
<ProjectFilterPage
projects={allProjects}
resolvedProjectFilters={resolvedProjectFilters}
preferences={preferences}
/>
),
});
}
if (showLabelFilter) {
definitions.push({
id: "labelFilter",
@@ -246,7 +270,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
});
}
return definitions;
}, [t, preferences, hosts, showHostFilter, showLabelFilter, labels, openManager]);
}, [
t,
preferences,
hosts,
showHostFilter,
showProjectFilter,
allProjects,
resolvedProjectFilters,
showLabelFilter,
labels,
openManager,
]);
return (
<>
@@ -297,6 +332,21 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
</MenuSubTrigger>
</>
) : null}
{showProjectFilter ? (
<>
{/* Host and Project narrow the same list, so they read as one block. The separator
belongs above whichever of the two is showing first — with a single host there is
no Host row and Project is what has to carry it. */}
{showHostFilter ? null : <MenuSeparator />}
<MenuSubTrigger
id="projectFilter"
indicator={resolvedProjectFilters.length > 0}
testID="sidebar-display-project-filter"
>
{t("sidebar.display.projectFilter.label")}
</MenuSubTrigger>
</>
) : null}
{showLabelFilter ? (
<>
<MenuSeparator />
@@ -550,6 +600,93 @@ function ChecksSubTrigger(): ReactElement {
);
}
/**
* Every project the sidebar could show, one row each.
*
* A workspace belongs to exactly one project, so this is a plain allowlist — the same shape as the
* host page, and deliberately not the label page's tri-state.
*
* Selection reads `resolvedProjectFilters`, not the stored list. A stored key whose project is not
* currently visible filters nothing, so showing it as checked here would contradict the sidebar.
*/
function ProjectFilterPage({
projects,
resolvedProjectFilters,
preferences,
}: {
projects: readonly SidebarProjectEntry[];
resolvedProjectFilters: readonly string[];
preferences: Preferences;
}): ReactElement {
const { t } = useTranslation();
const iconTargets = useMemo(() => resolveSidebarProjectIconTargets(projects), [projects]);
// Shares TanStack's cache with the sidebar's own call, so this subscribes rather than refetches.
const iconByProjectViewKey = useProjectIcons({ projects: iconTargets });
return (
<>
<MenuItem
selected={resolvedProjectFilters.length === 0}
closeOnSelect={false}
onSelect={preferences.clearProjectFilters}
testID="sidebar-project-filter-all"
>
{t("sidebar.display.projectFilter.all")}
</MenuItem>
{projects.map((project) => (
<ProjectFilterItem
key={project.viewKey}
viewKey={project.viewKey}
label={project.projectName}
iconDataUri={iconByProjectViewKey.get(project.viewKey) ?? null}
selected={resolvedProjectFilters.includes(project.viewKey)}
onToggle={preferences.toggleProjectFilter}
/>
))}
</>
);
}
function ProjectFilterItem({
viewKey,
label,
iconDataUri,
selected,
onToggle,
}: {
viewKey: string;
label: string;
iconDataUri: string | null;
selected: boolean;
onToggle: (viewKey: string) => void;
}): ReactElement {
const handleSelect = useCallback(() => onToggle(viewKey), [viewKey, onToggle]);
const leading = useMemo(
() => (
<ProjectIconView
iconDataUri={iconDataUri}
initial={projectIconPlaceholderLabelFromDisplayName(label).charAt(0).toUpperCase()}
projectViewKey={viewKey}
size={OPTION_ICON_SIZE}
textStyle={styles.projectIconText}
/>
),
[iconDataUri, label, viewKey],
);
return (
<MenuItem
selected={selected}
leading={leading}
closeOnSelect={false}
onSelect={handleSelect}
testID={`sidebar-project-filter-${viewKey}`}
>
{label}
</MenuItem>
);
}
function HostFilterPage({
preferences,
hosts,
@@ -627,4 +764,9 @@ const styles = StyleSheet.create((theme) => ({
triggerHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
// The icon sits in a 14pt menu slot, so the fallback initial is sized down to match rather
// than reusing the sidebar row's 16pt figure.
projectIconText: {
fontSize: 8,
},
}));
@@ -30,6 +30,10 @@ export interface SidebarDisplayPreferences {
hostFilters: readonly string[];
toggleHostFilter: (serverId: string) => void;
clearHostFilters: () => void;
/** Raw stored selection. For anything the user sees, use the model's resolved list instead. */
projectFilters: readonly string[];
toggleProjectFilter: (viewKey: string) => void;
clearProjectFilters: () => void;
labelFilter: SidebarLabelFilter;
toggleLabelFilter: (name: string) => void;
clearLabelFilter: () => void;
@@ -49,6 +53,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter);
const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters);
const projectFilters = useSidebarViewStore((state) => state.projectFilters);
const toggleProjectFilter = useSidebarViewStore((state) => state.toggleProjectFilter);
const clearProjectFilters = useSidebarViewStore((state) => state.clearProjectFilters);
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
const toggleLabelFilter = useSidebarViewStore((state) => state.toggleLabelFilter);
const clearLabelFilter = useSidebarViewStore((state) => state.clearLabelFilter);
@@ -110,6 +117,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
hostFilters,
toggleHostFilter,
clearHostFilters,
projectFilters,
toggleProjectFilter,
clearProjectFilters,
labelFilter,
toggleLabelFilter,
clearLabelFilter,
@@ -128,6 +138,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
hostFilters,
toggleHostFilter,
clearHostFilters,
projectFilters,
toggleProjectFilter,
clearProjectFilters,
labelFilter,
toggleLabelFilter,
clearLabelFilter,
@@ -1,3 +1,4 @@
import { useCallback } from "react";
import { Plus } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
@@ -13,16 +14,24 @@ import { useSidebarViewStore } from "@/stores/sidebar-view-store";
* trigger, and an empty state that replaces the list rather than its body takes the header with
* it — which is how filtering the last row away used to close the menu you were filtering from.
*/
export function SidebarLabelFilterEmptyState() {
export function SidebarFilterEmptyState() {
const { t } = useTranslation();
const clearLabelFilter = useSidebarViewStore((state) => state.clearLabelFilter);
const clearProjectFilters = useSidebarViewStore((state) => state.clearProjectFilters);
// Clears every filter that can empty the list, not just the one that did. The card names no
// filter, so a Clear that undid only one of two active filters would leave it on screen looking
// like it had failed.
const clearFilters = useCallback(() => {
clearLabelFilter();
clearProjectFilters();
}, [clearLabelFilter, clearProjectFilters]);
return (
<View style={styles.container} testID="sidebar-label-filter-empty-state">
<Text style={styles.title}>{t("workspaceLabels.filter.noMatchesTitle")}</Text>
<Text style={styles.description}>{t("workspaceLabels.filter.noMatchesDescription")}</Text>
<Button variant="ghost" size="sm" onPress={clearLabelFilter}>
{t("workspaceLabels.filter.clear")}
<View style={styles.container} testID="sidebar-filter-empty-state">
<Text style={styles.title}>{t("sidebar.filterEmpty.title")}</Text>
<Text style={styles.description}>{t("sidebar.filterEmpty.description")}</Text>
<Button variant="ghost" size="sm" onPress={clearFilters}>
{t("sidebar.filterEmpty.clear")}
</Button>
</View>
);
@@ -1,6 +1,7 @@
import React, { createContext, useContext, useEffect, useMemo, type ReactNode } from "react";
import {
useSidebarWorkspacesList,
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
type SidebarWorkspacesListResult,
} from "@/hooks/use-sidebar-workspaces-list";
@@ -17,6 +18,7 @@ import type { SidebarShortcutModel } from "@/utils/sidebar-shortcuts";
import { buildSidebarProjection } from "./sidebar-projection";
import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model";
import { filterWorkspacesByLabels, type SidebarWorkspaceGroup } from "./sidebar-labels";
import { filterWorkspacesByProjects, resolveActiveProjectFilters } from "./sidebar-project-filter";
import {
hasAuthoritativeWorkspaceLabelCatalog,
useWorkspaceLabelProjection,
@@ -24,7 +26,16 @@ import {
interface SidebarModel extends SidebarWorkspacesListResult {
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
hasProjectsBeforeLabelFilter: boolean;
/**
* Every project the sidebar could show, before any filter narrows it.
*
* `projects` is the FILTERED list. A surface that offers a filter picker must read this one, or
* narrowing the filter deletes the rows that would undo it.
*/
allProjects: SidebarProjectEntry[];
/** The project filter as it is actually being applied — see `resolveActiveProjectFilters`. */
resolvedProjectFilters: readonly string[];
hasProjectsBeforeFilter: boolean;
groupMode: SidebarGroupMode;
workspaceGroups: SidebarWorkspaceGroup[];
projectIconTargets: SidebarProjectIconTarget[];
@@ -46,6 +57,7 @@ export function SidebarModelProvider({
const list = useSidebarWorkspacesList();
const groupMode = useSidebarViewStore((state) => state.groupMode);
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
const projectFilters = useSidebarViewStore((state) => state.projectFilters);
const reconcileLabelFilter = useSidebarViewStore((state) => state.reconcileLabelFilter);
const { hosts: labelHosts } = useWorkspaceLabelProjection();
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
@@ -69,31 +81,63 @@ export function SidebarModelProvider({
reconcileLabelFilter(availableLabelNames);
}, [availableLabelNames, hasAuthoritativeLabelCatalog, reconcileLabelFilter]);
const hasActiveLabelFilter = hasActiveSidebarLabelFilter(labelFilter);
const resolvedProjectFilters = useMemo(
() =>
resolveActiveProjectFilters(
projectFilters,
new Set(list.projects.map((project) => project.viewKey)),
),
[projectFilters, list.projects],
);
const hasActiveProjectFilter = resolvedProjectFilters.length > 0;
// The project filter is deliberately absent from this gate. It reads `projectViewKey`, which
// lives on the project and the placement, so it can narrow the project list without hydrating
// anything; the label filter reads `labels`, which only exists on an entry. Hydration opens a
// live session-store subscription over every workspace on every visible host, so widening this
// for a filter that does not need it costs a retained-but-inactive sidebar real work.
const needsWorkspaceEntries = groupMode !== "project" || hasActiveLabelFilter;
const workspaceEntriesByKey = useSidebarWorkspaceEntries(
list.workspacePlacements,
active !== false || needsWorkspaceEntries,
);
const filteredWorkspaceEntriesByKey = useMemo(() => {
const filtered = filterWorkspacesByLabels({
const byProject = filterWorkspacesByProjects({
workspaces: [...workspaceEntriesByKey.values()],
...labelFilter,
projectFilters: resolvedProjectFilters,
});
const filtered = filterWorkspacesByLabels({ workspaces: byProject, ...labelFilter });
return new Map(filtered.map((workspace) => [workspace.workspaceKey, workspace]));
}, [labelFilter, workspaceEntriesByKey]);
}, [labelFilter, resolvedProjectFilters, workspaceEntriesByKey]);
const visibleWorkspaceKeys = useMemo(
() => new Set(filteredWorkspaceEntriesByKey.keys()),
[filteredWorkspaceEntriesByKey],
);
// The two filters prune differently on purpose. The project filter is a membership test on the
// project itself, so a project you filtered TO survives even with no workspaces — it still owns
// a header row you can create your first workspace under. The label filter can only ask about
// workspaces, so a project it empties has nothing left to show.
const filteredProjects = useMemo(() => {
if (!hasActiveLabelFilter) return list.projects;
return list.projects.flatMap((project) => {
const workspaces = project.workspaces.filter((workspace) =>
visibleWorkspaceKeys.has(workspace.workspaceKey),
);
return workspaces.length > 0 ? [{ ...project, workspaces }] : [];
});
}, [hasActiveLabelFilter, list.projects, visibleWorkspaceKeys]);
let projects = list.projects;
if (hasActiveProjectFilter) {
const included = new Set(resolvedProjectFilters);
projects = projects.filter((project) => included.has(project.viewKey));
}
if (hasActiveLabelFilter) {
projects = projects.flatMap((project) => {
const workspaces = project.workspaces.filter((workspace) =>
visibleWorkspaceKeys.has(workspace.workspaceKey),
);
return workspaces.length > 0 ? [{ ...project, workspaces }] : [];
});
}
return projects;
}, [
hasActiveLabelFilter,
hasActiveProjectFilter,
resolvedProjectFilters,
list.projects,
visibleWorkspaceKeys,
]);
const pinnedKeys = usePinnedSidebarKeys(filteredProjects);
const projectionInput = useMemo(
() => ({
@@ -124,7 +168,9 @@ export function SidebarModelProvider({
() => ({
...list,
projects: filteredProjects,
hasProjectsBeforeLabelFilter: list.projects.length > 0,
allProjects: list.projects,
resolvedProjectFilters,
hasProjectsBeforeFilter: list.projects.length > 0,
workspaceEntriesByKey: filteredWorkspaceEntriesByKey,
groupMode,
workspaceGroups: projection.workspaceGroups,
@@ -135,6 +181,7 @@ export function SidebarModelProvider({
shortcutModel: projection.shortcutModel,
}),
[
resolvedProjectFilters,
collapsedProjectKeys,
groupMode,
list,
@@ -0,0 +1,83 @@
import { describe, expect, test } from "vitest";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { filterWorkspacesByProjects, resolveActiveProjectFilters } from "./sidebar-project-filter";
function workspace(workspaceId: string, projectViewKey: string): SidebarWorkspaceEntry {
return {
workspaceKey: `host:${workspaceId}`,
serverId: "host",
workspaceId,
projectViewKey,
projectName: projectViewKey,
projectRootPath: `/repo/${projectViewKey}`,
workspaceDirectory: `/repo/${projectViewKey}/${workspaceId}`,
workspaceDirectoryLabel: workspaceId,
projectKind: "git",
workspaceKind: "worktree",
name: workspaceId,
title: null,
pinnedAt: null,
labels: [],
currentBranch: "main",
statusBucket: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
prHint: null,
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
scripts: [],
hasRunningScripts: false,
};
}
describe("resolveActiveProjectFilters", () => {
test("reads an empty stored filter as every project", () => {
expect(resolveActiveProjectFilters([], new Set(["alpha", "beta"]))).toEqual([]);
});
test("keeps only the keys that still name a project it can see", () => {
expect(resolveActiveProjectFilters(["alpha", "gone"], new Set(["alpha", "beta"]))).toEqual([
"alpha",
]);
expect(resolveActiveProjectFilters(["alpha", "beta"], new Set(["alpha", "beta"]))).toEqual([
"alpha",
"beta",
]);
});
// Nothing deletes the stored keys, so a filter naming only projects on a host that has not
// connected yet must go inert rather than blank the sidebar. It comes back with the host.
test("goes inert instead of matching nothing", () => {
expect(resolveActiveProjectFilters(["alpha"], new Set(["beta"]))).toEqual([]);
expect(resolveActiveProjectFilters(["alpha"], new Set())).toEqual([]);
});
});
describe("filterWorkspacesByProjects", () => {
const workspaces = [
workspace("one", "alpha"),
workspace("two", "alpha"),
workspace("three", "beta"),
];
function filtered(projectFilters: string[]) {
return filterWorkspacesByProjects({ workspaces, projectFilters }).map(
(entry) => entry.workspaceId,
);
}
test("keeps every workspace when nothing is pinned", () => {
expect(filtered([])).toEqual(["one", "two", "three"]);
});
test("keeps the workspaces of the pinned projects", () => {
expect(filtered(["alpha"])).toEqual(["one", "two"]);
expect(filtered(["beta"])).toEqual(["three"]);
expect(filtered(["alpha", "beta"])).toEqual(["one", "two", "three"]);
});
test("keeps nothing for a project with no workspaces", () => {
expect(filtered(["gamma"])).toEqual([]);
});
});
@@ -0,0 +1,40 @@
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
/**
* The subset of a stored project filter that still names a project the sidebar can see.
*
* Empty means "all projects" — both when nothing is pinned and when everything pinned has gone
* away. That second case is why nothing ever deletes a stored key: the project list is narrowed
* by the host filter and is empty before any host connects, so absence does not mean the project
* is gone. A filter goes inert while its host is away and comes back with it.
*
* This is the only definition of "is a project filter active". The menu's indicator, the
* "All projects" check and the per-row selection all read it, so they cannot disagree with what
* the list is actually doing.
*/
export function resolveActiveProjectFilters(
projectFilters: readonly string[],
availableViewKeys: ReadonlySet<string>,
): readonly string[] {
if (projectFilters.length === 0) return EMPTY_PROJECT_FILTERS;
const matching = projectFilters.filter((viewKey) => availableViewKeys.has(viewKey));
return matching.length > 0 ? matching : EMPTY_PROJECT_FILTERS;
}
/**
* Applies the Project page's selection to the sidebar's workspace entries.
*
* A workspace belongs to exactly one project, so this is a plain allowlist — no tri-state, and
* no "unassigned" row of the kind the label filter needs.
*/
export function filterWorkspacesByProjects(input: {
workspaces: readonly SidebarWorkspaceEntry[];
projectFilters: readonly string[];
}): SidebarWorkspaceEntry[] {
const { workspaces, projectFilters } = input;
if (projectFilters.length === 0) return [...workspaces];
const included = new Set(projectFilters);
return workspaces.filter((workspace) => included.has(workspace.projectViewKey));
}
const EMPTY_PROJECT_FILTERS: readonly string[] = [];
@@ -23,7 +23,7 @@ import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspac
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { StatusBucket } from "@/hooks/sidebar-status-view-model";
import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels";
import { SidebarLabelFilterEmptyState } from "@/components/sidebar/empty-states";
import { SidebarFilterEmptyState } from "@/components/sidebar/empty-states";
import type { HostBadgeModel } from "@/hosts/appearance";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -127,7 +127,7 @@ interface StatusWorkspaceListProps {
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspaceEntry[]) => void;
listHeaderComponent?: ReactNode;
/** Swaps the group list for the label filter's empty state. Never the header above it. */
labelFilterEmpty?: boolean;
sidebarFilterEmpty?: boolean;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostPresented?: boolean;
}
@@ -144,7 +144,7 @@ export function SidebarStatusWorkspaceList({
onToggleWorkspacePin,
onPinnedWorkspaceReorder,
listHeaderComponent,
labelFilterEmpty = false,
sidebarFilterEmpty = false,
parentGestureRef,
dragGestureHostPresented,
}: StatusWorkspaceListProps) {
@@ -231,8 +231,8 @@ export function SidebarStatusWorkspaceList({
</View>
) : null}
{listHeaderComponent}
{labelFilterEmpty ? (
<SidebarLabelFilterEmptyState />
{sidebarFilterEmpty ? (
<SidebarFilterEmptyState />
) : (
<StatusGroupList
groups={groups}
+47 -14
View File
@@ -129,9 +129,14 @@ interface SplitContainerProps {
position: "left" | "right" | "top" | "bottom";
}) => void;
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
onClosePane: (paneId: string) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void;
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
renderPaneEmptyState?: () => ReactNode;
renderPaneEmptyState?: (input: {
paneId: string;
paneIsolated: boolean;
onClosePane: () => void;
}) => ReactNode;
focusModeEnabled?: boolean;
onExitFocusMode: () => void;
}
@@ -174,9 +179,10 @@ interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMov
node: SplitNode;
uiTabs: WorkspaceTab[];
focusedPaneId: string | null;
explorerPaneId: string | null;
sidePanelPaneId: string | null;
maximizedPaneId: string | null;
onTogglePaneMaximized: (paneId: string) => void;
paneIsolated: boolean;
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
@@ -263,6 +269,7 @@ function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
cachedDescriptor &&
cachedDescriptor.key === tabDescriptor.key &&
cachedDescriptor.kind === tabDescriptor.kind &&
cachedDescriptor.state === tabDescriptor.state &&
workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target)
) {
next.set(tabDescriptor.tabId, cachedDescriptor);
@@ -404,6 +411,7 @@ export function SplitContainer({
onSplitPane,
onSplitPaneEmpty,
onMoveTabToPane,
onClosePane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState = () => null,
@@ -421,8 +429,8 @@ export function SplitContainer({
} | null>(null);
const maximizedPaneId =
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
const explorerPaneId = useWorkspaceLayoutStore(
(state) => state.explorerPaneIdByWorkspace[workspaceKey] ?? null,
const sidePanelPaneId = useWorkspaceLayoutStore(
(state) => state.sidePanelPaneIdByWorkspace[workspaceKey] ?? null,
);
useEffect(() => {
@@ -451,7 +459,7 @@ export function SplitContainer({
if (action.id !== "workspace.explorer.maximize.toggle") {
return false;
}
const explorerPane = findPaneById(layout.root, explorerPaneId);
const explorerPane = findPaneById(layout.root, sidePanelPaneId);
if (focusModeEnabled || !explorerPane || explorerPane.hidden === true) {
return true;
}
@@ -459,7 +467,7 @@ export function SplitContainer({
handleTogglePaneMaximized(explorerPane.id);
return true;
},
[explorerPaneId, focusModeEnabled, handleTogglePaneMaximized, layout.root, onFocusPane],
[sidePanelPaneId, focusModeEnabled, handleTogglePaneMaximized, layout.root, onFocusPane],
);
useKeyboardActionHandler({
@@ -494,6 +502,11 @@ export function SplitContainer({
[focusModeEnabled, layout.focusedPaneId, layout.root, maximizedPaneId],
);
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
// Isolating a pane (focus mode, maximize) hides its siblings without removing
// them, so the one pane on screen is not really the last one and closing it is not
// what anyone means. That is all this container knows; whether the layout itself
// allows the dismissal is `canDismissPaneInLayout`, which the caller reads.
const paneIsolated = focusModeEnabled || Boolean(maximizedPaneId);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = asWorkspaceTabDragData(event.active.data.current);
@@ -654,7 +667,7 @@ export function SplitContainer({
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
explorerPaneId={explorerPaneId}
sidePanelPaneId={sidePanelPaneId}
maximizedPaneId={maximizedPaneId}
onTogglePaneMaximized={handleTogglePaneMaximized}
normalizedServerId={normalizedServerId}
@@ -682,6 +695,8 @@ export function SplitContainer({
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
@@ -863,7 +878,7 @@ function SplitNodeView({
workspaceKey,
uiTabs,
focusedPaneId,
explorerPaneId,
sidePanelPaneId,
maximizedPaneId,
onTogglePaneMaximized,
normalizedServerId,
@@ -891,6 +906,8 @@ function SplitNodeView({
onFocusPane,
onSplitPane,
onSplitPaneEmpty,
paneIsolated,
onClosePane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState,
@@ -942,7 +959,7 @@ function SplitNodeView({
pane={node.pane}
uiTabs={uiTabs}
isFocused={node.pane.id === focusedPaneId}
explorerPaneId={explorerPaneId}
sidePanelPaneId={sidePanelPaneId}
maximizedPaneId={maximizedPaneId}
onTogglePaneMaximized={onTogglePaneMaximized}
normalizedServerId={normalizedServerId}
@@ -970,6 +987,8 @@ function SplitNodeView({
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
@@ -994,7 +1013,7 @@ function SplitNodeView({
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={focusedPaneId}
explorerPaneId={explorerPaneId}
sidePanelPaneId={sidePanelPaneId}
maximizedPaneId={maximizedPaneId}
onTogglePaneMaximized={onTogglePaneMaximized}
normalizedServerId={normalizedServerId}
@@ -1022,6 +1041,8 @@ function SplitNodeView({
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
@@ -1082,13 +1103,15 @@ function SplitPaneView({
onFocusPane,
onSplitPane: _onSplitPane,
onSplitPaneEmpty: _onSplitPaneEmpty,
paneIsolated,
onClosePane,
onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId,
showDropZones,
dropPreview,
tabDropPreview,
explorerPaneId,
sidePanelPaneId,
maximizedPaneId,
onTogglePaneMaximized,
focusModeEnabled,
@@ -1195,9 +1218,15 @@ function SplitPaneView({
() => onTogglePaneMaximized(paneId),
[onTogglePaneMaximized, paneId],
);
const handleClosePane = useCallback(() => onClosePane(paneId), [onClosePane, paneId]);
return (
<RenderProfile id={`SplitPaneView:${pane.id}`}>
<View ref={paneRef} collapsable={false} style={styles.pane}>
<View
ref={paneRef}
collapsable={false}
style={styles.pane}
testID={pane.id === sidePanelPaneId ? "workspace-side-panel" : `workspace-pane-${pane.id}`}
>
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
@@ -1228,7 +1257,7 @@ function SplitPaneView({
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
showPaneMaximizeAction={pane.id === explorerPaneId && !focusModeEnabled}
showPaneMaximizeAction={pane.id === sidePanelPaneId && !focusModeEnabled}
paneMaximized={pane.id === maximizedPaneId}
onTogglePaneMaximized={handleToggleMaximized}
focusModeEnabled={Boolean(focusModeEnabled)}
@@ -1257,7 +1286,11 @@ function SplitPaneView({
/>
);
})
: (renderPaneEmptyState?.() ?? null)}
: (renderPaneEmptyState?.({
paneId,
paneIsolated,
onClosePane: handleClosePane,
}) ?? null)}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View>
</View>
+50 -29
View File
@@ -1,17 +1,14 @@
import React, { createContext, useContext, useCallback, useMemo } from "react";
import type { ReactNode } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated from "react-native-reanimated";
import {
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { BottomSheetScrollView, BottomSheetBackdrop } from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import {
IsolatedBottomSheetModal,
type ContextBridge,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import type { ToolCallIconComponent } from "@/utils/tool-call-icon";
@@ -45,17 +42,43 @@ export function useToolCallSheet(): ToolCallSheetContextValue {
return context;
}
// ----- Custom Background Component -----
function CustomSheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles();
const containerStyle = useMemo(
() => [style, { backgroundColor: theme.colors.surface2, borderRadius: 16 }],
[style, theme.colors.surface2],
export function useToolCallSheetContextBridge(): ContextBridge {
const context = useToolCallSheet();
return useCallback(
(children) => (
<ToolCallSheetContext.Provider value={context}>{children}</ToolCallSheetContext.Provider>
),
[context],
);
return <Animated.View pointerEvents="none" style={containerStyle} />;
}
interface ToolCallHeaderIconProps {
icon: ToolCallIconComponent;
size: number;
color?: string;
}
function ToolCallHeaderIcon({ icon: Icon, size, color }: ToolCallHeaderIconProps) {
return <Icon size={size} color={color} />;
}
const ThemedToolCallHeaderIcon = withUnistyles(ToolCallHeaderIcon, (theme) => ({
color: theme.colors.foreground,
}));
const ThemedCloseIcon = withUnistyles(X, (theme) => ({
color: theme.colors.foregroundMuted,
}));
export const ToolCallSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({
backgroundStyle: {
backgroundColor: theme.colors.surface2,
borderRadius: 16,
},
handleIndicatorStyle: {
backgroundColor: theme.colors.palette.zinc[600],
},
}));
// ----- Provider Component -----
interface ToolCallSheetProviderProps {
@@ -63,7 +86,6 @@ interface ToolCallSheetProviderProps {
}
export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) {
const { theme } = useUnistyles();
const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null);
const [isSheetOpen, setIsSheetOpen] = React.useState(false);
@@ -104,15 +126,10 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
[openToolCall, closeToolCall],
);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.palette.zinc],
);
return (
<ToolCallSheetContext.Provider value={contextValue}>
{children}
<IsolatedBottomSheetModal
<ToolCallSheetModal
ref={bottomSheetRef}
contextBridge={null}
snapPoints={snapPoints}
@@ -122,11 +139,9 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
onDismiss={handleToolCallSheetDismiss}
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundComponent={CustomSheetBackground}
handleIndicatorStyle={handleIndicatorStyle}
>
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
</IsolatedBottomSheetModal>
</ToolCallSheetModal>
</ToolCallSheetContext.Provider>
);
}
@@ -139,7 +154,7 @@ interface ToolCallSheetContentProps {
}
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const { displayName, detail, errorText, icon: IconComponent, showLoadingSkeleton } = data;
return (
@@ -147,13 +162,19 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
{/* Header */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<IconComponent size={20} color={theme.colors.foreground} />
<ThemedToolCallHeaderIcon icon={IconComponent} size={20} />
<Text style={styles.headerTitle} numberOfLines={1}>
{displayName}
</Text>
</View>
<Pressable onPress={onClose} style={styles.closeButton}>
<X size={20} color={theme.colors.foregroundMuted} />
<Pressable
onPress={onClose}
style={styles.closeButton}
accessibilityRole="button"
accessibilityLabel={t("common.actions.close")}
testID="tool-call-sheet-close"
>
<ThemedCloseIcon size={20} />
</Pressable>
</View>
@@ -1,13 +1,14 @@
import { MIN_TREE_RAIL_WIDTH, clampTreeRailWidth } from "@/stores/panel-store";
import { MIN_TREE_RAIL_WIDTH } from "@/stores/panel-store";
const MIN_CONTENT_WIDTH = 240;
export function resolveVisibleTreeRailWidth(
requestedWidth: number,
containerWidth: number,
minimumWidth = MIN_TREE_RAIL_WIDTH,
): number {
const maximumVisibleWidth = Math.max(MIN_TREE_RAIL_WIDTH, containerWidth - MIN_CONTENT_WIDTH);
return Math.min(clampTreeRailWidth(requestedWidth), maximumVisibleWidth);
const maximumVisibleWidth = Math.max(minimumWidth, containerWidth - MIN_CONTENT_WIDTH);
return Math.min(Math.max(minimumWidth, Math.min(600, requestedWidth)), maximumVisibleWidth);
}
export function acceptTreeRailContainerMeasurement(
@@ -0,0 +1,65 @@
import { useMemo } from "react";
import { Pressable, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { FolderTree } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
paneContentToolbarIconSize,
paneContentToolbarIconButtonStyle,
} from "@/components/ui/pane-content-toolbar";
import type { Theme } from "@/styles/theme";
const ThemedFolderTree = withUnistyles(FolderTree);
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/**
* Opens and closes a `TreeRail`. Every panel that owns a tree rail uses this one
* button so the affordance reads the same wherever the rail appears; only the
* flag it drives is per-panel.
*/
export function TreeRailToggle({
visible,
testID,
onToggle,
}: {
visible: boolean;
testID: string;
onToggle: () => void;
}) {
const { t } = useTranslation();
const label = t(visible ? "workspace.tree.hideFolderTree" : "workspace.tree.showFolderTree");
const buttonStyle = useMemo(
() => (state: { hovered?: boolean; pressed: boolean }) =>
paneContentToolbarIconButtonStyle(state, visible),
[visible],
);
const accessibilityState = useMemo(() => ({ selected: visible }), [visible]);
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={accessibilityState}
aria-selected={visible}
testID={testID}
style={buttonStyle}
onPress={onToggle}
>
<ThemedFolderTree size={paneContentToolbarIconSize(false)} uniProps={iconColorMapping} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
}));
+20 -6
View File
@@ -27,16 +27,20 @@ export interface TreeRailProps {
*/
children: ReactNode;
testID?: string;
width?: number;
onWidthChange?: (width: number) => void;
minimumWidth?: number;
}
/** Content-left/tree-right master-detail geometry with one app-wide tree width. */
export function TreeRail({ children, testID }: TreeRailProps) {
export function TreeRail({ children, testID, width, onWidthChange, minimumWidth }: TreeRailProps) {
const [content, tree] = Children.toArray(children);
const requestedWidth = usePanelStore((state) => state.treeRailWidth);
const setTreeRailWidth = usePanelStore((state) => state.setTreeRailWidth);
const sharedWidth = usePanelStore((state) => state.treeRailWidth);
const setSharedWidth = usePanelStore((state) => state.setTreeRailWidth);
const requestedWidth = width ?? sharedWidth;
const { width: viewportWidth } = useWindowDimensions();
const [containerWidth, setContainerWidth] = useState(viewportWidth);
const visibleWidth = resolveVisibleTreeRailWidth(requestedWidth, containerWidth);
const visibleWidth = resolveVisibleTreeRailWidth(requestedWidth, containerWidth, minimumWidth);
const startWidthRef = useRef(visibleWidth);
const resizeWidth = useSharedValue(visibleWidth);
const [resizePressed, setResizePressed] = useState(false);
@@ -62,11 +66,21 @@ export function TreeRail({ children, testID }: TreeRailProps) {
resizeWidth.value = resolveVisibleTreeRailWidth(
startWidthRef.current - event.translationX,
containerWidth,
minimumWidth,
);
})
.onEnd(() => runOnJS(setTreeRailWidth)(resizeWidth.value))
.onEnd(() => runOnJS(onWidthChange ?? setSharedWidth)(resizeWidth.value))
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
[containerWidth, hideResizeGrip, resizeWidth, setTreeRailWidth, showResizeGrip, visibleWidth],
[
containerWidth,
hideResizeGrip,
onWidthChange,
minimumWidth,
resizeWidth,
setSharedWidth,
showResizeGrip,
visibleWidth,
],
);
const railWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value }));
const handleLayout = useCallback(
@@ -0,0 +1,65 @@
import type { ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
/** Shared chrome at the boundary between a workspace pane's tabs and content. */
export function PaneContentToolbar({
children,
style,
testID,
}: {
children: ReactNode;
style?: StyleProp<ViewStyle>;
testID?: string;
}) {
return (
<View style={[styles.toolbar, style]} testID={testID}>
{children}
</View>
);
}
export function paneContentToolbarIconSize(isCompact: boolean): number {
return isCompact ? 18 : 14;
}
export function paneContentToolbarIconButtonStyle(
{ hovered, pressed }: { hovered?: boolean; pressed: boolean },
selected = false,
isCompact = false,
) {
return [
isCompact ? styles.iconButtonCompact : styles.iconButton,
(selected || Boolean(hovered) || pressed) && styles.iconButtonSelected,
];
}
const styles = StyleSheet.create((theme) => ({
toolbar: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
backgroundColor: theme.colors.surface0,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
flexShrink: 0,
},
iconButton: {
width: 24,
height: 24,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
flexShrink: 0,
},
iconButtonCompact: {
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
flexShrink: 0,
},
iconButtonSelected: {
backgroundColor: theme.colors.surface2,
},
}));
+9 -1
View File
@@ -39,6 +39,7 @@ export function Shortcut({
if (displayChord.length === 1) {
return (
<View style={badgeStyle}>
<View style={styles.badgeBackground} />
<Text style={textCombinedStyle}>{formatShortcut(singleCombo, shortcutOs)}</Text>
</View>
);
@@ -49,6 +50,7 @@ export function Shortcut({
{displayChord.map(function (combo) {
return (
<View key={combo.join("+")} style={styles.badge}>
<View style={styles.badgeBackground} />
<Text style={textCombinedStyle}>{formatShortcut(combo, shortcutOs)}</Text>
</View>
);
@@ -59,12 +61,18 @@ export function Shortcut({
const styles = StyleSheet.create((theme) => ({
badge: {
position: "relative",
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
borderWidth: 0,
},
badgeBackground: {
...StyleSheet.absoluteFillObject,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface4,
opacity: theme.opacity[50],
},
sequence: {
flexDirection: "row",
alignItems: "center",
@@ -0,0 +1,62 @@
// @vitest-environment jsdom
import React, { act, createRef } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { EditingTextInput } from "./text-input";
import type { EditingTextInputHandle } from "./types";
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root && container) {
act(() => {
root?.unmount();
});
container.remove();
}
root = null;
container = null;
});
function noop() {}
describe("EditingTextInputNative", () => {
it("clears text via clear() when replaceText receives an empty string", () => {
const handleRef = createRef<EditingTextInputHandle>();
act(() => {
root?.render(<EditingTextInput ref={handleRef} initialValue="initial" onChangeText={noop} />);
});
expect(handleRef.current?.getText()).toBe("initial");
act(() => {
handleRef.current?.replaceText("");
});
expect(handleRef.current?.getText()).toBe("");
});
it("updates textRef and text when replaceText receives non-empty text", () => {
const handleRef = createRef<EditingTextInputHandle>();
act(() => {
root?.render(<EditingTextInput ref={handleRef} initialValue="hello" />);
});
expect(handleRef.current?.getText()).toBe("hello");
act(() => {
handleRef.current?.replaceText("world", { start: 0, end: 5 });
});
expect(handleRef.current?.getText()).toBe("world");
});
});
@@ -11,7 +11,9 @@ type NativeInput = (TextInput | PasteTextInputInstance) & {
blur(): void;
focus(): void;
isFocused(): boolean;
setNativeProps(props: { text: string; selection?: { start: number; end: number } }): void;
clear?(): void;
replaceText?(text: string, selection?: { start: number; end: number }): void;
setNativeProps?(props: { text?: string; selection?: { start: number; end: number } }): void;
setSelection?(start: number, end: number): void;
getNativeRef?(): unknown;
};
@@ -39,7 +41,18 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
getText: () => textRef.current,
replaceText: (nextText, selection) => {
textRef.current = nextText;
inputRef.current?.setNativeProps({ text: nextText, ...(selection ? { selection } : {}) });
if (inputRef.current?.replaceText) {
inputRef.current.replaceText(nextText, selection);
return;
}
if (nextText === "") {
inputRef.current?.clear?.();
} else {
inputRef.current?.setNativeProps?.({
text: nextText,
...(selection ? { selection } : {}),
});
}
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
},
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
@@ -20,7 +20,6 @@ import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liv
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { usePanelStore } from "@/stores/panel-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { Agent } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
@@ -49,7 +48,11 @@ import {
useIsCompactFormFactor,
} from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
import {
buildWorkspaceTabPersistenceKey,
type WorkspaceDraftTabSetup,
} from "@/workspace-tabs/model";
import { openSidePanelView } from "@/workspace-tabs/side-panel";
const EMPTY_PENDING_PERMISSIONS = new Map();
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
@@ -443,28 +446,19 @@ export function WorkspaceDraftAgentTab({
const clearWorkspaceAttachments = useWorkspaceAttachmentsStore(
(state) => state.clearWorkspaceAttachments,
);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const handleOpenWorkspaceAttachment = useCallback(
(attachment: WorkspaceComposerAttachment) => {
if (attachment.kind !== "review") {
return;
}
const checkout = {
serverId,
cwd: attachment.attachment.cwd,
isGit: true,
};
openFileExplorerForCheckout({
checkout,
openSidePanelView({
isCompact: isCompactFormFactor,
});
setExplorerTabForCheckout({
...checkout,
tab: "changes",
workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? "" }),
checkout: { serverId, cwd: attachment.attachment.cwd, isGit: true },
view: "changes",
});
},
[isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
[isCompactFormFactor, serverId, workspaceId],
);
const {
@@ -69,6 +69,50 @@ describe("focused chat target", () => {
parentTabIdByTabId: { "changes-tab": "agent-tab" },
} as WorkspaceLayout;
expect(resolveFocusedChatTarget({ serverId: "server-1", layout })).toEqual({
tabId: "agent-tab",
draftKey: "agent:server-1:agent-1",
});
});
it("targets a chat in a neighbouring pane when the focused pane holds none", () => {
const layout = {
root: {
kind: "group",
group: {
id: "group-1",
direction: "horizontal",
sizes: [0.5, 0.5],
children: [
{
kind: "pane",
pane: {
id: "main",
tabIds: ["agent-tab"],
focusedTabId: "agent-tab",
tabs: [
{
tabId: "agent-tab",
target: { kind: "agent", agentId: "agent-1" },
createdAt: 1,
},
],
},
},
{
kind: "pane",
pane: {
id: "explorer",
tabIds: ["changes-tab"],
focusedTabId: "changes-tab",
tabs: [{ tabId: "changes-tab", target: { kind: "working_diff" }, createdAt: 2 }],
},
},
],
},
},
focusedPaneId: "explorer",
} as unknown as WorkspaceLayout;
expect(resolveFocusedChatTarget({ serverId: "server-1", layout })).toEqual({
tabId: "agent-tab",
draftKey: "agent:server-1:agent-1",
@@ -1,5 +1,6 @@
import { buildDraftStoreKey } from "@/stores/draft-keys";
import {
collectAllPanes,
collectAllTabs,
findPaneById,
type WorkspaceLayout,
@@ -33,6 +34,12 @@ function resolveChatTab(
return null;
}
/**
* The chat a file should attach to. The focused pane answers first, then the tab it
* came from — a file opened beside a chat belongs to that chat. Failing both, any
* chat the user can see: standing in the Side panel looking at Changes, "add to
* chat" plainly means the chat in the pane next to it.
*/
export function resolveFocusedChatTarget(input: {
serverId: string;
layout: WorkspaceLayout | undefined;
@@ -40,20 +47,34 @@ export function resolveFocusedChatTarget(input: {
if (!input.layout) {
return null;
}
const pane = findPaneById(input.layout.root, input.layout.focusedPaneId);
const focusedTabId = pane?.focusedTabId;
if (!focusedTabId) {
return null;
}
const tabs = collectAllTabs(input.layout.root);
const tab = tabs.find((candidate) => candidate.tabId === focusedTabId);
const focusedChat = resolveChatTab(input.serverId, tab);
if (focusedChat) {
return focusedChat;
const focusedTabId = findPaneById(input.layout.root, input.layout.focusedPaneId)?.focusedTabId;
if (focusedTabId) {
const focusedChat = resolveChatTab(
input.serverId,
tabs.find((candidate) => candidate.tabId === focusedTabId),
);
if (focusedChat) {
return focusedChat;
}
const parentTabId = input.layout.parentTabIdByTabId?.[focusedTabId];
const parentChat = resolveChatTab(
input.serverId,
tabs.find((candidate) => candidate.tabId === parentTabId),
);
if (parentChat) {
return parentChat;
}
}
const parentTabId = input.layout.parentTabIdByTabId?.[focusedTabId];
return resolveChatTab(
input.serverId,
tabs.find((candidate) => candidate.tabId === parentTabId),
);
for (const pane of collectAllPanes(input.layout.root)) {
const paneChat = resolveChatTab(
input.serverId,
tabs.find((candidate) => candidate.tabId === pane.focusedTabId),
);
if (paneChat) {
return paneChat;
}
}
return null;
}
+16 -1
View File
@@ -96,6 +96,7 @@ import {
} from "@/attachments/service";
import { resolveAgentControlsMode } from "@/composer/agent-controls/mode";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import { resolveActiveSendBehavior } from "./input/state";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
@@ -1476,6 +1477,20 @@ function ComposerContentImpl({
const beginAgentCancellation = useSessionStore((state) => state.beginAgentCancellation);
const settleAgentCancellation = useSessionStore((state) => state.settleAgentCancellation);
const isAgentRunning = hasActiveTurn;
// Queueing behind a permission prompt would strand the message: the turn is
// parked until the request is answered.
const hasPendingPermission = useSessionStore((state) => {
const pendingPermissions = state.sessions[serverId]?.pendingPermissions;
if (!pendingPermissions) return false;
for (const permission of pendingPermissions.values()) {
if (permission.agentId === agentId) return true;
}
return false;
});
const activeSendBehavior = resolveActiveSendBehavior(
appSettings.sendBehavior,
hasPendingPermission,
);
const hasAgent = agentState.status !== null;
const queueWriter = useMemo<QueueWriter>(
@@ -2302,7 +2317,7 @@ function ComposerContentImpl({
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
defaultSendBehavior={activeSendBehavior}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
+2 -2
View File
@@ -1980,9 +1980,9 @@ const styles = StyleSheet.create((theme: Theme) => ({
textInput: {
width: "100%",
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontSize: theme.fontSize.content,
fontWeight: theme.fontWeight.normal,
lineHeight: theme.fontSize.base * 1.4,
lineHeight: theme.fontSize.content * 1.4,
...(isWeb
? ({
outlineStyle: "none",

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