Compare commits

..
Author SHA1 Message Date
Mohamed Boudra acdce33a7c test(e2e): desktop browser-tabs hides developer toasts on its attached page 2026-10-20 09:50:27 +02:00
Mohamed Boudra 6c383fbf8c test(e2e): hide the developer error toast instead of clicking it away
The locator handler that dismissed #error-toast could match a toast that closed before its
click; dispatchEvent then waited for the gone button until the test timed out. Hiding the toast
with CSS keeps it off app actions with no race.
2026-10-20 09:29:37 +02:00
paseo-ai[bot] 3081b99b06 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-20 05:08:53 +00:00
Mohamed Boudra adf27de7d0 Keep Explorer consistent across phone, tablet and desktop layouts (#7424)
* Upgrade to Expo 57 and unify Explorer across layouts

* feat(app): retain Explorer across layout and appearance changes

Keep one workspace Explorer host and destination-aware navigation so folding, theme updates and compact sheets preserve the current panel. Scope pull request discovery and timeline demand to active surfaces.

* fix(app): keep Explorer resize handles stretched through the dock

The shared frame moved the divider into an absolute column, leaving its horizontal handle without height. Restore the row so pointer drags reach it, preserving the existing resize and performance contract.

* fix(app): keep Explorer drag widths out of the shared stylesheet

Carry the existing inline sizing policy into the shared frame so pointer previews do not accumulate CSS rules.

* fix(test): align isolated test runtimes with Expo 57

Hoisted React consumers need the matching DOM renderer at the repository root. Automated Metro processes must not install native DevTools into disposable homes, and development toast overlays must not cover compact actions.

Keep Explorer journeys on the shared host and synchronize setup fixtures on daemon output rather than a one-second file-marker window. A delayed real child start reproduces the readiness failure on every platform.

* fix(test): await retained shell layout after composer resize

The gesture host now stays mounted across breakpoints, so its presence no longer identifies compact layout. Report the current layout on the retained host and synchronize composer journeys with that state.

* fix(app): preserve browser interactions in retained workspace hosts

Initially disabled gesture hosts still registered pointer and wheel listeners, while hover cards used window positions inside a workspace-local sheet portal. Respect initial gesture enablement and position cards relative to their measured floating host.

Keep developer-toast dismissal independent of pointer input and replacement toast visibility so modal, hover, and focus checks retain their input state.

* fix(test): share developer toast handling with Electron browser runs

The standalone Electron browser runner bypassed the browser fixture, allowing developer errors to cover Settings. Use the same non-pointer dismissal in both runners while retaining their console logs.
2026-10-20 11:59:49 +07:00
paseo-bot[bot] ab6cdb7c89 fix(claude): return to Auto when approving a plan (#7453)
Offer Auto the same explicit resume action as Bypass and apply the saved mode before releasing plan approval. Ordinary Implement keeps selecting Accept File Edits.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-20 03:57:25 +02:00
paseo-bot[bot] 0d61cca50c fix(app): identify Android certificate failures as TLS errors (#7429)
Recognize Android certificate-path validation failures before the generic not-found classification, so Direct connection no longer tells users to debug DNS for an untrusted certificate. Keep the existing TLS copy and technical details.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-20 00:53:37 +02:00
paseo-bot[bot] 0eee4fd9b0 docs(schedules): explain archive-on-finish behavior (#7426)
The schedule and worktree guides omitted the existing retention control and its default. Document the app setting and clarify that CLI and MCP tools do not expose it.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 23:56:49 +02:00
paseo-bot[bot]andPete Keen 69ef741a73 fix(app): pause native status rings in hidden panels (#7423)
Retained panels keep their children mounted, so mount-only registration kept hidden rings subscribed to the shared UI clock. Detach inactive rings and retain a local phase so hidden animated styles also stop receiving updates while visible rings continue.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Pete Keen <pete@petekeen.net>
2026-10-19 23:26:01 +02:00
paseo-bot[bot] 90f3cab585 fix(nix): report the project license in package metadata (#7415)
The Apache-2.0 relicensing left both Nix derivations declaring AGPL. Match their metadata to the project license and check both packages against the root manifest on every supported Nix system.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 21:58:09 +02:00
paseo-bot[bot]andIvan Malison fc775b6db5 Load translations when their language is selected (#7410)
* perf(app): load translations when their language is selected

Eager locale imports evaluated and registered all nine translation bundles on every cold start. Load bundled locales through a synchronous i18next backend so language selection keeps its before-render contract and offline behavior.

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>

* test(app): normalize translation entry paths across platforms

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
2026-10-19 21:03:24 +02:00
paseo-bot[bot]anddwyanewang 71e7cf6ff8 Build native Android modules with the project NDK (#7403)
* fix(android): align Unistyles NDK version

* fix(android): use the project NDK for every native module

The diff module still selected the Android Gradle default NDK, causing a second installation after Unistyles was aligned. Inherit the project toolchain and check all configured native projects.

---------

Co-authored-by: dwyanewang <908280968@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 20:09:07 +02:00
paseo-bot[bot]andMichael Wu eda3e4318f Clarify and localize provider usage (#7376)
* Localize provider usage UI

* fix: restore compact provider usage meter

* fix: localize compact context percentage

* fix(app): deduplicate context meter refresh

* fix(app): localize provider usage time counts

* fix(app): stabilize compact provider usage row

* fix(app): localize provider usage plurals

* test(app): allow locale-specific plural keys

* fix(app): show compact quota without context usage

* fix(app): preserve context overflow percentage

* fix(app): truncate provider balance reset values

* fix(app): add Korean provider usage translations

* fix(app): refresh provider usage timing

* fix(app): localize provider usage labels

* fix(app): clarify compact usage policies

* fix(app): localize monthly provider usage

* test(app): require Arabic provider usage plurals

* docs(i18n): keep migration guidance focused

* fix(app): hide empty compact usage slot

* fix(app): keep usage in context tooltip

* fix(app): align usage with sidebar reports

* test(app): pass usage timing clock explicitly

* fix(app): restore context usage details

* fix(app): make context usage tooltip interactive

* fix(app): support keyboard tooltip controls

* fix(app): keep focused tooltip controls open

* fix(app): address usage localization review

* fix(app): localize bulk usage refresh

* fix(app): localize Claude session window

* fix(app): complete provider usage localization

* fix(app): keep mounted usage times and translations current

Pass time and translation values into usage rendering so React Compiler invalidates cached labels when they change. Use the same localized window names and percentage meanings in sidebar summaries.

* test(desktop): wait for restored tabs before shortcut input

Document load completes before the workspace reconnects. Wait for the restored selection so Ctrl+1 reaches the mounted workspace rather than the startup screen.

* test(app): allow the shared usage clock minute phase

The browser clock advances independently of daemon timestamps, and the shared ticker samples on wall-minute boundaries. Assert the bounded one-to-two-minute age and matching countdown progress instead of a single phase-dependent value.

---------

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 18:01:45 +02:00
paseo-bot[bot]andgszep 91154ba4e8 fix(opencode): advertise agent-scoped bridge tool schemas (#7383)
Co-authored-by: gszep <contact@gszep.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 16:58:21 +02:00
paseo-bot[bot] 05920a236c test(codex): observe permission events in auto-review scenarios (#7382)
List snapshots do not subscribe modern clients to permission events. Activate that feed before both scenarios so the request assertion and auto-review absence assertion exercise delivery.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 15:49:47 +02:00
paseo-bot[bot] dd244c076d fix(pi): remove running rows for refused subagent resumes (#7369)
A text-only refusal finishes successfully without identifying a child. Remove its provisional subagent row when the tool completes, while retaining the refusal in the tool timeline.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 14:11:41 +02:00
Mohamed Boudra 1c8c68f93e Keep workspace setup logs scoped to the visible setup panel (#7407)
* fix(workspaces): observe setup output only in the visible setup panel

Setup logs were requested for the whole session, including unrelated workspaces. Use directory status for discovery and source-owned workspace observations for details, preserving legacy delivery only for clients without the negotiated capability.

* fix(workspaces): publish external setup progress once per daemon

MCP and scheduled setup enter through the broadcast API. Publish them to the shared setup owner before session fanout so each observation gets one update and snapshots survive periods without connected clients. Keep the setup action available on older hosts to explain the required host update.

* fix(app): replace setup details with each observation snapshot

A host restart can return an empty setup snapshot. Apply that authoritative snapshot so an open panel does not retain output from the previous daemon. Assert setup membership requests as well as delivery in the browser lifecycle regression.

* test(workspaces): cover setup status catch-up after directory release

* test(workspaces): synchronize setup output and release fixture resources
2026-10-19 18:34:53 +07:00
paseo-bot[bot] 19749baeec Keep Android settings inputs visible above the keyboard (#7362)
* fix(app): reveal Android settings inputs above the keyboard

PageLayout kept its full-height scroll viewport while the keyboard controller handled Android insets, leaving Code size covered. Use the existing keyboard-aware scroller for Android pages and cover the real focused field and keyboard geometry in the mobile regression harness.

* fix(app): keep settings field frames above the Android keyboard

Keyboard scrolling revealed only the native text input, leaving the enclosing field border and padding covered. Allow theme spacing around the input and assert the visible Plugins source frame against the real keyboard.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 13:11:20 +02:00
paseo-bot[bot] e760764b37 fix(app): require a number when recording index shortcuts (#7365)
Tab and workspace navigation need a final 1–9 index. Prevent saving an incomplete recorded sequence and explain the requirement while preserving arbitrary chord prefixes.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 11:55:10 +02:00
Mohamed Boudra b6dde61a7f feat(plugins): accept inline SVG and component icons (#7368)
Every contribution `icon` now takes a Lucide name, `{ svg }`, or a
component. Attachment sources take only the first two because composer
drafts persist the source icon.

The daemon's provider-icon check and the app's inline-SVG check are the
same function in the shared SDK root, so a plugin sees the same reasons
in both places. The app parses each icon once at registration and fails
the plugin with that reason. Rendering goes through one module that
turns any form into a component, so surfaces never branch on the form.
2026-10-19 15:36:04 +07:00
Mohamed Boudra 1e0e56c721 refactor(app): unify built-in file panel selection (#7366)
* refactor(app): unify built-in file panel selection

* refactor(app): resolve file panels once per pane
2026-10-19 15:35:41 +07:00
c575464e8f Skip saving unchanged workspace layouts (#7358)
* perf(app): throttle workspace layout writes and skip no-op persists

Zustand persist calls setItem after every set, even when the updater
returns the same state. The layout store re-normalized every workspace
tree, re-validated the envelope, stringified it, and wrote to
AsyncStorage on each agent status change because reconcileTabs runs from
an effect keyed on agent visibility.

The draft store's throttled writer moves to storage/ as a generic
throttled persist storage, gains a referential short-circuit for a value
whose partialized state was already accepted, and now backs both the
draft and layout stores. The layout store's partialize reuses its
previous projection while every persisted slice is the same reference,
and the store is flushed alongside drafts when the app leaves the
foreground.

Before: 50 no-op reconciles produced 50 writes. After: 0 writes; 50 real
sets inside one interval produce 2 writes (leading and trailing).

Fixes #4814

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(app): build layout stores with options and flush before rehydrating

Six call sites still passed the id factories positionally, which this
branch replaced with an options object, so those stores silently used
default ids. The once-only PR test also rehydrated a second store before
the throttled write landed; flush the source first.

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

* fix(app): avoid shadowing layout options

---------

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 10:02:25 +02:00
paseo-ai[bot] 9a0b95aa10 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-19 07:09:09 +00:00
paseo-bot[bot]andaz33zy 9be1547741 fix(app): show a promoted git action once in the compact menu (#7355)
The compact Changes toolbar flattens every git action into one menu, and
`secondary` keeps the actions the policy can promote to `primary`. When the
policy promoted one of them - Pull behind origin, Push ahead of it, the merge
and PR actions - it rendered twice, under the same React key.

Dedupe by id in `actionsWithoutPrimary` so the split button and the command
center share one rule, cover it in policy.test.ts against real buildGitActions
output, and pin the rendered menu in a compact Changes browser spec.

(cherry picked from commit 9bb26cc471)

Co-authored-by: az33zy <10743275+az33zy@users.noreply.github.com>
2026-10-19 08:54:23 +02:00
311 changed files with 14978 additions and 7479 deletions
+42 -16
View File
@@ -11,7 +11,7 @@ contracts.
## Panel host contract
Every desktop panel registers its supported `PaneHost` values and presentation. Launchers derive
Every panel registers its supported `PaneHost` values and presentation. Launchers derive
fixed-target labels and icons from that registration, filter by host, and never substitute one
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
a compatible pane.
@@ -21,7 +21,10 @@ so a pane’s + menu omits each while that pane already contains it. Closing one
item available again. Other compatible tabs, including agents, terminals, files, and diffs,
can move between Explorer and main panes.
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
retention. The shared Explorer owns its tab rail; its shared frame owns visibility, activity and
close-gesture lifetime. Platform adapters own resizing and inset geometry.
The host keeps three recently used tabs plus modified tabs; unmodified tabs beyond that budget
can remount. Native retained content stays live, while web chat uses its existing deferred Freeze.
## Sheets
@@ -40,11 +43,11 @@ remounts its panel. Revealing a target that already has a sheet uncovers that sh
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag
Sheets are modal and cover the whole compact workspace, including the header and Explorer. A sheet's header is its drag
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
Close, so the title keeps its width. The sheets present
from inside the compact column's `SheetHostScope`, so their panels read the navigator and every
from the shared workspace's `SheetHostScope`, so their panels read the navigator and every
workspace provider an ordinary pane has without a context bridge
([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)).
@@ -56,12 +59,16 @@ the sheet's tab into a pane, and edits the autosave has not started writing are
## Explorer sidebar
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
On desktop, the shell is rendered outside the workspace split canvas so it divides the full
workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
One Explorer host stays beside the center column across compact overlays and docks. Keep its
providers and gesture roots at the same React position when width or appearance changes. The
workspace route preserves panel ancestry during appearance updates. Appearance-dependent leaves
subscribe to theme changes; the file editor reconfigures its existing editor theme compartment.
Ordinary leaf routes retain the stack appearance policy.
The shell is outside the workspace split canvas so it divides the full workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
never read or modify that width.
`packages/app/src/workspace-tabs/open-supporting-view.ts` owns semantic Changes and pull-request
opens. Compact and wide native layouts select the matching Explorer tab. Desktop Changes opens
opens. Compact and wide native layouts select the matching workspace tab in Explorer. Desktop Changes opens
follow the shared diff preference. Desktop pull requests use their Main panel, On the side, or
Explorer sidebar setting. Automatic PR discovery follows that preference once per workspace without
interrupting the user's work. Closing the tab opts that workspace out of future automatic opens,
@@ -76,18 +83,37 @@ literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibili
Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes
hide their close buttons through the panel manifest; close them from the tab context menu.
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock and
omits Agent and terminal profiles. Agents and terminals can still be dragged into Explorer.
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock, including Agent and terminal profiles.
Web also supports dragging agents and terminals into Explorer.
Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged
between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab
does not change workspace focus.
does not change workspace focus. Web dragging shares the workspace drag scope; compact layouts
accept only reordering inside the source pane.
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders.
Wide native layouts without pane splits use the same combined content in a resizable inline dock;
opening a file leaves that dock visible. Both presentations keep their selection in the panel store
and reuse the layout store's per-workspace Explorer width. They do not create a second Explorer
lifecycle.
Cmd+E shows or hides Explorer without changing its selected view. All layouts use the same
workspace tabs, launcher, and panel host. Below 600 points the shell is the swipeable Explorer
overlay; at 600 points and above it reserves width beside the workspace. Native docking keeps its
touch resize handle. Full workspace splits remain a separate web-only capability.
The dock reserves at least 240 points and the workspace 360. App navigation yields when it cannot
share the remaining width with both. This keeps a portrait tablet from rendering three unusably
narrow columns; widening restores navigation without changing its saved open state.
Compact panel gestures own overlay visibility; the workspace layout owns its tab selection and
contents on every platform. Explicit pane destinations bypass compact sheet placement, so choosing
a panel in Explorer's + menu keeps it there. Ordinary compact file and subagent look-ups still use
the sheet policy. Resolving an existing main tab reveals center immediately; a new file sheet
keeps Explorer underneath and closing the sheet uncovers it. Semantic navigation resolves the
actual destination before revealing it, including deduplicated targets.
Compact overlay selection and saved dock visibility are independent. Folding changes geometry,
not either mode’s visibility intent. Native workspace retention remains one workspace; web retains
up to ten with its existing inactive TTL.
PR discovery belongs to the active workspace: split layouts discover while active, and other
layouts discover while Explorer is active. The rail requests summary status; selected visible PR
content also requests timeline data. Inactive consumers read cached data without request demand.
Discovery uses ordinary PR tabs and their existing once-per-workspace close opt-out.
## Side pane
+3 -1
View File
@@ -157,7 +157,9 @@ a small `withUnistyles` boundary without subscribing the route tree itself to
every Unistyles runtime update.
Navigators keep their identity across appearance changes. `ThemedStack` remounts
each screen's content below the native stack when appearance tokens change; a
each ordinary screen's content below the native stack when appearance tokens change.
Workspace screens own retained panel lifetimes and update appearance at their
leaves through `screenOwnedAppearance` ([Explorer](explorer-sidebar.md)). A
keyed wrapper above a stack remounts the stack itself, and Android crashes when
that happens while a FragmentManager transaction is running, which settings
hydration at startup makes likely. A screen that mounts a nested navigator (the
+3 -1
View File
@@ -32,7 +32,9 @@ The UI worklet owns transient motion:
React publishes the active panel only when the canonical target and the UI-thread position agree at
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
The gesture hosts stay mounted. Native worklets own overlay opacity and pointer events while
The gesture hosts stay mounted. The Gesture Handler web patch keeps initially disabled hosts
from capturing paragraph pointers; that capture confines browser selection to its starting paragraph.
Native worklets own overlay opacity and pointer events while
React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing
their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or
release. Native panel hosts and their dependent draggable lists
+27 -9
View File
@@ -240,8 +240,9 @@ Do not put any other code modules in the plugin root.
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
`@getpaseo/plugin/client/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
set; an unknown name renders nothing so it cannot break the plugin surface.
`@getpaseo/plugin/client/react-native`. Its `Icon` draws a Lucide name from the client's installed
icon set, or any `PluginIcon` through its `icon` prop; an unknown name or unsafe SVG renders nothing
so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
[host UI contract](../public-docs/plugins/reference.md#host-ui).
@@ -332,7 +333,7 @@ created by plugin code. Paseo removes registered contributions, unmounts surface
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
@@ -400,6 +401,25 @@ See the public [navigation fields](../public-docs/plugins/reference.md#screens-a
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
for the author-facing contract.
### Plugin icons
The [public reference](../public-docs/plugins/reference.md#icons) owns the author API: a contribution
`icon` is a Lucide name, `{ svg }`, or a component.
- `findSvgIconProblem` in the SDK root (`packages/plugin/src/icons.ts`) is the only SVG rule set.
The daemon applies it to provider and usage-source icon files; the app applies it to inline
icons. Change the rules there, never at a call site.
- The app validates each icon once, at registration, through `parsePluginIcon` in
`packages/app/src/plugins/icons.ts`. Rendering trusts the stored icon.
- That module is the only code that branches on the icon form. Surfaces draw with
`PluginIconView`, or take a stable component from `pluginIconComponent` when their slot expects
one. Lucide and SVG icons receive only size and color, so the host props a button passes to its
component icon never reach an SVG element.
- Attachment sources take `PluginSerializableIcon`: composer drafts persist the source's icon with
each attachment, and a component cannot be stored.
- The plugin compiler loads `.svg` imports as text, so `import icon from "./client/icon.svg"` is the
markup string.
## Lifecycle hooks
Server entries register lifecycle observers with `server.on()` and request transforms with
@@ -491,15 +511,13 @@ provider implementation. The public [provider plugin guide](../public-docs/plugi
owns author workflow, lifecycle, testing, and distribution guidance.
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be
self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and
external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
SVG or URL.
It must resolve inside that directory to a regular file that passes the shared SVG icon rules
(see [Plugin icons](#plugin-icons)). Paseo reads and validates the file when the plugin starts; the
string is never inline SVG or a URL.
## Usage sources
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
+8
View File
@@ -74,6 +74,14 @@ The adapter keys legacy slots by physical socket, so an old connection cannot re
sibling's observation even when both use the same logical client ID. Optional wire IDs stay accepted
for parsing compatibility; modern requests cannot select their subscription ID.
Workspace setup details negotiate `workspace_setup_observation` with
`server_info.features.workspaceSetupObservation`. Capable clients cannot request details through
the legacy session event category. The setup panel owns a workspace-scoped observation while
active and visible; directory metadata signals failures without requesting output. Older clients
retain their legacy event delivery. A new app makes no setup event demand on older hosts and
requires a host update to view details. Subscription-capable older hosts honor that absence of
demand; broadcast-era hosts require an update before wire silence is guaranteed.
## Every shim is tagged and dated
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
+1 -1
View File
@@ -95,7 +95,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
+1 -1
View File
@@ -193,7 +193,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- Playwright specs that exercise only the daemon import `daemonTest` from the shared fixtures so they do not create a browser context or page.
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay. Automated Metro launches set `EXPO_UNSTABLE_HEADLESS=1` so Expo does not download the native DevTools shell into temporary test homes; its read-only cache breaks teardown. Browser fixtures and the standalone Electron browser runner share the developer-toast dismissal helper while retaining console logs. A pointer click moves hover away from the tested control and stalls behind app modal backdrops.
- The app Playwright harness boots on Windows as well as POSIX. Spawn Node entrypoints through `process.execPath`, not `npx` or `node_modules/.bin` shims: Node refuses to spawn `.cmd` or `.bat` without `shell: true`, and shell mode concatenates argv without escaping and sends kill signals to `cmd.exe` instead of the real child.
- Teardown kills the process tree, because a Windows signal reaches only the direct child and leaves forked workers holding the listening port.
- The `asdf`-backed local Elixir relay stays POSIX-only, so the `relay-deployment` Playwright project is unavailable on Windows.
+15
View File
@@ -45,6 +45,21 @@
}
);
checks = forAllSystems (
system:
let
pkgs = pkgsFor system;
packages = self.packages.${system};
license = (builtins.fromJSON (builtins.readFile ./package.json)).license;
in
{
license-metadata =
assert packages.paseo.meta.license.spdxId == license;
assert packages.desktop.meta.license.spdxId == license;
pkgs.runCommand "paseo-license-metadata" { } "touch $out";
}
);
nixosModules.default = self.nixosModules.paseo;
nixosModules.paseo =
{ pkgs, lib, ... }:
+1 -1
View File
@@ -257,7 +257,7 @@ buildNpmPackage {
meta = {
description = "Paseo desktop app (Electron wrapper)";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
license = lib.licenses.asl20;
mainProgram = "paseo-desktop";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
+1 -1
View File
@@ -1 +1 @@
sha256-tSE4nb4E06GFZ8I9UEbo93nlPiAKtW00nkywgKQmMhI=
sha256-7B/Zyo/DaHdd3PVq8Hn1HG+x+wLdZBVxgx/cbtaM/S8=
+1 -1
View File
@@ -148,7 +148,7 @@ buildNpmPackage rec {
meta = {
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
license = lib.licenses.asl20;
mainProgram = "paseo";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
+6649 -3440
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -137,16 +137,21 @@
"oxlint-tsgolint": "^0.22.1",
"patch-package": "^8.0.1",
"playwright": "^1.56.1",
"react-dom": "19.2.3",
"typescript": "^5.9.3",
"ws": "^8.20.0"
},
"overrides": {
"@codemirror/language": "6.12.4",
"@codemirror/view": "6.43.6",
"expo": "~57.0.30",
"expo-router": "~57.0.28",
"lightningcss": "1.30.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native-reanimated": "4.3.1",
"react-native-worklets": "0.8.3"
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.3",
"react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.1",
"react-native-worklets": "0.10.1"
}
}
+1
View File
@@ -171,6 +171,7 @@ export default {
[
"expo-build-properties",
{
ios: { enableSceneSupport: true },
android: {
minSdkVersion: 29,
kotlinVersion: "2.1.20",
@@ -2,48 +2,56 @@ import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
import { openChangesPanel, openChangesTreePanel } from "../support/helpers/workspace-tabs";
function visibleComposer(page: Page) {
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
}
test("adds a changed file to the focused chat without replacing its composer draft", async ({
page,
}) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "add-file-to-chat-",
title: "Target chat",
});
const relativePath = "src/changed file.ts";
try {
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
await workspace.client.checkoutRefresh(workspace.cwd);
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
for (const width of [1400, 390]) {
test(`adds a changed file to the focused chat without replacing its composer draft at ${width}px`, async ({
page,
}) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "add-file-to-chat-",
title: "Target chat",
});
const relativePath = "src/changed file.ts";
const agentComposer = visibleComposer(page);
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
await agentComposer.fill("Preserve this thought");
try {
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
await workspace.client.checkoutRefresh(workspace.cwd);
await openChangesPanel(page);
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await page.getByTestId("diff-file-0-add-to-chat").click();
await page.setViewportSize({ width, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
await expect(attachment).toContainText("changed file.ts");
await expect(attachment).toContainText(relativePath);
await expect(agentComposer).toHaveValue("Preserve this thought");
await expect(agentComposer).toBeFocused();
} finally {
await workspace.cleanup();
}
});
const agentComposer = visibleComposer(page);
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
await agentComposer.fill("Preserve this thought");
if (width < 600) await openChangesTreePanel(page);
else await openChangesPanel(page);
const changedFile = page.getByTestId("diff-file-0-toggle");
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await page.getByTestId("diff-file-0-add-to-chat").click();
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
if (width < 600)
await expect(
page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }),
).toHaveCount(0);
await expect(attachment).toContainText("changed file.ts");
await expect(attachment).toContainText(relativePath);
await expect(agentComposer).toHaveValue("Preserve this thought");
if (width >= 600) await expect(agentComposer).toBeFocused();
else await expect(agentComposer).toBeEditable();
} finally {
await workspace.cleanup();
}
});
}
+27 -4
View File
@@ -833,13 +833,15 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
await openWorkspaceChanges(page, workspace);
await page.setViewportSize({ width: 480, height: 900 });
const compactChangesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
const compactChangesTab = page
.getByTestId("workspace-tab-changes_tree")
.filter({ visible: true });
if (!(await compactChangesTab.isVisible())) {
await page.getByTestId("workspace-explorer-toggle").first().click();
}
await expect(compactChangesTab).toBeVisible();
await compactChangesTab.click();
const compactExplorer = page.getByTestId("explorer-content-area").filter({ visible: true });
const compactExplorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
await expect(compactExplorer.getByTestId("changes-header")).toBeVisible();
const actions = compactExplorer.getByTestId("changes-actions-menu-trigger");
@@ -876,6 +878,27 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
).toContainText("Scroll long lines");
});
test("compact Explorer renders inline hunks with a saved tree-only desktop preference", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await page.addInitScript(
({ key }) => {
localStorage.setItem(key, JSON.stringify({ inlineDiff: false, layout: "split" }));
},
{ key: CHANGES_PREFERENCES_KEY },
);
const explorer = await openCompactChanges(page, workspace);
await expect(explorer.getByTestId("git-diff-canvas")).toBeVisible();
await expect(explorer.getByTestId("diff-file-0-toggle")).toBeVisible();
await explorer.getByTestId("diff-file-0-toggle").click({ button: "right" });
page.once("dialog", (dialog) => dialog.accept());
await page.getByTestId("diff-file-0-revert").click();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
.toBe(BEFORE);
});
test("compact Changes jumps to a file from the changed-files sheet", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
await useUnwrappedDiffLines(page);
@@ -1674,10 +1697,10 @@ async function openCompactChanges(page: Page, workspace: DirtyWorkspace): Promis
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await page.reload();
await page.getByTestId("workspace-explorer-toggle").first().click();
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
const changesTab = page.getByTestId("workspace-tab-changes_tree").filter({ visible: true });
await expect(changesTab).toBeVisible({ timeout: 30_000 });
await changesTab.click();
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
return explorer;
}
@@ -0,0 +1,115 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo, resolveTempRoot } from "../support/helpers/workspace";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
// The compact toolbar flattens every git action into one menu, and the policy keeps the actions
// it can promote to `primary` in `secondary` as well. A promoted action must still show once.
const cleanupTasks: Array<() => Promise<void>> = [];
test.afterEach(async () => {
for (const task of cleanupTasks.splice(0)) {
await task();
}
});
async function createRemoteRepo(prefix: string): Promise<string> {
const repo = await createTempGitRepo(prefix, {
withRemote: true,
files: [{ path: "src/app.ts", content: "export const app = 1;\n" }],
});
cleanupTasks.push(repo.cleanup);
// The helper puts the bare remote inside the checkout; keep it out of the status.
await writeFile(path.join(repo.path, ".git/info/exclude"), "remote.git/\n");
return repo.path;
}
async function registerWorkspace(repoPath: string): Promise<string> {
const client = await connectSeedClient();
cleanupTasks.push(async () => {
await client.close().catch(() => undefined);
});
const created = await client.createWorkspace({ source: { kind: "directory", path: repoPath } });
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${repoPath}`);
}
return created.workspace.id;
}
async function openCompactActionsMenu(page: Page, workspaceId: string) {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
await page.reload();
await page.getByTestId("workspace-explorer-toggle").first().click();
const changesTab = page.getByTestId("workspace-tab-changes_tree").filter({ visible: true });
await expect(changesTab).toBeVisible({ timeout: 30_000 });
await changesTab.click();
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
const trigger = explorer.getByTestId("changes-actions-menu-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const menu = page.getByTestId("changes-primary-cta-menu");
await expect(menu).toBeVisible();
return menu;
}
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
const repoPath = await createRemoteRepo("compact-menu-pull-");
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", repoPath]);
});
// Advance origin so the clean checkout is behind it and Pull is the promoted action.
const other = await mkdtemp(path.join(await resolveTempRoot(), "compact-menu-other-"));
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", other]);
});
execFileSync("git", ["clone", path.join(repoPath, "remote.git"), other], { stdio: "ignore" });
execFileSync("git", ["config", "user.email", "e2e@paseo.test"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Paseo E2E"], { cwd: other, stdio: "ignore" });
await writeFile(path.join(other, "src/incoming.ts"), "export const incoming = 1;\n");
execFileSync("git", ["add", "--all"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Incoming commit"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["push", "origin", "main"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["fetch", "origin"], { cwd: repoPath, stdio: "ignore" });
const workspaceId = await registerWorkspace(repoPath);
const menu = await openCompactActionsMenu(page, workspaceId);
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
await expect(menu.getByRole("menuitem")).toHaveText([
"Pull",
"Push",
"Pull and push",
"Archive workspace",
]);
});
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
const repoPath = await createRemoteRepo("compact-menu-push-");
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", repoPath]);
});
const workspaceId = await registerWorkspace(repoPath);
// Commit so the clean checkout is ahead of origin and Push is the promoted action.
await writeFile(path.join(repoPath, "src/mine.ts"), "export const mine = 1;\n");
execFileSync("git", ["add", "--all"], { cwd: repoPath, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Local commit"], { cwd: repoPath, stdio: "ignore" });
const menu = await openCompactActionsMenu(page, workspaceId);
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
await expect(menu.getByRole("menuitem")).toHaveText([
"Push",
"Pull",
"Pull and push",
"Archive workspace",
]);
});
@@ -125,7 +125,7 @@ test("composer diff stat opens the compact explorer instead of a Changes tab", a
});
const closeExplorer = page
.getByTestId("explorer-header")
.getByTestId("workspace-explorer-sidebar")
.getByRole("button", { name: "Close Explorer sidebar" });
await expect(closeExplorer).not.toBeInViewport();
@@ -809,7 +809,7 @@ async function expectUnmatchedFeedback(page: Page) {
}
function compactReviewSurface(page: Page) {
return page.getByTestId("explorer-content-area").filter({ visible: true });
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
}
async function openCompactReviewExplorer(
page: Page,
@@ -847,7 +847,10 @@ async function scrollReviewToLastLine(page: Page) {
async function revealCompactChanges(page: Page) {
await page.getByTestId("workspace-explorer-toggle").first().click();
await page.getByTestId("explorer-header").getByText("Changes", { exact: true }).click();
await page
.getByTestId("workspace-explorer-sidebar")
.getByTestId("workspace-tab-changes_tree")
.click();
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
}
async function expectReviewEditorBody(page: Page, body: string) {
@@ -149,7 +149,7 @@ for (const layout of [
await expect(details.getByText(/tokens$/)).toHaveCount(0);
const account = usageCard(details, "login-journey:account");
await expect(account.getByText("Session", { exact: true })).toBeVisible();
await expect(account.getByText("31%", { exact: true })).toBeVisible();
await expect(account.getByText("31% used", { exact: true })).toBeVisible();
await qaScreenshot(page, `context-without-data-${layout.name}`);
await layout.close(page);
});
@@ -161,7 +161,7 @@ for (const layout of [
await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible();
await expect(details.getByText("No context data", { exact: true })).toHaveCount(0);
await expect(
usageCard(details, "login-journey:account").getByText("31%", { exact: true }),
usageCard(details, "login-journey:account").getByText("31% used", { exact: true }),
).toBeVisible();
});
} finally {
+281 -11
View File
@@ -1,3 +1,7 @@
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
import { writeFile } from "node:fs/promises";
import { openFileFromExplorer } from "../support/helpers/file-explorer";
import { openSettingsSection, clickSettingsBackToWorkspace } from "../support/helpers/settings";
import { expect, test } from "../support/fixtures";
import {
gotoWorkspace,
@@ -136,10 +140,9 @@ test("Explorer keeps Files and Changes close actions in the context menu", async
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
await expect(menu).toBeVisible();
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(1);
await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: /^Changes/ })).toHaveCount(0);
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: /^Terminal/ })).toBeVisible();
const entries = await menu.getByRole("menuitem").allTextContents();
expect(entries.findIndex((entry) => entry.startsWith("Terminal"))).toBeLessThan(
@@ -191,13 +194,11 @@ test("Explorer keeps Files and Changes close actions in the context menu", async
}
});
async function dragAgentIntoExplorer(
async function dragTabIntoExplorer(
page: Parameters<typeof ensureExplorerSidebar>[0],
agentId: string,
tabId: string,
) {
const source = page
.getByTestId("workspace-pane-main")
.getByTestId(`workspace-tab-agent_${agentId}`);
const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`);
const destination = explorerSidebar(page).getByTestId("workspace-tab-files");
const sourceBox = await source.boundingBox();
const destinationBox = await destination.boundingBox();
@@ -213,7 +214,7 @@ async function dragAgentIntoExplorer(
await page.mouse.up();
}
test("agents can still be dragged into Explorer when absent from its + menu", async ({
test("moves agents into Explorer and keeps the shared Agent launcher available", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-agent-drag-" });
@@ -230,14 +231,13 @@ test("agents can still be dragged into Explorer when absent from its + menu", as
});
const main = page.getByTestId("workspace-pane-main");
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
await dragAgentIntoExplorer(page, agent.id);
await dragTabIntoExplorer(page, `agent_${agent.id}`);
await expect(explorer.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toHaveCount(0);
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
await expect(menu).toBeVisible();
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(1);
await page.keyboard.press("Escape");
await testInfo.attach("agent-in-explorer", {
body: await page.screenshot(),
@@ -247,3 +247,273 @@ test("agents can still be dragged into Explorer when absent from its + menu", as
await workspace.cleanup();
}
});
test("tablet prototype keeps Explorer tabs when resizing down to a phone", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "tablet-explorer-" });
try {
await page.setViewportSize({ width: 951, height: 669 });
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await ensureExplorerSidebar(page);
await expect(
explorerSidebar(page).getByRole("button", { name: "Browse workspace files", exact: true }),
).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("tablet-951.png") });
await openExplorerAgentDraft(page);
await page.screenshot({ path: testInfo.outputPath("tablet-agent-draft.png") });
await page.setViewportSize({ width: 390, height: 844 });
await revealPhoneExplorer(page);
await expect(
explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }),
).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("phone-shared-explorer.png") });
} finally {
await workspace.cleanup();
}
});
async function openExplorerAgentDraft(page: Parameters<typeof ensureExplorerSidebar>[0]) {
await explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }).click();
await page.getByRole("menuitem", { name: /^Agent/ }).click();
await expect(explorerSidebar(page).getByRole("textbox").first()).toBeVisible();
}
async function revealPhoneExplorer(page: Parameters<typeof ensureExplorerSidebar>[0]) {
await page.getByRole("button", { name: "Open Explorer sidebar", exact: true }).click();
await expect(explorerSidebar(page)).toBeVisible();
await expect(
explorerSidebar(page).getByRole("button", { name: "Close Explorer sidebar", exact: true }),
).toBeInViewport({ ratio: 1 });
}
async function cycleWorkspaceTheme(page: Parameters<typeof ensureExplorerSidebar>[0]) {
await page.keyboard.press("Control+Alt+t");
}
test("retains the Explorer host through appearance changes and folding", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-lifetime-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
const explorer = await ensureExplorerSidebar(page);
await openFilesPanel(page);
const host = await explorer.elementHandle();
if (!host) throw new Error("Explorer must be mounted before changing appearance");
const previousColor = await explorer.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await cycleWorkspaceTheme(page);
await expect
.poll(() => explorer.evaluate((element) => getComputedStyle(element).backgroundColor))
.not.toBe(previousColor);
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
await page.setViewportSize({ width: 390, height: 844 });
await ensureExplorerSidebar(page);
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
await page.setViewportSize({ width: 1280, height: 900 });
await expect(explorer).toBeVisible();
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
} finally {
await workspace.cleanup();
}
});
async function reorderCompactExplorerTabs(page: Parameters<typeof ensureExplorerSidebar>[0]) {
const explorer = explorerSidebar(page);
const files = explorer.getByTestId("workspace-tab-files");
const changes = explorer.getByTestId("workspace-tab-changes_tree");
const source = await changes.boundingBox();
const target = await files.boundingBox();
if (!source || !target) throw new Error("Explorer tabs must be visible to reorder");
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
await page.mouse.down();
await page.mouse.move(source.x + source.width / 2 - 12, source.y + source.height / 2);
await page.mouse.move(target.x + 3, target.y + target.height / 2, { steps: 20 });
await page.mouse.up();
await expect
.poll(async () => {
const afterFiles = await files.boundingBox();
const afterChanges = await changes.boundingBox();
return Boolean(afterFiles && afterChanges && afterChanges.x < afterFiles.x);
})
.toBe(true);
}
test("reorders compact Explorer tabs with a pointer without changing selection", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-compact-drag-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await page.setViewportSize({ width: 390, height: 844 });
await openFilesPanel(page);
await reorderCompactExplorerTabs(page);
await expect(
explorerSidebar(page).getByRole("button", { name: "Browse workspace files", exact: true }),
).toHaveAttribute("aria-selected", "true");
} finally {
await workspace.cleanup();
}
});
// Exercise appearance at its consumers while the real workspace navigator retains the panels.
test("updates populated code and diff appearance without losing the editor instance or draft", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-populated-appearance-" });
const source = Array.from(
{ length: 120 },
(_, index) => `export const line${index} = "retained ${index}";`,
).join("\n");
try {
await writeFile(`${workspace.repoPath}/appearance.ts`, source);
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ codeFontSize: 13, theme: "light" }),
);
localStorage.setItem(
"@paseo:changes-preferences",
JSON.stringify({ inlineDiff: true, layout: "unified" }),
);
});
await gotoWorkspace(page, workspace.workspaceId);
await openFilesPanel(page);
await openFileFromExplorer(page, "appearance.ts");
const editor = page.getByTestId("file-source-editor").filter({ visible: true });
const text = editor.locator(".cm-content");
await expect(text).toBeVisible();
await text.fill(`${source}\n// unsaved local edit`);
const instance = await editor.elementHandle();
const scroller = editor.locator(".cm-scroller");
await scroller.evaluate((node) => {
node.scrollTop = 400;
});
const scrollBefore = await scroller.evaluate((node) => node.scrollTop);
await explorerSidebar(page).getByTestId("workspace-tab-changes_tree").click();
const diff = explorerSidebar(page).getByTestId("git-diff-canvas");
await expect(diff).toBeVisible();
await page.keyboard.press("Control+,");
await openSettingsSection(page, "appearance");
await page.getByLabel("Code font size", { exact: true }).fill("19");
await page.getByLabel("Code font size", { exact: true }).press("Tab");
await clickSettingsBackToWorkspace(page);
await expect(text).toHaveCSS("font-size", "19px");
expect(await instance!.evaluate((node) => node.isConnected)).toBe(true);
await expect(text).toContainText("unsaved local edit");
expect(await scroller.evaluate((node) => node.scrollTop)).toBe(scrollBefore);
await expect(diff).toHaveCSS("font-size", "19px");
await cycleWorkspaceTheme(page);
expect(await instance!.evaluate((node) => node.isConnected)).toBe(true);
await expect(text).toContainText("unsaved local edit");
await dragTabIntoExplorer(page, "file_appearance.ts");
await expect(explorerSidebar(page).getByTestId("file-source-editor")).toBeVisible();
const explorerEditor = await explorerSidebar(page)
.getByTestId("file-source-editor")
.elementHandle();
await text.fill(`${source}\n// retained Explorer edit`);
await page.setViewportSize({ width: 390, height: 844 });
await ensureExplorerSidebar(page);
await page.setViewportSize({ width: 1280, height: 900 });
expect(await explorerEditor!.evaluate((node) => node.isConnected)).toBe(true);
await expect(text).toContainText("retained Explorer edit");
} finally {
await workspace.cleanup();
}
});
test("retains populated Explorer chat and terminal attachments through appearance, hide and fold", async ({
page,
}) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "explorer-chat-appearance-",
title: "Retained chat",
initialPrompt: "Show the retention marker",
featureValues: {
mockAssistantResponse: "Explorer populated paragraph.\n\n```ts\nconst retained = 42;\n```",
},
});
const attaches: string[] = [];
page.on("websocket", (socket) =>
socket.on("framesent", (frame) => {
if (typeof frame.payload !== "string") return;
try {
const message = JSON.parse(frame.payload).message;
if (message?.type === "subscribe_terminal_request") attaches.push(message.terminalId);
} catch {
/* Binary terminal traffic is not a JSON request. */
}
}),
);
try {
await openAgentRoute(page, workspace);
await ensureExplorerSidebar(page);
await dragTabIntoExplorer(page, `agent_${workspace.agentId}`);
const explorer = explorerSidebar(page);
const paragraph = explorer.getByText("Explorer populated paragraph.", { exact: true });
await expect(paragraph).toBeVisible();
const composer = explorer.getByRole("textbox", { name: "Message agent..." });
await composer.fill("Unsubmitted Explorer draft");
const composerNode = await composer.elementHandle();
await page.keyboard.press("Control+,");
await openSettingsSection(page, "appearance");
await page.getByLabel("Content font size", { exact: true }).fill("20");
await page.getByLabel("Content font size", { exact: true }).press("Tab");
await clickSettingsBackToWorkspace(page);
await expect(paragraph).toHaveCSS("font-size", "20px");
expect(await composerNode!.evaluate((node) => node.isConnected)).toBe(true);
await expect(composer).toHaveValue("Unsubmitted Explorer draft");
const previousColor = await paragraph.evaluate((node) => getComputedStyle(node).color);
await cycleWorkspaceTheme(page);
await expect
.poll(() => paragraph.evaluate((node) => getComputedStyle(node).color))
.not.toBe(previousColor);
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
await page
.getByRole("menuitem", { name: /^Terminal/ })
.first()
.click();
const terminal = explorer.getByTestId("terminal-surface");
await expect(terminal).toBeVisible();
await expect.poll(() => attaches.length).toBe(1);
const terminalNode = await terminal.elementHandle();
await page.getByTestId("workspace-explorer-toggle").first().click();
await ensureExplorerSidebar(page);
await page.setViewportSize({ width: 390, height: 844 });
await ensureExplorerSidebar(page);
await page.setViewportSize({ width: 1280, height: 900 });
await cycleWorkspaceTheme(page);
expect(await terminalNode!.evaluate((node) => node.isConnected)).toBe(true);
expect(attaches).toHaveLength(1);
await explorer.getByTestId(`workspace-tab-agent_${workspace.agentId}`).click();
expect(await composerNode!.evaluate((node) => node.isConnected)).toBe(true);
await expect(composer).toHaveValue("Unsubmitted Explorer draft");
await expect(paragraph).toHaveCSS("font-size", "20px");
} finally {
await workspace.cleanup();
}
});
test("compact file sheets cover Explorer and return to its retained file list", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-sheet-layer-" });
try {
await writeFile(`${workspace.repoPath}/sheet-marker.txt`, "Explorer sheet marker\n");
await gotoWorkspace(page, workspace.workspaceId);
await page.setViewportSize({ width: 390, height: 844 });
const explorer = await ensureExplorerSidebar(page);
await openFilesPanel(page);
const host = await explorer.elementHandle();
await openFileFromExplorer(page, "sheet-marker.txt");
await expect(page.getByText("Explorer sheet marker", { exact: true })).toBeVisible();
// A real click also proves the sheet draws above the retained Explorer overlay.
await page.getByRole("button", { name: "Close", exact: true }).click();
await expect(page.getByText("Explorer sheet marker", { exact: true })).toHaveCount(0);
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
expect(await host!.evaluate((node) => node.isConnected)).toBe(true);
} finally {
await workspace.cleanup();
}
});
@@ -247,9 +247,7 @@ test.describe("New workspace: fullscreen composer across the compact breakpoint"
await expectNewWorkspaceDraft(page, `${draft} wide`);
});
// Terminal mode and its prompt are screen state, and the shell remounts the screen
// at the breakpoint, so the terminal prompt's fullscreen is exercised on the phone
// layout only.
// Exercise the terminal prompt's fullscreen and compact launch sheet on the phone.
await test.step("terminal: the prompt's fullscreen shows only exit and Launch", async () => {
await resizeComposerViewport(page, "portrait");
// The compact launch control opens a sheet, not the desktop menu.
@@ -5,10 +5,6 @@ import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import {
expectMobileAgentSidebarVisible,
openMobileAgentSidebar,
} from "../support/helpers/sidebar";
const PLUGIN_ID = "plugin-host-ui-e2e";
@@ -91,12 +87,6 @@ async function useCompactLayout(page: Page): Promise<void> {
await page.setViewportSize({ width: 390, height: 844 });
}
async function reopenHostUiPluginFromCompactSidebar(page: Page): Promise<void> {
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
await page.getByRole("button", { name: "Host UI", exact: true }).click();
}
async function openPluginModal(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
}
@@ -158,7 +148,6 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
await test.step("compact layouts preserve host contexts inside a sheet", async () => {
await useCompactLayout(page);
await reopenHostUiPluginFromCompactSidebar(page);
await openPluginModal(page);
await expectCompactPluginSheet(page);
await savePluginIssue(page);
@@ -0,0 +1,138 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Locator, TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
const PLUGIN_ID = "plugin-icons-e2e";
const SVG_PATH = "M4 12l5 5L20 6";
const pluginClientSource = `import React from "react";
import { Text, View } from "react-native";
const checkIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="${SVG_PATH}" /></svg>';
function BadgeIcon({ size, color }) {
return <View testID="plugin-component-icon" style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
}
function Panel() {
return <View><Text>Plugin icon panel</Text></View>;
}
const panels = [
{ id: "lucide", title: "Lucide icon panel", icon: "Scan" },
{ id: "svg", title: "SVG icon panel", icon: { svg: checkIcon } },
{ id: "component", title: "Component icon panel", icon: BadgeIcon },
];
export default function contribute(client) {
for (const panel of panels) {
client.addWorkspacePanel({ ...panel, context: "workspace", Component: Panel });
client.addCommandCenterItem({
id: panel.id,
title: "Open " + panel.title,
icon: panel.icon,
context: "workspace",
onSelect({ openPanel }) { openPanel(panel.id); },
});
}
return () => {};
}`;
function pluginTab(page: Page, panelId: string): Locator {
const identity = `${PLUGIN_ID.length}_${PLUGIN_ID}_${panelId.length}_${panelId}`;
return page.getByTestId(`workspace-tab-plugin_workspace_${identity}`).filter({ visible: true });
}
async function runCommand(page: Page, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const screenshot = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: screenshot });
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
}
test.describe("plugin icons", () => {
test.describe.configure({ timeout: 180_000 });
test("renders a Lucide name, inline SVG, and a component", async ({ page }, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-icons-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const workspace = await seedWorkspace({ repoPrefix: "plugin-icons-" });
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), pluginClientSource);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
await waitForWorkspaceInSidebar(page, target);
await switchWorkspaceViaSidebar({ page, ...target });
await test.step("Command Center rows draw each icon form", async () => {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("icon panel");
for (const title of ["Lucide icon panel", "SVG icon panel", "Component icon panel"]) {
await expect(
panel.getByRole("button", { name: `Open ${title}`, exact: true }),
).toBeVisible();
}
await expect(
panel
.getByRole("button", { name: "Open SVG icon panel", exact: true })
.locator(`path[d="${SVG_PATH}"]`),
).toBeVisible();
await expect(
panel
.getByRole("button", { name: "Open Component icon panel", exact: true })
.getByTestId("plugin-component-icon"),
).toBeVisible();
await capture(page, testInfo, "command-center-icons");
await page.keyboard.press("Escape");
});
await test.step("workspace panel tabs draw each icon form", async () => {
await runCommand(page, "Open Lucide icon panel");
await runCommand(page, "Open SVG icon panel");
await runCommand(page, "Open Component icon panel");
await expect(pluginTab(page, "lucide").locator("svg")).toBeVisible();
await expect(pluginTab(page, "svg").locator(`path[d="${SVG_PATH}"]`)).toBeVisible();
await expect(
pluginTab(page, "component").getByTestId("plugin-component-icon"),
).toBeVisible();
await capture(page, testInfo, "workspace-tab-icons");
});
} finally {
try {
await client.removePlugin(PLUGIN_ID);
} finally {
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await workspace.cleanup();
await client.close();
await rm(directory, { recursive: true, force: true });
}
}
});
});
@@ -2,10 +2,6 @@ import { copyPluginExample } from "../support/helpers/plugin-fixture";
import { expect, test as base, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import {
openMobileAgentSidebar,
expectMobileAgentSidebarVisible,
} from "../support/helpers/sidebar";
async function openExample(page: Page, example: string) {
await page.getByRole("button", { name: `Open ${example}`, exact: true }).click();
@@ -122,9 +118,7 @@ async function openWideExamples(page: Page) {
async function openCompactExamples(page: Page) {
await page.setViewportSize({ width: 390, height: 844 });
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
await page.getByRole("button", { name: "Modal examples", exact: true }).click();
await expect(page.getByRole("button", { name: "Open Form", exact: true })).toBeVisible();
}
async function expectDeniedCopyFeedback(page: Page) {
@@ -15,6 +15,7 @@ async function closeMenuSheet(page: Page): Promise<void> {
test("project and workspace kebabs open action sheets on compact layouts", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-sheet-" });
const secondProject = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-filter-" });
try {
await gotoAppShell(page);
@@ -26,6 +27,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async
/^Grouping/,
/^Title/,
"Show",
"Project",
"Show background",
]);
await closeMenuSheet(page);
@@ -53,6 +55,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async
});
await expect(page.getByText("Workspace actions", { exact: true })).toBeVisible();
} finally {
await secondProject.cleanup();
await seeded.cleanup();
}
});
@@ -624,7 +624,9 @@ test.describe("Half-screen desktop layout", () => {
await expect(page.getByTestId("sidebar-settings")).not.toBeVisible();
});
test("keeps app navigation beside the Explorer pane", async ({ page }) => {
test("yields app navigation to Explorer and restores it when Explorer closes", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" });
try {
@@ -636,7 +638,7 @@ test.describe("Half-screen desktop layout", () => {
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 sidebar");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
await expect(
page
+103 -15
View File
@@ -4,9 +4,10 @@ import path from "node:path";
import type { Page } from "@playwright/test";
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { openSettingsSection } from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import {
@@ -131,7 +132,7 @@ async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixt
await refreshAllUsage(page);
await usage.waitForListRequests(requestCount + 1);
const group = page.getByTestId(`usage-host-${getServerId()}`);
await expect(group.getByText("41%", { exact: true })).toBeVisible();
await expect(group.getByText("41% used", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled();
}
@@ -178,7 +179,7 @@ test.describe("usage modal", () => {
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(hostFilter(page)).toHaveCount(0);
await qaScreenshot(page, "usage-modal-one-host");
await expect(group.getByText("31%")).toBeVisible();
await expect(group.getByText("31% used")).toBeVisible();
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
@@ -240,15 +241,15 @@ test.describe("usage modal", () => {
await gotoAppShell(page);
await openUsageFromItem(page);
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
await expect(group.getByText("31% used")).toBeVisible({ timeout: 10_000 });
const alphaRefresh = group.getByTestId("usage-refresh").first();
await alphaRefresh.click();
await expect
.poll(() => forcedRefreshes(usage))
.toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]);
await expect(group.getByText("58%")).toBeVisible();
await expect(group.getByText("12%")).toBeVisible();
await expect(group.getByText("58% used")).toBeVisible();
await expect(group.getByText("12% used")).toBeVisible();
await group.getByTestId("usage-refresh").nth(1).hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
@@ -332,7 +333,7 @@ test.describe("usage modal", () => {
await hostFilter(page).click();
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
).toBeVisible({ timeout: 30_000 });
await expect(hostFilter(page)).toContainText("Secondary box");
await qaScreenshot(page, "usage-modal-picked-host");
@@ -347,7 +348,7 @@ test.describe("usage modal", () => {
await expectPinnedUsage(page, ["12% Weekly"]);
await openUsageFromItem(page);
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
).toBeVisible({ timeout: 30_000 });
} finally {
await secondary.close().catch(() => undefined);
@@ -513,7 +514,7 @@ for (const theme of ["light", "dark"] as const) {
await expect(screen.getByText("69% left")).toBeVisible();
await showUsageAs(page, "used");
await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName(
/^Pin Claude Session, 31% · resets /,
/^Pin Claude Session, 31% used · resets /,
);
await openUsageOptions(page);
await closeUsageOptions(page);
@@ -576,7 +577,7 @@ test("an account with all logins failing shows each harness and remedy, then onl
},
]);
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "42%");
await expectLoginUsageOnly(page, "42% used");
} finally {
await fixture.cleanup();
}
@@ -599,6 +600,7 @@ for (const compact of [false, true]) {
}) => {
const fixture = await installLoginUsage({ status: "error", error: "Initial failure" });
try {
await page.clock.install();
await page.setViewportSize(
compact ? { width: 390, height: 844 } : { width: 1440, height: 900 },
);
@@ -606,7 +608,10 @@ for (const compact of [false, true]) {
if (compact) await openCompactSidebar(page);
await openUsageFromIcon(page);
await expectLoginErrors(page, ["Claude: Initial failure"]);
await expectLoginUsageFreshness(page, "No successful update", compact);
await test.step("developer errors do not interrupt freshness inspection", async () => {
await page.evaluate(() => console.error("E2E: developer toast while Usage is open"));
await expectLoginUsageFreshness(page, "No successful update", compact);
});
await fixture.setReport({
status: "available",
@@ -615,7 +620,8 @@ for (const compact of [false, true]) {
// Move only the browser clock; real daemon fetches continue using the host clock.
await page.clock.setFixedTime(new Date(Date.now() + 3_900_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "42%");
await expectLoginUsageOnly(page, "42% used");
await advanceUsageClock(page, 60_000);
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
await fixture.setReport({ status: "error", error: "Rate limited" });
@@ -640,8 +646,9 @@ for (const compact of [false, true]) {
});
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "43%");
await expect(page.getByText("42%", { exact: true })).toHaveCount(0);
await expectLoginUsageOnly(page, "43% used");
await advanceUsageClock(page, 60_000);
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
} finally {
await fixture.cleanup();
@@ -651,7 +658,88 @@ for (const compact of [false, true]) {
async function expectRetainedLoginUsage(page: Page, message: string) {
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByText(/^42%(?:$| ·)/)).toBeVisible();
await expect(card.getByText(/^42% used(?:$| ·)/)).toBeVisible();
await expect(card.getByTestId("usage-login-error")).toHaveText(message);
await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible();
}
async function switchToJapanese(page: Page) {
await page.getByRole("button", { name: "Close", exact: true }).click();
await openSettings(page);
await openSettingsSection(page, "general");
await page.getByRole("button", { name: "System", exact: true }).click();
await page.getByRole("menuitem", { name: "日本語 - Japanese", exact: true }).click();
await page.getByRole("button", { name: "戻る", exact: true }).click();
}
async function openJapaneseUsage(page: Page) {
await page.getByRole("button", { name: "使用状況", exact: true }).click();
await expect(page.getByTestId("usage-modal").first()).toBeVisible();
}
test("usage percentages state their meaning and follow the selected language", async ({ page }) => {
test.setTimeout(120_000);
const login = await installLoginUsage({
status: "available",
windows: [{ id: "session", label: "Session", usedPct: 70, summary: true }],
});
try {
await gotoAppShell(page);
await openUsageFromIcon(page);
await expect(page.getByText("70% used", { exact: true })).toBeVisible();
await switchToJapanese(page);
await expect(usageItem(page)).toHaveAccessibleName(/Claude セッション 70% 使用済み/);
await openJapaneseUsage(page);
await expect(page.getByText("70% 使用済み", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "すべて更新", exact: true })).toBeVisible();
await page.getByRole("button", { name: "設定", exact: true }).last().click();
await expect(page.getByText("割合", { exact: true })).toBeVisible();
await expect(page.getByText("使用済み", { exact: true })).toBeVisible();
await expect(page.getByText("残り", { exact: true })).toBeVisible();
} finally {
await login.cleanup();
}
});
test("open usage clocks advance without refreshing or reopening", async ({ page }) => {
test.setTimeout(120_000);
const now = new Date();
const reset = new Date(now.getTime() + 30.5 * 60_000).toISOString();
const login = await installLoginUsage({
status: "available",
windows: [
{ id: "session", label: "Session", usedPct: 42, resetsAt: reset },
{ id: "weekly", label: "Weekly", usedPct: 80, runsOutAt: reset, shortfallPct: 10 },
],
balances: [{ id: "credits", label: "Credits", unit: "usd", remaining: 12, resetsAt: reset }],
});
try {
await openCompactUsageWithClock(page, now);
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
// After two browser minutes the latest displayed tick is one or two minutes old.
await advanceUsageClock(page, 120_000);
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
/^resets (28|29)m$/,
);
} finally {
await login.cleanup();
}
});
async function openCompactUsageWithClock(page: Page, now: Date) {
await page.setViewportSize({ width: 390, height: 844 });
await page.clock.install({ time: now });
await gotoAppShell(page);
await openCompactSidebar(page);
await openUsageFromIcon(page);
}
async function advanceUsageClock(page: Page, milliseconds: number) {
await page.clock.fastForward(milliseconds);
}
@@ -93,7 +93,7 @@ test.describe("usage settings", () => {
await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible();
await expect(card.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(card.getByText("70%")).toBeVisible();
await expect(card.getByText("70% used")).toBeVisible();
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
@@ -131,11 +131,11 @@ test.describe("usage settings", () => {
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
const card = page.getByTestId("usage-card");
await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("23% used")).toBeVisible({ timeout: 10_000 });
await refreshAllUsage(page);
await expect.poll(() => forcedRefreshCount(usage)).toBe(1);
await expect(card.getByText("64%")).toBeVisible();
await expect(card.getByText("64% used")).toBeVisible();
});
test("asks to update a host without usage support and never calls it", async ({ page }) => {
@@ -28,6 +28,7 @@ import {
closeWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
waitForWorkspaceSetupProgress,
observeSetupPanelTraffic,
} from "../support/helpers/workspace-setup";
interface WorkspaceScriptStarter {
@@ -116,6 +117,45 @@ test.describe("Workspace setup streaming", () => {
}
});
test("setup panel releases details while retained behind another tab and resubscribes when shown", async ({
page,
}) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-membership-", {
paseoConfig: { worktree: { setup: ["echo setup complete"] } },
});
const traffic = observeSetupPanelTraffic(page);
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "completed",
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "setup-membership",
});
await completed;
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await expectSetupTabNotSeeded(page, workspace.id);
await traffic.expectNoDemand();
await expectSetupPanel(page);
await traffic.expectSubscribed(workspace.id, 1);
await expect(page.getByTestId("workspace-setup-log").first()).toContainText("setup complete");
await clickNewChat(page);
await expectComposerVisible(page);
await traffic.expectReleased(0);
await expectSetupPanel(page);
await traffic.expectSubscribed(workspace.id, 2);
await closeSetupTab(page, workspace.id);
await traffic.expectReleased(1);
} finally {
await client.close();
await repo.cleanup();
}
});
daemonTest("streams running and completed setup snapshots for a successful setup", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-success-", {
@@ -4,8 +4,8 @@ env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-explorer-toggle\""
wait "id=\"explorer-tab-files\"" 10000
press "id=\"explorer-tab-files\""
wait "id=\"workspace-tab-files\"" 10000
press "id=\"workspace-tab-files\""
wait "id=\"file-explorer-row-0\"" 10000
longpress "id=\"file-explorer-row-0\"" 600
wait "id=\"file-explorer-row-0-context-menu-content\"" 10000
@@ -0,0 +1,145 @@
// Run against an Android dev client serving this checkout, with a real software keyboard.
// node packages/app/e2e/mobile/appearance-keyboard/android.mjs <session> <metro-url> <server-id> [app-id]
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const [, , session, metroUrl, serverId, appId = "sh.paseo.debug"] = process.argv;
assert(
session && metroUrl && serverId,
"Pass the agent-device session, Metro URL and connected host ID",
);
const artifacts = resolve(".dev/agent-device-artifacts/appearance-keyboard-android");
mkdirSync(artifacts, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", ["--session", session, ...args], {
encoding: "utf8",
timeout: 60_000,
});
}
function evaluate(expression) {
return ad("cdp", "runtime", "eval", "--expr", expression, "--json");
}
// Read the mounted app's public React Native APIs through Metro. Accessibility
// snapshots with Gboard open can block while waiting for an idle hierarchy.
const reactNative = `Array.from(__r.getModules().values()).find(
m => m.verboseName?.endsWith("/react-native/index.js")
).publicModule.exports`;
function measureFocusedInput() {
evaluate(`(() => {
const rn = ${reactNative};
globalThis.__appearanceKeyboardMeasurement = null;
rn.TextInput.State.currentlyFocusedInput()?.measureInWindow((x, y, width, height) => {
globalThis.__appearanceKeyboardMeasurement = JSON.stringify({
x, y, width, height,
statusBarHeight: rn.StatusBar.currentHeight,
scale: rn.PixelRatio.get(),
keyboard: rn.Keyboard.metrics(),
});
});
})()`);
const value = JSON.parse(evaluate("globalThis.__appearanceKeyboardMeasurement"));
return value ? JSON.parse(value) : null;
}
async function waitForFocusedInput() {
let measurement;
let previous;
let stable = 0;
const deadline = Date.now() + 30_000;
do {
measurement = measureFocusedInput();
const current = measurement?.keyboard
? [measurement.y, measurement.height, measurement.keyboard.screenY].map(Math.round).join(",")
: null;
stable = measurement?.keyboard && current === previous ? stable + 1 : 0;
previous = current;
if (stable >= 3) break;
await delay(150);
} while (Date.now() < deadline);
assert(stable >= 3, `Focused input and keyboard must settle: ${JSON.stringify(measurement)}`);
return measurement;
}
ad("open", appId, "--platform", "android", "--no-test-ime");
const targets = ad("cdp", "target", "list", "--url", metroUrl)
.split("\n")
.filter((line) => line.includes(appId));
assert.equal(targets.length, 1, "Connect only the test app to this Metro server");
ad("cdp", "target", "select", targets[0].split(/\s+/)[0]);
try {
evaluate(`(${reactNative}).Keyboard.dismiss()`);
ad("open", appId, "paseo://settings", "--platform", "android", "--no-test-ime");
ad("wait", "text", "Appearance", "5000");
ad("open", appId, "paseo://settings/appearance", "--platform", "android", "--no-test-ime");
ad("wait", "text", "Code size", "5000");
const snapshot = JSON.parse(ad("snapshot", "-i", "--json"));
const numericFields = snapshot.data.nodes.filter(
(node) => node.type === "android.widget.EditText" && /^\d+$/.test(node.value ?? ""),
);
assert.equal(numericFields.length, 3, "All three font sizes must be visible without scrolling");
ad("screenshot", resolve(artifacts, "before-keyboard.png"));
// Appearance orders Interface size, Content size, then Code size.
ad("press", `@${numericFields[2].ref}`);
const measurement = await waitForFocusedInput();
// Android measureInWindow excludes the status bar, Keyboard.metrics uses screen coordinates.
const inputBottom = measurement.y + measurement.height + measurement.statusBarHeight;
const keyboardTop = measurement.keyboard.screenY;
console.log(JSON.stringify({ inputBottom, keyboardTop, measurement }));
ad("screenshot", resolve(artifacts, "keyboard.png"));
assert(
inputBottom <= keyboardTop + 1 / measurement.scale,
"Code size must be fully visible above the number keyboard (within one physical pixel)",
);
console.log("PASS: focusing Code size reveals the whole field above the keyboard");
evaluate(`(${reactNative}).Keyboard.dismiss()`);
ad(
"open",
appId,
`paseo://settings/hosts/${encodeURIComponent(serverId)}/plugins`,
"--platform",
"android",
"--no-test-ime",
);
ad("wait", "text", "Plugin source", "5000");
const plugins = JSON.parse(ad("snapshot", "-i", "--json"));
const source = plugins.data.nodes.find((node) => node.type === "android.widget.EditText");
assert(source, "The connected host must support installing plugins from source");
ad("press", `@${source.ref}`);
ad("type", "qa-source");
const pluginMeasurement = await waitForFocusedInput();
// A FormTextInput draws its border and padding on the native input's parent.
// Measure that visible frame, rather than accepting only the text as visible.
const hierarchy = JSON.parse(ad("snapshot", "--json")).data.nodes;
const focused = hierarchy.find((node) => node.type === "android.widget.EditText" && node.focused);
assert(focused, "Plugin source must remain focused after typing");
assert.equal(focused.value, "qa-source", "Typed source must remain in the focused field");
const frame = hierarchy.find((node) => node.index === focused.parentIndex);
assert(frame, "The source input's visible frame must be present in the native hierarchy");
assert(
frame.rect.height > focused.rect.height,
"Measure the surrounding frame, including padding",
);
const frameBottom = frame.rect.y + frame.rect.height;
const pluginKeyboardTop = pluginMeasurement.keyboard.screenY * pluginMeasurement.scale;
console.log(JSON.stringify({ frameBottom, keyboardTop: pluginKeyboardTop, frame: frame.rect }));
ad("screenshot", resolve(artifacts, "plugin-source-keyboard.png"));
assert(
frameBottom <= pluginKeyboardTop + 1,
"Plugin source frame must be fully visible above the keyboard (within one physical pixel)",
);
console.log("PASS: focusing Plugin source reveals the whole field frame above the keyboard");
} finally {
evaluate(`(${reactNative}).Keyboard.dismiss()`);
evaluate("delete globalThis.__appearanceKeyboardMeasurement");
}
@@ -0,0 +1,45 @@
# Native retained status-ring regression
This runs the production native ring and clock inside real retained panels with
Reanimated on Android. A probe consumes the same public rotation hook and counts
UI-runtime value changes. No Reanimated mocks or provider processes are used.
Use an Android development client compatible with the checkout's native dependencies.
In a disposable checkout, temporarily replace `packages/app/index.ts` with:
```tsx
import "./src/polyfills/install-early";
import "./src/styles/unistyles";
import { registerRootComponent } from "expo";
import NativeRingRegression from "./e2e/mobile/status-ring/native";
registerRootComponent(NativeRingRegression);
```
Start Metro in `packages/app`:
```sh
APP_VARIANT=development npx expo start --localhost --port 8096
```
With one Android emulator running, open that bundle:
```sh
agent-device open sh.paseo.debug 'exp+voice-mobile://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A8096' --platform android --session status-ring --metro-port 8096
```
From the repository root:
```sh
node packages/app/e2e/mobile/status-ring/android.mjs status-ring http://127.0.0.1:8096
```
The test checks visible rotation, hidden-panel inactivity while another ring is
visible, inactivity with both panels hidden, and synchronized resumption. Each
sample reads counters on the UI runtime; elapsed time includes device-command
latency. The baseline fails at the first hidden-panel assertion.
Restore the original `packages/app/index.ts`, close the agent-device session,
and stop Metro afterward. The fixture substitutes running rings for a live agent
and toggles retained panels directly; it does not exercise workspace navigation
or measure whole-app CPU usage. It is excluded from routine unit tests because
it requires a native runtime.
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
const [, , session, metroUrl] = process.argv;
assert(session && metroUrl, "Pass an agent-device session and Metro URL serving native.tsx");
function ad(...args) {
return execFileSync("agent-device", ["--session", session, ...args], {
encoding: "utf8",
timeout: 60_000,
});
}
function evaluate(expression) {
const value = ad("cdp", "runtime", "eval", "--expr", expression, "--json").trim();
return value === "undefined" ? undefined : JSON.parse(value);
}
let target;
const deadline = Date.now() + 30_000;
do {
target = ad("cdp", "target", "list", "--url", metroUrl)
.split("\n")
.find((line) => line.includes("sh.paseo.debug"));
if (!target) await delay(250);
} while (!target && Date.now() < deadline);
assert(target, "Open the Android dev client on this Metro bundle first");
ad("cdp", "target", "select", target.split(/\s+/)[0]);
let ready = false;
do {
ready = evaluate("Object.keys(globalThis.__ringReaders ?? {}).length === 2");
if (!ready) await delay(250);
} while (!ready && Date.now() < deadline);
assert(ready, "The two native probes must mount before sampling");
async function sample() {
evaluate("Object.values(globalThis.__ringReaders).forEach(read => read())");
await delay(100);
return JSON.parse(evaluate("JSON.stringify(globalThis.__ringSamples)"));
}
async function observe(label) {
const before = await sample();
await delay(600);
const after = await sample();
const deltas = Object.fromEntries(
Object.keys(before).map((key) => [key, after[key].updates - before[key].updates]),
);
console.log(JSON.stringify({ label, deltas, after }));
return { deltas, after };
}
const visible = await observe("both visible");
assert(visible.deltas.first > 0 && visible.deltas.second > 0);
assert.equal(visible.after.first.rotation, visible.after.second.rotation);
ad("press", 'label="HIDE FIRST"');
await delay(200);
const hidden = await observe("first hidden, second visible");
assert.equal(hidden.deltas.first, 0, "A retained hidden ring must stop UI-thread rotation updates");
assert(hidden.deltas.second > 0, "Visible rings must continue rotating");
ad("press", 'label="HIDE SECOND"');
await delay(200);
const allHidden = await observe("both hidden");
assert.equal(allHidden.deltas.first, 0);
assert.equal(allHidden.deltas.second, 0);
ad("press", 'label="SHOW FIRST"');
ad("press", 'label="SHOW SECOND"');
await delay(200);
const restored = await observe("both shown again");
assert(restored.deltas.first > 0 && restored.deltas.second > 0);
assert.equal(
restored.after.first.rotation,
restored.after.second.rotation,
"Restored rings must share the current phase",
);
console.log("PASS native retained-ring lifecycle");
@@ -0,0 +1,69 @@
import { useCallback, useLayoutEffect, useState } from "react";
import { Button, Text, View } from "react-native";
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
import { RetainedPanel } from "@/components/retained-panel";
import { useStatusRingRotation } from "@/components/status-ring/clock";
import { StatusRing } from "@/components/status-ring";
interface Sample {
updates: number;
rotation: number;
}
declare global {
var __ringSamples: Record<string, Sample>;
var __ringReaders: Record<string, () => void>;
}
function recordSample(name: string, value: { updates: number; rotation: number }) {
globalThis.__ringSamples[name] = value;
}
function Probe({ name }: { name: string }) {
const rotation = useStatusRingRotation();
const updates = useSharedValue(0);
useAnimatedReaction(
() => rotation.value,
() => {
updates.value += 1;
},
);
// Read the real UI runtime, without scheduling a continuous JS observer.
useLayoutEffect(() => {
globalThis.__ringSamples ??= {};
globalThis.__ringReaders ??= {};
globalThis.__ringReaders[name] = () =>
scheduleOnUI(() => {
"worklet";
const sample = { updates: updates.value, rotation: rotation.value };
scheduleOnRN(recordSample, name, sample);
});
return () => {
delete globalThis.__ringReaders[name];
};
}, [name, rotation, updates]);
return <StatusRing />;
}
const containerStyle = { flex: 1, padding: 60, backgroundColor: "white" };
export default function NativeRingRegression() {
const [first, setFirst] = useState(true);
const [second, setSecond] = useState(true);
const toggleFirst = useCallback(() => setFirst((value) => !value), []);
const toggleSecond = useCallback(() => setSecond((value) => !value), []);
return (
<View style={containerStyle}>
<Text>Native retained status rings</Text>
<Button title={first ? "Hide first" : "Show first"} onPress={toggleFirst} />
<Button title={second ? "Hide second" : "Show second"} onPress={toggleSecond} />
<RetainedPanel active={first}>
<Probe name="first" />
</RetainedPanel>
<RetainedPanel active={second}>
<Probe name="second" />
</RetainedPanel>
</View>
);
}
+15
View File
@@ -0,0 +1,15 @@
const HIDE_DEVELOPER_TOASTS = "#error-toast { display: none !important; }";
/** @param {import('@playwright/test').Page} page */
export async function hideDeveloperToasts(page) {
// Developer chrome must not cover app actions or move the test's pointer/focus.
// Console errors remain in the runner's logs. Hiding the toast, instead of clicking it away,
// leaves no race with a toast that closes or is replaced between matching and clicking.
await page.addInitScript((css) => {
const style = document.createElement("style");
style.textContent = css;
document.addEventListener("DOMContentLoaded", () => document.head.append(style));
}, HIDE_DEVELOPER_TOASTS);
// A page attached after it loaded (Electron over CDP) runs no init script until it navigates.
await page.addStyleTag({ content: HIDE_DEVELOPER_TOASTS });
}
+3
View File
@@ -1,4 +1,5 @@
import { test as base, expect, type Page } from "@playwright/test";
import { hideDeveloperToasts } from "./developer-ui.mjs";
import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update";
import { startE2EWorker } from "./helpers/e2e-worker";
import { getE2EDaemonPort } from "./helpers/daemon-port";
@@ -130,6 +131,8 @@ const test = daemonTest.extend<{
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
await hideDeveloperToasts(page);
const entries: string[] = [];
page.on("console", (message) => {
+2
View File
@@ -134,6 +134,8 @@ function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>):
env: {
...process.env,
BROWSER: "none",
// Automation never opens native DevTools or installs its desktop shell.
EXPO_UNSTABLE_HEADLESS: "1",
...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_WEB_PLATFORM: "electron" } : {}),
},
stdio: ["ignore", "pipe", "pipe"],
+5 -4
View File
@@ -363,10 +363,11 @@ export async function resizeComposerViewport(
await page.setViewportSize(sizes[orientation]);
// Before the app has loaded there is no layout to wait for.
if (page.url() === "about:blank") return;
// Crossing the compact breakpoint remounts the shell and the editor. Anything that
// follows (a shortcut, a locator) belongs to the layout the new size implies, so
// wait for that layout before returning.
await expect(page.getByTestId("compact-shell")).toHaveCount(orientation === "landscape" ? 0 : 1);
// The shell and editor survive the breakpoint. Wait for the shell's layout
// marker to update before interacting with the resized composer.
await expect(
page.getByTestId(orientation === "landscape" ? "wide-shell" : "compact-shell"),
).toBeVisible();
}
export async function selectComposerDraftRange(
page: Page,
@@ -37,7 +37,7 @@ export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Pr
await page.getByRole("img", { name, exact: true }).hover({ timeout: 30_000 });
const card = contextWindowDetails(page);
await expect(card).toBeVisible();
await expect(card).toBeInViewport();
await expect(card).toBeInViewport({ ratio: 1 });
return card;
}
@@ -24,6 +24,7 @@ interface E2EDaemonClientConfig {
clientType: "cli";
appVersion?: string;
webSocketFactory?: NodeWebSocketFactory;
capabilities?: Record<string, unknown>;
}
function resolveDaemonWsUrl(port?: number): string {
@@ -34,6 +35,7 @@ export interface ConnectDaemonClientOptions {
clientIdPrefix: string;
appVersion?: string;
port?: number;
capabilities?: Record<string, unknown>;
}
/**
@@ -51,6 +53,7 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
clientType: "cli",
appVersion: options.appVersion ?? loadAppVersion(),
webSocketFactory: createNodeWebSocketFactory(),
capabilities: options.capabilities,
});
await client.connect();
return client;
+10 -3
View File
@@ -276,15 +276,22 @@ export async function swipeOpenMobileExplorer(page: Page): Promise<void> {
}
export async function closeMobileExplorer(page: Page): Promise<void> {
const closeButton = page.getByTestId("explorer-close");
const closeButton = page
.getByTestId("workspace-explorer-sidebar")
.getByRole("button", { name: "Close Explorer sidebar", exact: true });
await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 });
await closeButton.click();
}
export async function expectMobileExplorerVisible(page: Page): Promise<void> {
await expect(page.getByTestId("explorer-header")).toBeInViewport({ ratio: 1, timeout: 5_000 });
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeInViewport({
ratio: 1,
timeout: 5_000,
});
}
export async function expectMobileExplorerHidden(page: Page): Promise<void> {
await expect(page.getByTestId("explorer-header")).not.toBeInViewport({ timeout: 5_000 });
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeInViewport({
timeout: 5_000,
});
}
@@ -35,6 +35,8 @@ export type { WorkspaceSetupDaemonClient };
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
const client = await connectDaemonClient<WorkspaceSetupDaemonClient>({
clientIdPrefix: "workspace-setup",
// COMPAT(workspaceSetupObservation): legacy streaming coverage; remove after 2027-04-19.
capabilities: { workspace_setup_observation: false },
});
// Establish demand before a caller can launch setup. Client close releases it.
try {
@@ -326,3 +328,51 @@ export async function waitForWorkspaceSetupProgress(
});
});
}
/** Observe the browser's actual setup membership without requesting any output. */
export function observeSetupPanelTraffic(page: Page) {
const subscriptions: Array<{ workspaceId: string; subscriptionId: string }> = [];
const releases = new Set<string>();
const requests: string[] = [];
let legacySetupDemand = false;
const outputWorkspaces: string[] = [];
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const frame = JSON.parse(String(payload));
if (frame.type !== "session") return;
if (frame.message?.type === "subscription.release.request")
releases.add(frame.message.subscriptionId);
if (frame.message?.type === "workspace.setup.subscribe.request")
requests.push(frame.message.workspaceId);
if (
frame.message?.type === "session.events.set_subscription.request" &&
frame.message.events.includes("workspace_setup_progress")
)
legacySetupDemand = true;
});
socket.on("framereceived", ({ payload }) => {
const frame = JSON.parse(String(payload));
if (frame.type !== "session") return;
if (frame.message?.type === "workspace.setup.subscribe.response")
subscriptions.push(frame.message.payload);
if (frame.message?.type === "workspace_setup_progress")
outputWorkspaces.push(frame.message.payload.workspaceId);
});
});
return {
async expectNoDemand() {
expect(requests).toEqual([]);
expect(legacySetupDemand).toBe(false);
expect(subscriptions).toEqual([]);
expect(outputWorkspaces).toEqual([]);
},
async expectSubscribed(workspaceId: string, count: number) {
await expect.poll(() => subscriptions.length).toBe(count);
expect(subscriptions[count - 1].workspaceId).toBe(workspaceId);
expect(requests).toEqual(Array(count).fill(workspaceId));
},
async expectReleased(index: number) {
await expect.poll(() => releases.has(subscriptions[index].subscriptionId)).toBe(true);
},
};
}
@@ -12,6 +12,7 @@ useDefaultAndroidSdkVersions()
def skiaPackage = new File(["node", "--print", "require.resolve('@shopify/react-native-skia/package.json')"].execute(null, projectDir).text.trim()).parentFile
android {
ndkVersion rootProject.ext.ndkVersion
namespace "sh.paseo.diffprototype"
defaultConfig {
externalNativeBuild {
+39 -38
View File
@@ -58,11 +58,11 @@
"@marijn/find-cluster-break": "^1.0.2",
"@mattermost/react-native-paste-input": "2.0.1",
"@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",
"@react-native-masked-view/masked-view": "0.3.2",
"@react-native/normalize-colors": "0.86.3",
"@react-navigation/native": "^7.1.8",
"@replit/codemirror-vim": "6.3.0",
"@shopify/react-native-skia": "2.2.12",
"@shopify/react-native-skia": "2.6.2",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.0-beta.213",
@@ -75,30 +75,30 @@
"@xterm/addon-webgl": "^0.20.0-beta.212",
"@xterm/xterm": "^6.1.0-beta.213",
"buffer": "^6.0.3",
"expo": "^54.0.18",
"expo-asset": "~12.0.12",
"expo-audio": "~1.0.13",
"expo-build-properties": "^1.0.9",
"expo-camera": "~17.0.10",
"expo-clipboard": "~8.0.7",
"expo-constants": "~18.0.9",
"expo-crypto": "^15.0.8",
"expo-dev-client": "^6.0.15",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.17",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8",
"expo-localization": "~17.0.9",
"expo-notifications": "^0.32.16",
"expo-router": "~6.0.13",
"expo-sharing": "^14.0.8",
"expo-splash-screen": "~31.0.10",
"expo-sqlite": "~16.0.10",
"expo-system-ui": "~6.0.7",
"expo": "~57.0.30",
"expo-asset": "~57.0.20",
"expo-audio": "~57.0.5",
"expo-build-properties": "~57.0.22",
"expo-camera": "~57.0.6",
"expo-clipboard": "~57.0.2",
"expo-constants": "~57.0.22",
"expo-crypto": "~57.0.4",
"expo-dev-client": "~57.0.20",
"expo-document-picker": "~57.0.3",
"expo-file-system": "~57.0.7",
"expo-haptics": "~57.0.3",
"expo-image": "~57.0.5",
"expo-image-manipulator": "~57.0.21",
"expo-image-picker": "~57.0.20",
"expo-keep-awake": "~57.0.2",
"expo-linking": "~57.0.13",
"expo-localization": "~57.0.2",
"expo-notifications": "~57.0.23",
"expo-router": "~57.0.28",
"expo-sharing": "~57.0.23",
"expo-splash-screen": "~57.0.10",
"expo-sqlite": "~57.0.4",
"expo-system-ui": "~57.0.4",
"fast-deep-equal": "^3.1.3",
"htmlparser2": "^12.0.0",
"i18next": "^26.3.0",
@@ -111,25 +111,25 @@
"micromark-util-decode-string": "2.0.1",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-freeze": "1.0.4",
"react-i18next": "^17.0.8",
"react-native": "0.81.5",
"react-native": "0.86.3",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-enriched-markdown": "1.2.0-nightly-20261014-766fc0e67",
"react-native-gesture-handler": "~2.28.0",
"react-native-gesture-handler": "~2.32.0",
"react-native-keyboard-controller": "1.21.12",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.3.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "~0.8.3",
"react-native-webview": "13.16.1",
"react-native-worklets": "0.10.1",
"tiny-invariant": "^1.3.3",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
@@ -151,11 +151,12 @@
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@xterm/headless": "^6.1.0-beta.213",
"babel-preset-expo": "~57.0.16",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
"esbuild": "0.28.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"eslint-config-expo": "~57.0.2",
"expo-gradle-jvmargs": "^1.1.2",
"fake-indexeddb": "^6.2.5",
"jsdom": "^20.0.3",
+37 -27
View File
@@ -4,26 +4,33 @@ const {
IOSConfig,
withAppDelegate,
withDangerousMod,
withInfoPlist,
withPodfile,
} = require("expo/config-plugins");
const MODULE_SETUP = " PasteInputModule.setup(factory.rootViewFactory)";
const REACT_NATIVE_START_END = " launchOptions: launchOptions)\n#endif";
const BRIDGING_HEADER_IMPORT = "#import <react-native-paste-input/PasteInputModule.h>";
const PODFILE_NEW_ARCH_DISABLED =
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'";
const PODFILE_NEW_ARCH_ENABLED =
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '1' if podfile_properties['newArchEnabled'] == 'true'";
const SCENE_DELEGATE = `
@objc(PaseoSceneDelegate)
class PaseoSceneDelegate: ExpoAppSceneDelegate {
override func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
super.scene(scene, willConnectTo: session, options: connectionOptions)
guard let provider = UIApplication.shared.delegate as? ExpoReactNativeFactoryProvider,
let factory = provider.reactNativeFactory else { return }
PasteInputModule.setup(factory.rootViewFactory)
}
}
`;
function configurePasteInputAppDelegate(contents) {
const configured = contents.replace(`\n${MODULE_SETUP}`, "");
if (!configured.includes(REACT_NATIVE_START_END)) {
throw new Error("Could not register paste input after React Native starts");
if (contents.includes("class PaseoSceneDelegate:")) return contents;
if (!contents.includes("ExpoReactNativeFactoryProvider")) {
throw new Error("Paste input requires Expo scene support");
}
return configured.replace(
REACT_NATIVE_START_END,
` launchOptions: launchOptions)\n${MODULE_SETUP}\n#endif`,
);
return `${contents.trimEnd()}\n${SCENE_DELEGATE}`;
}
function configurePasteInputBridgingHeader(contents) {
@@ -33,19 +40,6 @@ function configurePasteInputBridgingHeader(contents) {
return `${contents.trimEnd()}\n${BRIDGING_HEADER_IMPORT}\n`;
}
function configurePasteInputPodfile(contents) {
if (contents.includes(PODFILE_NEW_ARCH_ENABLED)) {
return contents;
}
if (!contents.includes(PODFILE_NEW_ARCH_DISABLED)) {
throw new Error("Could not configure the new architecture for paste input");
}
return contents.replace(
PODFILE_NEW_ARCH_DISABLED,
`${PODFILE_NEW_ARCH_DISABLED}\n${PODFILE_NEW_ARCH_ENABLED}`,
);
}
function withPasteInput(config) {
const withDelegate = withAppDelegate(config, (modConfig) => {
if (modConfig.modResults.language !== "swift") {
@@ -54,7 +48,18 @@ function withPasteInput(config) {
modConfig.modResults.contents = configurePasteInputAppDelegate(modConfig.modResults.contents);
return modConfig;
});
const withPods = withPodfile(withDelegate, (modConfig) => {
const withScene = withInfoPlist(withDelegate, (modConfig) => {
const scenes =
modConfig.modResults.UIApplicationSceneManifest?.UISceneConfigurations
?.UIWindowSceneSessionRoleApplication;
if (!scenes || scenes.length !== 1) {
throw new Error("Paste input requires Expo's single scene configuration");
}
scenes[0].UISceneDelegateClassName = "PaseoSceneDelegate";
return modConfig;
});
const withPods = withPodfile(withScene, (modConfig) => {
// Paste input checks this legacy flag even though RN 0.86 only supports Fabric.
modConfig.modResults.contents = configurePasteInputPodfile(modConfig.modResults.contents);
return modConfig;
});
@@ -79,4 +84,9 @@ function withPasteInputBridgingHeader(config) {
module.exports = withPasteInput;
module.exports.configurePasteInputAppDelegate = configurePasteInputAppDelegate;
module.exports.configurePasteInputBridgingHeader = configurePasteInputBridgingHeader;
function configurePasteInputPodfile(contents) {
const flag = "ENV['RCT_NEW_ARCH_ENABLED'] = '1'";
return contents.includes(flag) ? contents : `${flag}\n${contents}`;
}
module.exports.configurePasteInputPodfile = configurePasteInputPodfile;
+9 -21
View File
@@ -7,22 +7,14 @@ const {
} = require("./with-paste-input");
describe("withPasteInput", () => {
it("registers the module after React Native starts", () => {
const source = [
"import ReactAppDependencyProvider",
"",
" bindReactNativeFactory(factory)",
" factory.startReactNative(",
' withModuleName: "main",',
" in: window,",
" launchOptions: launchOptions)",
"#endif",
].join("\n");
it("registers paste input after Expo connects the React Native scene", () => {
const source = "class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider {}";
const configured = configurePasteInputAppDelegate(source);
expect(configured).toContain(
" launchOptions: launchOptions)\n PasteInputModule.setup(factory.rootViewFactory)\n#endif",
expect(configured).toContain("class PaseoSceneDelegate: ExpoAppSceneDelegate");
expect(configured.indexOf("super.scene(")).toBeLessThan(
configured.indexOf("PasteInputModule.setup("),
);
expect(configured).toContain("provider.reactNativeFactory");
expect(configurePasteInputAppDelegate(configured)).toBe(configured);
});
@@ -33,15 +25,11 @@ describe("withPasteInput", () => {
expect(configured).toContain("#import <react-native-paste-input/PasteInputModule.h>");
expect(configurePasteInputBridgingHeader(configured)).toBe(configured);
});
it("sets the new architecture environment before CocoaPods evaluates dependencies", () => {
it("enables the legacy new-architecture flag required by the paste input podspec", () => {
const source =
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'";
"require File.join(File.dirname(`node --print \"require.resolve('expo/package.json')\"`), 'scripts/autolinking')";
const configured = configurePasteInputPodfile(source);
expect(configured).toContain(
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '1' if podfile_properties['newArchEnabled'] == 'true'",
);
expect(configured).toBe("ENV['RCT_NEW_ARCH_ENABLED'] = '1'\n" + source);
expect(configurePasteInputPodfile(configured)).toBe(configured);
});
});
@@ -0,0 +1,22 @@
// After Expo prebuild, run from packages/app/android:
// ./gradlew --init-script ../scripts/verify-android-ndk.gradle verifyAndroidNdk
gradle.projectsEvaluated {
rootProject.tasks.register('verifyAndroidNdk') {
doLast {
def expected = rootProject.ext.ndkVersion
def nativeProjects = rootProject.subprojects.findAll { subproject ->
def android = subproject.extensions.findByName('android')
android != null && (android.externalNativeBuild.cmake.path != null ||
android.externalNativeBuild.ndkBuild.path != null)
}
assert !nativeProjects.empty : 'No native Android modules found'
println "Project NDK: ${expected}"
def mismatches = nativeProjects.findAll { subproject ->
def actual = subproject.android.ndkVersion
println "${subproject.path} NDK: ${actual}"
actual != expected
}
assert mismatches.empty : "Native Android modules must use project NDK ${expected}: ${mismatches*.path}"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { useFocusEffect } from "expo-router/react-navigation";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ArrowUpRight, Blocks, Check, Settings2 } from "lucide-react-native";
import type { AgentSkillOperation, AgentSkillsStatus } from "@getpaseo/protocol/messages";
-5
View File
@@ -445,11 +445,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
serverId: resolvedServerId,
workspaceId: context.workspaceId ?? "",
}),
checkout: {
serverId: resolvedServerId,
cwd: context.cwd,
isGit: context.projectPlacement?.checkout?.isGit ?? true,
},
view: "files",
});
});
+47 -25
View File
@@ -1,3 +1,6 @@
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
import { ComposerEditingSessionsProvider } from "@/composer/editing-session";
import "@/styles/unistyles";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
@@ -41,7 +44,7 @@ import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
import { WorkspaceRenameHost } from "@/components/workspace-rename-host";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
import { WorkspaceExplorerOpenGesture } from "@/mobile-panels/explorer-open-gesture";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
@@ -90,7 +93,6 @@ import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-ac
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
import { KeyboardShiftProvider } from "@/keyboard/shift";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
@@ -468,17 +470,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
const { width: windowWidth } = useWindowDimensions();
const insets = useSafeAreaInsets();
const viewportWidth = windowWidth - insets.left - insets.right;
const cycleTheme = useCallback(() => {
void updateSettings({ theme: getNextThemePreference(settings.theme) });
}, [settings.theme, updateSettings]);
const isCompactLayout = useIsCompactFormFactor();
const explorerSidebarPresentation = resolveExplorerSidebarPresentation({
isCompact: isCompactLayout,
});
const usesCompactExplorerHost = explorerSidebarPresentation !== "pane";
useCompactWebViewportZoomLock(isCompactLayout);
const pathname = usePathname();
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
@@ -516,7 +516,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
useActiveWorktreeNewAction();
useGlobalNewWorkspaceAction();
const activeWorkspace = useActiveWorkspaceSelection();
const explorerVisible = useIsExplorerSidebarOpen({
isCompact: isCompactLayout,
workspaceKey: activeWorkspace
? `${activeWorkspace.serverId}:${activeWorkspace.workspaceId}`
: null,
});
const appContentMinimumWidth = resolveDesktopAppContentMinimum({
explorerVisible: isWorkspaceRoute && explorerVisible,
isSettingsRoute: pathname.includes("/settings"),
});
const desktopSidebarMounted = hasMountedDesktopSidebar && !isWorkspaceFocusModeEnabled;
@@ -548,28 +556,25 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
if (isWeb) {
themedSidebarChrome = <AppearanceStyleBoundary>{sidebarChrome}</AppearanceStyleBoundary>;
}
const workspaceChromeStyle = useMemo(
() => [rowStyle, !isCompactLayout && { paddingLeft: insets.left, paddingRight: insets.right }],
[insets.left, insets.right, isCompactLayout],
);
const workspaceChrome = (
<View style={rowStyle}>
<View style={workspaceChromeStyle}>
<WorkspaceLayoutModePublisher />
{!isCompactLayout ? (
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
{themedSidebarChrome}
</WindowChromeRegion>
) : null}
{usesCompactExplorerHost ? (
<CompactExplorerSidebarHost
enabled={chromeEnabled}
presentation={explorerSidebarPresentation === "dock" ? "dock" : "overlay"}
<WorkspaceExplorerOpenGesture enabled={chromeEnabled && isCompactLayout}>
<WindowChromeRegion
corners={isCompactLayout && chromeEnabled ? "both" : appChromeLayout.contentCorners}
>
<WindowChromeRegion corners={chromeEnabled ? "both" : appChromeLayout.contentCorners}>
<View style={flexStyle}>{children}</View>
</WindowChromeRegion>
</CompactExplorerSidebarHost>
) : (
<WindowChromeRegion corners={appChromeLayout.contentCorners}>
<View style={flexStyle}>{children}</View>
</WindowChromeRegion>
)}
</WorkspaceExplorerOpenGesture>
</View>
);
@@ -620,15 +625,27 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
</View>
);
const content = isCompactLayout ? (
<MobileGestureWrapper chromeEnabled={chromeEnabled}>{surface}</MobileGestureWrapper>
) : (
surface
return (
<AppContainerLifetime compactGesturesEnabled={chromeEnabled && isCompactLayout}>
{surface}
</AppContainerLifetime>
);
}
function AppContainerLifetime({
children,
compactGesturesEnabled,
}: {
children: ReactNode;
compactGesturesEnabled: boolean;
}) {
return (
<ComposerEditingSessionsProvider>
<CommandCenterProvider>{content}</CommandCenterProvider>
<CommandCenterProvider>
<MobileGestureWrapper chromeEnabled={compactGesturesEnabled}>
{children}
</MobileGestureWrapper>
</CommandCenterProvider>
</ComposerEditingSessionsProvider>
);
}
@@ -662,10 +679,15 @@ function MobileGestureWrapper({
chromeEnabled: boolean;
}) {
const openGesture = useOpenAgentListGesture(chromeEnabled);
const isCompactLayout = useIsCompactFormFactor();
return (
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
<View collapsable={false} style={layoutStyles.surfaceFill} testID="compact-shell">
<View
collapsable={false}
style={layoutStyles.surfaceFill}
testID={isCompactLayout ? "compact-shell" : "wide-shell"}
>
{children}
</View>
</GestureDetector>
@@ -10,6 +10,8 @@ const HOST_STACK_SCREEN_OPTIONS = {
animation: "none" as const,
};
const SCREEN_OWNED_APPEARANCE = ["workspace/[workspaceId]/index"] as const;
const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
export default function HostRouteLayout() {
@@ -34,7 +36,10 @@ function KnownHostRoute() {
}
const stack = (
<ThemedStack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
<ThemedStack
screenOptions={HOST_STACK_SCREEN_OPTIONS}
screenOwnedAppearance={SCREEN_OWNED_APPEARANCE}
>
<Stack.Screen name="index" />
<Stack.Screen name="workspace/[workspaceId]/index" />
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useNavigation } from "@react-navigation/native";
import { useNavigation } from "expo-router/react-navigation";
import { StyleSheet, View } from "react-native";
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
+1 -1
View File
@@ -33,7 +33,7 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
},
overlay: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
justifyContent: "center",
alignItems: "center",
},
@@ -943,7 +943,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
paddingTop: theme.spacing[12],
},
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
backdrop: { ...StyleSheet.absoluteFill, backgroundColor: "rgba(0, 0, 0, 0.5)" },
panel: {
width: 640,
height: 560,
@@ -1,4 +1,4 @@
import type { GitAction, GitActions } from "@/git/policy";
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type {
WorkspacePanelPlacement,
@@ -581,9 +581,7 @@ export function buildWorkspaceCommandCenterContributions(
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
];
const primary = source.gitActions.primary;
for (const [index, action] of source.gitActions.secondary.entries()) {
if (action.id === primary?.id) continue;
for (const [index, action] of actionsWithoutPrimary(source.gitActions).entries()) {
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
}
@@ -41,7 +41,7 @@ import {
findPaneById,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { shouldShowWorkspaceSetup } from "@/stores/workspace-setup-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getShortcutOs } from "@/utils/shortcut-platform";
import {
@@ -188,6 +188,7 @@ export function useWorkspaceCommandCenterActions(): void {
workspaceDirectory: workspace.workspaceDirectory ?? null,
currentBranch: workspace.gitRuntime?.currentBranch ?? null,
pinnedAt: workspace.pinnedAt ?? null,
setupStatus: workspace.setupStatus ?? null,
labels: workspace.labels ?? [],
}));
const cwd = useWorkspaceDirectory(serverId, workspaceId);
@@ -195,12 +196,10 @@ export function useWorkspaceCommandCenterActions(): void {
const isPinned = fields?.pinnedAt != null;
const isCompact = useIsCompactFormFactor();
const canPin = useHostFeature(serverId, "workspacePinning");
const persistenceKey =
serverId && fields
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId: fields.id })
: null;
const canShowSetup = useWorkspaceSetupStore((state) =>
shouldShowWorkspaceSetup(persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null),
const supportsSetupObservation = useHostFeature(serverId, "workspaceSetupObservation");
const canShowSetup = shouldShowWorkspaceSetup(
fields?.setupStatus ?? null,
supportsSetupObservation,
);
const { overrides } = useKeyboardShortcutOverrides();
const { gitActions, isGit } = useGitActions({
@@ -66,7 +66,7 @@ export interface SheetHeader {
}
const SCROLL_CONTENT_GROW = { flexGrow: 1 };
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFill };
const NATIVE_DIALOG_SNAP_POINTS = ["100%"];
const styles = StyleSheet.create((theme) => ({
@@ -77,7 +77,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: "transparent",
},
desktopOverlay: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
backgroundColor: "rgba(0,0,0,0.55)",
justifyContent: "center",
alignItems: "center",
+1 -90
View File
@@ -11,10 +11,7 @@ import {
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import {
DaemonConnectionTestError,
getConnectionAuthFailureReason,
} from "@/utils/test-daemon-connection";
import { buildConnectionFailureCopy } from "./connection-failure-copy";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { PairingTargetTracker } from "./pair-link-credentials";
@@ -195,92 +192,6 @@ function draftFromConnectionUri(uri: string): DirectConnectionDraft {
};
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
if (!message) return null;
const trimmed = message.trim();
if (!trimmed) return null;
return trimmed;
}
function formatTechnicalTransportDetails(
details: (string | null)[],
labels: DirectConnectionLabels,
): string | null {
const unique = Array.from(
new Set(
details
.map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value))
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
);
if (unique.length === 0) return null;
const allGeneric = unique.every((value) => {
const lower = value.toLowerCase();
return lower === "transport error" || lower === "transport closed";
});
if (allGeneric) {
return labels.noAdditionalDetails(unique[0] ?? "");
}
return unique.join(" — ");
}
function buildConnectionFailureCopy(input: {
endpoint: string;
error: unknown;
labels: DirectConnectionLabels;
}): { title: string; detail: string | null; raw: string | null } {
const { endpoint, error, labels } = input;
const title = labels.failedToConnect(endpoint);
const raw = (() => {
if (error instanceof DaemonConnectionTestError) {
return (
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
normalizeTransportMessage(error.message)
);
}
if (error instanceof Error) {
return normalizeTransportMessage(error.message);
}
return null;
})();
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (getConnectionAuthFailureReason(error)) {
detail = error instanceof Error ? error.message : raw;
} else if (rawLower.includes("timed out")) {
detail = labels.timedOut;
} else if (
rawLower.includes("econnrefused") ||
rawLower.includes("connection refused") ||
rawLower.includes("err_connection_refused")
) {
detail = labels.refused;
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = labels.hostUnreachable;
} else if (
rawLower.includes("certificate") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail = labels.tlsError;
} else {
detail = labels.unableToConnect;
}
return { title, detail, raw };
}
export interface AddHostModalProps {
visible: boolean;
onClose: () => void;
@@ -1007,7 +1007,7 @@ const styles = StyleSheet.create((theme) => ({
paddingTop: theme.spacing[12],
},
backdrop: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
panel: {
@@ -20,54 +20,53 @@ describe("appearanceStyleBoundaryKey", () => {
// This is a placement contract against the production JSX, not a simulated native mount.
// Native Gesture Handler/Fabric lifetime behavior is verified on the release app.
describe("retained native gesture host appearance placement", () => {
it.each([
["../app/_layout.tsx", ["sidebarChrome", "themedSidebarChrome"]],
[
"./compact-explorer-sidebar-host.tsx",
["explorer", "themedExplorer", "CompactExplorerSidebar", "NativeExplorerSidebarDock"],
],
] as const)("keeps native hosts outside appearance keys in %s", (path, hosts) => {
const source = ts.createSourceFile(
path,
readFileSync(new URL(path, import.meta.url), "utf8"),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
let hostsFound = 0;
let webKeysFound = 0;
function visit(node: ts.Node) {
let name: string | undefined;
if (ts.isIdentifier(node)) name = node.text;
if (ts.isJsxSelfClosingElement(node)) name = node.tagName.getText(source);
if (name && hosts.some((host) => host === name)) {
hostsFound++;
let webOnly = false;
let keyed = false;
for (
let child = node, parent = node.parent;
parent;
child = parent, parent = parent.parent
) {
if (
ts.isIfStatement(parent) &&
parent.expression.getText(source) === "isWeb" &&
parent.thenStatement === child
)
webOnly = true;
if (
ts.isJsxElement(parent) &&
parent.openingElement.tagName.getText(source) === "AppearanceStyleBoundary"
)
keyed = true;
it.each([["../app/_layout.tsx", ["sidebarChrome", "themedSidebarChrome"], true]] as const)(
"keeps native hosts outside appearance keys in %s",
(path, hosts, hasWebKeys) => {
const source = ts.createSourceFile(
path,
readFileSync(new URL(path, import.meta.url), "utf8"),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
let hostsFound = 0;
let webKeysFound = 0;
function visit(node: ts.Node) {
let name: string | undefined;
if (ts.isIdentifier(node)) name = node.text;
if (ts.isJsxSelfClosingElement(node)) name = node.tagName.getText(source);
if (name && hosts.some((host) => host === name)) {
hostsFound++;
let webOnly = false;
let keyed = false;
for (
let child = node, parent = node.parent;
parent;
child = parent, parent = parent.parent
) {
if (
ts.isIfStatement(parent) &&
parent.expression.getText(source) === "isWeb" &&
parent.thenStatement === child
)
webOnly = true;
if (
ts.isJsxElement(parent) &&
parent.openingElement.tagName.getText(source) === "AppearanceStyleBoundary"
)
keyed = true;
}
if (keyed && webOnly) webKeysFound++;
expect(keyed && !webOnly, `${name} is remounted on native appearance changes`).toBe(
false,
);
}
if (keyed && webOnly) webKeysFound++;
expect(keyed && !webOnly, `${name} is remounted on native appearance changes`).toBe(false);
ts.forEachChild(node, visit);
}
ts.forEachChild(node, visit);
}
visit(source);
expect(hostsFound).toBeGreaterThan(0);
expect(webKeysFound).toBeGreaterThan(0);
});
visit(source);
expect(hostsFound).toBeGreaterThan(0);
if (hasWebKeys) expect(webKeysFound).toBeGreaterThan(0);
},
);
});
@@ -1,106 +0,0 @@
import { describe, expect, it } from "vitest";
import {
resolveCompactExplorerSidebarHostModel,
type CompactExplorerSidebarHostModel,
} from "@/components/compact-explorer-sidebar-host-state";
import type { WorkspaceDescriptor } from "@/stores/session-store";
function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project 1",
projectRootPath: input.projectRootPath ?? "/repo",
workspaceDirectory: input.workspaceDirectory ?? "/repo",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
statusEnteredAt: null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
}
function createModel(
overrides: Partial<CompactExplorerSidebarHostModel> = {},
): CompactExplorerSidebarHostModel {
return {
serverId: overrides.serverId ?? "server-1",
workspaceId: overrides.workspaceId ?? "workspace-a",
persistenceKey: overrides.persistenceKey ?? "server-1:workspace-a",
workspaceRoot: overrides.workspaceRoot ?? "/repo/a",
isGit: overrides.isGit ?? true,
};
}
describe("resolveCompactExplorerSidebarHostModel", () => {
it("retains the last workspace root for the same active selection while the workspace reloads", () => {
const previous = createModel();
const result = resolveCompactExplorerSidebarHostModel({
previous,
selection: { serverId: "server-1", workspaceId: "workspace-a" },
workspace: null,
isGit: false,
});
expect(result).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
persistenceKey: "server-1:workspace-a",
workspaceRoot: "/repo/a",
isGit: true,
});
});
it("switches ownership to the active workspace instead of leaking the previous one", () => {
const previous = createModel();
const result = resolveCompactExplorerSidebarHostModel({
previous,
selection: { serverId: "server-1", workspaceId: "workspace-b" },
workspace: null,
isGit: false,
});
expect(result).toEqual({
serverId: "server-1",
workspaceId: "workspace-b",
persistenceKey: "server-1:workspace-b",
workspaceRoot: "",
isGit: false,
});
});
it("does not retain a previous owner when there is no active workspace selection", () => {
const result = resolveCompactExplorerSidebarHostModel({
previous: createModel(),
selection: null,
workspace: null,
isGit: false,
});
expect(result).toBeNull();
});
it("uses the current workspace directory when it is available", () => {
const result = resolveCompactExplorerSidebarHostModel({
previous: null,
selection: { serverId: "server-1", workspaceId: "workspace-a" },
workspace: createWorkspace({ id: "workspace-a", workspaceDirectory: "/repo/current" }),
isGit: true,
});
expect(result).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
persistenceKey: "server-1:workspace-a",
workspaceRoot: "/repo/current",
isGit: true,
});
});
});
@@ -1,59 +0,0 @@
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
export interface CompactExplorerSidebarHostModel {
serverId: string;
workspaceId: string;
persistenceKey: string;
workspaceRoot: string;
isGit: boolean;
}
interface ResolveCompactExplorerSidebarHostModelInput {
previous: CompactExplorerSidebarHostModel | null;
selection: ActiveWorkspaceSelection | null;
workspace: WorkspaceDescriptor | null;
isGit: boolean;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function resolveCompactExplorerSidebarHostModel(
input: ResolveCompactExplorerSidebarHostModelInput,
): CompactExplorerSidebarHostModel | null {
const serverId = trimNonEmpty(input.selection?.serverId);
const workspaceId = trimNonEmpty(input.selection?.workspaceId);
if (!serverId || !workspaceId) {
return null;
}
const persistenceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (!persistenceKey) {
return null;
}
const previousForSelection =
input.previous &&
input.previous.serverId === serverId &&
input.previous.workspaceId === workspaceId
? input.previous
: null;
return {
serverId,
workspaceId,
persistenceKey,
workspaceRoot:
trimNonEmpty(input.workspace?.workspaceDirectory) ??
previousForSelection?.workspaceRoot ??
"",
isGit: input.workspace ? input.isGit : (previousForSelection?.isGit ?? input.isGit),
};
}
@@ -1,216 +0,0 @@
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View, type LayoutChangeEvent } from "react-native";
import { GestureDetector } from "react-native-gesture-handler";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import {
CompactExplorerSidebar,
NativeExplorerSidebarDock,
} from "@/components/compact-explorer-sidebar";
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { 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";
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
import {
resolveCompactExplorerSidebarHostModel,
type CompactExplorerSidebarHostModel,
} from "@/components/compact-explorer-sidebar-host-state";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
interface CompactExplorerOpenGestureSurfaceProps {
children: ReactNode;
enabled: boolean;
onOpenExplorer: () => void;
}
const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
function CompactExplorerOpenGestureSurface({
children,
enabled,
onOpenExplorer,
}: CompactExplorerOpenGestureSurfaceProps) {
const explorerOpenGesture = useOpenFileExplorerGesture({
enabled,
onOpen: onOpenExplorer,
});
return (
<GestureDetector gesture={explorerOpenGesture} touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}>
<View style={styles.fill}>{children}</View>
</GestureDetector>
);
}
function useActiveCompactExplorerSidebarModel(
enabled: boolean,
): CompactExplorerSidebarHostModel | null {
const selection = useActiveWorkspaceSelection();
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
const isExplorerActive = useIsMobilePanelActive("file-explorer");
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const client = useHostRuntimeClient(selection?.serverId ?? "");
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
const retainedModelRef = useRef<CompactExplorerSidebarHostModel | null>(null);
const { checkoutQuery } = useWorkspaceCheckoutStatus({
client,
isConnected,
isRouteFocused: enabled && selection !== null,
normalizedServerId: selection?.serverId ?? "",
normalizedWorkspaceId: selection?.workspaceId ?? "",
workspaceDirectory: workspace?.workspaceDirectory || null,
});
const resolvedModel = useMemo(
() =>
resolveCompactExplorerSidebarHostModel({
previous: isExplorerActive ? retainedModelRef.current : null,
selection,
workspace,
isGit: checkoutQuery.data?.isGit ?? false,
}),
[checkoutQuery.data?.isGit, isExplorerActive, selection, workspace],
);
useEffect(() => {
if (!selection) {
retainedModelRef.current = null;
if (enabled && isExplorerActive) {
showMobileAgent();
}
return;
}
if (!isExplorerActive) {
retainedModelRef.current = null;
return;
}
if (resolvedModel) {
retainedModelRef.current = resolvedModel;
}
}, [enabled, isExplorerActive, resolvedModel, selection, showMobileAgent]);
return selection ? (resolvedModel ?? (isExplorerActive ? retainedModelRef.current : null)) : null;
}
interface CompactExplorerSidebarHostProps {
children: ReactNode;
enabled: boolean;
presentation: "overlay" | "dock";
}
export function CompactExplorerSidebarHost({
children,
enabled,
presentation,
}: CompactExplorerSidebarHostProps) {
const model = useActiveCompactExplorerSidebarModel(enabled);
const [containerWidth, setContainerWidth] = useState(0);
const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
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;
}
openCompactFileExplorer({
serverId: model.serverId,
cwd: model.workspaceRoot,
isGit: model.isGit,
});
}, [model, openCompactFileExplorer]);
const handleOpenFile = useCallback(
(filePath: string) => {
if (!model) {
return;
}
openWorkspaceFileFromExplorer({
filePath,
persistenceKey: model.persistenceKey,
closeExplorerAfterOpen: presentation === "overlay",
showMobileAgent,
openWorkspaceTabInFocusedPane,
focusWorkspaceTab,
});
},
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, presentation, showMobileAgent],
);
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = event.nativeEvent.layout.width;
setContainerWidth((current) => (current === nextWidth ? current : nextWidth));
}, []);
const explorer =
enabled && model ? (
<DiffDocumentWorkspaceCacheProvider key={model.persistenceKey}>
{presentation === "dock" ? (
<NativeExplorerSidebarDock
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
persistenceKey={model.persistenceKey}
containerWidth={containerWidth}
onOpenFile={handleOpenFile}
/>
) : (
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
onOpenFile={handleOpenFile}
/>
)}
</DiffDocumentWorkspaceCacheProvider>
) : null;
let themedExplorer = explorer;
if (isWeb) {
themedExplorer = <AppearanceStyleBoundary>{explorer}</AppearanceStyleBoundary>;
}
if (presentation === "dock") {
return (
<View style={styles.row} onLayout={handleContainerLayout}>
<View style={styles.fill}>{children}</View>
{themedExplorer}
</View>
);
}
return (
<>
<CompactExplorerOpenGestureSurface
enabled={enabled && Boolean(model?.workspaceRoot)}
onOpenExplorer={handleOpenExplorer}
>
{children}
</CompactExplorerOpenGestureSurface>
{themedExplorer}
</>
);
}
const styles = {
fill: {
flex: 1,
},
row: {
flex: 1,
flexDirection: "row",
},
} as const;
@@ -1,574 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View, Text, Pressable } from "react-native";
import { Gesture } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles, withUnistyles } 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,
selectIsCompactFileExplorerOpen,
type ExplorerTab,
} from "@/stores/panel-store";
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
useIsCompactFormFactor,
} 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 "@/keyboard/shift";
import { shouldUseCompactExplorerKeyboardPadding } from "@/keyboard/shift";
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 { usePullRequestPanelAvailability } from "@/panels/pull-request-availability";
import { PullRequestContent } from "@/panels/pull-request";
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout";
import {
EXPLORER_TAB_RAIL_INSET,
explorerSidebarCloseButtonLayout,
resolveExplorerSidebarWidth,
} from "@/components/explorer-sidebar-layout";
import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
import { mutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
const ThemedX = withUnistyles(X);
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
interface ExplorerSidebarProps {
serverId: string;
workspaceId?: string | null;
workspaceRoot: string;
isGit: boolean;
onOpenFile?: (filePath: string) => void;
}
interface ExplorerSidebarSharedState {
explorerTab: ExplorerTab;
handleTabPress: (tab: ExplorerTab) => void;
}
function useExplorerSidebarSharedState({
serverId,
workspaceRoot,
isGit,
}: Pick<ExplorerSidebarProps, "serverId" | "workspaceRoot" | "isGit">): ExplorerSidebarSharedState {
const explorerTab = usePanelStore((state) => state.explorerTab);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const handleTabPress = useCallback(
(tab: ExplorerTab) => {
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
},
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
);
return { explorerTab, handleTabPress };
}
export function CompactExplorerSidebar({
serverId,
workspaceId,
workspaceRoot,
isGit,
onOpenFile,
}: ExplorerSidebarProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isActive = useIsMobilePanelActive("file-explorer");
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
serverId,
workspaceRoot,
isGit,
});
const usePanelKeyboardPadding = shouldUseCompactExplorerKeyboardPadding({ isGit, explorerTab });
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
mode: "padding",
enabled: usePanelKeyboardPadding,
});
const { gesture: closeGesture } = useCloseFileExplorerGesture();
const handleClose = useCallback(
(reason: string) => {
logExplorerSidebar("handleClose", {
reason,
isOpen: isActive,
});
showMobileAgent();
},
[isActive, showMobileAgent],
);
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
const mobileSidebarStyle = useMemo(
() => [
{
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
paddingBottom: usePanelKeyboardPadding ? 0 : insets.bottom,
backgroundColor: theme.colors.surfaceSidebar,
},
mobileKeyboardInsetStyle,
],
[
insets.bottom,
insets.top,
mobileKeyboardInsetStyle,
theme.colors.surfaceSidebar,
usePanelKeyboardPadding,
],
);
return (
<RetainedPanelActivity active={isActive}>
<MobilePanelOverlay
panel="file-explorer"
closeGesture={closeGesture}
panelStyle={mobileSidebarStyle}
>
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleHeaderClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isOpen={isActive}
onOpenFile={onOpenFile}
/>
</MobilePanelOverlay>
</RetainedPanelActivity>
);
}
interface NativeExplorerSidebarDockProps extends ExplorerSidebarProps {
persistenceKey: string;
containerWidth: number;
}
export function NativeExplorerSidebarDock({
serverId,
workspaceId,
workspaceRoot,
isGit,
onOpenFile,
persistenceKey,
containerWidth,
}: NativeExplorerSidebarDockProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const storedWidth = useWorkspaceLayoutStore(
(state) => state.explorerSidebarWidthByWorkspace[persistenceKey],
);
const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar);
const visibleWidth = resolveExplorerSidebarWidth({
requestedWidth: storedWidth,
containerWidth,
});
const resizeWidth = useSharedValue(visibleWidth);
const startWidthRef = useRef(visibleWidth);
const [resizePressed, setResizePressed] = useState(false);
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
serverId,
workspaceRoot,
isGit,
});
useEffect(() => {
resizeWidth.value = visibleWidth;
}, [resizeWidth, visibleWidth]);
const showResizeGrip = useCallback(() => setResizePressed(true), []);
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
const commitWidth = useCallback(
(width: number) => resizeExplorerSidebar(persistenceKey, width),
[persistenceKey, resizeExplorerSidebar],
);
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.enabled(true)
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onBegin(() => scheduleOnRN(showResizeGrip))
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
.onStart((event) => {
startWidthRef.current = visibleWidth + event.translationX;
resizeWidth.value = visibleWidth;
})
.onUpdate((event) => {
resizeWidth.value = resolveExplorerSidebarWidth({
requestedWidth: startWidthRef.current - event.translationX,
containerWidth,
});
})
.onEnd(() => runOnJS(commitWidth)(resizeWidth.value))
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
[commitWidth, containerWidth, hideResizeGrip, resizeWidth, showResizeGrip, visibleWidth],
);
const animatedWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value }));
const dockStyle = useMemo(
() => [
styles.nativeDock,
{
display: isOpen ? ("flex" as const) : ("none" as const),
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
backgroundColor: theme.colors.surfaceSidebar,
},
animatedWidthStyle,
],
[animatedWidthStyle, insets.top, isOpen, theme.colors.surfaceSidebar],
);
const dockContentStyle = useMemo(
() => [styles.nativeDockContent, { borderLeftColor: theme.colors.border }],
[theme.colors.border],
);
return (
<RetainedPanelActivity active={isOpen}>
<Animated.View style={dockStyle} testID="native-explorer-sidebar-dock">
<View style={dockContentStyle}>
<SidebarResizeHandle
edge="left"
gesture={resizeGesture}
pressed={resizePressed}
testID="native-explorer-sidebar-resize-handle"
/>
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={showMobileAgent}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</View>
</Animated.View>
</RetainedPanelActivity>
);
}
interface ExplorerTabButtonProps {
tab: ExplorerTab;
active: boolean;
label?: string;
onTabPress: (tab: ExplorerTab) => void;
testID: string;
children?: React.ReactNode;
}
function ExplorerTabButton({
tab,
active,
label,
onTabPress,
testID,
children,
}: ExplorerTabButtonProps) {
const isCompact = useIsCompactFormFactor();
const handlePress = useCallback(() => onTabPress(tab), [onTabPress, tab]);
const tabStyle = useMemo(
() => [styles.tab(isCompact), active && styles.tabActive],
[active, isCompact],
);
const tabTextStyle = useMemo(() => [styles.tabText, active && styles.tabTextActive], [active]);
return (
<Pressable testID={testID} style={tabStyle} onPress={handlePress}>
{children}
{label !== undefined ? <Text style={tabTextStyle}>{label}</Text> : null}
</Pressable>
);
}
interface SidebarContentProps {
activeTab: ExplorerTab;
onTabPress: (tab: ExplorerTab) => void;
onClose: () => void;
serverId: string;
workspaceId?: string | null;
workspaceRoot: string;
isGit: boolean;
isOpen: boolean;
onOpenFile?: (filePath: string) => void;
}
function ExplorerSidebarContent({
activeTab,
onTabPress,
onClose,
serverId,
workspaceId,
workspaceRoot,
isGit,
isOpen,
onOpenFile,
}: SidebarContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const closeButtonLayout = explorerSidebarCloseButtonLayout(isCompact);
const closeButtonStyle = useMemo(
() => ({ width: closeButtonLayout.size, height: closeButtonLayout.size }),
[closeButtonLayout.size],
);
// The close glyph shares the trailing rail with the toolbar rows below it.
const headerRightSectionStyle = useMemo(
() => [styles.headerRightSection, { paddingRight: closeButtonLayout.trailingPadding }],
[closeButtonLayout.trailingPadding],
);
const { prPane, showPullRequest: showPrTab } = usePullRequestPanelAvailability({
serverId,
cwd: workspaceRoot,
isGit,
requested: activeTab === "pr",
enabled: isOpen,
timelineEnabled: activeTab === "pr",
});
const requestedTab: ExplorerTab =
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab;
const prTabLabel = formatPrTabLabel(prPane.prNumber);
const availableTabs = useMemo<ExplorerTab[]>(() => {
const tabs: ExplorerTab[] = isGit ? ["changes", "files"] : ["files"];
if (isGit && showPrTab) tabs.push("pr");
return tabs;
}, [isGit, showPrTab]);
const { mountedTabIds } = useMountedTabSet({
activeTabId: resolvedTab,
allTabIds: availableTabs,
cap: availableTabs.length,
});
return (
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */}
<WindowChromeSafeArea placement="inline" style={styles.header} testID="explorer-header">
<TitlebarDragRegion />
<View style={styles.tabsContainer(isCompact)}>
{isGit && (
<ExplorerTabButton
tab="changes"
active={resolvedTab === "changes"}
label={t("workspace.tabs.explorerSidebar.changes")}
onTabPress={onTabPress}
testID="explorer-tab-changes"
/>
)}
<ExplorerTabButton
tab="files"
active={resolvedTab === "files"}
label={t("workspace.tabs.explorerSidebar.files")}
onTabPress={onTabPress}
testID="explorer-tab-files"
/>
{isGit && showPrTab && (
<ExplorerTabButton
tab="pr"
active={resolvedTab === "pr"}
label={prTabLabel}
onTabPress={onTabPress}
testID="explorer-tab-pr"
>
<PullRequestTabIcon
forge={prPane.forge}
size={13}
color={
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
</ExplorerTabButton>
)}
</View>
<View style={headerRightSectionStyle}>
<ToolbarButton
compact={isCompact}
style={closeButtonStyle}
hitSlop={closeButtonLayout.hitSlop}
label={t("workspace.tabs.explorerSidebar.close")}
onPress={onClose}
testID="explorer-close"
nativeID="explorer-close"
>
<ThemedX size={closeButtonLayout.iconSize} uniProps={mutedIconColorMapping} />
</ToolbarButton>
</View>
</WindowChromeSafeArea>
{/* Content based on active tab */}
<View style={styles.contentArea} testID="explorer-content-area">
{mountedTabIds.has("changes") ? (
<RetainedPanel active={resolvedTab === "changes"}>
<ChangedFilesPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</RetainedPanel>
) : null}
{mountedTabIds.has("files") ? (
<RetainedPanel active={resolvedTab === "files"}>
<FilesPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
/>
</RetainedPanel>
) : null}
{mountedTabIds.has("pr") ? (
<RetainedPanel active={resolvedTab === "pr"}>
<PrTabContent
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
prPane={prPane}
/>
</RetainedPanel>
) : null}
</View>
</View>
);
}
function ChangedFilesPane({
serverId,
workspaceId,
workspaceRoot,
isOpen,
onOpenFile,
}: Pick<
SidebarContentProps,
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
>) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
const [changesState, setChangesState] = useState<ChangesState>(() =>
changesStateSchema.parse(defaultChangesState),
);
return (
<ChangesSurface
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
enabled={isOpen}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
state={changesState}
onStateChange={setChangesState}
/>
);
}
function FilesPane({
serverId,
workspaceId,
workspaceRoot,
onOpenFile,
}: Pick<SidebarContentProps, "serverId" | "workspaceId" | "workspaceRoot" | "onOpenFile">) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return (
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
);
}
const PrTabContent = PullRequestContent;
const styles = StyleSheet.create((theme) => ({
nativeDock: {
position: "relative",
height: "100%",
minHeight: 0,
overflow: "hidden",
},
nativeDockContent: {
position: "relative",
flex: 1,
minHeight: 0,
borderLeftWidth: 1,
},
sidebarContent: {
flex: 1,
minHeight: 0,
overflow: "hidden",
},
header: {
position: "relative",
height: {
xs: HEADER_INNER_HEIGHT_MOBILE,
md: HEADER_INNER_HEIGHT,
},
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
tabsContainer: (isCompact: boolean) => ({
flexDirection: "row",
gap: theme.spacing[1],
// With the tab's own horizontal padding this puts the label on the pane's
// leading rail, the same way the desktop tab rail does.
paddingLeft: isCompact ? EXPLORER_TAB_RAIL_INSET : theme.spacing[2],
}),
tab: (isCompact: boolean) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: isCompact ? theme.spacing[2] : theme.spacing[3],
borderRadius: theme.borderRadius.md,
}),
tabActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
tabText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
tabTextActive: {
color: theme.colors.foreground,
},
tabTextMuted: {
opacity: 0.8,
},
headerRightSection: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
contentArea: {
flex: 1,
minHeight: 0,
},
}));
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { buildConnectionFailureCopy } from "./connection-failure-copy";
const labels = {
hostRequired: "host required",
invalidPort: "invalid port",
invalidConnection: "invalid connection",
failedToConnect: (endpoint: string) => `Failed: ${endpoint}`,
noAdditionalDetails: (detail: string) => `No details: ${detail}`,
timedOut: "timeout",
refused: "refused",
hostNotFound: "DNS failure",
hostUnreachable: "unreachable",
tlsError: "TLS failure",
unableToConnect: "connection failure",
};
function failure(message: string) {
return buildConnectionFailureCopy({
endpoint: "tcp://example.test:443?ssl=true",
error: new DaemonConnectionTestError(message, {
reason: "transport error",
lastError: message,
}),
labels,
});
}
describe("direct connection failure copy", () => {
it("identifies Android certificate validation errors without calling them DNS failures", () => {
const message =
"java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.";
expect(failure(message)).toEqual({
title: "Failed: tcp://example.test:443?ssl=true",
detail: labels.tlsError,
raw: `transport error — ${message}`,
});
});
it.each([
["certificate not found", labels.tlsError],
["SSL handshake failed", labels.tlsError],
["TLS handshake failed", labels.tlsError],
["getaddrinfo ENOTFOUND example.test", labels.hostNotFound],
["Host not found", labels.hostNotFound],
["connect ECONNREFUSED", labels.refused],
["connection timed out", labels.timedOut],
["EHOSTUNREACH", labels.hostUnreachable],
["unexpected transport failure", labels.unableToConnect],
])("classifies %s", (message, expected) => {
expect(failure(message).detail).toBe(expected);
});
it("preserves authentication failures and their technical details", () => {
const message = "Invalid daemon password";
expect(
buildConnectionFailureCopy({
endpoint: "example.test:6767",
error: new DaemonConnectionTestError(message, {
reason: message,
lastError: message,
authFailureReason: "incorrect_password",
}),
labels,
}),
).toMatchObject({ detail: message, raw: message });
});
});
@@ -0,0 +1,103 @@
import {
DaemonConnectionTestError,
getConnectionAuthFailureReason,
} from "@/utils/test-daemon-connection";
interface ConnectionFailureLabels {
failedToConnect: (endpoint: string) => string;
noAdditionalDetails: (detail: string) => string;
timedOut: string;
refused: string;
hostNotFound: string;
hostUnreachable: string;
tlsError: string;
unableToConnect: string;
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
if (!message) return null;
const trimmed = message.trim();
if (!trimmed) return null;
return trimmed;
}
function formatTechnicalTransportDetails(
details: (string | null)[],
labels: ConnectionFailureLabels,
): string | null {
const unique = Array.from(
new Set(
details
.map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value))
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
);
if (unique.length === 0) return null;
const allGeneric = unique.every((value) => {
const lower = value.toLowerCase();
return lower === "transport error" || lower === "transport closed";
});
if (allGeneric) {
return labels.noAdditionalDetails(unique[0] ?? "");
}
return unique.join(" — ");
}
export function buildConnectionFailureCopy(input: {
endpoint: string;
error: unknown;
labels: ConnectionFailureLabels;
}): { title: string; detail: string | null; raw: string | null } {
const { endpoint, error, labels } = input;
const title = labels.failedToConnect(endpoint);
const raw = (() => {
if (error instanceof DaemonConnectionTestError) {
return (
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
normalizeTransportMessage(error.message)
);
}
if (error instanceof Error) {
return normalizeTransportMessage(error.message);
}
return null;
})();
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (getConnectionAuthFailureReason(error)) {
detail = error instanceof Error ? error.message : raw;
} else if (rawLower.includes("timed out")) {
detail = labels.timedOut;
} else if (
rawLower.includes("econnrefused") ||
rawLower.includes("connection refused") ||
rawLower.includes("err_connection_refused")
) {
detail = labels.refused;
} else if (
rawLower.includes("certificate") ||
rawLower.includes("certpathvalidator") ||
rawLower.includes("trust anchor") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail = labels.tlsError;
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = labels.hostUnreachable;
} else {
detail = labels.unableToConnect;
}
return { title, detail, raw };
}
@@ -11,6 +11,7 @@ import { useHostReportsUsage } from "@/usage";
import type { Theme } from "@/styles/theme";
import { ContextWindowDetails } from "./context-window-details";
import { ContextWindowSheet } from "./context-window-sheet";
import { formatContextPercentage } from "./context-window-meter.utils";
interface ContextWindowMeterProps {
serverId: string;
@@ -150,7 +151,7 @@ export function ContextWindowMeter({
showPercentage = false,
glyphSize,
}: ContextWindowMeterProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { width } = useWindowDimensions();
// Usage cards need a wider popover; without them it keeps the plain tooltip shape.
const showsUsage = useHostReportsUsage(serverId);
@@ -192,7 +193,9 @@ export function ContextWindowMeter({
);
const percentageLabel =
showPercentage && context ? (
<Text style={styles.percentageLabel}>{`${context.percentage}%`}</Text>
<Text style={styles.percentageLabel}>
{formatContextPercentage(context.percentage, i18n.resolvedLanguage)}
</Text>
) : null;
const accessibilityLabel = context
? t("contextWindow.accessibility", { percentage: context.percentage })
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { formatContextPercentage } from "./context-window-meter.utils";
describe("context window meter formatting", () => {
it("localizes percentages without hiding context overflow", () => {
expect(formatContextPercentage(125, "en")).toBe("125%");
expect(formatContextPercentage(42, "fr")).toBe("42\u00a0%");
});
});
@@ -1,3 +1,10 @@
export function formatContextPercentage(value: number, locale?: string): string {
return new Intl.NumberFormat(locale, {
style: "percent",
maximumFractionDigits: 0,
}).format(value / 100);
}
export function formatTokenCount(value: number): string {
if (value >= 1_000_000) {
return `${Math.round(value / 1_000_000)}m`;
@@ -101,3 +101,25 @@ describe("desktop sidebar layout", () => {
).toBe(true);
});
});
it("reserves room for Explorer and the workspace before showing app navigation", () => {
const contentMinimumWidth = resolveDesktopAppContentMinimum({
isSettingsRoute: false,
explorerVisible: true,
});
expect(contentMinimumWidth).toBe(600);
expect(
canDesktopAppSidebarShare({
contentMinimumWidth,
requestedSidebarWidth: 320,
viewportWidth: 744,
}),
).toBe(false);
expect(
canDesktopAppSidebarShare({
contentMinimumWidth,
requestedSidebarWidth: 320,
viewportWidth: 951,
}),
).toBe(true);
});
@@ -1,3 +1,4 @@
import { EXPLORER_WORKSPACE_MIN_WIDTH } from "@/components/explorer-sidebar-layout";
import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout";
import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
@@ -62,8 +63,12 @@ export function resolveDesktopSidebarWidth(input: {
});
}
export function resolveDesktopAppContentMinimum(input: { isSettingsRoute: boolean }): number {
return input.isSettingsRoute ? SETTINGS_DESKTOP_SPLIT_MIN_WIDTH : 0;
export function resolveDesktopAppContentMinimum(input: {
isSettingsRoute: boolean;
explorerVisible?: boolean;
}): number {
if (input.isSettingsRoute) return SETTINGS_DESKTOP_SPLIT_MIN_WIDTH;
return input.explorerVisible ? EXPLORER_WORKSPACE_MIN_WIDTH : 0;
}
export function canDesktopAppSidebarShare(input: {
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import {
explorerSidebarCloseButtonLayout,
resolveExplorerSidebarDockSizes,
resolveExplorerSidebarWidth,
} from "@/components/explorer-sidebar-layout";
@@ -17,24 +16,7 @@ describe("Explorer sidebar layout", () => {
it("has no fixed maximum while preserving room for the workspace body", () => {
expect(resolveExplorerSidebarWidth({ requestedWidth: 100, containerWidth: 1200 })).toBe(240);
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1600 })).toBe(900);
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(800);
expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(350);
});
});
describe("Explorer close action", () => {
it("preserves the wide-native touch target and glyph rail", () => {
const layout = explorerSidebarCloseButtonLayout(false);
expect(layout.size).toBe(34);
expect(layout.iconSize).toBe(18);
expect(layout.size + layout.hitSlop * 2).toBe(50);
expect(layout.trailingPadding + (layout.size - layout.iconSize) / 2).toBe(16);
});
it("keeps the compact close glyph on the pane rail", () => {
const layout = explorerSidebarCloseButtonLayout(true);
expect(layout.size).toBe(32);
expect(layout.iconSize).toBe(18);
expect(layout.trailingPadding + (layout.size - layout.iconSize) / 2).toBe(8);
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(840);
expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(390);
});
});
@@ -1,36 +1,13 @@
import {
paneContentToolbarIconSize,
paneContentToolbarTrailingPadding,
} from "@/components/ui/pane-content-toolbar";
import { smallIconButtonChromeFrameSize } from "@/components/ui/icon-button-chrome";
export function explorerSidebarCloseButtonLayout(compact: boolean) {
// The inline native dock retains its padded close action and touch slop.
if (!compact) {
return { size: 34, iconSize: 18, hitSlop: 8, trailingPadding: 8 };
}
return {
size: smallIconButtonChromeFrameSize(compact),
iconSize: paneContentToolbarIconSize(compact),
hitSlop: 0,
trailingPadding: paneContentToolbarTrailingPadding(compact, "glyph"),
};
}
/**
* Outer inset of an Explorer tab rail. With each tab's own horizontal padding it puts
* the tab label on the pane's shared leading rail (`treeRowPaddingLeft(0)`).
*/
export const EXPLORER_TAB_RAIL_INSET = 4;
const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 320;
const MIN_EXPLORER_SIDEBAR_WIDTH = 240;
const MIN_WORKSPACE_BODY_WIDTH = 400;
const MIN_WORKSPACE_BODY_WIDTH = 360;
export const EXPLORER_WORKSPACE_MIN_WIDTH = MIN_EXPLORER_SIDEBAR_WIDTH + MIN_WORKSPACE_BODY_WIDTH;
export function resolveExplorerSidebarWidth(input: {
requestedWidth?: number;
containerWidth: number;
}): number {
"worklet";
const requestedWidth = input.requestedWidth ?? DEFAULT_EXPLORER_SIDEBAR_WIDTH;
const maximumVisibleWidth =
input.containerWidth > 0
@@ -46,7 +46,7 @@ export function FileDropBackdrop() {
const positionStyles = RNStyleSheet.create({
overlay: {
...RNStyleSheet.absoluteFillObject,
...RNStyleSheet.absoluteFill,
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
@@ -55,7 +55,7 @@ const positionStyles = RNStyleSheet.create({
const styles = StyleSheet.create((theme) => ({
backdrop: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
backgroundColor: theme.colors.surface0,
opacity: 0.7,
},
@@ -1,17 +0,0 @@
export type FilePreviewRenderKind = "markdown" | "html";
export function isRenderedMarkdownFile(filePath: string): boolean {
const normalizedPath = filePath.trim().toLowerCase();
return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown");
}
function isRenderedHtmlFile(filePath: string): boolean {
const normalizedPath = filePath.trim().toLowerCase();
return normalizedPath.endsWith(".html") || normalizedPath.endsWith(".htm");
}
export function filePreviewRenderKind(filePath: string): FilePreviewRenderKind | null {
if (isRenderedMarkdownFile(filePath)) return "markdown";
if (isRenderedHtmlFile(filePath)) return "html";
return null;
}
@@ -52,8 +52,15 @@ export function CompactHeader({
}: CompactHeaderProps) {
const insets = useSafeAreaInsets();
const rootStyle = useMemo(
() => [styles.root, { paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE }],
[insets.top],
() => [
styles.root,
{
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
marginLeft: insets.left,
marginRight: insets.right,
},
],
[insets.top, insets.left, insets.right],
);
return (
+3 -1
View File
@@ -679,7 +679,9 @@ function DesktopSidebar({
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
const { width: windowWidth } = useWindowDimensions();
const insets = useSafeAreaInsets();
const viewportWidth = windowWidth - insets.left - insets.right;
const visibleSidebarWidth = resolveDesktopSidebarWidth({
requestedWidth: sidebarWidth,
viewportWidth,
@@ -1,10 +1,14 @@
import { useMemo, type ReactNode } from "react";
import { ScrollView, Text, View } from "react-native";
import { Platform, ScrollView, Text, View } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header";
import { MenuHeader } from "@/components/headers/menu-header";
import { useIsCompactFormFactor } from "@/constants/layout";
import { SPACING } from "@/styles/theme";
const PageScrollView = Platform.OS === "android" ? KeyboardAwareScrollView : ScrollView;
interface PageLayoutProps {
title?: string;
@@ -42,7 +46,13 @@ export function PageLayout({
) : (
<MenuHeader borderless />
)}
<ScrollView style={styles.scroll} contentContainerStyle={scrollContentStyle} testID={testID}>
<PageScrollView
// Leave room for field borders and padding around the focused native input.
{...(Platform.OS === "android" ? { bottomOffset: SPACING[4] } : {})}
style={styles.scroll}
contentContainerStyle={scrollContentStyle}
testID={testID}
>
<View style={styles.content}>
{showTitle ? (
<View style={styles.titleRow} testID={titleTestID}>
@@ -54,7 +64,7 @@ export function PageLayout({
) : null}
{children}
</View>
</ScrollView>
</PageScrollView>
</View>
);
}
@@ -2761,7 +2761,7 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
},
workspaceArchivingOverlay: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
borderRadius: theme.borderRadius.lg,
backgroundColor: `${theme.colors.surface0}cc`,
alignItems: "center",
+67 -654
View File
@@ -1,3 +1,4 @@
import { useWorkspaceTabDrag } from "./workspace-tab-drag-context";
import {
Fragment,
memo,
@@ -11,35 +12,14 @@ import {
type SetStateAction,
} from "react";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
closestCenter,
pointerWithin,
useSensor,
useSensors,
type CollisionDetection,
type DragEndEvent,
type DragMoveEvent,
type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { View, Text, type LayoutChangeEvent } from "react-native";
import { View, type LayoutChangeEvent } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
type SharedValue,
} from "react-native-reanimated";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import {
resolveExplorerSidebarDockSizes,
resolveExplorerSidebarWidth,
} from "@/components/explorer-sidebar-layout";
import { RetainedPanel } from "@/components/retained-panel";
import {
hasMultipleVisiblePanes,
@@ -48,41 +28,26 @@ import {
} from "@/components/split-container-focus";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
import {
removeWindowChromeCorner,
WindowChromeRegion,
WindowChromeSafeArea,
useWindowChromeCorners,
removeWindowChromeCorner,
type WindowChromeCorners,
} from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import {
computeTabDropPreview,
type TabDropPreview,
} from "@/components/split-container-tab-drop-preview";
import {
SplitDropZone,
resolveSplitDropPosition,
type SplitDropZoneHover,
} from "@/components/split-drop-zone";
import {
deriveWorkspacePaneState,
getWorkspacePaneDescriptors,
} from "@/screens/workspace/workspace-pane-state";
import { type TabDropPreview } from "@/components/split-container-tab-drop-preview";
import { SplitDropZone, type SplitDropZoneHover } from "@/components/split-drop-zone";
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content";
import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host";
import {
WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row";
import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
} from "@/screens/workspace/workspace-tab-presentation";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
collectAllPanes,
createDefaultLayout,
findPaneById,
useWorkspaceLayoutStore,
type SplitNode,
type SplitPane,
@@ -90,14 +55,11 @@ import {
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { recordRenderProfileReasons, RenderProfile } from "@/utils/render-profiler";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { isNative } from "@/constants/platform";
import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations";
interface SplitContainerProps {
layout: WorkspaceLayout;
renderMainHeader?: () => ReactNode;
renderExplorerSidebarHeaderAction?: () => ReactNode;
workspaceKey: string;
normalizedServerId: string;
normalizedWorkspaceId: string;
@@ -140,41 +102,6 @@ interface SplitContainerProps {
onExitFocusMode: () => void;
}
interface WorkspaceTabDragData {
kind: "workspace-tab";
paneId: string;
tabId: string;
}
interface SplitPaneDropData {
kind: "split-pane-drop";
paneId: string;
}
const EMPTY_SPLIT_NODES: SplitNode[] = [];
const EMPTY_SPLIT_SIZES: number[] = [];
const EXPLORER_SIDEBAR_RESIZE_GROUP_ID = "explorer-sidebar";
function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData {
return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab";
}
function isSplitPaneDropData(data: unknown): data is SplitPaneDropData {
return (
typeof data === "object" && data !== null && Reflect.get(data, "kind") === "split-pane-drop"
);
}
function asWorkspaceTabDragData(data: unknown): WorkspaceTabDragData | undefined {
return isWorkspaceTabDragData(data) ? data : undefined;
}
function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData | undefined {
if (isWorkspaceTabDragData(data)) return data;
if (isSplitPaneDropData(data)) return data;
return undefined;
}
interface SplitNodeViewProps extends Omit<
SplitContainerProps,
"layout" | "onMoveTabToPane" | "onSelectTabInPane"
@@ -212,105 +139,12 @@ interface SplitPaneViewProps extends Omit<
tabDropPreview: TabDropPreview | null;
}
interface DragMoveRects {
translatedRect: { left: number; top: number; width: number; height: number };
overRect: { left: number; top: number; width: number; height: number };
}
function resolveDragMoveRects(
event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">,
): DragMoveRects | null {
const translatedRect = event.active.rect.current.translated;
const overRect = event.over?.rect;
if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) {
return null;
}
return { translatedRect, overRect };
}
function computeTabOverDropPreview(input: {
activeData: WorkspaceTabDragData;
overData: WorkspaceTabDragData;
rects: DragMoveRects;
panesById: Map<string, SplitPane>;
uiTabs: WorkspaceTab[];
}): TabDropPreview | null {
const { activeData, overData, rects, panesById, uiTabs } = input;
const targetPane = panesById.get(overData.paneId) ?? null;
if (!targetPane) {
return null;
}
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
return computeTabDropPreview({
activePaneId: activeData.paneId,
activeTabId: activeData.tabId,
overPaneId: overData.paneId,
overTabId: overData.tabId,
targetTabs,
activeRect: {
left: rects.translatedRect.left,
width: rects.translatedRect.width,
},
overRect: {
left: rects.overRect.left,
width: rects.overRect.width,
},
});
}
function computePaneOverDropPreview(input: {
overData: SplitPaneDropData;
rects: DragMoveRects;
}): SplitDropZoneHover | null {
const { overData, rects } = input;
const centerX = rects.translatedRect.left + rects.translatedRect.width / 2;
const centerY = rects.translatedRect.top + rects.translatedRect.height / 2;
const relativeX = centerX - rects.overRect.left;
const relativeY = centerY - rects.overRect.top;
if (
Number.isNaN(relativeX) ||
Number.isNaN(relativeY) ||
relativeX < 0 ||
relativeX > rects.overRect.width ||
relativeY < 0 ||
relativeY > rects.overRect.height
) {
return null;
}
return {
paneId: overData.paneId,
position: resolveSplitDropPosition({
width: rects.overRect.width,
height: rects.overRect.height,
x: relativeX,
y: relativeY,
}),
};
}
const dropCollisionDetection: CollisionDetection = (args) => {
const pointerHits = pointerWithin(args);
const tabHits = pointerHits.filter(
(entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab",
);
if (tabHits.length > 0) {
return tabHits;
}
const paneHits = pointerHits.filter(
(entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop",
);
if (paneHits.length > 0) {
return paneHits;
}
return closestCenter(args);
};
const EMPTY_SPLIT_NODES: SplitNode[] = [];
const EMPTY_SPLIT_SIZES: number[] = [];
export function SplitContainer({
layout,
renderMainHeader,
renderExplorerSidebarHeaderAction,
workspaceKey,
normalizedServerId,
normalizedWorkspaceId,
@@ -335,8 +169,6 @@ export function SplitContainer({
onFocusPane,
onSplitPane,
onSplitPaneEmpty,
onMoveTabToPane,
onSelectTabInPane,
onResizeSplit,
onReorderTabsInPane,
focusModeEnabled,
@@ -344,9 +176,7 @@ export function SplitContainer({
}: SplitContainerProps) {
const inheritedWindowChromeCorners = useWindowChromeCorners();
const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none";
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
const [tabDropPreview, setTabDropPreview] = useState<TabDropPreview | null>(null);
const { activeDragTabId, dropPreview, tabDropPreview } = useWorkspaceTabDrag();
const [maximizedPane, setMaximizedPane] = useState<{
workspaceKey: string;
paneId: string;
@@ -357,22 +187,6 @@ export function SplitContainer({
(state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null,
);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
const explorerSidebarPane = useMemo(
() => findPaneById(layout.root, explorerSidebarPaneId),
[layout.root, explorerSidebarPaneId],
);
const mainRoot = useMemo(
() => removePaneFromSplitTree(layout.root, explorerSidebarPaneId),
[layout.root, explorerSidebarPaneId],
@@ -408,434 +222,68 @@ export function SplitContainer({
}),
[focusModeEnabled, layout.focusedPaneId, mainRoot],
);
const storedExplorerSidebarWidth = useWorkspaceLayoutStore(
(state) => state.explorerSidebarWidthByWorkspace[workspaceKey],
);
const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar);
const [workspaceShellWidth, setWorkspaceShellWidth] = useState(0);
const [previewExplorerSidebarWidth, setPreviewExplorerSidebarWidth] = useState<number | null>(
null,
);
const requestedExplorerSidebarWidth = previewExplorerSidebarWidth ?? storedExplorerSidebarWidth;
const explorerSidebarWidth = resolveExplorerSidebarWidth({
requestedWidth: requestedExplorerSidebarWidth,
containerWidth: workspaceShellWidth,
});
const explorerSidebarDockSizes = useMemo(
() =>
resolveExplorerSidebarDockSizes({
requestedWidth: requestedExplorerSidebarWidth,
containerWidth: workspaceShellWidth,
}),
[requestedExplorerSidebarWidth, workspaceShellWidth],
);
const renderExplorerSidebarDock = Boolean(
!focusModeEnabled && explorerSidebarPane && explorerSidebarPane.hidden !== true,
);
const mainColumnWindowChromeCorners = renderExplorerSidebarDock
? removeWindowChromeCorner(inheritedWindowChromeCorners, "top-right")
: inheritedWindowChromeCorners;
const explorerPane = explorerSidebarPaneId
? collectAllPanes(layout.root).find((pane) => pane.id === explorerSidebarPaneId)
: null;
const mainColumnWindowChromeCorners =
!focusModeEnabled && explorerPane && !explorerPane.hidden
? removeWindowChromeCorner(inheritedWindowChromeCorners, "top-right")
: inheritedWindowChromeCorners;
const mainColumnStyle = styles.mainColumn;
const explorerSidebarDockStyle = useMemo(
() => [styles.explorerSidebarDock, inlineUnistylesStyle({ width: explorerSidebarWidth })],
[explorerSidebarWidth],
);
const handleWorkspaceShellLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = event.nativeEvent.layout.width;
setWorkspaceShellWidth((current) => (current === nextWidth ? current : nextWidth));
}, []);
const previewExplorerSidebarResize = useCallback(
(_groupId: string, sizes: number[]) => {
const nextRatio = sizes[1];
if (nextRatio !== undefined) {
setPreviewExplorerSidebarWidth(
resolveExplorerSidebarWidth({
requestedWidth: nextRatio * workspaceShellWidth,
containerWidth: workspaceShellWidth,
}),
);
}
},
[workspaceShellWidth],
);
const commitExplorerSidebarResize = useCallback(
(_groupId: string, sizes: number[]) => {
setPreviewExplorerSidebarWidth(null);
const nextRatio = sizes[1];
if (nextRatio !== undefined) {
resizeExplorerSidebar(
workspaceKey,
resolveExplorerSidebarWidth({
requestedWidth: nextRatio * workspaceShellWidth,
containerWidth: workspaceShellWidth,
}),
);
}
},
[resizeExplorerSidebar, workspaceKey, workspaceShellWidth],
);
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = asWorkspaceTabDragData(event.active.data.current);
if (!data) {
setActiveDragTabId(null);
setDropPreview(null);
setTabDropPreview(null);
return;
}
setActiveDragTabId(data.tabId);
}, []);
const handleDragCancel = useCallback(() => {
setActiveDragTabId(null);
setDropPreview(null);
setTabDropPreview(null);
}, []);
const updateDropPreview = useCallback(
(event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">) => {
const activeData = asWorkspaceTabDragData(event.active.data.current);
const overData = asDragOverData(event.over?.data.current);
if (activeData?.kind !== "workspace-tab") {
setDropPreview(null);
setTabDropPreview(null);
return;
}
const rects = resolveDragMoveRects(event);
if (!rects) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
const activeTab = uiTabs.find((tab) => tab.tabId === activeData.tabId) ?? null;
const destinationPaneId =
overData?.kind === "workspace-tab" || overData?.kind === "split-pane-drop"
? overData.paneId
: null;
const destinationHost = destinationPaneId === explorerSidebarPaneId ? "explorer" : "main";
if (
!activeTab ||
!panelTargetSupportsHost(normalizedServerId, activeTab.target, destinationHost)
) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (overData?.kind === "workspace-tab") {
const preview = computeTabOverDropPreview({
activeData,
overData,
rects,
panesById,
uiTabs,
});
setDropPreview(null);
setTabDropPreview(preview);
return;
}
setTabDropPreview(null);
if (overData?.kind !== "split-pane-drop") {
setDropPreview(null);
return;
}
setDropPreview(computePaneOverDropPreview({ overData, rects }));
},
[normalizedServerId, panesById, explorerSidebarPaneId, uiTabs],
);
const applyTabDropEnd = useCallback(
(input: { activeData: WorkspaceTabDragData; overData: WorkspaceTabDragData }): void => {
const { activeData, overData } = input;
const sourcePane = panesById.get(activeData.paneId) ?? null;
const targetPane = panesById.get(overData.paneId) ?? null;
if (!sourcePane || !targetPane) {
return;
}
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
const resolvedTabDropPreview =
tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null;
if (sourceIndex < 0 || !resolvedTabDropPreview) {
return;
}
if (activeData.paneId === overData.paneId) {
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
const nextTabs = arrayMove(
sourceTabs,
sourceIndex,
resolvedTabDropPreview.insertionIndex,
);
onReorderTabsInPane(
activeData.paneId,
nextTabs.map((tab) => tab.tabId),
);
}
return;
}
const nextTargetTabIds = targetTabs.map((tab) => tab.tabId);
nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId);
onMoveTabToPane(activeData.tabId, overData.paneId);
onReorderTabsInPane(overData.paneId, nextTargetTabIds);
},
[onMoveTabToPane, onReorderTabsInPane, panesById, tabDropPreview, uiTabs],
);
const applyPaneDropEnd = useCallback(
(input: { activeData: WorkspaceTabDragData; overData: SplitPaneDropData }): void => {
const { activeData, overData } = input;
if (dropPreview?.paneId !== overData.paneId) {
return;
}
if (dropPreview.position === "center") {
if (activeData.paneId !== overData.paneId) {
onMoveTabToPane(activeData.tabId, overData.paneId);
}
return;
}
onSplitPane({
tabId: activeData.tabId,
targetPaneId: overData.paneId,
position: dropPreview.position,
});
},
[dropPreview, onMoveTabToPane, onSplitPane],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const activeData = asWorkspaceTabDragData(event.active.data.current);
const overData = asDragOverData(event.over?.data.current);
setActiveDragTabId(null);
if (activeData?.kind === "workspace-tab" && event.over) {
if (overData?.kind === "workspace-tab") {
applyTabDropEnd({ activeData, overData });
} else if (overData?.kind === "split-pane-drop") {
applyPaneDropEnd({ activeData, overData });
}
}
setDropPreview(null);
setTabDropPreview(null);
},
[applyTabDropEnd, applyPaneDropEnd],
);
return (
<RenderProfile id="SplitContainer">
<DndContext
sensors={sensors}
collisionDetection={dropCollisionDetection}
onDragStart={handleDragStart}
onDragMove={updateDropPreview}
onDragOver={updateDropPreview}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
>
<View style={styles.workspaceShell} onLayout={handleWorkspaceShellLayout}>
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
<View style={mainColumnStyle}>
{renderMainHeader?.()}
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
{renderRoot ? (
<SplitNodeView
node={renderRoot}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateNewTab={onCreateNewTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
maximizedPaneId={maximizedPaneId}
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
onTogglePaneMaximized={handleTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
) : null}
</View>
</WindowChromeRegion>
{renderExplorerSidebarDock && explorerSidebarPane ? (
<>
<ResizeHandle
testID="workspace-explorer-sidebar-resize-handle"
direction="horizontal"
hitAreaAlignment="end"
groupId={EXPLORER_SIDEBAR_RESIZE_GROUP_ID}
index={0}
sizes={explorerSidebarDockSizes}
containerSize={workspaceShellWidth}
onPreviewResizeSplit={previewExplorerSidebarResize}
onResizeSplit={commitExplorerSidebarResize}
<View style={styles.workspaceShell}>
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
<View style={mainColumnStyle}>
{renderMainHeader?.()}
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
{renderRoot ? (
<SplitNodeView
node={renderRoot}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateNewTab={onCreateNewTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
maximizedPaneId={maximizedPaneId}
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
onTogglePaneMaximized={handleTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
<View style={explorerSidebarDockStyle}>
<ExplorerSidebarDock
pane={explorerSidebarPane}
uiTabs={uiTabs}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
closingTabIds={closingTabIds}
onSelectTab={onSelectTabInPane}
onCloseTab={onCloseTab}
onCreateNewTab={onCreateNewTab}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onExitFocusMode={onExitFocusMode}
buildPaneContentModel={buildPaneContentModel}
onReorderTabsInPane={onReorderTabsInPane}
activeDragTabId={activeDragTabId}
tabDropPreview={tabDropPreview}
headerAction={renderExplorerSidebarHeaderAction?.()}
/>
</View>
</>
) : null}
</View>
<DragOverlay dropAnimation={null}>
{activeDragTabId ? (
<DragOverlayTabChip
tabId={activeDragTabId}
uiTabs={uiTabs}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
) : null}
</DragOverlay>
</DndContext>
</RenderProfile>
);
}
function DragOverlayTabChip({
tabId,
uiTabs,
normalizedServerId,
normalizedWorkspaceId,
}: {
tabId: string;
uiTabs: WorkspaceTab[];
normalizedServerId: string;
normalizedWorkspaceId: string;
}) {
const tab = uiTabs.find((t) => t.tabId === tabId);
const descriptor = useMemo<WorkspaceTabDescriptor | null>(
() =>
tab
? {
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
}
: null,
[tab],
);
if (!descriptor) {
return null;
}
return (
<DragOverlayTabChipInner
tab={descriptor}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
);
}
function DragOverlayTabChipInner({
tab,
normalizedServerId,
normalizedWorkspaceId,
}: {
tab: WorkspaceTabDescriptor;
normalizedServerId: string;
normalizedWorkspaceId: string;
}) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const chipStyle = useMemo(
() => [
styles.dragOverlayChip,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
],
[theme.colors.surface1, theme.colors.borderAccent],
);
const chipLabelStyle = useMemo(
() => [styles.dragOverlayLabel, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
return (
<WorkspaceTabPresentationResolver
tab={tab}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
>
{(presentation) => {
const label =
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
return (
<View style={chipStyle}>
<WorkspaceTabIcon presentation={presentation} active size={14} backdrop="surface1" />
<Text numberOfLines={1} style={chipLabelStyle}>
{label}
</Text>
) : null}
</View>
);
}}
</WorkspaceTabPresentationResolver>
</WindowChromeRegion>
</View>
</RenderProfile>
);
}
@@ -1317,21 +765,6 @@ const SplitPaneView = memo(function SplitPaneView({
);
});
function collectPanesById(node: SplitNode): Map<string, SplitPane> {
const next = new Map<string, SplitPane>();
function visit(current: SplitNode) {
if (current.kind === "pane") {
next.set(current.pane.id, current.pane);
return;
}
for (const child of current.group.children) {
visit(child);
}
}
visit(node);
return next;
}
function getNodeKey(node: SplitNode): string {
if (node.kind === "pane") {
return node.pane.id;
@@ -1396,12 +829,6 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
minHeight: 0,
},
explorerSidebarDock: {
flexShrink: 0,
minWidth: 240,
minHeight: 0,
backgroundColor: theme.colors.surfaceSidebar,
},
group: {
flex: 1,
minWidth: 0,
@@ -1436,18 +863,4 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
minHeight: 0,
},
dragOverlayChip: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
maxWidth: 200,
},
dragOverlayLabel: {
fontSize: theme.fontSize.base,
flexShrink: 1,
},
}));
@@ -147,7 +147,7 @@ function getPreviewFrameStyle(position: SplitDropZonePosition) {
const styles = StyleSheet.create((theme) => ({
overlay: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
zIndex: 40,
},
previewOverlay: {
@@ -1,11 +1,13 @@
import { useLayoutEffect } from "react";
import { useLayoutEffect, useState } from "react";
import { makeMutable, type SharedValue, useSharedValue } from "react-native-reanimated";
import { scheduleOnUI } from "react-native-worklets";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { getStatusRingRotation } from "@/components/status-ring/geometry";
const sharedRotation = makeMutable(getStatusRingRotation(Date.now()));
const activeRingCount = makeMutable(0);
const clockRunning = makeMutable(false);
let nextListenerId = 1;
function advanceSharedRotation(): void {
"worklet";
@@ -18,13 +20,21 @@ function advanceSharedRotation(): void {
requestAnimationFrame(advanceSharedRotation);
}
function registerStatusRing(registered: SharedValue<boolean>): void {
function registerStatusRing(
rotation: SharedValue<number>,
registered: SharedValue<boolean>,
listenerId: number,
): void {
"worklet";
if (registered.value) {
return;
}
registered.value = true;
rotation.value = getStatusRingRotation(Date.now());
sharedRotation.addListener(listenerId, (value) => {
rotation.value = value;
});
activeRingCount.value += 1;
if (!clockRunning.value) {
@@ -34,25 +44,31 @@ function registerStatusRing(registered: SharedValue<boolean>): void {
}
}
function unregisterStatusRing(registered: SharedValue<boolean>): void {
function unregisterStatusRing(registered: SharedValue<boolean>, listenerId: number): void {
"worklet";
if (!registered.value) {
return;
}
registered.value = false;
sharedRotation.removeListener(listenerId);
activeRingCount.value -= 1;
}
export function useStatusRingRotation(): SharedValue<number> {
const active = useRetainedPanelActive();
// Hidden retained views must not subscribe their animated styles to the shared clock.
const rotation = useSharedValue(getStatusRingRotation(Date.now()));
const registered = useSharedValue(false);
const [listenerId] = useState(() => nextListenerId++);
useLayoutEffect(() => {
scheduleOnUI(registerStatusRing, registered);
if (!active) return;
scheduleOnUI(registerStatusRing, rotation, registered, listenerId);
return () => {
scheduleOnUI(unregisterStatusRing, registered);
scheduleOnUI(unregisterStatusRing, registered, listenerId);
};
}, [registered]);
}, [active, listenerId, registered, rotation]);
return sharedRotation;
return rotation;
}
@@ -1171,7 +1171,7 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
},
attachOverlay: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.16)",
+1 -1
View File
@@ -1765,7 +1765,7 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
},
desktopOverlayWeb: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
pointerEvents: "auto" as const,
},
desktopBackdrop: {
@@ -81,6 +81,6 @@ export function FloatingPanelPortalHost({
const styles = StyleSheet.create({
host: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
},
});
+17 -11
View File
@@ -14,13 +14,16 @@ import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "rea
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import {
OverlayLayerProvider,
useOverlayLayer,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import { FloatingSurface } from "@/components/ui/floating";
import {
measureFloatingPanelPortalHost,
useFloatingPanelPortalHostName,
} from "@/components/ui/floating-panel-portal";
import { isWeb } from "@/constants/platform";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import {
@@ -326,28 +329,31 @@ function HoverCardSurface({
triggerRef: RefObject<View | null>;
contentRef: RefObject<View | null>;
}): ReactElement {
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const portalHostName = useFloatingPanelPortalHostName();
const [anchor, setAnchor] = useState<{ triggerRect: Rect; hostRect: Rect } | null>(null);
const [contentSize, setContentSize] = useState<Size | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!triggerRef.current) return;
let cancelled = false;
void measureElement(triggerRef.current).then((rect) => {
if (!cancelled) setTriggerRect(rect);
void Promise.all([
measureElement(triggerRef.current),
measureFloatingPanelPortalHost(portalHostName),
]).then(([triggerRect, hostRect]) => {
if (!cancelled && hostRect) setAnchor({ triggerRect, hostRect });
return undefined;
});
return () => {
cancelled = true;
};
}, [triggerRef]);
}, [portalHostName, triggerRef]);
useEffect(() => {
if (!triggerRect || !contentSize) return;
if (!anchor || !contentSize) return;
const { width, height } = Dimensions.get("window");
const { x, y } = computePosition({
triggerRect,
triggerRect: anchor.triggerRect,
contentSize,
displayArea: { x: 0, y: 0, width, height },
placement,
@@ -355,8 +361,8 @@ function HoverCardSurface({
alignment,
offset,
});
setPosition({ x, y });
}, [alignment, contentSize, offset, placement, triggerRect]);
setPosition({ x: x - anchor.hostRect.x, y: y - anchor.hostRect.y });
}, [alignment, anchor, contentSize, offset, placement]);
const handleLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
@@ -377,7 +383,7 @@ function HoverCardSurface({
const surfaceStyle = useMemo(() => [styles.surface, style], [style]);
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<Portal hostName={portalHostName}>
<View pointerEvents="box-none" style={[styles.portalOverlay, { zIndex: layer }]}>
<FloatingSurface
ref={contentRef}
@@ -86,6 +86,6 @@ const styles = StyleSheet.create({
minHeight: 0,
},
host: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
},
});
@@ -527,7 +527,7 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
},
overlayWeb: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
pointerEvents: "auto" as const,
},
backdrop: {
@@ -66,9 +66,9 @@ export const PressHighlight = forwardRef<View, PressHighlightProps>(function Pre
const styles = StyleSheet.create({
highlight: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
},
highlightFill: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
},
});
+1 -1
View File
@@ -68,7 +68,7 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 0,
},
badgeBackground: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface3,
opacity: theme.opacity[50],
@@ -0,0 +1,12 @@
import { createContext, useContext } from "react";
import type { TabDropPreview } from "./split-container-tab-drop-preview";
import type { SplitDropZoneHover } from "./split-drop-zone";
export const WorkspaceTabDragContext = createContext<{
activeDragTabId: string | null;
dropPreview: SplitDropZoneHover | null;
tabDropPreview: TabDropPreview | null;
externalDndContext: boolean;
}>({ activeDragTabId: null, dropPreview: null, tabDropPreview: null, externalDndContext: false });
export function useWorkspaceTabDrag() {
return useContext(WorkspaceTabDragContext);
}
@@ -0,0 +1,9 @@
export function acceptsWorkspaceTabDrop(
allowCrossPane: boolean,
sourcePaneId: string,
destination: { kind: "workspace-tab" | "split-pane-drop"; paneId: string },
): boolean {
return (
allowCrossPane || (destination.kind === "workspace-tab" && sourcePaneId === destination.paneId)
);
}
@@ -0,0 +1,5 @@
import type { ComponentProps } from "react";
import type { WorkspaceTabDragScope as WebScope } from "./workspace-tab-drag-scope.web";
export function WorkspaceTabDragScope({ children }: ComponentProps<typeof WebScope>) {
return children;
}
@@ -0,0 +1,494 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { View, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
closestCenter,
pointerWithin,
useSensor,
useSensors,
type CollisionDetection,
type DragEndEvent,
type DragMoveEvent,
type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { computeTabDropPreview, type TabDropPreview } from "./split-container-tab-drop-preview";
import { resolveSplitDropPosition, type SplitDropZoneHover } from "./split-drop-zone";
import { getWorkspacePaneDescriptors } from "@/screens/workspace/workspace-pane-state";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
} from "@/screens/workspace/workspace-tab-presentation";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
collectAllPanes,
createWorkspaceLayoutWithExplorerSidebar,
type WorkspaceLayout,
type SplitPane,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations";
import { acceptsWorkspaceTabDrop } from "./workspace-tab-drag-policy";
import { WorkspaceTabDragContext } from "./workspace-tab-drag-context";
interface WorkspaceTabDragData {
kind: "workspace-tab";
paneId: string;
tabId: string;
}
interface SplitPaneDropData {
kind: "split-pane-drop";
paneId: string;
}
function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData {
return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab";
}
function isSplitPaneDropData(data: unknown): data is SplitPaneDropData {
return (
typeof data === "object" && data !== null && Reflect.get(data, "kind") === "split-pane-drop"
);
}
function asWorkspaceTabDragData(data: unknown): WorkspaceTabDragData | undefined {
return isWorkspaceTabDragData(data) ? data : undefined;
}
function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData | undefined {
if (isWorkspaceTabDragData(data)) return data;
if (isSplitPaneDropData(data)) return data;
return undefined;
}
interface DragMoveRects {
translatedRect: { left: number; top: number; width: number; height: number };
overRect: { left: number; top: number; width: number; height: number };
}
function resolveDragMoveRects(
event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">,
): DragMoveRects | null {
const translatedRect = event.active.rect.current.translated;
const overRect = event.over?.rect;
if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) {
return null;
}
return { translatedRect, overRect };
}
function computeTabOverDropPreview(input: {
activeData: WorkspaceTabDragData;
overData: WorkspaceTabDragData;
rects: DragMoveRects;
panesById: Map<string, SplitPane>;
uiTabs: WorkspaceTab[];
}): TabDropPreview | null {
const { activeData, overData, rects, panesById, uiTabs } = input;
const targetPane = panesById.get(overData.paneId) ?? null;
if (!targetPane) {
return null;
}
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
return computeTabDropPreview({
activePaneId: activeData.paneId,
activeTabId: activeData.tabId,
overPaneId: overData.paneId,
overTabId: overData.tabId,
targetTabs,
activeRect: {
left: rects.translatedRect.left,
width: rects.translatedRect.width,
},
overRect: {
left: rects.overRect.left,
width: rects.overRect.width,
},
});
}
function computePaneOverDropPreview(input: {
overData: SplitPaneDropData;
rects: DragMoveRects;
}): SplitDropZoneHover | null {
const { overData, rects } = input;
const centerX = rects.translatedRect.left + rects.translatedRect.width / 2;
const centerY = rects.translatedRect.top + rects.translatedRect.height / 2;
const relativeX = centerX - rects.overRect.left;
const relativeY = centerY - rects.overRect.top;
if (
Number.isNaN(relativeX) ||
Number.isNaN(relativeY) ||
relativeX < 0 ||
relativeX > rects.overRect.width ||
relativeY < 0 ||
relativeY > rects.overRect.height
) {
return null;
}
return {
paneId: overData.paneId,
position: resolveSplitDropPosition({
width: rects.overRect.width,
height: rects.overRect.height,
x: relativeX,
y: relativeY,
}),
};
}
const dropCollisionDetection: CollisionDetection = (args) => {
const pointerHits = pointerWithin(args);
const tabHits = pointerHits.filter(
(entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab",
);
if (tabHits.length > 0) {
return tabHits;
}
const paneHits = pointerHits.filter(
(entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop",
);
if (paneHits.length > 0) {
return paneHits;
}
return closestCenter(args);
};
const EMPTY_LAYOUT = createWorkspaceLayoutWithExplorerSidebar();
interface WorkspaceTabDragScopeProps {
children: ReactNode;
layout: WorkspaceLayout | null;
explorerSidebarPaneId: string | null;
allowCrossPane: boolean;
normalizedServerId: string;
normalizedWorkspaceId: string;
uiTabs: WorkspaceTab[];
onMoveTabToPane: (tabId: string, paneId: string) => void;
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
onSplitPane: (input: {
tabId: string;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
}) => void;
}
export function WorkspaceTabDragScope({
children,
layout,
explorerSidebarPaneId,
allowCrossPane,
normalizedServerId,
normalizedWorkspaceId,
uiTabs,
onMoveTabToPane,
onReorderTabsInPane,
onSplitPane,
}: WorkspaceTabDragScopeProps) {
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
const [tabDropPreview, setTabDropPreview] = useState<TabDropPreview | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const panesById = useMemo(
() => new Map(collectAllPanes((layout ?? EMPTY_LAYOUT).root).map((pane) => [pane.id, pane])),
[layout],
);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = asWorkspaceTabDragData(event.active.data.current);
if (!data) {
setActiveDragTabId(null);
setDropPreview(null);
setTabDropPreview(null);
return;
}
setActiveDragTabId(data.tabId);
}, []);
const handleDragCancel = useCallback(() => {
setActiveDragTabId(null);
setDropPreview(null);
setTabDropPreview(null);
}, []);
const updateDropPreview = useCallback(
(event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">) => {
const activeData = asWorkspaceTabDragData(event.active.data.current);
const overData = asDragOverData(event.over?.data.current);
if (
!activeData ||
!overData ||
!acceptsWorkspaceTabDrop(allowCrossPane, activeData.paneId, overData)
) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
const rects = resolveDragMoveRects(event);
if (!rects) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
const activeTab = uiTabs.find((tab) => tab.tabId === activeData.tabId) ?? null;
const destinationPaneId =
overData?.kind === "workspace-tab" || overData?.kind === "split-pane-drop"
? overData.paneId
: null;
const destinationHost = destinationPaneId === explorerSidebarPaneId ? "explorer" : "main";
if (
!activeTab ||
!panelTargetSupportsHost(normalizedServerId, activeTab.target, destinationHost)
) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (overData?.kind === "workspace-tab") {
const preview = computeTabOverDropPreview({
activeData,
overData,
rects,
panesById,
uiTabs,
});
setDropPreview(null);
setTabDropPreview(preview);
return;
}
setTabDropPreview(null);
if (overData?.kind !== "split-pane-drop") {
setDropPreview(null);
return;
}
setDropPreview(computePaneOverDropPreview({ overData, rects }));
},
[allowCrossPane, normalizedServerId, panesById, explorerSidebarPaneId, uiTabs],
);
const applyTabDropEnd = useCallback(
(input: { activeData: WorkspaceTabDragData; overData: WorkspaceTabDragData }): void => {
const { activeData, overData } = input;
const sourcePane = panesById.get(activeData.paneId) ?? null;
const targetPane = panesById.get(overData.paneId) ?? null;
if (!sourcePane || !targetPane) {
return;
}
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
const resolvedTabDropPreview =
tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null;
if (sourceIndex < 0 || !resolvedTabDropPreview) {
return;
}
if (activeData.paneId === overData.paneId) {
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
const nextTabs = arrayMove(
sourceTabs,
sourceIndex,
resolvedTabDropPreview.insertionIndex,
);
onReorderTabsInPane(
activeData.paneId,
nextTabs.map((tab) => tab.tabId),
);
}
return;
}
const nextTargetTabIds = targetTabs.map((tab) => tab.tabId);
nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId);
onMoveTabToPane(activeData.tabId, overData.paneId);
onReorderTabsInPane(overData.paneId, nextTargetTabIds);
},
[onMoveTabToPane, onReorderTabsInPane, panesById, tabDropPreview, uiTabs],
);
const applyPaneDropEnd = useCallback(
(input: { activeData: WorkspaceTabDragData; overData: SplitPaneDropData }): void => {
const { activeData, overData } = input;
if (dropPreview?.paneId !== overData.paneId) {
return;
}
if (dropPreview.position === "center") {
if (activeData.paneId !== overData.paneId) {
onMoveTabToPane(activeData.tabId, overData.paneId);
}
return;
}
onSplitPane({
tabId: activeData.tabId,
targetPaneId: overData.paneId,
position: dropPreview.position,
});
},
[dropPreview, onMoveTabToPane, onSplitPane],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const activeData = asWorkspaceTabDragData(event.active.data.current);
const overData = asDragOverData(event.over?.data.current);
setActiveDragTabId(null);
if (
activeData &&
overData &&
event.over &&
acceptsWorkspaceTabDrop(allowCrossPane, activeData.paneId, overData)
) {
if (overData?.kind === "workspace-tab") {
applyTabDropEnd({ activeData, overData });
} else if (overData?.kind === "split-pane-drop") {
applyPaneDropEnd({ activeData, overData });
}
}
setDropPreview(null);
setTabDropPreview(null);
},
[allowCrossPane, applyTabDropEnd, applyPaneDropEnd],
);
const context = useMemo(
() => ({ activeDragTabId, dropPreview, tabDropPreview, externalDndContext: true }),
[activeDragTabId, dropPreview, tabDropPreview],
);
return (
<DndContext
sensors={sensors}
collisionDetection={dropCollisionDetection}
onDragStart={handleDragStart}
onDragMove={updateDropPreview}
onDragOver={updateDropPreview}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
>
<WorkspaceTabDragContext value={context}>{children}</WorkspaceTabDragContext>
<DragOverlay dropAnimation={null}>
{activeDragTabId ? (
<DragOverlayTabChip
tabId={activeDragTabId}
uiTabs={uiTabs}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
) : null}
</DragOverlay>
</DndContext>
);
}
function DragOverlayTabChip({
tabId,
uiTabs,
normalizedServerId,
normalizedWorkspaceId,
}: {
tabId: string;
uiTabs: WorkspaceTab[];
normalizedServerId: string;
normalizedWorkspaceId: string;
}) {
const tab = uiTabs.find((t) => t.tabId === tabId);
const descriptor = useMemo<WorkspaceTabDescriptor | null>(
() =>
tab
? {
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
}
: null,
[tab],
);
if (!descriptor) {
return null;
}
return (
<DragOverlayTabChipInner
tab={descriptor}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
);
}
function DragOverlayTabChipInner({
tab,
normalizedServerId,
normalizedWorkspaceId,
}: {
tab: WorkspaceTabDescriptor;
normalizedServerId: string;
normalizedWorkspaceId: string;
}) {
const { t } = useTranslation();
return (
<WorkspaceTabPresentationResolver
tab={tab}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
>
{(presentation) => {
const label =
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
return (
<View style={styles.dragOverlayChip}>
<WorkspaceTabIcon presentation={presentation} active size={14} backdrop="surface1" />
<Text numberOfLines={1} style={styles.dragOverlayLabel}>
{label}
</Text>
</View>
);
}}
</WorkspaceTabPresentationResolver>
);
}
const styles = StyleSheet.create((theme) => ({
dragOverlayChip: {
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
maxWidth: 200,
},
dragOverlayLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
flexShrink: 1,
},
}));
@@ -415,11 +415,10 @@ export function WorkspaceDraftAgentTab({
openWorkspaceChanges({
isCompact: isCompactFormFactor,
workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? "" }),
checkout: { serverId, cwd: composerState.workingDir, isGit: true },
preferences: openInSidePane,
});
},
[composerState.workingDir, isCompactFormFactor, openInSidePane, serverId, workspaceId],
[isCompactFormFactor, openInSidePane, serverId, workspaceId],
);
const {
+1 -1
View File
@@ -33,7 +33,7 @@ export {
getIsElectronMac as getIsElectronRuntimeMac,
} from "./platform";
/** Compact layouts are the breakpoints below `md`. */
/** Below 600 points, sidebars overlay the workspace. Medium and wider layouts dock them. */
export function isCompactBreakpoint(breakpoint: string | undefined): boolean {
return breakpoint === "xs" || breakpoint === "sm";
}
@@ -36,7 +36,6 @@ import {
useSessionStore,
type SessionState,
} from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { sendOsNotification } from "@/utils/os-notifications";
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
import {
@@ -168,11 +167,6 @@ function resolveAgentAttentionNotification(
});
}
type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_progress" }
>["payload"];
function notifyVoiceAbortFailure(
data: Extract<SessionOutboundMessage, { type: "activity_log" }>["payload"],
notifyError: (message: string) => void,
@@ -225,7 +219,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync);
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
// Track focused agent for heartbeat
const focusedAgentId = useSessionStore(
@@ -365,13 +358,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
}, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]);
const applyWorkspaceSetupProgress = useCallback(
(payload: WorkspaceSetupProgressPayload) => {
upsertWorkspaceSetupProgress({ serverId, payload });
},
[serverId, upsertWorkspaceSetupProgress],
);
useEffect(() => {
const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents);
const onStream = (message: SessionOutboundMessage) => {
@@ -508,7 +494,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
"agent_permission_resolved",
"agent.provider_subagents.update",
"checkout_status_update",
"workspace_setup_progress",
"status.server_info",
],
{ notifications: true },
@@ -547,11 +532,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, message });
});
const unsubWorkspaceSetupProgress = onFeed("workspace_setup_progress", (message) => {
if (message.type !== "workspace_setup_progress") return;
applyWorkspaceSetupProgress(message.payload);
});
const unsubStatus = onFeed("status", (message) => {
if (message.type !== "status") return;
const serverInfo = parseServerInfoStatusPayload(message.payload);
@@ -710,7 +690,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubProviderSubagentUpdate();
unsubAgentAttention();
unsubCheckoutStatusUpdate();
unsubWorkspaceSetupProgress();
unsubStatus();
unsubPermissionRequest();
unsubPermissionResolved();
@@ -732,7 +711,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setAgents,
setPendingPermissions,
notifyAgentAttention,
applyWorkspaceSetupProgress,
updateSessionServerInfo,
toast,
voiceRuntime,
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { useFocusEffect } from "expo-router/react-navigation";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowUpRight, Check, Terminal } from "lucide-react-native";
import { Button } from "@/components/ui/button";
@@ -4,7 +4,7 @@ import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/s
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import { getCM, vim } from "@replit/codemirror-vim";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isRenderedMarkdownFile } from "@/file-panels";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { FileEditorModel } from "./model";
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
+56 -128
View File
@@ -1,20 +1,13 @@
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ScrollView as RNScrollView, Text, View } from "react-native";
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
import { FilePanelPreview, resolveFilePreviewPanel, type FilePanel } from "@/file-panels";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { getFileNameFromPath } from "@/attachments/utils";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
@@ -28,8 +21,6 @@ import { useLiveFile } from "./live-file/hook";
import { useFilePreview } from "./preview-lifecycle/hook";
import { resolveFilePreviewLifecycle } from "./preview-lifecycle/model";
import { FilePanelBar } from "./bar";
import { FileHtmlPreview } from "./html-preview";
import { FileMarkdownPreview } from "./markdown-preview";
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
import { createFileObservationSource } from "./editor/observation-source";
import { FileEditorView } from "./editor/view";
@@ -39,7 +30,25 @@ import type { LiveFileModel } from "./live-file/model";
import { confirmDialog } from "@/utils/confirm-dialog";
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
import type { Theme } from "@/styles/theme";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
// CodeMirror and native source rendering consume appearance as props. Subscribe at the
// rendering leaf so theme updates reconfigure it without replacing the editor/model.
const sourceAppearance = (theme: Theme) => ({
theme: {
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
},
});
const ThemedFileEditorView = withUnistyles(FileEditorView, sourceAppearance);
const ThemedFileSourceView = withUnistyles(FileSourceView, sourceAppearance);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -47,6 +56,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
});
interface FilePreviewBodyProps {
panel: FilePanel | null;
preview: ExplorerFile | null;
mode?: "preview" | "source";
isLoading: boolean;
@@ -90,31 +100,14 @@ function ReadonlySource({
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const theme = UnistylesRuntime.getTheme();
const { t } = useTranslation();
const visualTheme = useMemo(
() => ({
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
}),
[theme],
);
return (
<FileSourceView
<ThemedFileSourceView
content={preview.content ?? ""}
filename={filename}
location={location}
navigationRevision={navigationRevision}
size={preview.size}
theme={visualTheme}
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
/>
);
@@ -131,6 +124,7 @@ function TooLargeSource({ size }: { size?: number }) {
}
function FilePreviewBody({
panel,
preview,
mode,
isLoading,
@@ -144,14 +138,6 @@ function FilePreviewBody({
}: FilePreviewBodyProps) {
const { t } = useTranslation();
const filePath = location.path;
// A line target means the caller wants to land on that line, so fall back to
// the highlighted source view even for renderable files.
const renderKind =
preview?.kind === "text" && !location.lineStart && mode !== "source"
? filePreviewRenderKind(filePath)
: null;
const previewScrollRef = useRef<RNScrollView>(null);
if (isLoading && !preview) {
return (
@@ -170,36 +156,21 @@ function FilePreviewBody({
);
}
const showSource = preview.kind === "text" && (Boolean(location.lineStart) || mode === "source");
if (panel && !showSource) {
return (
<FilePanelPreview
panel={panel}
content={preview.content ?? ""}
filePath={filePath}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
imagePreviewUri={imagePreviewUri}
/>
);
}
if (preview.kind === "text") {
if (renderKind === "html") {
// The HTML document owns its own scrolling, so no ScrollView wrapper here.
return (
<View style={styles.previewScrollContainer}>
<FileHtmlPreview html={preview.content ?? ""} testID="file-html-preview" />
</View>
);
}
if (renderKind === "markdown") {
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
showsVerticalScrollIndicator
>
<FileMarkdownPreview
source={preview.content ?? ""}
filePath={filePath}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
/>
</RNScrollView>
</View>
);
}
return (
<ReadonlySource
preview={preview}
@@ -210,19 +181,6 @@ function FilePreviewBody({
);
}
if (preview.kind === "image") {
if (!imagePreviewUri) {
return (
<View style={styles.centerState}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
);
}
return <ZoomableImage uri={imagePreviewUri} testID="image-file-preview" />;
}
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
@@ -243,6 +201,7 @@ export function FilePane({
navigationRevision: number;
}) {
const { t } = useTranslation();
const { settings } = useAppSettings();
const isMobile = useIsCompactFormFactor();
const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview");
@@ -292,7 +251,12 @@ export function FilePane({
const { file: preview, imageAttachment } = resolveFilePreviewLifecycle(previewLifecycle);
const imagePreviewUri = useAttachmentPreviewUrl(imageAttachment);
const isRenderable = isRenderablePreview(preview, location.path);
const panel = resolveFilePreviewPanel({
preview,
path: location.path,
associations: settings.filePanelAssociations,
});
const isRenderable = preview?.kind === "text" && panel !== null;
const editable = isEditableTextFile({
preview,
supportsEditing,
@@ -308,6 +272,7 @@ export function FilePane({
return (
<FilePanePresentation
panel={panel}
serverId={serverId}
workspaceRoot={normalizedWorkspaceRoot}
client={client}
@@ -333,10 +298,6 @@ export function FilePane({
);
}
function isRenderablePreview(preview: ExplorerFile | null, path: string): boolean {
return preview?.kind === "text" && filePreviewRenderKind(path) !== null;
}
function isEditableTextFile(input: {
preview: ExplorerFile | null;
supportsEditing: boolean;
@@ -350,6 +311,7 @@ function isEditableTextFile(input: {
}
function FilePanePresentation({
panel,
serverId,
workspaceRoot,
client,
@@ -372,6 +334,7 @@ function FilePanePresentation({
navigationRevision,
imagePreviewUri,
}: {
panel: FilePanel | null;
serverId: string;
workspaceRoot: string;
client: DaemonClient | null;
@@ -407,6 +370,7 @@ function FilePanePresentation({
if (editable && client && readTarget && preview?.kind === "text") {
return (
<EditableFilePane
panel={panel}
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
serverId={serverId}
workspaceRoot={workspaceRoot}
@@ -459,6 +423,7 @@ function FilePanePresentation({
/>
) : null}
<FilePreviewBody
panel={panel}
preview={preview}
mode={previewMode}
isLoading={isLoading}
@@ -475,6 +440,7 @@ function FilePanePresentation({
}
function EditableFilePane({
panel,
serverId,
workspaceRoot,
client,
@@ -492,6 +458,7 @@ function EditableFilePane({
location,
navigationRevision,
}: {
panel: FilePanel | null;
serverId: string;
workspaceRoot: string;
client: DaemonClient;
@@ -546,34 +513,6 @@ function EditableFilePane({
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]);
usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave });
const theme = UnistylesRuntime.getTheme();
const visualTheme = useMemo(
() => ({
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
}),
[
theme.colors.border,
theme.colors.foreground,
theme.colors.foregroundMuted,
theme.colors.surface0,
theme.colors.syntax,
theme.colors.terminal.cursor,
theme.colors.terminal.selectionBackground,
theme.colorScheme,
theme.fontFamily.mono,
theme.fontSize.code,
],
);
useEffect(() => () => model.dispose(), [model]);
const handleReload = useCallback(() => {
@@ -627,18 +566,18 @@ function EditableFilePane({
onModeChange={onModeChange}
/>
{showSource ? (
<FileEditorView
<ThemedFileEditorView
model={model}
filename={filename}
location={location}
navigationRevision={navigationRevision}
vimEnabled={settings.vimKeybindings}
theme={visualTheme}
onCursorChange={setCursor}
onVimModeChange={handleVimModeChange}
/>
) : (
<FilePreviewBody
panel={panel}
preview={renderedPreview}
mode={mode}
isLoading={isLoading}
@@ -708,15 +647,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
previewScrollContainer: {
flex: 1,
minHeight: 0,
},
previewContent: {
flex: 1,
minHeight: 0,
},
previewCodeScrollContent: {
padding: theme.spacing[4],
},
}));

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