Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] c17bd1e614 docs(voice): scope prompt hints and translate the new section
Vocabulary hints apply to compatible transcription endpoints; local speech
providers ignore them. Keep the new section available at the same anchor
in both documentation languages.
2026-11-06 03:20:13 +00:00
d0zingcatandJoey Hotz f898537233 fix(dictation): keep transcription context opt-in
An English instruction used as transcript context caused the transcription
model to discard Portuguese list items. Omit built-in context and retain
explicit vocabulary overrides, including an empty override to disable it.

Co-authored-by: d0zingcat <d0zingcat@users.noreply.github.com>
Co-authored-by: Joey Hotz <joeyhotz1@gmail.com>
2026-11-06 03:13:21 +00:00
Mohamed BoudraandTianyu Yang 3ed57e9dd7 Change a worktree workspace base from Changes (#8642)
Change a worktree workspace's base from the Changes pane. The choice persists and diffs, counts, commits and Git actions follow it.

Supersedes #6872.

Co-authored-by: Tianyu Yang <yangtianyu.cl@bytedance.com>
2026-11-05 22:30:14 +07:00
Mohamed BoudraandElsa Granger e9f897a66a fix(app): rest the iPhone composer at the screen's bottom clearance (#8823)
* fix(app): rest the iPhone composer at the screen's bottom clearance

The composer sat 50pt above the bottom edge on every iPhone: the dock
padded the rectangular home-indicator safe area (34pt) and the composer
stacked another 16pt under it. On iPhone Duo that safe area is also
taller than needed; iOS 27.1's bottom-bar layout guide, which follows
the screen's corners, rests 26pt from the edge.

The native window-layout probe now publishes that clearance, and
WindowLayoutProvider overrides the safe-area context's bottom with it,
so every useSafeAreaInsets() consumer reads one bottom value and null
falls back to the system inset. The docked composer applies
max(bottom, 16) once and keeps 16pt above the keyboard through the
translate view's transform; the gap is not animated as padding because
layout updates through the keyboard transition are expensive on Android
Fabric.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): keep the Import session gap under the wide new workspace composer

The composer's own bottom spacing used to separate it from the Import
session pill on the wide form. With that spacing moved to the dock, the
centered slot padded below the pill instead, so the pill touched the
composer. The pill now owns its gap above.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

---------

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
2026-11-05 21:33:17 +07:00
Mohamed Boudra b0a8a5db6d Stop the double reconnect when returning to the app (#8855)
* fix(app): keep the selected connection when it reconnects during a probe cycle

When the app returns from the background with more than one saved
connection and the selected socket was closed meanwhile, the probe cycle
marks the selected connection unavailable at probe start, while its client
is still reconnecting. The alternative probe lands later, after the
selected client has already reconnected, and finalizing the cycle switched
to the alternative using that stale verdict. That disposed the live client
(rejecting the in-flight timeline catch-up) and reconnected a second time.

The cycle now reconciles the selected connection's verdict with its
client's current state before deciding to fail over. A client that is
still down at that point still fails over as before.

* test(app): cover reconnect with and without a heartbeat RTT
2026-11-05 21:30:09 +07:00
Mohamed Boudra 412550c36f Keep active transfers connected when the app returns to foreground (#8651)
* fix(client): keep active transfers alive during foreground checks

Foreground used an independent three-second probe that closed live connections while a pong waited behind bulk data. Share the heartbeat clock and refresh its outstanding deadline on validated inbound frames, including incomplete chunk assemblies.

* test(client): deliver bulk response before the queued pong

* fix(client): preserve latency deadlines when sharing heartbeat pings

* fix(client): keep heartbeat cadence during active downloads
2026-11-05 20:57:13 +07:00
paseo-ai[bot] e6064bb374 fix: update lockfile signatures and Nix hash [skip ci] 2026-11-05 11:58:31 +00:00
Mohamed Boudra 59be8975a4 Dismiss a failed agent's red dot by reading it (#8831)
* fix(sidebar): clear the failed status once the error is read

Reading an errored agent already cleared its error attention, but the
state bucket also mapped lifecycle status "error" to failed. Lifecycle
only leaves "error" when the next turn starts, so the red dot and the
Failed group stuck until the agent was prompted again. The failed bucket
now follows unread error attention, the same way the green dot follows
unread finished attention; lifecycle status stays "error".

Because failed now depends on attention, a failure must replace unread
finished attention, otherwise a reload that fails on an unread agent
would show green.

* fix(sidebar): keep failed subagents red and let read failures be marked unread

The failed bucket now follows unread error attention, which broke three
places that relied on lifecycle "error" alone:

- Subagent track rows and provider-subagent tabs have no read state of
  their own, so failed children lost their red mark. They now map the
  child's lifecycle directly.
- A read errored workspace became "done" and offered Mark as unread, but
  the daemon only accepted idle or closed agents. Marking it unread now
  restores error attention.
- A reload that failed again while the lifecycle was already "error"
  raised no new attention. A failed reload now raises error attention
  directly instead of relying on a lifecycle transition.
2026-11-05 18:49:12 +07:00
paseo-bot[bot] cdd7800fed test(app): wait for workspace hover cards to settle (#8846)
Keyboard focus can briefly mount the next card while the previous card closes. Poll uniqueness and visibility together so the assertion tolerates that transition but still rejects persistent duplicates.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-05 09:46:14 +00:00
Mohamed Boudra 902d43da7c chore(release): reconcile v0.15.2 into main (#8845)
* fix(app): hold the loading screen until first-run setup is decided

A new user saw the empty home, the sidebar and a logo jump before setup
popped in. Startup now keeps the splash while setup may still open over
the empty home, for at most 3 s and only while the setup host is
reachable. Remembered-workspace restore does not wait. Web dialogs now
fade in as they already fade out.

(cherry picked from commit a0a7620b62)
(cherry picked from commit 72d7c0bf26)

* fix(app): fade the dialog card in and land the dim at once

(cherry picked from commit 6ef05705a0)
(cherry picked from commit 71c28b431c)

* fix(app): keep the loading screen up while startup redirects

(cherry picked from commit 0b28d4cbff)
(cherry picked from commit 13cc485e6f)

* test(app): cover a setup host that drops while startup waits

(cherry picked from commit 2a906ef83e)
(cherry picked from commit 7b6e21207a)

* fix(app): type the web-only dialog enter animation style

The CSS animation props are not part of ViewStyle. CI typechecks without the
generated expo-env.d.ts, which declares them, so the cast is required there.

(cherry picked from commit b887d979d2)

* fix(app): keep the setup window mounted when the theme changes

The shell's appearance boundary remounts its dialog hosts whenever a theme
color changes, so picking a theme in setup, or a system light/dark switch,
replaced the open setup window and replayed its fade. Setup draws colors
through CSS variables and themed props, which update in place, and changes
no font size, so it now sits outside that remount.

The dialog enter fade is skipped with reduced motion, and its style is typed
for CI, which typechecks without the generated expo-env.d.ts.

(cherry picked from commit b24c6eacf8)
(cherry picked from commit cb0e9907f5)

* docs(changelog): prepare 0.15.2

(cherry picked from commit 5af1a8ff66)

* chore(release): cut 0.15.2

(cherry picked from commit e896c05b09)
2026-11-05 16:40:53 +07:00
92c8c55f31 Fix registry plugin installs through npm mirrors (#8756)
* fix(plugins): install registry npm pins through the configured registry

Registry npm pins carry an npmjs.org tarball URL. Passing that URL to npm made it rewrite the host onto the configured registry and drop any path, so mirrors such as https://mirrors.tencent.com/npm/ returned 404. Install the pinned version instead and identify the reviewed artifact by version and integrity, since a mirror records its own tarball URL.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(plugins): address review on npm artifact comparison

Take an object parameter in isSameNpmArtifact, and clean up the mirror fixture when test setup fails.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(plugins): verify mirror artifacts with a warm npm cache

A shared npm cache lets the mirror regression reuse tarballs from earlier
fixture installations, so successful installs made no tarball requests.
Check the installed artifact's mirror URL, version and integrity instead
of requiring network transfers.

Co-authored-by: finofliu <finofliu@tencent.com>

---------

Co-authored-by: finofliu <finofliu@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-05 08:59:05 +00:00
Mohamed Boudra b920243c74 docs(release): dispatch the instant-admit flip only after the release is published (#8816)
A draft release has no tag, so dispatching desktop-rollout.yml before finalize-rollout publishes it fails with "release not found". Seen on v0.15.1.
2026-11-05 11:00:04 +07:00
paseo-ai[bot] 9667ec5576 fix: update lockfile signatures and Nix hash [skip ci] 2026-11-05 03:35:49 +00:00
Mohamed Boudra 72955cef60 chore(release): reconcile 0.15.1 into main (#8814)
Brings the shipped 0.15.1 changelog entries, version, and F-Droid changelog files from release/v0.15.1 into main.
2026-11-05 10:26:47 +07:00
Mohamed Boudra 8dfdb479ab fix(terminal): start the daemon when the terminal worker cannot start (#8810)
On 0.15.0 the daemon launches the detached terminal worker while it is
being created, and the worker refuses to start unless it can read its own
birth time and command line (Windows PowerShell 5.1 `Get-CimInstance`,
`ps` elsewhere). Any failure of that query used to escape
startWorkerTerminalManager and take the whole daemon down with
"Daemon failed to start (exit 1)", as two Windows machines hit after the
update: one whose PowerShell 5.1 cannot load CimCmdlets, one where
powershell.exe cannot be spawned at all. 0.14.0 never ran that query at
startup.

The launch error is now logged and the daemon starts without a current
worker. The pool already launches one lazily on the first terminal
creation, so that request reports the same error while the machine still
cannot run the query, and terminals come back once it can. The identity
requirement itself is unchanged: a worker without a verified identity is
never written or adopted.
2026-11-05 08:59:04 +07:00
Mohamed Boudra 5d1a2c10f8 test(app): wheel the thinking group into view before opening it (#8791)
The "reasoning follows Expand thinking (false)" spec clicked a tool-call
group above the viewport. Playwright's scroll-into-view is programmatic,
and the web timeline only leaves sticky-bottom on user input (wheel, key,
touch, scrollbar), so it snapped back to the bottom while the click was
in flight and the group never opened. Wheel the group into view first,
reusing the helper the timeline pagination specs already use for
prompts.
2026-11-04 16:35:23 +01:00
paseo-ai[bot] a91bb4d406 fix: update lockfile signatures and Nix hash [skip ci] 2026-11-04 15:01:56 +00:00
Mohamed Boudra 15997a06d0 fix(ci): pin semver away from incomplete attestations (#8787)
The pkijs lockfile regeneration bumped tiny-async-pool's semver to 5.7.3
and app-builder-lib's @electron/get semver to 6.3.2, both published
2026-11-02 in one batch. Some Cloudflare edges cached the attestation
document for 5.7.3 before the publish attestation attached, so
`npm audit signatures` fails with EMISSINGSIGNATUREKEY depending on the
runner's region. Pin both back to the 2023 releases with per-parent,
range-keyed overrides so global-agent's semver ^7 under @electron/get
is left alone.
2026-11-04 15:54:29 +01:00
paseo-bot[bot]andsino 2d5b8eb1fd Handle web terminal finger scrolling once (#8776)
* fix(app): scroll web terminal history with a finger swipe

A phone browser claims the vertical drag and drops it, because the canvas covers the scrollable viewport and the page itself does not scroll. Keep the gesture in the touch handler and move one line per cell.

* fix(app): handle terminal finger scrolling once

Keep the vertical gesture in the existing terminal handler so xterm does not also scroll it at document level. Retain only the confirmed propagation correction from the contributor patch and cover the actual phone-sized app with trusted touch input.

Co-authored-by: sino <sino2322@gmail.com>

* fix(app): let xterm own terminal touch input

Remove the duplicate gesture implementation so history scrolls once and terminal applications retain arrow-key and mouse-report input. Cover live application modes with trusted touch gestures and a real raw-stdin process.

---------

Co-authored-by: sino <sino2322@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 13:17:32 +00:00
Mohamed BoudraandElsa Granger ac775a2904 Reveal collapsed navigation as a touch overlay on wide layouts (#8739)
* feat(app): swipe open collapsed tablet navigation

Reuse the retained sidebar and mobile panel motion for a partial-width
navigation overlay when the wide dock is hidden. Preserve the dock
preference, project gestures through the sidebar width, and dismiss the
overlay when revealing workspace content.

Reset the animated frame width explicitly across dock transitions and
keep the resize origin on the UI thread.

Validated on Duo portrait and landscape, iPad portrait and landscape,
iPhone full-width navigation, and browser sidebar interactions.
Typecheck, lint, targeted formatting, and 31 relevant tests passed.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): refine touch navigation overlays

Enable navigation swipes only on touch-capable runtimes and reject mouse
and pen input per gesture event. Allow swiping from anywhere in the
workspace instead of requiring an edge start.

Cover the full application window with the backdrop, including safe-area
padding, and remove the wide overlay's inline close button. Preserve
swipe, backdrop, and destination-selection dismissal.

Validated interior swipes on Duo and iPad, mouse exclusion in a browser,
and full-window dimming and dismissal in Duo portrait and landscape.
Typecheck, lint, and targeted formatting passed.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* refactor(app): let the panel store drop the overlay target on dock

Docking the sidebar, or any layout that no longer presents it as an
overlay, now drops the transient agent-list target inside the panel
store. App chrome publishes one left sidebar presentation (overlay,
dock, hidden) instead of an effect in SidebarChrome watching flags.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* refactor(app): answer fine pointer and touch screen from one module

Merge use-fine-pointer and use-has-touch-screen into
hooks/pointer-capabilities, which reads both answers from the media
queries it subscribes to. The touch answer now comes from
(any-pointer: coarse) instead of navigator.maxTouchPoints, which that
query's change events never reported.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): keep the phone web drawer on its edge swipe

Restore the 32px left-edge start for the compact web navigation drawer,
so interior rightward swipes over terminals, code blocks, and diffs stay
with their horizontal scrollers. The wide navigation overlay still opens
from anywhere. The edge rule moves into resolveMobilePanelGestureIntent
so both drawers share it and it has a test.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* refactor(app): one sidebar handler per destination

Settings, host settings, add host, add project, and every navigation
row now use one handler that closes the navigation overlay before it
navigates. Closing is idempotent, so the docked sidebar shares it and
the overlay ? mobile : desktop handler pairs go away.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* refactor(app): resolve the left panel width in one place

MobilePanelsProvider now resolves the left sidebar width (full window
on compact, the saved width clamped to the safe viewport on wide) and
exposes it through the panel runtime. The open and close gestures, the
overlay frame, and the docked sidebar all read that value, so gesture
travel and panel width cannot disagree. MobileGestureWrapper no longer
recomputes it, and the panelWidth/width parameters go away.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* docs: fold the wide navigation overlay into the mobile panel rules

Move the facts the overlay PR appended into the sections that own them:
the destinations intro, store ownership of the agent-list target, the
projection widths, gesture pointer and edge rules, and the sidebar
presentation bullets.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): keep the sidebar pans live across dock and overlay switches

On an Android tablet, opening the dock with the header toggle and collapsing
it again left the touch swipe unable to open the overlay until the app
reloaded. Swipe-to-close from the sidebar also stopped working after the
same toggle cycle.

Both pans took the dock/overlay state through RNGH's `.enabled()`. On
Android, RNGH 2.32 cancels the native handler when `isEnabled` changes and
resets its config on every update. After a false -> true flip the handler
stayed inert. `.enabled()` now carries only the device capability (touch
screen present), which does not change at runtime. The dock/overlay state
moves to a UI-thread derived value that fails the gesture in
onTouchesDown, next to the existing non-touch pointer check.

The wide sidebar's workspace list now always receives the close gesture
ref, as the compact sidebar already does. Toggling it between undefined
and the ref rewired the list's simultaneous handlers on every dock switch,
and that also left the close pan dead after a toggle cycle.

This is a native RNGH handler behavior, so there is no unit repro. It was
verified on the Android fold emulator: toggle open, toggle closed, swipe
opens the overlay, swipe on an empty sidebar strip closes it, no reload.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): keep the workspace full width after collapsing the dock

On an Android tablet, opening the dock with the header toggle and
collapsing it again could leave the workspace at the docked width, with a
blank strip on the right, until the overlay was next opened. Elsa's
original head showed the same.

The sidebar's panel host flips between an in-flow dock and an absolute
overlay. Its width came from a Reanimated style that read the React
`overlay` flag, so the width reached the native view on the UI thread
after React had already committed the new presentation. Layout logs on
the fold emulator show the lag on every toggle-open: the workspace laid
out at width 0 while the docked host still had the overlay's full window
width, then a second pass laid out the 320dp dock. When the late update
was lost on collapse, the workspace kept its dock width.

The host no longer has a width of its own:
- The dock sizes to the inner sidebar view, which already carries the
  animated resize width.
- The overlay host is absoluteFill, which covers the whole row including
  the safe-area padding.

With no animated layout prop on the host, a presentation switch is one
React commit, and the host and the workspace lay out in the same pass.

Device evidence on the fold emulator:
- Toggle open and collapse cycles: the host and the workspace lay out
  together (320/521dp, then 841/841dp).
- A swipe opens the 320dp overlay with a full-window backdrop, and a
  swipe on the sidebar closes it.
- Dragging the dock edge still resizes the dock and the workspace
  together.

There is no unit repro: the defect is the ordering of Reanimated's
UI-thread layout updates against React commits.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): cancel pending sidebar swipes when docking

An opening swipe still has the agent target until release. Leaving overlay presentation must advance its revision even then, so motion ownership and queued commits reject the obsolete swipe.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): keep mouse selection free of the touch-only pan's capture

On a touch-capable browser the navigation pan registers pointer listeners
over the whole workspace. Gesture Handler's web pointer manager captured
the pressed element before the handler saw the pointer, and captured it
again on every move, so a mouse drag that the pan rejects stayed captured
and the browser's selection stopped at the starting paragraph.

The patched manager now captures after pointer-down, and only a pointer the
handler still tracks. The resize handle and touch swipes keep capturing.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): drop the compact Explorer target when the layout widens

Opening the Explorer on a compact layout selects the `file-explorer` mobile panel target. Widening
the window (unfolding a foldable, rotating a tablet) kept that target, because the store only
heard the left sidebar presentation, which is `overlay` on both compact layouts and wide layouts
with a collapsed dock. The navigation swipe begins only from `agent`, so on the wide layout every
swipe was rejected until something else reset the target.

App chrome now publishes the whole panel layout (compact or wide, plus the left sidebar
presentation) and the store drops any target that layout does not present.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): keep wide overlay sidebar content inside the left safe area

The overlay starts at the window's left edge, over the workspace's
safe-area padding, so its content now takes the left inset itself. The
dock already sits inside app chrome's padding and stays unchanged.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): return an interrupted swipe to its anchor when docking cancels it

Docking mid-swipe bumps the panel revision while keeping the agent
target. The command treated the unchanged target as motion already in
progress and skipped the animation, leaving the dragged position
stranded between anchors. A command that interrupts a drag now always
animates to its target.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): synchronize startup panel selection and gate pending swipes

A child layout effect can publish panel layout before the motion provider subscribes. Establish the current selection when subscribing, then observe later commands. Recheck presentation eligibility before gesture activation so docking also cancels touches that have not started dragging.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(app): cover panel subscription through its public contract

Verify initial snapshot delivery, later commands, and unsubscription directly in the existing panel-store suite instead of mounting test-only React components.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(app): reopen compact navigation after leaving settings

Destination navigation clears the transient overlay selection. Reopen the compact drawer before asserting its configured footer rather than relying on a stale open target surviving a wide layout.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(app): open navigation by keyboard in composer focus checks

Navigation pans intentionally reject mouse drags. Use the keyboard sidebar command to exercise focus and fullscreen Escape behavior without relying on the former mouse swipe helper.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): complete sidebar integration after main rebase

Retain the resize width resolver and format the shared division-width and hybrid touch-sizing integration.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(desktop): give split browser panes room for tab rails

After #8740, narrow panes replace tab rails with compact dropdowns. The desktop browser journey counts and selects rail tabs, so widen the real Electron content window before splitting instead of treating missing narrow-pane rails as missing panes.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

---------

Co-authored-by: Elsa <zeyugao@outlook.com>
2026-11-04 13:30:39 +01:00
paseo-bot[bot] 0e7f452c2f Keep duplicate-path setup out of the Git filter CI budget (#8753)
* test(server): prepare duplicate paths before measuring Git filtering

* test(server): clarify the observer path preparation boundary

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 12:25:42 +00:00
Mohamed BoudraandElsa Granger cab5eedfe3 Keep workspace layouts usable in narrow columns across platforms (#8740)
* fix(app): retain Explorer resize width until acknowledged

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): choose workspace header by measured column width

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): separate iOS composer clipping from its shadow

Keep explicit coverage of the existing keyboard rotation guard.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): choose workspace chrome within each desktop pane

The outer width wrapper added a compact header while the desktop split container kept its full header and pane rails. Each pane now owns the complete choice, replacing its header and rail together.

Each split pane uses its own measured width: below 500 points it shows its own tab dropdown, even if the neighboring pane is wide. Pane width determines usable header space. Keep new-tab keyboard ownership outside the switched chrome and retain positive measurements while hidden.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): reserve readable width for the compact tab popover

Native scroll content does not contribute intrinsic width to the anchored frame. The count badge alone allowed the popover to compress its tab list and footer labels.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): preserve compact pane actions and window clearance

Carry the owning pane into compact launch actions, keep split and restore controls in its menu, and distribute window corners through visible split children. Keep tab rails below header-owned window controls.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): retain window dragging in compact panes

Keep the shared titlebar drag region when pane width replaces full desktop chrome. Cover two compact panes with both sidebars closed through the desktop host adapter.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(app): align pane journeys with measured workspace chrome

Give rail-specific split journeys enough column width, assert launched Diff content independently of header shape, and retain pane identity across compact transitions. Expect the compact selector in the half-screen Explorer workflow.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* refactor(app): share pane tab-switcher rendering after header rebase

Keep compact and vertical-bar header actions on the same pane-aware switcher contract while preserving the merged control geometry and script ownership.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): retain Explorer acknowledgements across consecutive drags

Keep earlier released widths as expected allocations while a newer gesture owns the frame, and restore the latest release if that gesture is canceled.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): keep focus mode exit in the compact workspace menu

Carry the workspace focus state into its compact action menu so a narrow header retains an exit without keyboard shortcuts.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): distinguish resize rollback from pending acknowledgements

Restore the last released width when repeated widths precede a canceled gesture, and retain ownership when an unchanged old allocation matches a newer release before its predecessors arrive.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(app): allow software WebGL time to finish atlas growth coverage

The 256-batch pixel regression crosses more than 500 render boundaries and exceeds its 30-second total budget under CPU contention. Keep the corpus, every pixel assertion, and the two-second per-frame deadline; allow 60 seconds for the complete workload. Reproduced the timeout with Chromium confined to one CPU and verified two successful constrained runs after the adjustment.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

---------

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
2026-11-04 12:23:41 +01:00
183 changed files with 5031 additions and 818 deletions
+23
View File
@@ -1,5 +1,28 @@
# Changelog
## 0.15.2 - 2026-11-05
### Added
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step ([#8363](https://github.com/getpaseo/paseo/pull/8363))
### Improved
- Changed the theme picker to a grid of tiles with a preview of each theme ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- Replaced the "Loading…" text in Providers settings with skeleton rows ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- Changed controls to two sizes: default controls are 32px tall on desktop and 44px on phones, and compact controls are 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- Changed first launch to go from the loading screen straight into setup instead of showing the empty home first ([#8824](https://github.com/getpaseo/paseo/pull/8824))
- Changed dialogs on desktop and web to fade in over about 160ms ([#8824](https://github.com/getpaseo/paseo/pull/8824))
- Changed modal and sheet backgrounds to match the screen background ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- Changed documentation links in Settings sections to a `Label ↗` link beside the section title ([#8363](https://github.com/getpaseo/paseo/pull/8363))
## 0.15.1 - 2026-11-05
### Fixed
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run `Get-CimInstance` ([#8810](https://github.com/getpaseo/paseo/pull/8810))
## 0.15.0 - 2026-11-03
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
+23
View File
@@ -1,5 +1,28 @@
# Changelog
## 0.15.2 - 2026-11-05
### 新增
- 在 Paseo 桌面版和网页版新增首次运行引导,依次带新用户设置主题、提供商、项目、Agent 编排和移动端应用,并可通过“设置 → 通用 → Show setup again”重新打开 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- 在引导的“项目”步骤中,根据你运行过 Claude Code、Codex、OpenCode 或 Pi 的文件夹推荐项目 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
### 改进
- 将主题选择器改为带各主题预览的图块网格 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- 将“提供商”设置中的“Loading…”文字替换为骨架占位行 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- 将控件统一为两种尺寸:默认控件在桌面端高 32px、在手机上高 44px,紧凑控件高 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- 将首次启动改为从加载界面直接进入引导,不再先显示空白的主页 ([#8824](https://github.com/getpaseo/paseo/pull/8824))
- 将桌面端和网页版的对话框改为约 160 毫秒的淡入 ([#8824](https://github.com/getpaseo/paseo/pull/8824))
- 将弹窗和面板的背景改为与屏幕背景一致 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
- 将“设置”各分区的文档链接改为分区标题旁的 `Label ↗` 链接 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
## 0.15.1 - 2026-11-05
### 修复
- 修复终端工作进程无法读取自身进程标识时 daemon 无法启动的问题;此前在 Windows PowerShell 5.1 无法运行 `Get-CimInstance` 的 Windows 机器上,Paseo 桌面版因此没有可用的 daemon ([#8810](https://github.com/getpaseo/paseo/pull/8810))
## 0.15.0 - 2026-11-03
daemon 重启后,终端中的程序继续运行。开启 Agent 恢复后,被中断的 Agent 会在原有会话中恢复工作。
+10 -3
View File
@@ -305,9 +305,9 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**.
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
A finished workspace can be marked unread after it has been reviewed. The daemon restores
`finished` attention on its newest eligible workspace-root agent without sending a new completion
notification. Opening the workspace clears that attention through the normal focus flow.
A finished or failed workspace can be marked unread after it has been reviewed. The daemon restores
attention on its newest eligible workspace-root agent, `error` for an agent whose lifecycle is
`error` and `finished` otherwise, without sending a new notification. Opening the workspace clears that attention through the normal focus flow.
Attention is set by the agent finishing or failing and cleared by the client's
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
@@ -316,6 +316,13 @@ untouched. Forging either makes a background resume look like the user read a wo
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start.
The `failed` bucket follows unread `error` attention, not lifecycle status. An agent stays `error`
until its next turn starts, so reading the chat dismisses the red dot the same way it dismisses the
green one. For the same reason every new failure raises `error` attention, including a failed reload
that leaves the lifecycle at `error`, and replaces unread `finished` attention; otherwise the failed
agent would show the green dot or none. Subagents track marks are the exception: they follow the
child's lifecycle, because the track is where a failed child stays visible.
## The subagents track
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
+5 -1
View File
@@ -26,6 +26,10 @@ checkout root. They intentionally differ for an exact subproject inside a worktr
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
An explicit base selection is stored in the checkout's Git-directory Paseo metadata and
mirrored to `baseBranch` in every workspace record sharing that checkout. The record keeps
recovery possible after the checkout metadata is deleted. A newly opened workspace inherits
the checkout's selected base; separate worktrees keep separate bases.
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
@@ -470,7 +474,7 @@ Array of workspace records. A workspace is a specific working directory within a
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
| `baseBranch` | `string \| null` | Comparison base retained across archive and restore. Branch-off creation stores the resolved ref; legacy and PR-checkout records hold a bare name. Null means no recorded base. |
| `baseBranch` | `string \| null` | Current base retained across archive and restore. Explicit selections and branch-off creation store exact refs; older records may store branch names. Null means no recorded base. |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `createdAt` | `string` (ISO 8601) | |
+2
View File
@@ -169,6 +169,8 @@ Electron window controls are top-corner obstructions, not a compact-layout condi
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
Each window edge has one safe-area owner. `WindowLayoutProvider` publishes the bottom inset that every surface reads through `useSafeAreaInsets()`: on iOS 27.1 and later it is UIKit's bottom-bar clearance, which refines the rectangular home-indicator inset to the screen's corners, and elsewhere the system inset. Surfaces apply that value once and never stack their own spacing under it; `WindowLayoutSurface` owns the horizontal insets the same way.
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
---
+10 -1
View File
@@ -594,7 +594,16 @@ rerun passes only by landing elsewhere.
To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo
suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or
replace that version in the lockfile. If the dependent’s range cannot be changed, pin a clean
version in root `overrides` and regenerate the lockfile. Report the URL to npm so they purge it.
version in root `overrides`. Report the URL to npm so they purge it.
A lockfile regeneration is itself how new bad versions enter: the pkijs fix re-resolved the
electron-builder tree and pulled in `semver` 5.7.3 and 6.3.2, published two days earlier, and
5.7.3 then failed the same way. After editing `overrides`, run `npm install --package-lock-only`
and check that the changed `resolved` lines in `package-lock.json` are only the ones you meant.
A per-parent override such as `"tiny-async-pool": { "semver": "5.7.2" }` applies to that
parent's whole subtree, so it can also downgrade a grandchild that needs a different major. Key
the child by range (`{ "semver@^5": "5.7.2" }`) so only matching edges are replaced.
## ACP provider catalog versions
+5
View File
@@ -172,6 +172,11 @@ slide under the header on open and out from under it on close. Do not swap the
dock to keyboard padding at rest. Changing its height creates either a blank
band or a paused transition when the stream list updates its viewport.
The docked composer rests at the larger of the safe-area bottom and 16pt. With
the keyboard open it keeps 16pt above the keyboard through the translate view's
`keyboardGap`, which rises further than the keyboard's safe-area overlap. Do not
grow the composer's bottom padding with the keyboard instead.
Do not animate a layout prop through the keyboard transition. On Android
Fabric, each animated layout update clones the shadow tree and runs Yoga over
the dock subtree. This measured about 10 ms per frame on the emulator and the
+39 -10
View File
@@ -9,6 +9,10 @@ Compact layouts have three mutually exclusive destinations:
They are one interaction, not two independent drawers. The implementation lives in
`packages/app/src/mobile-panels/`.
Wide layouts reuse `agent-list` for the navigation overlay. When the left dock is collapsed or
yields its width, a touch swipe reveals the same sidebar over the workspace at the resolved sidebar
width. The workspace does not move, and the saved dock preference does not change.
## Ownership
React/Zustand owns the durable intent:
@@ -23,6 +27,12 @@ interface MobilePanelSelection {
Every semantic target change increments `revision`. Repeating the current target is idempotent.
Compact panel selection is not persisted; a cold start begins at `agent`.
Only an overlay sidebar holds `agent-list`, and only a compact layout holds `file-explorer`. App
chrome publishes the layout (compact or wide, and the left sidebar presentation `overlay`, `dock`, or
`hidden`) to `panel-store`, which drops any target the layout does not present. A stale
`agent-list` would reopen the overlay on the next collapse; a stale `file-explorer` after widening
would block the navigation swipe, which begins only from `agent`.
The UI worklet owns transient motion:
- one normalized position (`-1` left, `0` center, `1` right)
@@ -32,8 +42,10 @@ 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. The Gesture Handler web patch keeps initially disabled hosts
from capturing paragraph pointers; that capture confines browser selection to its starting paragraph.
The gesture hosts stay mounted. A pointer capture on a paragraph confines browser selection to that
paragraph, so the Gesture Handler web patch keeps it off: initially disabled hosts register no
pointer listeners, and an enabled host captures only a pointer its handler still tracks after
pointer-down. The touch-only navigation pans fail a mouse at pointer-down and never capture it.
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
@@ -43,8 +55,11 @@ appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-them
## Why one position
Both transforms and both backdrop opacities are derived from the same normalized position. Window
width is only a projection input. Rotation changes the projection, not the panel state.
Both transforms and both backdrop opacities are derived from the same normalized position. Panel
width is only a projection input: the window width on the right, and on the left the width
`MobilePanelsProvider` resolves (the full window on compact layouts, the saved sidebar width
clamped to the viewport on wide ones). The dock, the overlay, and their gestures read that one
value. Rotation changes the projection, not the panel state.
This makes these invalid states unrepresentable:
@@ -62,7 +77,9 @@ A gesture captures the current revision when it becomes active. Per-frame update
while that revision still owns the gesture.
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
motion toward its target, even an unchanged one: docking mid-swipe keeps `agent` and still returns
the dragged position to its anchor. The older gesture's remaining updates and finish callback are
ignored.
Canceled gestures return to the latest canonical target. A successful gesture starts its finishing
motion immediately; the matching React command adopts that motion instead of restarting it. Position
settlement and command acceptance may arrive in either order. Activity changes after both agree on
@@ -80,6 +97,12 @@ definition, no longer eligible to begin.
- Callers request semantic targets through `panel-store`; they never write shared values.
- Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`.
- Navigation gestures accept touch pointers only. Mouse and pen fail at touch-down, so dragging a
mouse to select text on a hybrid laptop never moves a panel. Browsers without a touch screen
(`hooks/pointer-capabilities`) never enable them.
- Phone web opens a drawer only from the 32px edge it slides in from, so interior swipes stay with
terminals, code blocks, and diff scrollers. Native drawers and the wide overlay open from anywhere.
Horizontal-scroll ownership and the open-gesture blocker still take precedence.
- A focused interactive surface may block panel-opening gestures through
`useBlockMobilePanelOpenGestures`. Register only while the conflicting interaction is active and
unregister when the surface is hidden or unfocused. The blocker never disables gestures that close
@@ -87,11 +110,17 @@ definition, no longer eligible to begin.
- Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation
across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View`
so its injected `collapsable={false}` reaches Android/Fabric.
- Mobile sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion
styles in sidebar components.
- The desktop left sidebar is retained too. App chrome owns separate mounted and visible decisions:
closing it or yielding its width marks it inactive and applies `display: none` without conditionally
removing the sidebar tree.
- Sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion styles
in sidebar components. The wide left sidebar keeps the same content and gesture hosts when switching
between dock and overlay. App chrome owns mounting and presentation; settled panel activity owns
overlay work. A hidden dock is inactive with `display: none`; the sidebar tree stays mounted. Keep
the native overlay laid out while hidden, as on compact layouts. Give the switching host no
Reanimated layout props: a width keyed on the presentation reaches Android after React commits the
switch, and a lost update left the workspace at the dock width. The dock sizes to its content and
the overlay host fills the window.
- The overlay's backdrop covers the whole window; safe-area padding applies to the sidebar and
workspace content only. It closes by swiping back, tapping the backdrop, or picking a destination:
every sidebar destination closes the overlay before it navigates.
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
has caused native crashes.
+9 -6
View File
@@ -281,23 +281,26 @@ Drafts do not appear in GitHub's releases feed. Updater clients continue to see
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
### Instant-admit release (rollout_hours=0 from publish)
### Instant-admit release (rollout_hours=0)
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
`npm run release:patch` and `npm run release:minor` tag-push, and tag-push runs always stamp 36 hours. To admit everyone immediately (hotfix, doc-only change, a release you want out fast), cut the release normally, wait for it to publish, then flip the manifests:
```bash
# 1. Cut and publish (default 36h ramp from tag push).
# 1. Cut and publish (36h ramp from tag push).
npm run release:patch
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
# 2. Wait for Desktop Release to publish the GitHub Release.
gh run watch <desktop-release-run-id> --exit-status
# 3. Flip the published manifests to instant admit.
gh workflow run desktop-rollout.yml \
-f tag=v0.1.64 \
-f rollout_hours=0
```
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
Dispatch only after `finalize-rollout` has published the release. Until then the release is a draft, and a draft has no tag: `desktop-rollout.yml` looks the release up by tag and fails with `release not found`. The concurrency group `desktop-rollout-<tag>` that the two workflows share orders them only once both are queued; it does not hold an early dispatch until the draft is published.
Run the dispatch right after `release:patch` or `release:minor` returns. Don't wait for the tag-push CI to finish.
The first public manifests carry `rolloutHours=36` for the minutes between publish and the flip. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
### Adjusting an already-published release
@@ -0,0 +1,4 @@
Fixed
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
Improved
- Changed the theme picker to a grid of tiles with a preview of each theme
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
Improved
- Changed the theme picker to a grid of tiles with a preview of each theme
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
Improved
- Changed the theme picker to a grid of tiles with a preview of each theme
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
Improved
- Changed the theme picker to a grid of tiles with a preview of each theme
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-sLaLHD1u1eJRdGdflcwWBGxnnhRQpB1T74BlaWcmxbU=
sha256-wO0vJVhh1urkt4q9NiQ0RSXUj++WWuHTneWSPelU+JQ=
+34 -34
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.15.0",
"version": "0.15.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.15.0",
"version": "0.15.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -15088,9 +15088,9 @@
}
},
"node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": {
"version": "6.3.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
"integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -35100,9 +35100,9 @@
}
},
"node_modules/tiny-async-pool/node_modules/semver": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.3.tgz",
"integrity": "sha512-kM18BPOqTi0+TB68703/GJRpAML5Me+IDQsR1J26YB3EFKKNEyHIVZuWDX7+WKQB3tHwDwgk24BfhdpWUbytvQ==",
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"license": "ISC",
"bin": {
@@ -37468,7 +37468,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.15.0",
"version": "0.15.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39637,13 +39637,13 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.15.0",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/server": "0.15.0",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/server": "0.15.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39890,11 +39890,11 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.15.0",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/protocol": "0.15.0",
"@getpaseo/relay": "0.15.0",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -39906,7 +39906,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.15.0",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40149,7 +40149,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.0",
"version": "0.15.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40195,7 +40195,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.15.0",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@codemirror/language": "6.12.4",
@@ -40225,7 +40225,7 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.15.0",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
@@ -40233,8 +40233,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40244,8 +40244,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40267,7 +40267,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.15.0",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"semver": "^7.8.5",
@@ -40295,7 +40295,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.15.0",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
@@ -40517,17 +40517,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.15.0",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.0",
"@getpaseo/highlight": "0.15.0",
"@getpaseo/plugin": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/relay": "0.15.0",
"@getpaseo/client": "0.15.2",
"@getpaseo/highlight": "0.15.2",
"@getpaseo/plugin": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41474,7 +41474,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.15.0",
"version": "0.15.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43084,7 +43084,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.15.0",
"version": "0.15.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+8 -2
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.15.0",
"version": "0.15.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -149,6 +149,9 @@
"overrides": {
"@codemirror/language": "6.12.4",
"@codemirror/view": "6.43.6",
"@electron/get@^3.0.0": {
"semver@^6": "6.3.1"
},
"expo": "~57.0.30",
"expo-router": "~57.0.28",
"lightningcss": "1.30.1",
@@ -158,6 +161,9 @@
"react-native": "0.86.3",
"react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.5",
"react-native-worklets": "0.10.4"
"react-native-worklets": "0.10.4",
"tiny-async-pool": {
"semver@^5": "5.7.2"
}
}
}
@@ -723,3 +723,46 @@ test("copying a selection across an assistant image keeps its Markdown source an
await agent.cleanup();
}
});
test.describe("on a touch-capable desktop browser", () => {
// A touch screen registers the navigation pan's pointer listeners over the whole workspace.
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
test.use({ hasTouch: true });
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "assistant-selection-drag-",
title: "Assistant selection drag",
initialPrompt: "Render three paragraphs.",
featureValues: {
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
},
});
try {
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "idle",
30_000,
);
await openAgentRoute(page, agent);
const blocks = assistantMessageBlocks(page);
const first = await blocks.getByText("First paragraph.").boundingBox();
const third = await blocks.getByText("Third paragraph.").boundingBox();
if (!first || !third) {
throw new Error("Expected rendered paragraphs");
}
await page.mouse.move(first.x + 1, first.y + first.height / 2);
await page.mouse.down();
await page.mouse.move(third.x + third.width - 1, third.y + third.height / 2, { steps: 20 });
await page.mouse.up();
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
expect(selected).toContain("First paragraph.");
expect(selected).toContain("Third paragraph.");
} finally {
await agent.cleanup();
}
});
});
@@ -323,6 +323,110 @@ test("All changes combines committed and pending edits and preserves an unfinish
});
});
test("Changes selects exact local and remote bases and retains the selection after reload", async ({
page,
}, testInfo) => {
const workspace = await createWorktreeWithCommittedDiff();
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "main"], {
cwd: workspace.repoPath,
});
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace.repoPath }).toString();
await openWorkspaceChangesSurface(page, workspace);
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const picker = tree.getByTestId("workspace-base-branch-trigger");
await expect(picker).toBeVisible();
await picker.click();
await expect(page.getByRole("button", { name: "release", exact: true })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("base-picker.png") });
await page.getByRole("button", { name: "release", exact: true }).click();
await expect(picker).toContainText("release");
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible();
await picker.click();
await page.getByRole("button", { name: "upstream/release", exact: true }).click();
await expect(picker).toContainText("upstream/release");
await expect(tree.getByText("committed-only.ts", { exact: true })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("base-selected.png") });
await page.reload();
await waitForWorkspaceTabsVisible(page);
await expect(picker).toContainText("upstream/release");
expect(execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace.repoPath }).toString()).toBe(
head,
);
});
test("Changes lets a compact workspace change its base in a sheet", async ({ page }, testInfo) => {
const workspace = await createWorktreeWithCommittedDiff();
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
await openCompactChanges(page, workspace);
const picker = page.getByRole("button", { name: "Change base" }).filter({ visible: true });
await picker.click();
await expect(page.getByText("Change base", { exact: true })).toBeVisible();
const release = page.getByRole("button", { name: "release", exact: true });
await expect(release).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("compact-base-picker.png") });
await release.click();
await expect(picker).toContainText("release");
await page.screenshot({ path: testInfo.outputPath("compact-base-selected.png") });
});
test("Changes shows a failed base selection and lets the user retry", async ({ page }) => {
const workspace = await createWorktreeWithCommittedDiff();
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
await openWorkspaceChangesSurface(page, workspace);
const picker = page
.getByTestId("workspace-base-branch-trigger")
.filter({ visible: true })
.first();
await picker.click();
const option = page.getByRole("button", { name: "release", exact: true });
await expect(option).toBeVisible();
execFileSync("git", ["branch", "-D", "release"], { cwd: workspace.repoPath });
await option.click();
await expect(page.getByTestId("workspace-base-branch-error")).toContainText(
"Base branch not found",
);
await expect(picker).toContainText("main");
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
await option.click();
await expect(page.getByTestId("workspace-base-branch-error")).toHaveCount(0);
await expect(picker).toContainText("release");
});
test("Changes shows the base as text on a host without the base-branch capability", async ({
page,
}) => {
let baseRequests = 0;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
if (typeof frame === "string") {
const envelope = JSON.parse(frame) as { message?: { type?: string } };
if (envelope.message?.type === "workspace.base_branch.set.request") baseRequests += 1;
}
server.send(frame);
});
server.onMessage((frame) => {
if (typeof frame === "string") {
const envelope = JSON.parse(frame) as {
message?: { payload?: { status?: string; features?: Record<string, unknown> } };
};
const payload = envelope.message?.payload;
if (payload?.status === "server_info" && payload.features) {
delete payload.features.workspaceBaseBranch;
browser.send(JSON.stringify(envelope));
return;
}
}
browser.send(frame);
});
});
const workspace = await createWorktreeWithCommittedDiff();
await openWorkspaceChangesSurface(page, workspace);
await expect(page.getByTestId("workspace-base-branch-trigger")).toHaveCount(0);
expect(baseRequests).toBe(0);
});
test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
@@ -1657,6 +1761,28 @@ async function expectRenderedAddedLine(page: Page, contents: string): Promise<vo
}).toPass({ timeout: 15_000 });
}
async function createWorktreeWithCommittedDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-base-worktree-", {
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
});
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close();
await repo.cleanup();
},
});
const created = await client.createWorkspace({
source: { kind: "worktree", cwd: repo.path, worktreeSlug: "base-picker", baseBranch: "main" },
});
if (!created.workspace) throw new Error(created.error ?? "Failed to create worktree");
const cwd = created.workspace.workspaceDirectory;
await writeFile(path.join(cwd, "committed-only.ts"), "export const committed = true;\n");
execFileSync("git", ["add", "committed-only.ts"], { cwd });
execFileSync("git", ["commit", "-m", "Add committed-only file"], { cwd });
return { id: created.workspace.id, repoPath: cwd };
}
async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-committed-", {
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
@@ -0,0 +1,53 @@
import type { Locator } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
async function box(locator: Locator) {
await expect(locator).toBeVisible({ timeout: 30_000 });
const rect = await locator.boundingBox();
expect(rect).not.toBeNull();
return { top: rect!.y, bottom: rect!.y + rect!.height };
}
test.describe("Composer bottom spacing", () => {
test("the chat composer rests one content spacing above the window edge", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-bottom-spacing-",
title: "Bottom spacing",
});
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
const surface = await box(
page.getByTestId("message-input-root").filter({ visible: true }).first(),
);
const viewportHeight = await page.evaluate(() => window.innerHeight);
expect(viewportHeight - surface.bottom).toBe(16);
} finally {
await agent.cleanup();
}
});
test("the wide new workspace form keeps one content spacing above Import session", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "composer-bottom-spacing-form-" });
try {
await page.setViewportSize({ width: 1440, height: 900 });
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
const composer = await box(page.locator('[data-testid="message-input-root"]:visible'));
const importButton = await box(page.getByTestId("new-workspace-import-session"));
expect(importButton.top - composer.bottom).toBe(16);
} finally {
await workspace.cleanup();
}
});
});
@@ -78,7 +78,8 @@ test("composer diff stat reveals Changes, then opens the diff in the configured
const workspace = await seedChangedAgent("composer-diff-stat-side-");
try {
await page.setViewportSize({ width: 1400, height: 900 });
// Keep both workspace rails visible alongside app navigation and Explorer.
await page.setViewportSize({ width: 1900, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
@@ -25,7 +25,7 @@ import {
expectMobileAgentSidebarVisible,
expectMobileExplorerHidden,
expectMobileExplorerVisible,
swipeOpenMobileAgentSidebar,
openMobileAgentSidebarWithKeyboard,
swipeOpenMobileExplorer,
} from "../support/helpers/sidebar";
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
@@ -249,7 +249,7 @@ test.describe("touch screen wide enough for the desktop layout", () => {
const COMPACT_PANELS = [
{
name: "agent list",
open: swipeOpenMobileAgentSidebar,
open: openMobileAgentSidebarWithKeyboard,
expectOpen: expectMobileAgentSidebarVisible,
close: closeMobileAgentSidebar,
expectClosed: expectMobileAgentSidebarHidden,
@@ -37,7 +37,7 @@ import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
swipeOpenMobileAgentSidebar,
openMobileAgentSidebarWithKeyboard,
} from "../support/helpers/sidebar";
async function composerHeight(page: Page): Promise<number> {
@@ -541,8 +541,8 @@ test("the compact sidebar over a fullscreen editor leaves the exit action alone"
await writeCompactDraft(page, draft);
await enterComposerFullscreen(page);
await expectWorkspaceHeaderHidden(page);
// Fullscreen hides the header's menu button; the edge swipe still opens the list over it.
await swipeOpenMobileAgentSidebar(page);
// Fullscreen hides the header's menu button; the keyboard shortcut opens the list over it.
await openMobileAgentSidebarWithKeyboard(page);
await expectMobileAgentSidebarVisible(page);
// The list covers the pane, so the editor's exit action is not armed.
await page.keyboard.press("Escape");
@@ -1,3 +1,5 @@
import { installLocalDesktopHost } from "../support/helpers/local-desktop-host";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import {
@@ -9,6 +11,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
test.describe("Direct terminal shortcut pane placement", () => {
test.use({ viewport: { width: 1600, height: 900 } });
test("opens a terminal in the focused pane", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "direct-terminal-shortcut-pane-" });
@@ -47,3 +50,88 @@ test.describe("Direct terminal shortcut pane placement", () => {
}
});
});
async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) {
await page.setViewportSize({ width: 1600, height: 900 });
await gotoWorkspace(page, workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const draftPane = page
.locator('[data-testid^="workspace-pane-"]')
.filter({ has: page.getByTestId("message-input-root") });
const emptyPane = page
.locator('[data-testid^="workspace-pane-"]')
.filter({ has: page.getByTestId("workspace-new-tab-panel") });
const draftPaneId = await draftPane.getAttribute("data-testid");
const emptyPaneId = await emptyPane.getAttribute("data-testid");
expect(draftPaneId).not.toBeNull();
expect(emptyPaneId).not.toBeNull();
await page.setViewportSize({ width: 760, height: 900 });
return {
originalPane: page.getByTestId(draftPaneId!),
otherPane: page.getByTestId(emptyPaneId!),
};
}
async function launchTerminalFromCompactPane(page: Page, pane: Locator) {
await pane.getByTestId("workspace-tab-switcher-trigger").click();
await page.getByTestId("workspace-tab-switcher-new-terminal").click();
await expect(pane.getByTestId("terminal-surface")).toBeVisible({ timeout: 30_000 });
}
async function maximizeAndRestoreCompactPane(page: Page, pane: Locator) {
await pane.getByTestId("workspace-header-menu-trigger").click();
await expect(page.getByRole("menuitem", { name: "Split pane right", exact: true })).toBeVisible();
await page.getByRole("menuitem", { name: "Maximize pane", exact: true }).click();
await pane.getByTestId("workspace-header-menu-trigger").click();
await page.getByRole("menuitem", { name: "Restore pane", exact: true }).click();
}
test("compact pane launch stays in its owning pane and pane controls remain reachable", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-launch-" });
try {
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
page,
workspace.workspaceId,
);
await launchTerminalFromCompactPane(page, originalPane);
await expect(otherPane.getByTestId("workspace-new-tab-panel")).toBeVisible();
await maximizeAndRestoreCompactPane(page, originalPane);
await expect(otherPane).toBeVisible();
} finally {
await workspace.cleanup();
}
});
async function expectPaneHasWindowDragSurface(pane: Locator) {
await expect
.poll(() =>
pane.evaluate((element) =>
Array.from(element.querySelectorAll<HTMLElement>("div")).some(
(node) => node.style.getPropertyValue("-webkit-app-region") === "drag",
),
),
)
.toBe(true);
}
test("compact desktop panes retain window dragging with both sidebars closed", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-window-drag-" });
try {
await installLocalDesktopHost(page, workspace.repoPath);
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
page,
workspace.workspaceId,
);
await runWorkspaceActionFromCommandCenter(page, "Toggle left sidebar");
await expect(originalPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
await expect(otherPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
await expectPaneHasWindowDragSurface(originalPane);
await expectPaneHasWindowDragSurface(otherPane);
} finally {
await workspace.cleanup();
}
});
@@ -56,9 +56,9 @@ test.describe("New tab keyboard launcher", () => {
await expectLauncherSelection(launcher, "Diff");
await page.keyboard.press("Enter");
await expect(
page.getByTestId("workspace-tab-working_diff").filter({ visible: true }),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
} finally {
await workspace.cleanup();
}
@@ -105,6 +105,9 @@ function collectConsoleErrors(page: Page): string[] {
return errors;
}
// These drag and tab-menu journeys need full rails beside the docked Explorer.
test.use({ viewport: { width: 1900, height: 900 } });
test.describe("explorer pane tab placement", () => {
test("agents open in the main pane and New-tab splits keep the app usable", async ({
page,
@@ -435,7 +435,8 @@ test.describe("CodeMirror workspace file editing", () => {
});
try {
await page.setViewportSize({ width: 1280, height: 900 });
// Keep both tab rails available alongside the docked Explorer.
await page.setViewportSize({ width: 1900, height: 900 });
await openAgentRoute(page, session);
await openWorkspaceFile(page, "target.ts");
+137
View File
@@ -12,11 +12,15 @@ import {
openAppAsNewUser,
openRelayPairingFromSetup,
closeRelayPairing,
emptyHome,
loadingScreen,
openTailscaleDocsFromSetup,
paseoToolsSwitch,
pickTheme,
projectSuggestion,
projectSuggestionRow,
readFirstLaunch,
recordFirstLaunch,
setupModal,
setupPrimaryButton,
showSetupAgainFromSettings,
@@ -99,6 +103,139 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => {
}
});
test("a new user goes from the loading screen straight into setup", async ({ page }) => {
const host = await startSetupHost("first-launch");
try {
await recordFirstLaunch(page);
await openAppAsNewUser(page, host);
await expectSetupStep(page, "Welcome to Paseo");
const launch = await readFirstLaunch(page);
expect(launch.appBeforeSetup).toBe(false);
expect(launch.setupOpacityOnArrival).toBeLessThan(1);
await expect(setupModal(page)).toHaveCSS("opacity", "1");
} finally {
await host.close();
}
});
test("with reduced motion, setup appears without fading in", async ({ page }) => {
const host = await startSetupHost("reduced-motion");
try {
await page.emulateMedia({ reducedMotion: "reduce" });
await recordFirstLaunch(page);
await openAppAsNewUser(page, host);
await expectSetupStep(page, "Welcome to Paseo");
expect((await readFirstLaunch(page)).setupOpacityOnArrival).toBe(1);
} finally {
await host.close();
}
});
test("changing the theme keeps the setup window in place on every step", async ({ page }) => {
const host = await startSetupHost("theme-change");
try {
await openAppAsNewUser(page, host);
await expectSetupStep(page, "Welcome to Paseo");
const setupWindow = await setupModal(page).elementHandle();
for (const title of [
"Welcome to Paseo",
"Your agents",
"Add your projects",
"Let agents use Paseo",
"Take your agents with you",
]) {
if (title !== "Welcome to Paseo") await continueSetup(page);
await expectSetupStep(page, title);
const heading = setupModal(page).getByRole("heading", { name: title });
const headingNode = await heading.elementHandle();
const lightColor = await heading.evaluate((node) => getComputedStyle(node).color);
await page.emulateMedia({ colorScheme: "dark" });
await expect(heading).not.toHaveCSS("color", lightColor);
await page.emulateMedia({ colorScheme: "light" });
await expect(heading).toHaveCSS("color", lightColor);
expect(await setupWindow?.evaluate((node) => node.isConnected), title).toBe(true);
expect(await headingNode?.evaluate((node) => node.isConnected), title).toBe(true);
}
await goToSetupStep(page, "Appearance");
await pickTheme(page, "Midnight");
await pickTheme(page, "Light");
expect(await setupWindow?.evaluate((node) => node.isConnected)).toBe(true);
} finally {
await host.close();
}
});
test("the loading screen stays up while the setup host loads its workspaces", async ({ page }) => {
const host = await startSetupHost("slow-workspaces");
const gate = await installDaemonWebSocketGate(page, { port: host.port });
try {
await recordFirstLaunch(page);
gate.holdNextClientRequest("fetch_workspaces_request");
await openAppAsNewUser(page, host);
await gate.waitForHeldClientRequest();
await expect(loadingScreen(page)).toBeVisible();
await expect(emptyHome(page)).toHaveCount(0);
gate.releaseHeldClientRequest();
await expectSetupStep(page, "Welcome to Paseo");
expect((await readFirstLaunch(page)).appBeforeSetup).toBe(false);
} finally {
await host.close();
}
});
test("a setup host that never loads its workspaces still lands on the home", async ({ page }) => {
const host = await startSetupHost("stuck-workspaces");
const gate = await installDaemonWebSocketGate(page, { port: host.port });
try {
gate.holdNextClientRequest("fetch_workspaces_request");
await openAppAsNewUser(page, host);
await gate.waitForHeldClientRequest();
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
await expect(setupModal(page)).toHaveCount(0);
// Setup still opens once the host answers.
gate.releaseHeldClientRequest();
await expectSetupStep(page, "Welcome to Paseo");
} finally {
await host.close();
}
});
test("a setup host that drops while loading still lands on the home", async ({ page }) => {
const host = await startSetupHost("dropped-workspaces");
const gate = await installDaemonWebSocketGate(page, { port: host.port });
try {
gate.holdNextClientRequest("fetch_workspaces_request");
await openAppAsNewUser(page, host);
await gate.waitForHeldClientRequest();
await gate.drop();
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
await expect(setupModal(page)).toHaveCount(0);
} finally {
await host.close();
}
});
test("an unreachable setup host lands on the home without setup", async ({ page }) => {
const host = await startSetupHost("unreachable");
await host.close();
await openAppAsNewUser(page, host);
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
await expect(setupModal(page)).toHaveCount(0);
});
test("setup suggests the repos the user already ran agents in", async ({ page }, testInfo) => {
const repo = await createTempGitRepo("onboarding-history-");
const repoName = path.basename(repo.path);
@@ -97,6 +97,7 @@ test("fixed footer line keeps its five icons, Help and Settings at the end", asy
await setFooterItemVisible(page, "usage", true);
await leaveSettings(page);
await page.setViewportSize({ width: 390, height: 844 });
await openCompactSidebar(page);
await expectCompactSidebarOpen(page);
await expectFooterSeparator(page, true);
await expectFooterIconRow(page);
@@ -652,6 +652,9 @@ test.describe("Half-screen desktop layout", () => {
.locator('[data-testid^="workspace-pane-"]')
.getByTestId("workspace-tabs-row")
.filter({ visible: true }),
).toHaveCount(0);
await expect(
page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true }),
).toHaveCount(1);
await explorerToggle.click();
@@ -38,8 +38,12 @@ async function openParentInRightPane(page: Page, parentId: string, workspaceId:
await expect(left).toBeVisible();
await expect(right).toBeVisible();
await expect(right).not.toHaveAttribute("data-testid", "workspace-pane-main");
// A later split can replace the parent pane's rail with compact chrome.
// Keep the pane identity instead of finding it again through that rail.
const rightPaneId = await right.getAttribute("data-testid");
expect(rightPaneId).not.toBeNull();
await left.locator('[data-testid^="workspace-tab-draft_"]').click();
return { left, right };
return { left, right: page.getByTestId(rightPaneId!) };
}
test.beforeEach(async ({ page }) => {
+2 -33
View File
@@ -414,6 +414,8 @@ test("searches retained output without sending Find input to the shell", async (
test("keeps terminal Find owned by retained tabs and the focused split pane", async ({
page,
}, testInfo) => {
// This journey selects tabs from the full rails in both split panes.
await page.setViewportSize({ width: 1600, height: 900 });
const first = await harness.createTerminal({
name: "First search",
command: "bash",
@@ -627,36 +629,3 @@ async function dragTerminalWithFinger(
}
});
}
// Diagnostic-only: application modes emitted before the first attachment.
// The existing live-mode tests above are the control for this failure.
test.describe("mouse mode enabled before terminal attachment", () => {
test.use({ isMobile: true, hasTouch: true, viewport: { width: 390, height: 844 } });
test("preserves SGR wheel reports when opening a running terminal", async ({ page }) => {
await openPreAttachedMouseCapture(page);
await expectFingerNavigationInput(page, "mouse");
});
});
async function openPreAttachedMouseCapture(page: Page) {
await harness.createTerminal({
name: "Mouse mode before attachment",
command: process.execPath,
args: [
"-e",
`
process.stdin.setRawMode(true);
process.stdin.on("data", chunk => {
process.stdout.write("\\r\\nTOUCH_BYTES:" + chunk.toString("hex") + ":END\\r\\n");
});
process.stdout.write("\\x1b[?1000h\\x1b[?1006hTOUCH_INPUT_READY\\r\\n");
`,
],
});
await openPhoneTerminal(page);
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_INPUT_READY");
await expect
.poll(() => page.evaluate(() => window.__paseoTerminal!.modes.mouseTrackingMode))
.toBe("vt200");
}
@@ -2,6 +2,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import path from "node:path";
import {
holdOlderHistoryPages,
scrollTimelineRowIntoView,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination";
import { getActiveTabTestId } from "../support/helpers/launcher";
@@ -529,6 +530,7 @@ async function expectThinkingPresentation(page: Page, autoExpandReasoning: boole
.last();
await expect(group).toContainText(/read 1 file/i);
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
await scrollTimelineRowIntoView(page, group);
await group.getByRole("button").first().click();
// Expand thinking is off: the group opens its rows, but the thought row stays closed
// until the reader opens it.
@@ -357,6 +357,8 @@ test.describe("Viewed agent timelines", () => {
});
test("two visible split chats both stay current", async ({ page }) => {
// Keep both split panes wide enough for the tab-rail interactions below.
await page.setViewportSize({ width: 1600, height: 900 });
const scenario = await seedViewedTimelineScenario();
try {
await enableMoveTabShortcut(page);
@@ -101,3 +101,20 @@ test("focus mode only applies to the active workspace screen", async ({ page, wi
await expect(exitFocusMode).toHaveCount(0);
await expect(settingsButton).toBeVisible();
});
async function exitFocusModeFromCompactHeader(page: Page) {
await page.setViewportSize({ width: 400, height: 844 });
await page.getByTestId("workspace-header-menu-trigger").click();
await page.getByRole("menuitem", { name: "Exit focus mode", exact: true }).click();
await page.setViewportSize({ width: 1280, height: 900 });
}
test("compact workspace menu keeps focus mode exit reachable", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "compact-focus-exit-" });
await page.setViewportSize({ width: 1280, height: 900 });
await workspace.navigateTo();
await enterFocusMode(page);
await exitFocusModeFromCompactHeader(page);
await expect(exitFocusModeButton(page)).toHaveCount(0);
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
});
@@ -52,6 +52,8 @@ async function waitForWorkspaceRoute(page: Page, route: string) {
test.describe("Workspace pane mounting", () => {
test("workspace navigation keeps the existing agent composer mounted", async ({ page }) => {
test.setTimeout(90_000);
// Keep both split panes wide enough for the tab-rail interactions below.
await page.setViewportSize({ width: 1600, height: 900 });
const serverId = getServerId();
const workspace = await seedWorkspace({ repoPrefix: "pane-remount-" });
@@ -34,6 +34,54 @@ export async function openAppAsNewUser(
await page.goto("/");
}
interface FirstLaunchRecord {
appBeforeSetup: boolean;
setupOpacityOnArrival: number | null;
}
/**
* Watches the page from its first paint: whether the app's sidebar or home showed before setup,
* and how opaque setup was when it arrived. Call before `openAppAsNewUser`.
*/
export async function recordFirstLaunch(page: Page): Promise<void> {
await page.addInitScript(() => {
const record: FirstLaunchRecord = { appBeforeSetup: false, setupOpacityOnArrival: null };
Object.assign(window, { __firstLaunch: record });
const observer = new MutationObserver(() => {
const setup = document.querySelector('[data-testid="onboarding-modal"] [role="dialog"]');
if (setup) {
record.setupOpacityOnArrival = Number(getComputedStyle(setup).opacity);
observer.disconnect();
return;
}
if (
document.querySelector(
'[data-testid="open-project-submit"], [data-testid="sidebar-settings"]',
)
) {
record.appBeforeSetup = true;
}
});
observer.observe(document, { childList: true, subtree: true });
});
}
export async function readFirstLaunch(page: Page): Promise<FirstLaunchRecord> {
return page.evaluate(
() => (window as unknown as { __firstLaunch: FirstLaunchRecord }).__firstLaunch,
);
}
/** The loading screen the app shows while it starts. */
export function loadingScreen(page: Page): Locator {
return page.getByTestId("startup-splash");
}
/** The empty home a host with no projects lands on. */
export function emptyHome(page: Page): Locator {
return page.getByTestId("open-project-submit");
}
export function setupModal(page: Page): Locator {
return page.getByTestId("onboarding-modal").getByRole("dialog");
}
+10 -11
View File
@@ -81,7 +81,13 @@ export async function scrollWorkspaceList(page: Page): Promise<void> {
}
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
const cards = page.getByTestId("workspace-hover-card");
// Keyboard navigation can leave the previous card closing while the next opens.
// Retry both checks together: another card may mount between count and visibility.
await expect(async () => {
expect(await cards.count()).toBe(1);
expect(await cards.isVisible()).toBe(true);
}).toPass({ timeout: 10_000 });
}
export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<void> {
@@ -261,16 +267,9 @@ export async function expectMobileAgentSidebarHidden(page: Page): Promise<void>
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
}
/** Drag from the left edge: the only way to open the list while fullscreen hides the header. */
export async function swipeOpenMobileAgentSidebar(page: Page): Promise<void> {
const viewport = page.viewportSize();
if (!viewport) throw new Error("viewport size is required");
const y = Math.round(viewport.height / 2);
await page.mouse.move(8, y);
await page.mouse.down();
await page.mouse.move(40, y, { steps: 4 });
await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 });
await page.mouse.up();
/** Opens navigation without moving focus to the header, including over a fullscreen editor. */
export async function openMobileAgentSidebarWithKeyboard(page: Page): Promise<void> {
await page.keyboard.press("ControlOrMeta+b");
}
/** Drag from the right edge to open the compact Explorer over the agent. */
@@ -477,18 +477,29 @@ export async function expectStableHistoryStartGutter(page: Page): Promise<void>
.toBe(32);
}
export async function scrollTimelinePromptIntoView(page: Page, prompt: string): Promise<void> {
/**
* Wheels the timeline until `row` is centered, as a reader does. Playwright's own
* scroll-into-view is programmatic, which the timeline does not treat as the reader leaving
* the bottom: it snaps back to the bottom and can move the row away mid-click.
*/
export async function scrollTimelineRowIntoView(page: Page, row: Locator): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const row = timeline.getByTestId("user-message").filter({ hasText: prompt });
const [viewport, target] = await Promise.all([timeline.boundingBox(), row.boundingBox()]);
if (!viewport || !target) {
throw new Error(`Expected a rendered timeline prompt for ${prompt}`);
throw new Error("Expected a rendered timeline row");
}
// Programmatic scrolling does not express the user's intent to stop following output.
await timeline.hover();
await page.mouse.wheel(0, target.y - viewport.y - (viewport.height - target.height) / 2);
await expect(row).toBeInViewport({ ratio: 1 });
await waitForTimelineGeometryToSettle(page);
}
export async function scrollTimelinePromptIntoView(page: Page, prompt: string): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scrollTimelineRowIntoView(
page,
timeline.getByTestId("user-message").filter({ hasText: prompt }),
);
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeGreaterThan(HISTORY_START_THRESHOLD_PX);
@@ -17,6 +17,7 @@ final class WindowLayoutView: ExpoView {
private var divisions: [CGRect] = []
private var barGuide: UILayoutGuide?
private var barEdge = "none"
private var bottomBarGuide: UILayoutGuide?
private var measuredBounds = CGRect.zero
required init(appContext: AppContext? = nil) {
@@ -36,6 +37,7 @@ final class WindowLayoutView: ExpoView {
override func didMoveToWindow() {
super.didMoveToWindow()
previousPayload = nil
bottomBarGuide = nil
setNeedsUpdateConstraints()
scheduleUpdate()
}
@@ -66,6 +68,11 @@ final class WindowLayoutView: ExpoView {
case .trailing: edge = "trailing"
default: edge = "none"
}
if bottomBarGuide == nil {
// Where a bottom bar rests: above the home indicator, refined to the
// screen's corners rather than the rectangular safe area.
bottomBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.bottom, extent: 44))
}
if edge != barEdge {
barEdge = edge
barGuide = edge == "none" ? nil : layoutGuide(
@@ -93,10 +100,17 @@ final class WindowLayoutView: ExpoView {
let bar = barGuide?.layoutFrame
var barPayload: Any = NSNull()
if let bar, bar.width > 0, bar.height > 0 { barPayload = rect(bar) }
// Before UIKit resolves the guide its frame has no height: publish null (the
// system inset applies) rather than a false zero clearance.
var bottomClearance: Any = NSNull()
if let bottomBar = bottomBarGuide?.layoutFrame, bottomBar.height > 0 {
bottomClearance = max(0, bounds.maxY - bottomBar.maxY)
}
let payload: [String: Any] = [
"bounds": rect(bounds),
"verticalBarEdge": barEdge,
"verticalBar": barPayload,
"bottomClearance": bottomClearance,
"divisions": divisions.map(rect)
]
guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.15.0",
"version": "0.15.2",
"private": true,
"main": "index.ts",
"scripts": {
+63 -15
View File
@@ -25,6 +25,7 @@ import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState,
useSyncExternalStore,
@@ -45,7 +46,7 @@ import { DownloadToast } from "@/components/download-toast";
import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { ChangelogHost } from "@/changelog";
import { OnboardingHost } from "@/onboarding";
import { OnboardingHost, useIsSetupDecisionPending } from "@/onboarding";
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { LeftSidebar } from "@/components/left-sidebar";
@@ -68,6 +69,7 @@ import {
resolveDesktopAppContentMinimum,
resolveDesktopSidebarVisibility,
resolveSidebarActivity,
resolveLeftSidebarPresentation,
} from "@/components/desktop-sidebar-layout";
import { isNative, isWeb } from "@/constants/platform";
import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
@@ -121,7 +123,7 @@ import {
useHosts,
} from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import { usePanelStore } from "@/stores/panel-store";
import { usePanelStore, type LeftSidebarPresentation } from "@/stores/panel-store";
import { flushDraftPersistStorage } from "@/stores/draft-store";
import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme";
import { useSessionStore } from "@/stores/session-store";
@@ -149,6 +151,7 @@ export interface HostRuntimeBootstrapState {
splashError: string | null;
retry: () => void;
hasGivenUpWaitingForHost: boolean;
isWaitingForSetup: boolean;
storeReady: boolean;
startupBlocker: StartupBlocker;
}
@@ -157,6 +160,7 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
splashError: null,
retry: () => {},
hasGivenUpWaitingForHost: false,
isWaitingForSetup: false,
storeReady: false,
startupBlocker: { kind: "none" },
});
@@ -253,6 +257,9 @@ function useDaemonStartIsRunning(): boolean {
}
const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000;
// How long the splash waits on top of host startup to learn whether setup opens. A slow host
// lands on the home instead, and setup opens over it once it is decided.
const STARTUP_SETUP_WAIT_TIMEOUT_MS = 3_000;
async function shouldStartBuiltInDaemon(): Promise<boolean> {
if (!shouldUseDesktopDaemon()) {
@@ -314,6 +321,23 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
};
}, [shouldRunGiveUpTimer]);
const isSetupDecisionPending = useIsSetupDecisionPending();
const [hasGivenUpWaitingForSetup, setHasGivenUpWaitingForSetup] = useState(false);
const isWaitingForSetup = isSetupDecisionPending && !hasGivenUpWaitingForSetup;
const shouldRunSetupWaitTimer = isWaitingForSetup && startupBlocker.kind === "none";
useEffect(() => {
if (!shouldRunSetupWaitTimer) {
return;
}
const handle = setTimeout(() => {
setHasGivenUpWaitingForSetup(true);
}, STARTUP_SETUP_WAIT_TIMEOUT_MS);
return () => {
clearTimeout(handle);
};
}, [shouldRunSetupWaitTimer]);
const retry = useCallback(() => {
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
@@ -324,8 +348,15 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const storeReady = resolveStartupNavigationReady({ startupBlocker });
const state = useMemo<HostRuntimeBootstrapState>(
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker }),
[splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker],
() => ({
splashError,
retry,
hasGivenUpWaitingForHost,
isWaitingForSetup,
storeReady,
startupBlocker,
}),
[splashError, retry, hasGivenUpWaitingForHost, isWaitingForSetup, storeReady, startupBlocker],
);
return (
@@ -441,6 +472,17 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
viewportWidth,
}),
});
const sidebarPresentation = resolveLeftSidebarPresentation({
chromeEnabled,
isCompactLayout,
isFocusModeEnabled: isWorkspaceFocusModeEnabled,
desktopSidebarVisible,
});
const applyPanelLayout = usePanelStore((state) => state.applyPanelLayout);
// Publishes the layout to the store, which owns the panel targets each layout allows.
useLayoutEffect(() => {
applyPanelLayout({ isCompact: isCompactLayout, leftSidebar: sidebarPresentation });
}, [applyPanelLayout, isCompactLayout, sidebarPresentation]);
const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left");
const appChromeLayout = resolveDesktopAppChromeLayout({
desktopSidebarRendered: desktopSidebarVisible,
@@ -450,7 +492,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const sidebarChrome = (
<SidebarChrome
mounted={isCompactLayout ? chromeEnabled : desktopSidebarMounted}
visible={isCompactLayout ? chromeEnabled : desktopSidebarVisible}
presentation={sidebarPresentation}
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
/>
);
@@ -516,7 +558,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<WorkspacePinShortcutHandler />
<WorkspaceRenameHost />
<CommandCenter />
<OnboardingHost />
<AddProjectFlowHost />
<HostChooserModal />
<HostConfirmationSheet />
@@ -527,11 +568,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<ChangelogHost />
<QuittingOverlay />
</AppearanceStyleBoundary>
{/* Setup changes the theme while it is open, so it stays out of the remount above. Its
colors are CSS variables and themed props, which update in place, and setup changes no
font size. */}
<OnboardingHost />
</View>
);
return (
<AppContainerLifetime compactGesturesEnabled={chromeEnabled && isCompactLayout}>
<AppContainerLifetime sidebarGesturesEnabled={sidebarPresentation === "overlay"}>
<SheetHostScope enabled={isCompactLayout}>{surface}</SheetHostScope>
</AppContainerLifetime>
);
@@ -539,15 +584,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
function AppContainerLifetime({
children,
compactGesturesEnabled,
sidebarGesturesEnabled,
}: {
children: ReactNode;
compactGesturesEnabled: boolean;
sidebarGesturesEnabled: boolean;
}) {
return (
<ComposerEditingSessionsProvider>
<CommandCenterProvider>
<MobileGestureWrapper chromeEnabled={compactGesturesEnabled}>
<MobileGestureWrapper chromeEnabled={sidebarGesturesEnabled}>
{children}
</MobileGestureWrapper>
</CommandCenterProvider>
@@ -557,23 +602,26 @@ function AppContainerLifetime({
function SidebarChrome({
mounted,
visible,
presentation,
keyboardShortcutsEnabled,
}: {
mounted: boolean;
visible: boolean;
presentation: LeftSidebarPresentation;
keyboardShortcutsEnabled: boolean;
}) {
const isCompactLayout = useIsCompactFormFactor();
const isMobileActive = useIsMobilePanelActive("agent-list");
const active = resolveSidebarActivity(visible, isCompactLayout, isMobileActive);
const overlay = presentation === "overlay";
const active = resolveSidebarActivity(presentation !== "hidden", overlay, isMobileActive);
const hardwareKeyboardPresent = useHardwareKeyboardPresent();
return (
<SidebarModelProvider
active={active}
keyboardNavigationActive={keyboardShortcutsEnabled && hardwareKeyboardPresent}
>
{mounted ? <LeftSidebar active={active} /> : null}
{mounted ? (
<LeftSidebar active={active} desktopOverlay={overlay && !isCompactLayout} />
) : null}
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
</SidebarModelProvider>
);
@@ -586,8 +634,8 @@ function MobileGestureWrapper({
children: ReactNode;
chromeEnabled: boolean;
}) {
const openGesture = useOpenAgentListGesture(chromeEnabled);
const isCompactLayout = useIsCompactFormFactor();
const openGesture = useOpenAgentListGesture(chromeEnabled);
return (
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
+10 -2
View File
@@ -45,11 +45,19 @@ export default function Index() {
}),
isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
isWaitingForSetup: bootstrapState.isWaitingForSetup,
});
const splash = <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
if (startupRoute.kind === "redirect") {
return <Redirect href={startupRoute.href} />;
// The loading screen stays up until the target route paints; a redirect alone renders nothing.
return (
<>
{splash}
<Redirect href={startupRoute.href} />
</>
);
}
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
return splash;
}
@@ -232,6 +232,28 @@ const styles = StyleSheet.create((theme) => ({
}));
const WEB_EXIT_DURATION_MS = 160;
const WEB_ENTER_DURATION_MS = 160;
const WEB_ENTER_ANIMATION_NAME = "paseo-modal-enter";
// The desktop card fades in; the dim lands at once, so the screen behind is never seen undimmed
// while the dialog arrives. A CSS animation runs from the card's first paint, so a dialog that opens
// on its own, like first-run setup, never pops in. Closing fades the whole overlay. With reduced
// motion the keyframes are never defined, so the card appears at once.
function ensureWebEnterKeyframes() {
if (!isWeb || typeof document === "undefined") return;
if (document.getElementById(WEB_ENTER_ANIMATION_NAME)) return;
const style = document.createElement("style");
style.id = WEB_ENTER_ANIMATION_NAME;
style.textContent = `@media (prefers-reduced-motion: no-preference) { @keyframes ${WEB_ENTER_ANIMATION_NAME} { from { opacity: 0; } to { opacity: 1; } } }`;
document.head.appendChild(style);
}
ensureWebEnterKeyframes();
// The CSS animation props are not part of ViewStyle without the generated expo-env.d.ts.
const WEB_ENTER_STYLE = {
animationName: WEB_ENTER_ANIMATION_NAME,
animationDuration: `${WEB_ENTER_DURATION_MS}ms`,
animationTimingFunction: "ease-out",
} as unknown as ViewStyle;
/** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */
export function SheetBackground({ style }: BottomSheetBackgroundProps) {
@@ -662,6 +684,7 @@ export function AdaptiveModalSheet({
styles.desktopCard,
desktopHeight != null && { height: desktopHeight },
desktopMaxWidth != null && { maxWidth: desktopMaxWidth },
isWeb && WEB_ENTER_STYLE,
],
[desktopMaxWidth, desktopHeight],
);
@@ -9,9 +9,11 @@ import {
canDesktopAppSidebarShare,
resolveDesktopAppChromeLayout,
resolveDesktopAppContentMinimum,
resolveDesktopSidebarInsetLeft,
resolveDesktopSidebarVisibility,
resolveSidebarActivity,
resolveDesktopSidebarWidth,
resolveLeftSidebarPresentation,
} from "@/components/desktop-sidebar-layout";
describe("desktop sidebar layout", () => {
@@ -104,6 +106,40 @@ describe("desktop sidebar layout", () => {
).toBe(false);
});
it("offers an overlay only after wide navigation yields its dock", () => {
const input = {
chromeEnabled: true,
isCompactLayout: false,
isFocusModeEnabled: false,
};
expect(resolveLeftSidebarPresentation({ ...input, desktopSidebarVisible: true })).toBe("dock");
expect(resolveLeftSidebarPresentation({ ...input, desktopSidebarVisible: false })).toBe(
"overlay",
);
});
it("does not reveal navigation over a focused workspace or suppressed chrome", () => {
const input = {
chromeEnabled: true,
isCompactLayout: false,
isFocusModeEnabled: false,
desktopSidebarVisible: false,
};
expect(resolveLeftSidebarPresentation({ ...input, isFocusModeEnabled: true })).toBe("hidden");
expect(resolveLeftSidebarPresentation({ ...input, chromeEnabled: false })).toBe("hidden");
});
it("keeps phone navigation available independently of saved wide focus mode", () => {
expect(
resolveLeftSidebarPresentation({
chromeEnabled: true,
isCompactLayout: true,
isFocusModeEnabled: true,
desktopSidebarVisible: false,
}),
).toBe("overlay");
});
it("keeps the sidebar toggle window-owned beside left window controls", () => {
expect(
resolveDesktopAppChromeLayout({
@@ -207,3 +243,8 @@ it("reserves room for Explorer and the workspace before showing app navigation",
}),
).toBe(true);
});
it("insets an overlay sidebar's content by the left safe area and leaves the dock to app chrome", () => {
expect(resolveDesktopSidebarInsetLeft({ overlay: true, safeAreaLeft: 20 })).toBe(20);
expect(resolveDesktopSidebarInsetLeft({ overlay: false, safeAreaLeft: 20 })).toBe(0);
});
@@ -1,6 +1,10 @@
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";
import {
MAX_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
type LeftSidebarPresentation,
} from "@/stores/panel-store";
import type { DesktopDivisionLayout } from "@/window-layout";
const MIN_DESKTOP_CENTER_WIDTH = 400;
@@ -21,6 +25,33 @@ export function resolveDesktopSidebarVisibility(input: {
);
}
/**
* Compact layouts always present navigation as a drawer overlay. Wide layouts dock it when it is
* visible and fall back to an overlay when the dock is collapsed or yields its width.
*/
export function resolveLeftSidebarPresentation(input: {
chromeEnabled: boolean;
isCompactLayout: boolean;
isFocusModeEnabled: boolean;
desktopSidebarVisible: boolean;
}): LeftSidebarPresentation {
if (!input.chromeEnabled) return "hidden";
if (input.isCompactLayout) return "overlay";
if (input.isFocusModeEnabled) return "hidden";
return input.desktopSidebarVisible ? "dock" : "overlay";
}
/**
* The overlay sidebar starts at the window's left edge, over the workspace's safe-area padding, so
* its content takes the left inset itself. The dock sits inside app chrome, which already pads it.
*/
export function resolveDesktopSidebarInsetLeft(input: {
overlay: boolean;
safeAreaLeft: number;
}): number {
return input.overlay ? input.safeAreaLeft : 0;
}
export function resolveDesktopAppChromeLayout(input: {
desktopSidebarRendered: boolean;
hasTopLeftWindowControls: boolean;
@@ -1,7 +1,12 @@
import { describe, expect, it } from "vitest";
import {
beginExplorerSidebarResize,
finishExplorerSidebarResize,
reconcileExplorerSidebarResize,
resolveExplorerSidebarDockSizes,
resolveExplorerSidebarFrameWidth,
resolveExplorerSidebarWidth,
type ExplorerSidebarResize,
} from "@/components/explorer-sidebar-layout";
describe("Explorer sidebar layout", () => {
@@ -19,4 +24,196 @@ describe("Explorer sidebar layout", () => {
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(840);
expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(390);
});
describe("native resize ownership", () => {
const allocation = {
width: 320,
containerWidth: 1200,
overlay: false,
resizeEnabled: true,
};
const dragging: ExplorerSidebarResize = {
sourceWidths: [allocation.width],
restoreWidth: allocation.width,
containerWidth: allocation.containerWidth,
pendingWidth: null,
};
it.each([false, true])(
"keeps a second drag through the previous release acknowledgement (released=%s)",
(releaseSecond) => {
const first = finishExplorerSidebarResize(dragging, 480, true);
const second = beginExplorerSidebarResize(first, allocation);
const current = releaseSecond ? finishExplorerSidebarResize(second, 600, true) : second;
const acknowledged = reconcileExplorerSidebarResize(current, { ...allocation, width: 480 });
expect(
resolveExplorerSidebarFrameWidth({
...allocation,
width: 480,
resize: acknowledged,
resizeWidth: 600,
}),
).toBe(600);
const released = finishExplorerSidebarResize(acknowledged, 600, true);
expect(released?.pendingWidth).toBe(600);
expect(reconcileExplorerSidebarResize(released, { ...allocation, width: 600 })).toBeNull();
},
);
it("keeps a return to the minimum while an earlier wider release is acknowledged", () => {
const initial = { ...allocation, width: 240 };
const wider = finishExplorerSidebarResize(
beginExplorerSidebarResize(null, initial),
540,
true,
);
const returned = finishExplorerSidebarResize(
beginExplorerSidebarResize(wider, initial),
240,
true,
);
const awaiting = reconcileExplorerSidebarResize(returned, initial);
const intermediate = reconcileExplorerSidebarResize(awaiting, { ...initial, width: 540 });
expect(
resolveExplorerSidebarFrameWidth({
...initial,
width: 540,
resize: intermediate,
resizeWidth: 240,
}),
).toBe(240);
expect(reconcileExplorerSidebarResize(intermediate, initial)).toBeNull();
});
it("cancels back to the latest release when earlier widths repeat", () => {
const first = finishExplorerSidebarResize(dragging, 480, true);
const second = finishExplorerSidebarResize(
beginExplorerSidebarResize(first, allocation),
600,
true,
);
const third = finishExplorerSidebarResize(
beginExplorerSidebarResize(second, allocation),
480,
true,
);
const fourth = beginExplorerSidebarResize(third, allocation);
const canceled = finishExplorerSidebarResize(fourth, 700, false);
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: canceled, resizeWidth: 700 }),
).toBe(480);
});
it("cancels a newer gesture back to its last released width", () => {
const first = finishExplorerSidebarResize(dragging, 480, true);
const second = beginExplorerSidebarResize(first, allocation);
const canceled = finishExplorerSidebarResize(second, 600, false);
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: canceled, resizeWidth: 600 }),
).toBe(480);
expect(reconcileExplorerSidebarResize(canceled, { ...allocation, width: 480 })).toBeNull();
});
it("keeps the drag width after release while the JS commit still has the old prop", () => {
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: dragging, resizeWidth: 480 }),
).toBe(480);
const released = finishExplorerSidebarResize(dragging, 480, true);
expect(reconcileExplorerSidebarResize(released, allocation)).toBe(released);
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: released, resizeWidth: 480 }),
).toBe(480);
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: released, resizeWidth: 320 }),
).toBe(480);
});
it("acknowledges the committed width and immediately follows subsequent props", () => {
const released = finishExplorerSidebarResize(dragging, 480, true);
const acknowledgedAllocation = { ...allocation, width: 480 };
expect(
resolveExplorerSidebarFrameWidth({
...acknowledgedAllocation,
resize: released,
resizeWidth: 480,
}),
).toBe(480);
const acknowledged = reconcileExplorerSidebarResize(released, acknowledgedAllocation);
expect(acknowledged).toBeNull();
expect(
resolveExplorerSidebarFrameWidth({
...allocation,
width: 360,
resize: acknowledged,
resizeWidth: 480,
}),
).toBe(360);
});
it.each([
{ name: "a new width prop", next: { ...allocation, width: 360 }, expected: 360 },
{
name: "a window allocation with an unchanged width prop",
next: { ...allocation, containerWidth: 900 },
expected: 320,
},
{
name: "a window allocation that clamps the width prop",
next: { ...allocation, width: 540, containerWidth: 900 },
expected: 540,
},
{ name: "overlay presentation", next: { ...allocation, overlay: true }, expected: 1200 },
{ name: "an inactive dock", next: { ...allocation, resizeEnabled: false }, expected: 320 },
])(
"yields synchronously to $name and does not resurrect the old handoff",
({ next, expected }) => {
const released = finishExplorerSidebarResize(dragging, 600, true);
expect(
resolveExplorerSidebarFrameWidth({ ...next, resize: released, resizeWidth: 600 }),
).toBe(expected);
const interrupted = reconcileExplorerSidebarResize(released, next);
expect(interrupted).toBeNull();
expect(
resolveExplorerSidebarFrameWidth({
...allocation,
resize: interrupted,
resizeWidth: 600,
}),
).toBe(320);
},
);
it("restores canonical intent when a drag is canceled", () => {
const canceled = finishExplorerSidebarResize(dragging, 480, false);
expect(canceled).toBeNull();
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: canceled, resizeWidth: 480 }),
).toBe(320);
});
it("cannot finish an interrupted drag over a newer allocation", () => {
const next = { ...allocation, width: 360, containerWidth: 1000 };
const interrupted = reconcileExplorerSidebarResize(dragging, next);
const released = finishExplorerSidebarResize(interrupted, 480, true);
expect(released).toBeNull();
expect(
resolveExplorerSidebarFrameWidth({ ...next, resize: released, resizeWidth: 480 }),
).toBe(360);
});
it("settles a release at the already-committed width without retaining ownership", () => {
const released = finishExplorerSidebarResize(dragging, allocation.width, true);
expect(reconcileExplorerSidebarResize(released, allocation)).toBeNull();
});
it("keeps an earlier released handoff when a later touch fails before activation", () => {
const released = finishExplorerSidebarResize(dragging, 480, true);
const finalized = finishExplorerSidebarResize(released, 480, false);
expect(finalized).toBe(released);
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: finalized, resizeWidth: 480 }),
).toBe(480);
});
});
});
@@ -16,6 +16,85 @@ export function resolveExplorerSidebarWidth(input: {
return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maximumVisibleWidth, requestedWidth));
}
// A release keeps its UI width until React acknowledges it or replaces its allocation.
// Null pendingWidth means the gesture still owns the live resizeWidth instead.
export interface ExplorerSidebarResize {
sourceWidths: number[];
restoreWidth: number;
containerWidth: number;
pendingWidth: number | null;
}
interface ExplorerSidebarAllocation {
width: number;
containerWidth: number;
overlay: boolean;
resizeEnabled: boolean;
}
export function reconcileExplorerSidebarResize(
resize: ExplorerSidebarResize | null,
allocation: ExplorerSidebarAllocation,
): ExplorerSidebarResize | null {
"worklet";
if (
!resize ||
allocation.overlay ||
!allocation.resizeEnabled ||
allocation.containerWidth !== resize.containerWidth ||
!resize.sourceWidths.includes(allocation.width) ||
(allocation.width === resize.pendingWidth &&
resize.sourceWidths.indexOf(allocation.width) === resize.sourceWidths.length - 1)
) {
return null;
}
const acknowledgedIndex = resize.sourceWidths.indexOf(allocation.width);
return acknowledgedIndex > 0
? { ...resize, sourceWidths: resize.sourceWidths.slice(acknowledgedIndex) }
: resize;
}
export function beginExplorerSidebarResize(
previous: ExplorerSidebarResize | null,
allocation: ExplorerSidebarAllocation,
): ExplorerSidebarResize {
"worklet";
const current = reconcileExplorerSidebarResize(previous, allocation);
// Released widths remain expected allocations while a newer gesture owns the frame.
const sourceWidths = current ? [...current.sourceWidths] : [allocation.width];
if (current && current.pendingWidth !== null && !sourceWidths.includes(current.pendingWidth))
sourceWidths.push(current.pendingWidth);
return {
sourceWidths,
restoreWidth: current ? (current.pendingWidth ?? current.restoreWidth) : allocation.width,
containerWidth: allocation.containerWidth,
pendingWidth: null,
};
}
export function finishExplorerSidebarResize(
resize: ExplorerSidebarResize | null,
width: number,
success: boolean,
): ExplorerSidebarResize | null {
"worklet";
if (!resize || resize.pendingWidth !== null) return resize;
if (success) return { ...resize, pendingWidth: width };
return resize.sourceWidths.length > 1 ? { ...resize, pendingWidth: resize.restoreWidth } : null;
}
export function resolveExplorerSidebarFrameWidth(
allocation: ExplorerSidebarAllocation & {
resize: ExplorerSidebarResize | null;
resizeWidth: number;
},
): number {
"worklet";
if (allocation.overlay) return allocation.containerWidth;
const resize = reconcileExplorerSidebarResize(allocation.resize, allocation);
return resize ? (resize.pendingWidth ?? allocation.resizeWidth) : allocation.width;
}
export function resolveExplorerSidebarDockSizes(input: {
requestedWidth?: number;
containerWidth: number;
+141 -145
View File
@@ -25,7 +25,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarCloseButton } from "@/components/headers/sidebar-close-button";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import {
resolveDesktopSidebarInsetLeft,
resolveDesktopSidebarWidth,
} from "@/components/desktop-sidebar-layout";
import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
@@ -65,6 +68,7 @@ import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { useLeftPanelWidth } from "@/mobile-panels/provider";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
@@ -121,9 +125,16 @@ interface MobileSidebarProps extends SidebarSharedProps {
interface DesktopSidebarProps extends SidebarSharedProps {
insetsTop: number;
active: boolean;
overlay: boolean;
}
export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boolean }) {
export const LeftSidebar = memo(function LeftSidebar({
active,
desktopOverlay,
}: {
active: boolean;
desktopOverlay: boolean;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -164,27 +175,20 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
const openProjectPicker = useOpenAddProject();
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
const handleOpenProjectMobile = useCallback(() => {
// Each destination closes the navigation overlay first. Closing is idempotent, so a docked
// sidebar runs the same handler.
const handleOpenProject = useCallback(() => {
void openProjectPicker();
}, [openProjectPicker]);
const handleOpenProjectDesktop = useCallback(() => {
void openProjectPicker();
}, [openProjectPicker]);
const handleSettingsMobile = useCallback(() => {
const handleSettings = useCallback(() => {
showMobileAgent();
router.push(buildSettingsRoute());
}, [showMobileAgent]);
const handleSettingsDesktop = useCallback(() => {
router.push(buildSettingsRoute());
}, []);
const handleAddHost = useAddHost();
const handleAddHostMobile = useAddHost();
const handleAddHostDesktop = handleAddHostMobile;
const handleOpenHostSettingsMobile = useCallback(
const handleOpenHostSettings = useCallback(
(serverId: string) => {
showMobileAgent();
openHostOverview(serverId);
@@ -192,14 +196,6 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
[showMobileAgent],
);
const handleOpenHostSettingsDesktop = useCallback((serverId: string) => {
openHostOverview(serverId);
}, []);
const handleImportSessionMobile = useCallback(() => {
openImportSession();
}, [openImportSession]);
const labels = useMemo(
(): SidebarLabels => ({
addProject: t("sidebar.actions.addProject"),
@@ -232,41 +228,35 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
labels,
};
if (isCompactLayout) {
return (
<>
<RetainedPanelActivity active={active}>
<MobileSidebar
{...sharedProps}
active={active}
insetsTop={insets.top}
insetsBottom={insets.bottom}
closeSidebar={showMobileAgent}
handleOpenProject={handleOpenProjectMobile}
handleImportSession={handleImportSessionMobile}
handleSettings={handleSettingsMobile}
handleAddHost={handleAddHostMobile}
handleOpenHostSettings={handleOpenHostSettingsMobile}
/>
</RetainedPanelActivity>
{importSessionSheet}
</>
);
}
const handlers = {
handleOpenProject,
handleImportSession: openImportSession,
handleSettings,
handleAddHost,
handleOpenHostSettings,
};
return (
<>
<RetainedPanelActivity active={active}>
<DesktopSidebar
{...sharedProps}
insetsTop={insets.top}
active={active}
handleOpenProject={handleOpenProjectDesktop}
handleImportSession={openImportSession}
handleSettings={handleSettingsDesktop}
handleAddHost={handleAddHostDesktop}
handleOpenHostSettings={handleOpenHostSettingsDesktop}
/>
{isCompactLayout ? (
<MobileSidebar
{...sharedProps}
{...handlers}
active={active}
insetsTop={insets.top}
insetsBottom={insets.bottom}
closeSidebar={showMobileAgent}
/>
) : (
<DesktopSidebar
{...sharedProps}
{...handlers}
insetsTop={insets.top}
active={active}
overlay={desktopOverlay}
/>
)}
</RetainedPanelActivity>
{importSessionSheet}
</>
@@ -663,10 +653,10 @@ function DesktopSidebar({
handleOpenHostSettings,
insetsTop,
active,
overlay,
}: DesktopSidebarProps) {
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const insets = useSafeAreaInsets();
@@ -678,14 +668,12 @@ function DesktopSidebar({
leftInset: insets.left,
rightInset: insets.right,
});
const visibleSidebarWidth =
divisionLayout?.sidebarWidth ??
resolveDesktopSidebarWidth({
requestedWidth: sidebarWidth,
viewportWidth,
});
const visibleSidebarWidth = useLeftPanelWidth();
const startWidthRef = useRef(visibleSidebarWidth);
const closeSidebar = usePanelStore((state) => state.showMobileAgent);
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture(overlay);
const startWidth = useSharedValue(visibleSidebarWidth);
const resizeWidth = useSharedValue(visibleSidebarWidth);
const [resizePressed, setResizePressed] = useState(false);
const showResizeGrip = useCallback(() => setResizePressed(true), []);
@@ -709,12 +697,12 @@ function DesktopSidebar({
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
.onStart((event) => {
startWidthRef.current = visibleSidebarWidth - event.translationX;
startWidth.value = visibleSidebarWidth - event.translationX;
resizeWidth.value = visibleSidebarWidth;
})
.onUpdate((event) => {
// Dragging right (positive translationX) increases width
const newWidth = startWidthRef.current + event.translationX;
const newWidth = startWidth.value + event.translationX;
resizeWidth.value = resolveDesktopSidebarWidth({
requestedWidth: newWidth,
viewportWidth,
@@ -732,6 +720,7 @@ function DesktopSidebar({
resizeWidth,
setSidebarWidth,
showResizeGrip,
startWidth,
viewportWidth,
visibleSidebarWidth,
],
@@ -740,101 +729,110 @@ function DesktopSidebar({
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: divisionLayout?.sidebarWidth ?? resizeWidth.value,
}));
// The panel host gets no width of its own: the dock sizes to this view and the overlay fills the
// window. A Reanimated width on the host reached Android after React had switched presentation,
// and a lost update left the workspace at the dock width after the dock collapsed.
const desktopSidebarStyle = useMemo(
() => [
staticStyles.desktopSidebar,
!active && staticStyles.desktopSidebarHidden,
resizeAnimatedStyle,
],
[active, resizeAnimatedStyle],
() => [staticStyles.desktopSidebar, resizeAnimatedStyle],
[resizeAnimatedStyle],
);
const desktopSidebarBorderStyle = useMemo(
() => [
styles.desktopSidebarBorder(divisionLayout !== null),
{ flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom },
{
flex: 1,
paddingTop: insetsTop,
paddingBottom: insets.bottom,
paddingLeft: resolveDesktopSidebarInsetLeft({ overlay, safeAreaLeft: insets.left }),
},
],
[insetsTop, insets.bottom, divisionLayout],
[insetsTop, insets.bottom, insets.left, overlay, divisionLayout],
);
const sidebarHeaderGroupStyle = useMemo(
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
[ownsTopLeft],
);
return (
<Animated.View
accessibilityElementsHidden={!active}
importantForAccessibility={active ? "auto" : "no-hide-descendants"}
pointerEvents={active ? "auto" : "none"}
style={desktopSidebarStyle}
<MobilePanelOverlay
panel="agent-list"
presentation={overlay ? "overlay" : "dock"}
dockVisible={active}
closeGesture={closeGesture}
>
<View style={desktopSidebarBorderStyle}>
<View style={styles.sidebarDragArea}>
{ownsTopLeft || DEV_BUILD_LABEL ? (
<View style={styles.desktopChromeRow}>
<Animated.View style={desktopSidebarStyle}>
<View style={desktopSidebarBorderStyle}>
<View style={styles.sidebarDragArea}>
{ownsTopLeft || DEV_BUILD_LABEL ? (
<View style={styles.desktopChromeRow}>
<TitlebarDragRegion />
{DEV_BUILD_LABEL ? (
<View
pointerEvents="none"
style={styles.devBuildBadge}
testID="dev-build-label"
accessibilityLabel={`Development build: ${DEV_BUILD_LABEL}`}
>
<GitBranch size={12} color={theme.colors.accentForeground} />
<Text numberOfLines={1} ellipsizeMode="tail" style={styles.devBuildBadgeText}>
{DEV_BUILD_LABEL}
</Text>
</View>
) : null}
</View>
) : (
<TitlebarDragRegion />
{DEV_BUILD_LABEL ? (
<View
pointerEvents="none"
style={styles.devBuildBadge}
testID="dev-build-label"
accessibilityLabel={`Development build: ${DEV_BUILD_LABEL}`}
>
<GitBranch size={12} color={theme.colors.accentForeground} />
<Text numberOfLines={1} ellipsizeMode="tail" style={styles.devBuildBadgeText}>
{DEV_BUILD_LABEL}
</Text>
</View>
) : null}
</View>
)}
<SidebarNavRows style={sidebarHeaderGroupStyle} onBeforeNavigate={closeSidebar} />
</View>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<TitlebarDragRegion />
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
onWorkspacePress={closeSidebar}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<SidebarNavRows style={sidebarHeaderGroupStyle} />
<SidebarCalloutSlot />
<SidebarFooter
handleOpenProject={handleOpenProject}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
onBeforeNavigate={closeSidebar}
/>
{!overlay && divisionLayout === null ? (
<SidebarResizeHandle
edge="right"
gesture={resizeGesture}
pressed={resizePressed}
testID="left-sidebar-resize-handle"
/>
) : null}
</View>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<SidebarCalloutSlot />
<SidebarFooter
handleOpenProject={handleOpenProject}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
/>
{divisionLayout === null ? (
<SidebarResizeHandle
edge="right"
gesture={resizeGesture}
pressed={resizePressed}
testID="left-sidebar-resize-handle"
/>
) : null}
</View>
</Animated.View>
</Animated.View>
</MobilePanelOverlay>
);
}
@@ -865,9 +863,7 @@ const workspacesSectionHeaderElement = <WorkspacesSectionHeader />;
const staticStyles = RNStyleSheet.create({
desktopSidebar: {
position: "relative" as const,
},
desktopSidebarHidden: {
display: "none",
flex: 1,
},
});
@@ -3,7 +3,7 @@ import { View, type PointerEvent as RNPointerEvent } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { startResizeHandleDrag, type ResizeHandleDrag } from "@/components/resize-handle-drag";
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
@@ -47,7 +47,7 @@ export function ResizeHandle({
onResizeSplit,
}: ResizeHandleProps) {
const { theme } = useUnistyles();
const finePointer = useHasFinePointer();
const { finePointer } = usePointerCapabilities();
const pointerStatesRef = useRef(new Map<number, PointerState>());
const touchStateRef = useRef<PointerState | null>(null);
const cursorBeforeDragRef = useRef<string | null>(null);
@@ -7,7 +7,7 @@ import {
type SidebarResizeEdge,
} from "@/components/sidebar-resize-handle-layout";
import { isWeb } from "@/constants/platform";
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
interface SidebarResizeHandleProps {
edge: SidebarResizeEdge;
@@ -29,7 +29,7 @@ function edgeOffsetStyle(edge: SidebarResizeEdge, edgeOffset: number) {
}
export function SidebarResizeHandle({ edge, gesture, pressed, testID }: SidebarResizeHandleProps) {
const finePointer = useHasFinePointer();
const { finePointer } = usePointerCapabilities();
if (finePointer) {
return <PointerResizeHandle edge={edge} gesture={gesture} pressed={pressed} testID={testID} />;
+124 -41
View File
@@ -1,3 +1,7 @@
import {
AdaptiveWorkspaceChrome,
type WorkspaceCompactHeaderActions,
} from "@/screens/workspace/adaptive-workspace-chrome";
import { useWorkspaceTabDrag } from "./workspace-tab-drag-context";
import {
Fragment,
@@ -32,6 +36,7 @@ import {
WindowChromeSafeArea,
useWindowChromeCorners,
removeWindowChromeCorner,
resolveChildWindowChromeCorners,
type WindowChromeCorners,
} from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
@@ -42,6 +47,7 @@ import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pa
import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host";
import {
WorkspaceDesktopTabsRow,
useWorkspaceNewTabKeyboardAction,
type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
@@ -61,6 +67,11 @@ import { isNative } from "@/constants/platform";
interface SplitContainerProps {
layout: WorkspaceLayout;
renderMainHeader?: () => ReactNode;
renderCompactHeader: (
tabs: WorkspaceTabDescriptor[],
activeTabKey: string,
actions: WorkspaceCompactHeaderActions,
) => ReactNode;
workspaceKey: string;
normalizedServerId: string;
normalizedWorkspaceId: string;
@@ -145,7 +156,6 @@ const EMPTY_SPLIT_SIZES: number[] = [];
export function SplitContainer({
layout,
renderMainHeader,
workspaceKey,
normalizedServerId,
normalizedWorkspaceId,
@@ -174,9 +184,11 @@ export function SplitContainer({
onReorderTabsInPane,
focusModeEnabled,
onExitFocusMode,
renderMainHeader,
renderCompactHeader,
}: SplitContainerProps) {
const inheritedWindowChromeCorners = useWindowChromeCorners();
const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none";
const windowChromeCorners = inheritedWindowChromeCorners;
const { activeDragTabId, dropPreview, tabDropPreview } = useWorkspaceTabDrag();
const [maximizedPane, setMaximizedPane] = useState<{
workspaceKey: string;
@@ -237,7 +249,6 @@ export function SplitContainer({
<View style={styles.workspaceShell}>
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
<View style={mainColumnStyle}>
{renderMainHeader?.()}
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
{renderRoot ? (
<SplitNodeView
@@ -279,6 +290,8 @@ export function SplitContainer({
onTogglePaneMaximized={handleTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
renderMainHeader={renderMainHeader}
renderCompactHeader={renderCompactHeader}
/>
) : null}
</View>
@@ -410,6 +423,8 @@ function SplitNodeView({
onTogglePaneMaximized,
focusModeEnabled,
onExitFocusMode,
renderMainHeader,
renderCompactHeader,
}: SplitNodeViewProps) {
const [groupContainerSize, setGroupContainerSize] = useState(0);
const groupId = node.kind === "group" ? node.group.id : null;
@@ -496,6 +511,8 @@ function SplitNodeView({
onTogglePaneMaximized={onTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
renderMainHeader={renderMainHeader}
renderCompactHeader={renderCompactHeader}
/>
</WindowChromeRegion>
</RetainedPanel>
@@ -544,12 +561,25 @@ function SplitNodeView({
showDropZones={showDropZones}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={windowChromeCorners}
windowChromeCorners={resolveChildWindowChromeCorners({
corners: windowChromeCorners,
direction: node.group.direction,
isFirst:
node.group.children.findIndex(
(candidate) => !isSplitNodeHiddenForPresentation(candidate, maximizedPaneId),
) === index,
isLast:
node.group.children.findLastIndex(
(candidate) => !isSplitNodeHiddenForPresentation(candidate, maximizedPaneId),
) === index,
})}
maximizedPaneId={maximizedPaneId}
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
onTogglePaneMaximized={onTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
renderMainHeader={renderMainHeader}
renderCompactHeader={renderCompactHeader}
/>
</SplitGroupChild>
{index < node.group.children.length - 1 &&
@@ -608,6 +638,8 @@ const SplitPaneView = memo(function SplitPaneView({
onTogglePaneMaximized,
focusModeEnabled,
onExitFocusMode,
renderMainHeader,
renderCompactHeader,
}: SplitPaneViewProps) {
recordRenderProfileReasons(`SplitPaneView:${pane.id}`, ["render"]);
const paneRef = useRef<View | null>(null);
@@ -701,6 +733,51 @@ const SplitPaneView = memo(function SplitPaneView({
() => onTogglePaneMaximized(paneId),
[onTogglePaneMaximized, paneId],
);
const compactHeaderActions = useMemo(
() => ({
pane: {
id: paneId,
splitRight: focusModeEnabled ? undefined : handleSplitRight,
splitDown: focusModeEnabled ? undefined : handleSplitDown,
toggleMaximized:
workspaceHasMultiplePanes && !focusModeEnabled ? handleTogglePaneMaximized : undefined,
maximized: paneId === maximizedPaneId,
},
closeAbove: handleCloseTabsToLeft,
closeBelow: handleCloseTabsToRight,
closeOthers: handleCloseOtherTabs,
}),
[
paneId,
focusModeEnabled,
handleSplitRight,
handleSplitDown,
workspaceHasMultiplePanes,
handleTogglePaneMaximized,
maximizedPaneId,
handleCloseTabsToLeft,
handleCloseTabsToRight,
handleCloseOtherTabs,
],
);
const createNewTab = useCallback(() => onCreateNewTab({ paneId }), [onCreateNewTab, paneId]);
useWorkspaceNewTabKeyboardAction({
handlerName: "workspace-chrome-new-tab",
ownsKeyboardShortcuts: isFocused && isWorkspaceFocused,
normalizedServerId,
normalizedWorkspaceId,
paneId,
createNewTab,
});
const compactHeader = useMemo(
() => (
<WindowChromeSafeArea placement="inline">
<TitlebarDragRegion />
{renderCompactHeader(paneTabs, activeTabDescriptor?.key ?? "", compactHeaderActions)}
</WindowChromeSafeArea>
),
[renderCompactHeader, paneTabs, activeTabDescriptor?.key, compactHeaderActions],
);
return (
<RenderProfile id={`SplitPaneView:${pane.id}`}>
<View
@@ -709,43 +786,49 @@ const SplitPaneView = memo(function SplitPaneView({
style={styles.pane}
testID={`workspace-pane-${pane.id}`}
>
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused && isWorkspaceFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateNewTab={onCreateNewTab}
onReorderTabs={handleReorderTabs}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
showPaneSplitActions={!focusModeEnabled}
showPaneMaximizeAction={workspaceHasMultiplePanes && !focusModeEnabled}
paneMaximized={paneId === maximizedPaneId}
onTogglePaneMaximized={handleTogglePaneMaximized}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
focusModeEnabled={Boolean(focusModeEnabled)}
onExitFocusMode={onExitFocusMode}
/>
</WindowChromeSafeArea>
<AdaptiveWorkspaceChrome compactHeader={compactHeader}>
{renderMainHeader?.()}
<WindowChromeRegion corners={focusModeEnabled ? "both" : "none"}>
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused && isWorkspaceFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateNewTab={onCreateNewTab}
onReorderTabs={handleReorderTabs}
ownsKeyboardShortcuts={false}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
showPaneSplitActions={!focusModeEnabled}
showPaneMaximizeAction={workspaceHasMultiplePanes && !focusModeEnabled}
paneMaximized={paneId === maximizedPaneId}
onTogglePaneMaximized={handleTogglePaneMaximized}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
focusModeEnabled={Boolean(focusModeEnabled)}
onExitFocusMode={onExitFocusMode}
/>
</WindowChromeSafeArea>
</WindowChromeRegion>
</AdaptiveWorkspaceChrome>
<View style={styles.paneContent}>
<WorkspacePanelHost
@@ -1,4 +1,4 @@
import { hasTouchScreen } from "@/hooks/use-fine-pointer";
import { hasTouchScreen } from "@/hooks/pointer-capabilities";
// The sizing gate, apart from the control sizes so that low-level modules such as
// `constants/layout` can size by it without loading the theme. Import it through
@@ -436,7 +436,7 @@ export function WorkspaceSetupDialog() {
desktopMaxWidth={640}
>
<FileDropZone style={styles.section}>
<KeyboardTranslateView>
<KeyboardTranslateView style={styles.composerSlot}>
<Composer
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
serverId={serverId}
@@ -487,6 +487,9 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.destructive,
lineHeight: 20,
},
composerSlot: {
paddingBottom: theme.spacing[4],
},
composerInputWrapper: {
backgroundColor: theme.colors.surface2,
},
@@ -19,15 +19,18 @@ import Animated, {
import { useKeyboardShift } from "@/keyboard/shift";
import {
resolveCenteredClearance,
resolveComposerBottomPlacement,
resolveComposerSlotCapacity,
updateComposerCapacity,
type ComposerCapacity,
} from "./internal/capacity";
import { SPACING } from "@/styles/theme";
const ViewportSizing = createContext<SharedValue<ComposerCapacity | undefined> | null>(null);
interface ComposerViewportProps extends ViewProps {
bottomInset?: number;
keyboardGap?: number;
centered?: boolean;
}
@@ -36,6 +39,7 @@ function ComposerViewport({
children,
onLayout,
bottomInset = 0,
keyboardGap = 0,
centered = false,
...props
}: ComposerViewportProps) {
@@ -48,6 +52,7 @@ function ComposerViewport({
bottomInset,
safeAreaBottom: safeAreaBottom.value,
keyboardShift: layoutShift.value,
keyboardGap,
centered,
}),
(geometry) => {
@@ -129,6 +134,10 @@ export function ComposerDock({
const insets = useSafeAreaInsets();
const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings);
const centeredClearance = useCenteredClearance(insets.bottom);
const placement = resolveComposerBottomPlacement({
safeAreaBottom: insets.bottom,
contentSpacing: SPACING[4],
});
// Preserve the existing centered form's visual balance on tablets.
const bottomInset = centered && !fullscreen ? HEADER_INNER_HEIGHT + 24 : 0;
const fullscreenSurface = [
@@ -176,7 +185,9 @@ export function ComposerDock({
style={fullscreen ? dockStyles.surface : dockStyles.centeredComposer}
fullscreen={fullscreen}
>
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
<View style={fullscreen ? fullscreenSurface : dockStyles.centeredComposerSurface}>
{composer}
</View>
</ComposerViewportContent>
</ComposerViewportContent>
</KeyboardTranslateView>
@@ -185,8 +196,12 @@ export function ComposerDock({
);
}
return (
<ComposerViewport style={dockStyles.viewport}>
<KeyboardTranslateView style={dockStyles.surface} enabled={!fullscreen}>
<ComposerViewport style={dockStyles.viewport} keyboardGap={placement.keyboardGap}>
<KeyboardTranslateView
style={dockStyles.surface}
enabled={!fullscreen}
keyboardGap={placement.keyboardGap}
>
<View
testID="composer-dock-content"
collapsable={false}
@@ -208,7 +223,7 @@ export function ComposerDock({
style={
fullscreen
? fullscreenSurface
: [dockStyles.composer, { paddingBottom: insets.bottom }]
: [dockStyles.composer, { paddingBottom: placement.restingPadding }]
}
>
{composer}
@@ -232,6 +247,7 @@ const dockStyles = StyleSheet.create({
centered: { flexShrink: 1, width: "100%" },
// Reserve the composer's own capped height before the setup scroll view shrinks.
centeredComposer: { width: "100%", flexShrink: 0 },
centeredComposerSurface: { width: "100%", flexShrink: 1 },
setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
hiddenSetup: { display: "none" },
});
+1 -1
View File
@@ -72,7 +72,7 @@ export function ComposerDock({
const styles = StyleSheet.create((theme) => ({
surface: { flex: 1, overflow: "hidden" },
content: { flex: 1, justifyContent: "flex-end" },
composer: { width: "100%", flexShrink: 1 },
composer: { width: "100%", flexShrink: 1, paddingBottom: theme.spacing[4] },
// Fixed, not absolute: a touch pan that starts inside an overflow-hidden ancestor
// never reaches the zoomed visual viewport, while a fixed box chains to the viewport
// directly. React Native Web renders `fixed`; React Native's style type omits it.
@@ -1,18 +1,66 @@
import { describe, expect, it } from "vitest";
import {
resolveCenteredClearance,
resolveComposerBottomPlacement,
resolveComposerSlotCapacity,
resolveInlineComposerCapacity,
resolveComposerCapacity,
updateComposerCapacity,
} from "./capacity";
describe("composer bottom placement", () => {
const keyboardHeight = 264;
/** Keyboard top minus composer bottom, both measured up from the window bottom. */
function keyboardGapAbove(safeAreaBottom: number): number {
const placement = resolveComposerBottomPlacement({ safeAreaBottom, contentSpacing: 16 });
const shift = keyboardHeight - safeAreaBottom;
const lift = shift + Math.min(placement.keyboardGap, shift);
return placement.restingPadding + lift - keyboardHeight;
}
it("rests at the bottom clearance without stacking a second gutter", () => {
expect(resolveComposerBottomPlacement({ safeAreaBottom: 34, contentSpacing: 16 })).toEqual({
restingPadding: 34,
keyboardGap: 16,
});
expect(
resolveComposerBottomPlacement({ safeAreaBottom: 24, contentSpacing: 16 }).restingPadding,
).toBe(24);
});
it.each([0, 20, 34])("never rests closer to the edge than the content spacing (%s)", (inset) => {
expect(
resolveComposerBottomPlacement({ safeAreaBottom: inset, contentSpacing: 16 }).restingPadding,
).toBe(Math.max(inset, 16));
});
it("keeps a measured zero clearance at the content spacing", () => {
expect(resolveComposerBottomPlacement({ safeAreaBottom: 0, contentSpacing: 16 })).toEqual({
restingPadding: 16,
keyboardGap: 0,
});
});
it.each([0, 20, 24, 34])("keeps the content spacing above the keyboard (%s)", (inset) => {
expect(keyboardGapAbove(inset)).toBe(16);
});
it("reserves the gap's extra rise out of the docked composer's capacity", () => {
const viewport = { height: 582, bottomInset: 0, safeAreaBottom: 34, centered: false };
const closed = resolveComposerCapacity({ ...viewport, keyboardShift: 0, keyboardGap: 16 });
const open = resolveComposerCapacity({ ...viewport, keyboardShift: 230, keyboardGap: 16 });
expect(closed).toBe(577);
expect(open).toBe(331);
});
});
describe("composer viewport", () => {
it("caps the complete compact composer at sixty percent of keyboard-reserved space", () => {
const open = updateComposerCapacity(undefined, {
height: 582,
bottomInset: 24,
safeAreaBottom: 0,
keyboardGap: 0,
centered: false,
keyboardShift: 308,
});
@@ -25,6 +73,7 @@ describe("composer viewport", () => {
height: 582,
bottomInset: 24,
safeAreaBottom: 0,
keyboardGap: 0,
centered: false,
keyboardShift: 308,
});
@@ -35,7 +84,13 @@ describe("composer viewport", () => {
);
});
it("preserves the editing capacity when the keyboard closes", () => {
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
const viewport = {
height: 582,
bottomInset: 24,
safeAreaBottom: 0,
keyboardGap: 0,
centered: false,
};
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 });
expect(open.capacity).toBe(245);
@@ -48,6 +103,7 @@ describe("composer viewport", () => {
height: 582,
bottomInset: 24,
safeAreaBottom: 0,
keyboardGap: 0,
centered: false,
keyboardShift: 308,
});
@@ -56,6 +112,7 @@ describe("composer viewport", () => {
height: 650,
bottomInset: 24,
safeAreaBottom: 0,
keyboardGap: 0,
centered: false,
keyboardShift: 0,
}).capacity,
@@ -65,6 +122,7 @@ describe("composer viewport", () => {
height: 0,
bottomInset: 24,
safeAreaBottom: 0,
keyboardGap: 0,
centered: false,
keyboardShift: 0,
}),
@@ -76,6 +134,7 @@ describe("composer viewport", () => {
bottomInset: 24,
safeAreaBottom: 0,
keyboardShift: 308,
keyboardGap: 0,
centered: false,
});
expect(height).toBe(245);
@@ -91,6 +150,7 @@ describe("composer viewport", () => {
bottomInset,
safeAreaBottom: 0,
keyboardShift,
keyboardGap: 0,
centered: true,
});
expect(height).toBe(695);
@@ -110,6 +170,7 @@ describe("composer viewport", () => {
bottomInset: 72,
safeAreaBottom: 0,
keyboardShift: 313,
keyboardGap: 0,
centered: true,
}),
).toBe(407);
@@ -122,6 +183,7 @@ describe("composer viewport", () => {
bottomInset: 60,
safeAreaBottom: 32,
keyboardShift: 288,
keyboardGap: 0,
centered: true,
};
const height = resolveComposerCapacity(geometry);
@@ -145,6 +207,7 @@ describe("composer viewport", () => {
bottomInset: 24,
safeAreaBottom: 0,
keyboardShift: 0,
keyboardGap: 0,
centered: false,
}),
).toBe(553);
@@ -154,6 +217,7 @@ describe("composer viewport", () => {
bottomInset: 0,
safeAreaBottom: 0,
keyboardShift: 200,
keyboardGap: 0,
centered: false,
}),
).toBe(95);
@@ -4,6 +4,8 @@ interface ComposerGeometry {
/** The keyboard also covers this space below its shift. */
safeAreaBottom: number;
keyboardShift: number;
/** A docked composer rises this much beyond the keyboard shift; see the placement. */
keyboardGap: number;
centered: boolean;
}
@@ -24,10 +26,36 @@ export function resolveComposerCapacity(input: ComposerGeometry): number {
input.height - input.bottomInset,
input.height - input.safeAreaBottom - input.keyboardShift,
)
: input.height - input.bottomInset - input.keyboardShift;
: input.height -
input.bottomInset -
input.keyboardShift -
Math.min(input.keyboardGap, input.keyboardShift);
return Math.max(0, available - 5);
}
export interface ComposerBottomPlacement {
/** The composer's distance from the window bottom with the keyboard closed. */
restingPadding: number;
/** How much further than the keyboard's safe-area overlap the composer rises. */
keyboardGap: number;
}
/**
* One bottom distance for the docked composer: the safe-area bottom, never closer to
* the edge than the content spacing. With the keyboard open it keeps the content
* spacing above the keyboard.
*/
export function resolveComposerBottomPlacement(input: {
safeAreaBottom: number;
contentSpacing: number;
}): ComposerBottomPlacement {
const restingPadding = Math.max(input.safeAreaBottom, input.contentSpacing);
return {
restingPadding,
keyboardGap: input.safeAreaBottom + input.contentSpacing - restingPadding,
};
}
/** Space below a centered form that the keyboard fills before the form moves. */
export function resolveCenteredClearance(input: {
viewportHeight: number;
+2 -4
View File
@@ -37,7 +37,7 @@ import {
TOOLBAR_CONTROL_HEIGHT,
} from "@/components/ui/control-geometry";
import { usePanelStore } from "@/stores/panel-store";
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
import { useShallow } from "zustand/shallow";
import {
ArrowUp,
@@ -1312,7 +1312,7 @@ function ComposerContentImpl({
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const buttonIconSize = iconSizeFor("md")[controlSizingForLayout(isCompactFormFactor)];
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
const hasFinePointer = useHasFinePointer();
const { finePointer: hasFinePointer } = usePointerCapabilities();
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder);
const hasText = useSyncExternalStore(
@@ -2568,7 +2568,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
width: "100%",
overflow: "visible",
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[4],
},
inputAreaLocked: {
opacity: 0.6,
@@ -2578,7 +2577,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
minHeight: 0,
alignItems: "stretch",
paddingHorizontal: 0,
paddingBottom: 0,
},
fullscreenFill: {
flex: 1,
+9 -3
View File
@@ -8,6 +8,7 @@ import {
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
Platform,
Text,
useWindowDimensions,
NativeSyntheticEvent,
@@ -36,7 +37,7 @@ import {
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import * as Haptics from "expo-haptics";
import { useDictation } from "@/hooks/use-dictation";
import { hasFinePointer } from "@/hooks/use-fine-pointer";
import { hasFinePointer } from "@/hooks/pointer-capabilities";
import { DictationOverlay } from "@/components/dictation-controls";
import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -1936,7 +1937,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const inputWrapperCombinedStyle = useMemo(
() => [
styles.inputWrapper,
isCompact && !isFullscreen && styles.inputWrapperCompact,
Platform.OS !== "ios" && isCompact && !isFullscreen && styles.inputWrapperCompact,
readOnly && styles.inputWrapperReadOnly,
inputWrapperStyle,
{ opacity: surfacePresentation.input.opacity },
@@ -2230,7 +2231,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
return (
<View
ref={rootRef}
style={isFullscreen ? styles.fullscreenContainer : styles.container}
style={
isFullscreen
? styles.fullscreenContainer
: [styles.container, Platform.OS === "ios" && isCompact && styles.inputWrapperCompact]
}
testID="message-input-root"
onLayout={handleComposerLayout}
>
@@ -2276,6 +2281,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
position: "relative",
},
inputWrapper: {
...(Platform.OS === "ios" ? { overflow: "hidden" as const } : {}),
flexShrink: 1,
flexDirection: "column",
gap: theme.spacing[3],
@@ -25,7 +25,7 @@ export async function fetchCheckoutStatus({
serverId: string;
cwd: string;
}): Promise<CheckoutStatusPayload> {
const payload = await client.getCheckoutStatus(cwd);
const payload = normalizeCheckoutBaseRef(await client.getCheckoutStatus(cwd));
return payload;
}
@@ -56,7 +56,7 @@ export function applyCheckoutStatusUpdateFromEvent({
serverId: string;
message: CheckoutStatusUpdate;
}): void {
const { payload } = message;
const payload = normalizeCheckoutBaseRef(message.payload);
const prStatus = payload.prStatus
? normalizeCheckoutPrStatusPayload(payload.prStatus)
: undefined;
@@ -88,6 +88,13 @@ export function applyCheckoutStatusUpdateFromEvent({
}
}
function normalizeCheckoutBaseRef<T extends CheckoutStatusPayload>(payload: T): T {
// COMPAT(workspaceBaseBranch): hosts <= v0.11.2 only send the display ref; remove after 2027-10-14.
return !payload.isGit || payload.baseRefExact === undefined
? payload
: { ...payload, baseRef: payload.baseRefExact };
}
// requestId changes on every emission and carries no PR state.
function prStatusWithoutVolatileFields(
prStatus: CheckoutPrStatusPayload,
+29
View File
@@ -19,6 +19,7 @@ import type { Theme } from "@/styles/theme";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
AlignJustify,
ArrowRight,
ChevronDown,
Columns2,
ExternalLink,
@@ -61,6 +62,7 @@ import { GitActionsSplitButton } from "@/git/actions-split-button";
import type { GitActions } from "@/git/policy";
import { BranchSwitcher } from "@/components/branch-switcher";
import { useGitActions } from "@/git/use-actions";
import { WorkspaceBaseBranchPicker } from "@/git/workspace-base-branch-picker";
import { GIT_ACTION_ICONS } from "@/git/action-icons";
import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
@@ -201,6 +203,7 @@ const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedAlignJustify = withUnistyles(AlignJustify);
const ThemedArrowRight = withUnistyles(ArrowRight);
const ThemedColumns2 = withUnistyles(Columns2);
const ThemedPilcrow = withUnistyles(Pilcrow);
const ThemedWrapText = withUnistyles(WrapText);
@@ -470,6 +473,8 @@ interface ChangesPullRequestLinkModel extends Pick<PrHint, "forge" | "number" |
}
interface ChangesRepositoryToolbarModel {
baseRef: string | null;
isWorktree: boolean;
branchName: string | null;
cwd: string;
gitActions: GitActions | null;
@@ -496,6 +501,8 @@ interface ChangesHeaderProps {
}
interface BuildChangesHeaderModelInput {
baseRef: string | null;
isWorktree: boolean;
branchName: string | null;
committedDescription?: string;
compact: boolean;
@@ -519,6 +526,8 @@ function buildChangesHeaderModel(input: BuildChangesHeaderModelInput): {
} {
return {
repository: {
baseRef: input.baseRef,
isWorktree: input.isWorktree,
branchName: input.branchName,
cwd: input.cwd,
gitActions: input.compact ? input.gitActions : null,
@@ -629,6 +638,10 @@ function ChangesToolbarTrailing({ children }: { children: ReactNode }) {
return <ToolbarControls style={styles.changesToolbarControls}>{children}</ToolbarControls>;
}
function isManagedWorktree(status: { isPaseoOwnedWorktree?: boolean } | null | undefined): boolean {
return status?.isPaseoOwnedWorktree === true;
}
function ChangesRepositoryToolbar({
compact,
model,
@@ -654,6 +667,18 @@ function ChangesRepositoryToolbar({
isGitCheckout
testID="changes-branch-switcher"
/>
{model.baseRef ? (
<>
<ThemedArrowRight size={14} uniProps={foregroundMutedIconColorMapping} />
<WorkspaceBaseBranchPicker
serverId={model.serverId}
workspaceId={model.workspaceId}
cwd={model.cwd}
baseRef={model.baseRef}
isWorktree={model.isWorktree}
/>
</>
) : null}
</ChangesToolbarLeading>
<ChangesToolbarTrailing>
{model.pullRequest ? (
@@ -1904,6 +1929,8 @@ export function ChangesSurface({
const changesHeaderModel = useMemo(
() =>
buildChangesHeaderModel({
baseRef: baseRef ?? null,
isWorktree: isManagedWorktree(status),
branchName: currentBranchName,
committedDescription: committedDiffDescription,
compact: isMobile,
@@ -1921,6 +1948,8 @@ export function ChangesSurface({
workspaceId,
}),
[
baseRef,
status,
committedDiffDescription,
currentBranchName,
cwd,
+83 -1
View File
@@ -2,7 +2,13 @@ import { afterEach, describe, expect, it } from "vitest";
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { actionsWithoutPrimary, buildGitActions, type BuildGitActionsInput } from "./policy";
import {
actionsWithoutPrimary,
buildGitActions,
isCurrentBranchBase,
canMergeIntoLocalBase,
type BuildGitActionsInput,
} from "./policy";
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
@@ -72,6 +78,7 @@ function createInput(
hasRemote: false,
isPaseoOwnedWorktree: false,
isOnBaseBranch: true,
canMergeLocally: true,
hasUncommittedChanges: false,
baseRefAvailable: true,
baseRefLabel: "main",
@@ -208,6 +215,81 @@ describe("git-actions-policy", () => {
]);
});
it("distinguishes exact local and remote base refs and retains legacy names", () => {
expect(isCurrentBranchBase("release", "refs/heads/release")).toBe(true);
expect(isCurrentBranchBase("release", "refs/remotes/origin/release")).toBe(false);
expect(isCurrentBranchBase("release", "release")).toBe(true);
expect(isCurrentBranchBase("release", "origin/release")).toBe(true);
expect(isCurrentBranchBase(null, "refs/heads/release")).toBe(false);
expect(canMergeIntoLocalBase("release", "refs/heads/release")).toBe(false);
expect(canMergeIntoLocalBase("feature", "refs/remotes/origin/release")).toBe(true);
expect(canMergeIntoLocalBase("release", "refs/remotes/upstream/release")).toBe(false);
});
it("offers local merge when the selected local base starts with origin slash", () => {
const actions = buildGitActions(
createInput({
isPaseoOwnedWorktree: true,
isOnBaseBranch: isCurrentBranchBase("release", "refs/heads/origin/release"),
canMergeLocally: canMergeIntoLocalBase("release", "refs/heads/origin/release"),
baseRefLabel: "origin/release",
aheadCount: 1,
shipDefault: "merge",
}),
);
expect(actions.primary?.id).toBe("merge-branch");
});
it("offers update but not local merge for a non-origin remote base", () => {
const actions = buildGitActions(
createInput({
isPaseoOwnedWorktree: true,
isOnBaseBranch: false,
canMergeLocally: canMergeIntoLocalBase("feature", "refs/remotes/upstream/release"),
baseRefLabel: "upstream/release",
aheadCount: 1,
behindBaseCount: 1,
aheadOfOrigin: null,
behindOfOrigin: null,
shipDefault: "merge",
}),
);
expect(actions.primary?.id).toBe("merge-from-base");
expect(actionsWithoutPrimary(actions).map((action) => action.id)).not.toContain("merge-branch");
});
it("does not offer merging into the current local branch when the remote base shares its name", () => {
const actions = buildGitActions(
createInput({
isPaseoOwnedWorktree: true,
isOnBaseBranch: false,
canMergeLocally: canMergeIntoLocalBase("release", "refs/remotes/origin/release"),
baseRefLabel: "release",
aheadCount: 1,
behindBaseCount: 1,
aheadOfOrigin: null,
behindOfOrigin: null,
shipDefault: "merge",
}),
);
expect(actions.primary?.id).toBe("merge-from-base");
expect(actionsWithoutPrimary(actions).map((action) => action.id)).not.toContain("merge-branch");
});
it("offers update from the same-named remote base when it is ahead", () => {
const actions = buildGitActions(
createInput({
isPaseoOwnedWorktree: true,
isOnBaseBranch: isCurrentBranchBase("release", "refs/remotes/origin/release"),
baseRefLabel: "release",
behindBaseCount: 1,
aheadOfOrigin: null,
behindOfOrigin: null,
}),
);
expect(actions.primary?.id).toBe("merge-from-base");
});
it("drops a promoted merge action from the remaining menu actions", () => {
const actions = buildGitActions(
createInput({
+26 -2
View File
@@ -50,6 +50,29 @@ interface GitActionRuntimeState {
handler: () => void;
}
export function isCurrentBranchBase(
currentBranch: string | null | undefined,
baseRef: string | null | undefined,
): boolean {
if (!currentBranch || !baseRef) return false;
if (baseRef.startsWith("refs/heads/")) return baseRef === `refs/heads/${currentBranch}`;
if (baseRef.startsWith("refs/")) return false;
// Older hosts send a display name, which cannot distinguish a local ref from its remote.
return currentBranch === baseRef.replace(/^origin\//, "");
}
export function canMergeIntoLocalBase(
currentBranch: string | null | undefined,
baseRef: string | null | undefined,
): boolean {
if (!currentBranch || !baseRef) return false;
if (baseRef.startsWith("refs/remotes/") && !baseRef.startsWith("refs/remotes/origin/")) {
return false;
}
const localTarget = baseRef.replace(/^(?:refs\/heads\/|refs\/remotes\/origin\/|origin\/)/, "");
return currentBranch !== localTarget;
}
export interface BuildGitActionsInput {
isGit: boolean;
githubFeaturesEnabled: boolean;
@@ -68,6 +91,7 @@ export interface BuildGitActionsInput {
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
canMergeLocally: boolean;
hasUncommittedChanges: boolean;
baseRefAvailable: boolean;
baseRefLabel: string;
@@ -349,7 +373,7 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.shipDefault === "pr" && canUsePullRequestActionAsShipDefault(input)) {
return "pr";
}
if (!input.isOnBaseBranch && input.aheadCount > 0) {
if (!input.isOnBaseBranch && input.canMergeLocally && input.aheadCount > 0) {
return "merge-branch";
}
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
@@ -380,7 +404,7 @@ function getPullRequestActionIds(filter: {
function getFeatureActionIds(input: BuildGitActionsInput): GitActionId[] {
return [
"merge-from-base",
"merge-branch",
...(input.canMergeLocally ? (["merge-branch"] as const) : []),
...getPullRequestActionIds({ roles: ["status", "direct", "auto"], input }),
];
}
+11 -4
View File
@@ -12,6 +12,8 @@ import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-st
import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import {
buildGitActions,
isCurrentBranchBase,
canMergeIntoLocalBase,
narrowPullRequestState,
type BuildGitActionsInput,
type GitAction,
@@ -116,7 +118,7 @@ interface DeriveGitActionsStateArgs {
hasUncommittedChanges: boolean;
postShipArchiveSuggested: boolean;
isStatusLoading: boolean;
baseRefLabel: string;
baseRef: string | undefined;
}
interface DerivedGitActionsState {
@@ -129,6 +131,7 @@ interface DerivedGitActionsState {
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
canMergeLocally: boolean;
shouldPromoteArchive: boolean;
}
@@ -167,7 +170,7 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
hasUncommittedChanges,
postShipArchiveSuggested,
isStatusLoading,
baseRefLabel,
baseRef,
} = args;
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
@@ -178,7 +181,8 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
hasPullRequest: Boolean(prStatus?.url),
hasRemote: gitStatus?.hasRemote ?? false,
isPaseoOwnedWorktree,
isOnBaseBranch: gitStatus?.currentBranch === baseRefLabel,
isOnBaseBranch: isCurrentBranchBase(gitStatus?.currentBranch, baseRef),
canMergeLocally: canMergeIntoLocalBase(gitStatus?.currentBranch, baseRef),
shouldPromoteArchive: computeShouldPromoteArchive({
hasUncommittedChanges,
postShipArchiveSuggested,
@@ -653,7 +657,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
hasUncommittedChanges,
postShipArchiveSuggested,
isStatusLoading,
baseRefLabel,
baseRef,
});
const {
actionsDisabled,
@@ -665,6 +669,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
canMergeLocally,
shouldPromoteArchive,
} = derived;
@@ -695,6 +700,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
canMergeLocally,
hasUncommittedChanges,
baseRefAvailable: Boolean(baseRef),
baseRefLabel,
@@ -812,6 +818,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
behindBaseCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
canMergeLocally,
githubFeaturesEnabled,
forge,
githubAutoMergeActionsEnabled,
@@ -0,0 +1,179 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { Text, View } from "react-native";
import { GitBranch } from "lucide-react-native";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useFetchQuery } from "@/data/query";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox";
import { ToolbarLabelSelectTrigger } from "@/components/ui/toolbar-label-trigger";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Button } from "@/components/ui/button";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { invalidateCheckoutGitQueriesForClient } from "./query-keys";
interface Props {
serverId: string;
workspaceId?: string | null;
cwd: string;
baseRef: string;
isWorktree: boolean;
}
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedGitBranch = withUnistyles(GitBranch);
const mutedColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function refLabel(ref: string): string {
return ref.replace(/^refs\/(heads|remotes)\//, "");
}
export function WorkspaceBaseBranchPicker({
serverId,
workspaceId,
cwd,
baseRef,
isWorktree,
}: Props) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const connected = useHostRuntimeIsConnected(serverId);
// COMPAT(workspaceBaseBranch): added after v0.11.2, remove gate after 2027-10-14.
const supported = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceBaseBranch === true,
);
const queryClient = useQueryClient();
const anchorRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const query = useDebouncedValue(search, 200);
const branches = useFetchQuery({
queryKey: ["workspaceBaseBranches", serverId, cwd, query],
queryFn: async () => {
if (!client) throw new Error(t("common.errors.daemonClientUnavailable"));
const result = await client.getBranchSuggestions({
cwd,
refMode: "exact",
query,
limit: 200,
});
if (result.error) throw new Error(result.error);
return result.branches;
},
enabled: open && supported && connected && Boolean(client),
retry: false,
dataShape: "value",
staleTimeMs: 0,
});
const mutation = useMutation({
mutationFn: async (ref: string) => {
if (!client || !workspaceId) throw new Error(t("common.errors.daemonClientUnavailable"));
return client.setWorkspaceBaseBranch({ workspaceId, baseRef: ref });
},
onSuccess: async () => {
setOpen(false);
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
},
});
const options = useMemo(
() => (branches.data ?? []).map((ref) => ({ id: ref, label: refLabel(ref) })),
[branches.data],
);
const error = mutation.error ?? branches.error;
const title = t("workspace.git.diff.baseBranch.title");
const label = refLabel(baseRef).replace(/^origin\//, "");
const { reset, mutate, isPending } = mutation;
const { refetch, isError } = branches;
const handleOpen = useCallback(() => {
reset();
setSearch("");
setOpen(true);
}, [reset]);
const handleSelect = useCallback(
(ref: string) => {
if (!isPending) mutate(ref);
},
[isPending, mutate],
);
const handleRetry = useCallback(() => {
void refetch();
}, [refetch]);
const branchIcon = useMemo(() => <ThemedGitBranch size={14} uniProps={mutedColor} />, []);
const renderOption = useCallback<NonNullable<ComboboxProps["renderOption"]>>(
({ option, selected, active, onPress }) => (
<ComboboxItem
label={option.label}
leadingSlot={branchIcon}
selected={selected}
active={active}
onPress={onPress}
disabled={isPending}
/>
),
[isPending, branchIcon],
);
const footer = useMemo(
() => (
<View style={styles.footer}>
{isPending ? <ThemedLoadingSpinner size="small" uniProps={mutedColor} /> : null}
{error ? (
<Text accessibilityRole="alert" testID="workspace-base-branch-error" style={styles.error}>
{error.message}
</Text>
) : null}
{isError ? (
<Button variant="outline" onPress={handleRetry}>
{t("common.actions.retry")}
</Button>
) : null}
</View>
),
[t, isPending, error, isError, handleRetry],
);
if (!supported || !isWorktree || !workspaceId) return <Text style={styles.text}>{label}</Text>;
return (
<View ref={anchorRef} collapsable={false} style={styles.anchor}>
<ToolbarLabelSelectTrigger
testID="workspace-base-branch-trigger"
label={label}
open={open}
disabled={!connected || mutation.isPending}
accessibilityRole="button"
accessibilityLabel={title}
onPress={handleOpen}
/>
<Combobox
options={options}
value={baseRef}
onSelect={handleSelect}
keepOpenOnSelect
searchable
onSearchQueryChange={setSearch}
searchPlaceholder={t("branchSwitcher.searchPlaceholder")}
emptyText={branches.isFetching ? t("common.states.loading") : t("branchSwitcher.empty")}
title={title}
open={open}
onOpenChange={setOpen}
anchorRef={anchorRef}
desktopPlacement="bottom-start"
desktopPreventInitialFlash
desktopMinWidth={300}
renderOption={renderOption}
footer={error || isPending ? footer : undefined}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
anchor: { flexShrink: 1, minWidth: 0 },
footer: { padding: theme.spacing[3], gap: theme.spacing[2] },
text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm },
}));
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { createPointerCapabilitiesSource } from "./pointer-capabilities";
class FakeMediaQueryList {
private readonly listeners = new Set<() => void>();
constructor(
readonly media: string,
public matches: boolean,
) {}
addEventListener(_type: "change", listener: () => void) {
this.listeners.add(listener);
}
removeEventListener(_type: "change", listener: () => void) {
this.listeners.delete(listener);
}
setMatches(matches: boolean) {
this.matches = matches;
for (const listener of this.listeners) listener();
}
}
function createFakeDisplay(initial: { finePointer: boolean; touchScreen: boolean }) {
const queries = {
finePointer: new FakeMediaQueryList("(hover: hover) and (pointer: fine)", initial.finePointer),
touchScreen: new FakeMediaQueryList("(any-pointer: coarse)", initial.touchScreen),
};
const matchMedia = (query: string) => {
const match = Object.values(queries).find((list) => list.media === query);
if (!match) throw new Error(`Unexpected media query ${query}`);
return match as unknown as MediaQueryList;
};
return { queries, matchMedia };
}
describe("pointer capabilities", () => {
it("reports a hybrid touch laptop as both a fine pointer and a touch screen", () => {
const display = createFakeDisplay({ finePointer: true, touchScreen: true });
const source = createPointerCapabilitiesSource(display.matchMedia);
expect(source.getSnapshot()).toEqual({ finePointer: true, touchScreen: true });
});
it("notifies and updates the touch answer when a touch screen appears or goes away", () => {
const display = createFakeDisplay({ finePointer: true, touchScreen: false });
const source = createPointerCapabilitiesSource(display.matchMedia);
const seen: boolean[] = [];
const unsubscribe = source.subscribe(() => seen.push(source.getSnapshot().touchScreen));
display.queries.touchScreen.setMatches(true);
display.queries.touchScreen.setMatches(false);
unsubscribe();
display.queries.touchScreen.setMatches(true);
expect(seen).toEqual([true, false]);
});
it("keeps one snapshot until an answer changes", () => {
const display = createFakeDisplay({ finePointer: false, touchScreen: true });
const source = createPointerCapabilitiesSource(display.matchMedia);
const first = source.getSnapshot();
expect(source.getSnapshot()).toBe(first);
display.queries.finePointer.setMatches(true);
expect(source.getSnapshot()).toEqual({ finePointer: true, touchScreen: true });
});
it("reports neither capability without media queries", () => {
expect(createPointerCapabilitiesSource(null).getSnapshot()).toEqual({
finePointer: false,
touchScreen: false,
});
});
});
@@ -0,0 +1,88 @@
import { useSyncExternalStore } from "react";
import { isWeb } from "@/constants/platform";
// A touch tablet running the desktop layout is still `isWeb === false || true`
// depending on the build, so neither platform gate answers "can this user
// hover and point precisely" or "can this user touch the screen". Only the
// media queries do, and the same queries notify when the answer changes
// (detachable keyboards, tablet mode, devtools touch emulation).
const FINE_POINTER_QUERY = "(hover: hover) and (pointer: fine)";
const TOUCH_SCREEN_QUERY = "(any-pointer: coarse)";
export interface PointerCapabilities {
/** A mouse-like pointer drives the UI. */
finePointer: boolean;
/** A touch screen is available, possibly next to a mouse on hybrid devices. */
touchScreen: boolean;
}
type MatchMedia = (query: string) => MediaQueryList;
export interface PointerCapabilitiesSource {
getSnapshot: () => PointerCapabilities;
subscribe: (onChange: () => void) => () => void;
}
export function createPointerCapabilitiesSource(
matchMedia: MatchMedia | null,
): PointerCapabilitiesSource {
const finePointerQuery = matchMedia?.(FINE_POINTER_QUERY) ?? null;
const touchScreenQuery = matchMedia?.(TOUCH_SCREEN_QUERY) ?? null;
let snapshot: PointerCapabilities = { finePointer: false, touchScreen: false };
return {
getSnapshot() {
const finePointer = finePointerQuery?.matches ?? false;
const touchScreen = touchScreenQuery?.matches ?? false;
if (snapshot.finePointer !== finePointer || snapshot.touchScreen !== touchScreen) {
snapshot = { finePointer, touchScreen };
}
return snapshot;
},
subscribe(onChange) {
finePointerQuery?.addEventListener("change", onChange);
touchScreenQuery?.addEventListener("change", onChange);
return () => {
finePointerQuery?.removeEventListener("change", onChange);
touchScreenQuery?.removeEventListener("change", onChange);
};
},
};
}
function browserMatchMedia(): MatchMedia | null {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return null;
return (query) => window.matchMedia(query);
}
// Native builds run on iOS and Android touch screens. A paired mouse or pen is
// filtered per gesture event by its pointer type, not by this capability.
const NATIVE_POINTER_CAPABILITIES: PointerCapabilities = { finePointer: false, touchScreen: true };
const nativeSource: PointerCapabilitiesSource = {
getSnapshot: () => NATIVE_POINTER_CAPABILITIES,
subscribe: () => () => {},
};
const source = isWeb ? createPointerCapabilitiesSource(browserMatchMedia()) : nativeSource;
const SERVER_SNAPSHOT: PointerCapabilities = { finePointer: false, touchScreen: false };
function getServerSnapshot(): PointerCapabilities {
return SERVER_SNAPSHOT;
}
export function usePointerCapabilities(): PointerCapabilities {
return useSyncExternalStore(source.subscribe, source.getSnapshot, getServerSnapshot);
}
/** True when a mouse-like pointer drives the UI, read at call time. */
export function hasFinePointer(): boolean {
return source.getSnapshot().finePointer;
}
/** Touch sizing remains enabled on hybrid browsers that report touch points. */
export function hasTouchScreen(): boolean {
return (
source.getSnapshot().touchScreen ||
(isWeb && typeof window !== "undefined" && window.navigator.maxTouchPoints > 0)
);
}
@@ -1,50 +0,0 @@
import { useEffect, useState } from "react";
import { isWeb } from "@/constants/platform";
// A touch tablet running the desktop layout is still `isWeb === false || true`
// depending on the build, so neither platform gate answers "can this user
// hover and point precisely". Only the media query does.
const FINE_POINTER_QUERY = "(hover: hover) and (pointer: fine)";
function getFinePointerQueryList(): MediaQueryList | null {
if (!isWeb || typeof window === "undefined" || typeof window.matchMedia !== "function") {
return null;
}
return window.matchMedia(FINE_POINTER_QUERY);
}
/** True when a mouse-like pointer drives the UI. Always false on native. */
export function hasFinePointer(): boolean {
return getFinePointerQueryList()?.matches ?? false;
}
/**
* True when the device has a touch screen, even while a mouse or keyboard is attached to it:
* the primary pointer is coarse, or the screen reports touch points. Always true on native.
*/
export function hasTouchScreen(): boolean {
if (!isWeb) return true;
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia("(pointer: coarse)").matches || window.navigator.maxTouchPoints > 0;
}
/**
* Reactive `hasFinePointer()` — re-renders when the user switches between a
* mouse and touch (detachable keyboards, tablet mode).
*/
export function useHasFinePointer(): boolean {
const [finePointer, setFinePointer] = useState(hasFinePointer);
useEffect(() => {
const mediaQuery = getFinePointerQueryList();
if (!mediaQuery) return;
const handleChange = () => setFinePointer(mediaQuery.matches);
handleChange();
mediaQuery.addEventListener?.("change", handleChange);
return () => {
mediaQuery.removeEventListener?.("change", handleChange);
};
}, []);
return finePointer;
}
+3
View File
@@ -930,6 +930,9 @@ export const ar: TranslationResources = {
},
},
diff: {
baseBranch: {
title: "الفرع الأساسي",
},
openChangesTab: "فتح علامة تبويب التغييرات",
openDiffTab: "فتح علامة تبويب الفرق",
closeChangesTab: "إغلاق علامة تبويب التغييرات",
+3
View File
@@ -938,6 +938,9 @@ export const en = {
},
},
diff: {
baseBranch: {
title: "Change base",
},
openChangesTab: "Open Changes tab",
openDiffTab: "Open Diff tab",
closeChangesTab: "Close Changes tab",
+3
View File
@@ -963,6 +963,9 @@ export const es: TranslationResources = {
},
},
diff: {
baseBranch: {
title: "Rama base",
},
openChangesTab: "Abrir la pestaña Cambios",
openDiffTab: "Abrir la pestaña Diferencia",
closeChangesTab: "Cerrar la pestaña Cambios",
+3
View File
@@ -956,6 +956,9 @@ export const fr: TranslationResources = {
},
},
diff: {
baseBranch: {
title: "Branche de base",
},
openChangesTab: "Ouvrir l’onglet Modifications",
openDiffTab: "Ouvrir l’onglet Diff",
closeChangesTab: "Fermer l’onglet Modifications",
+3
View File
@@ -943,6 +943,9 @@ export const ja: TranslationResources = {
},
},
diff: {
baseBranch: {
title: "ベースブランチ",
},
openChangesTab: "変更タブを開く",
openDiffTab: "差分タブを開く",
closeChangesTab: "変更タブを閉じる",
+3
View File
@@ -937,6 +937,9 @@ export const ko: TranslationResources = {
},
},
diff: {
baseBranch: {
title: "기준 브랜치",
},
openChangesTab: "변경사항 탭 열기",
openDiffTab: "Diff 탭 열기",
closeChangesTab: "변경사항 탭 닫기",
+3
View File
@@ -953,6 +953,9 @@ export const ptBR: TranslationResources = {
},
},
diff: {
baseBranch: {
title: "Branch base",
},
openChangesTab: "Abrir a aba Alterações",
openDiffTab: "Abrir a aba Diff",
closeChangesTab: "Fechar a aba Alterações",
+3
View File
@@ -947,6 +947,9 @@ export const ru: TranslationResources = {
},
},
diff: {
baseBranch: {
title: "Базовая ветка",
},
openChangesTab: "Открыть вкладку «Изменения»",
openDiffTab: "Открыть вкладку «Дифф»",
closeChangesTab: "Закрыть вкладку «Изменения»",
+3
View File
@@ -922,6 +922,9 @@ export const zhCN: TranslationResources = {
},
},
diff: {
baseBranch: {
title: "基准分支",
},
openChangesTab: "打开“更改”标签页",
openDiffTab: "打开 Diff 标签页",
closeChangesTab: "关闭“更改”标签页",
@@ -17,6 +17,7 @@ export const KeyboardShiftContext = createContext<KeyboardShiftContextValue | nu
export interface KeyboardTranslation {
enabled: boolean;
bottomClearance: number;
keyboardGap: number;
}
export interface WindowRect {
@@ -38,7 +39,7 @@ export interface KeyboardTranslationScope {
/** Views outside a KeyboardTranslateView stay put. */
export const KeyboardTranslationContext = createContext<KeyboardTranslationScope>({
translation: { enabled: false, bottomClearance: 0 },
translation: { enabled: false, bottomClearance: 0, keyboardGap: 0 },
measureAtRest: measureInWindow,
});
@@ -29,6 +29,21 @@ describe("keyboard layout reservation", () => {
});
describe("resolveKeyboardShift", () => {
it.each([0, -1])(
"ignores a rotation report with height %s and progress one",
(rawKeyboardHeight) => {
expect(
resolveKeyboardShift({
rawKeyboardHeight,
keyboardProgress: 1,
bottomInset: 24,
isIos: true,
iosMinHeight: 120,
}),
).toBe(0);
},
);
it("keeps the existing open-keyboard offset behavior", () => {
expect(
resolveKeyboardShift({
@@ -68,16 +83,46 @@ describe("resolveKeyboardShift", () => {
describe("resolveKeyboardLift", () => {
it("lifts a docked view by the whole shift", () => {
expect(resolveKeyboardLift({ shift: 302, enabled: true, bottomClearance: 0 })).toBe(302);
expect(
resolveKeyboardLift({ shift: 302, enabled: true, bottomClearance: 0, keyboardGap: 0 }),
).toBe(302);
});
it("lets the keyboard fill a centered form's clearance before lifting it", () => {
expect(resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 267 })).toBe(136);
expect(resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 455 })).toBe(0);
expect(
resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 267, keyboardGap: 0 }),
).toBe(136);
expect(
resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 455, keyboardGap: 0 }),
).toBe(0);
});
it("rises a docked composer's gap beyond the keyboard overlap, never at rest", () => {
const docked = { enabled: true, bottomClearance: 0, keyboardGap: 16 };
expect(resolveKeyboardLift({ ...docked, shift: 0 })).toBe(0);
expect(resolveKeyboardLift({ ...docked, shift: 8 })).toBe(16);
expect(resolveKeyboardLift({ ...docked, shift: 276 })).toBe(292);
});
it("measures the shift from the window's bottom clearance, not the rectangular inset", () => {
// iPhone Duo outer screen: the bar guide rests 24pt up where safeAreaInsets reports 34.
const shift = resolveKeyboardShift({
rawKeyboardHeight: 300,
keyboardProgress: 1,
bottomInset: 24,
isIos: true,
iosMinHeight: 120,
});
expect(shift).toBe(276);
const lift = resolveKeyboardLift({ shift, enabled: true, bottomClearance: 0, keyboardGap: 16 });
const restingComposerBottom = 24;
expect(restingComposerBottom + lift - 300).toBe(16);
});
it("keeps a disabled view in place", () => {
expect(resolveKeyboardLift({ shift: 302, enabled: false, bottomClearance: 0 })).toBe(0);
expect(
resolveKeyboardLift({ shift: 302, enabled: false, bottomClearance: 0, keyboardGap: 0 }),
).toBe(0);
});
});
@@ -37,9 +37,14 @@ export function resolveKeyboardLift(input: {
shift: number;
enabled: boolean;
bottomClearance: number;
keyboardGap: number;
}): number {
"worklet";
return input.enabled ? Math.max(0, input.shift - input.bottomClearance) : 0;
if (!input.enabled) return 0;
// The gap opens over the shift's first points, so the view never rises at rest.
return (
Math.max(0, input.shift - input.bottomClearance) + Math.min(input.keyboardGap, input.shift)
);
}
export function shouldReconcileHiddenKeyboardEnd(input: {
@@ -10,18 +10,27 @@ interface KeyboardTranslateViewProps extends ViewProps {
enabled?: boolean;
/** Space below the view's resting position that the keyboard may fill before the view moves. */
bottomClearance?: number;
/**
* Distance the view rises beyond the keyboard's overlap of the safe area, so content
* resting on the safe-area boundary keeps this gap above the open keyboard.
*/
keyboardGap?: number;
}
export function KeyboardTranslateView({
children,
enabled = true,
bottomClearance = 0,
keyboardGap = 0,
style,
...props
}: KeyboardTranslateViewProps) {
const { shift } = useKeyboardShift();
const frameRef = useRef<View>(null);
const translation = useMemo(() => ({ enabled, bottomClearance }), [bottomClearance, enabled]);
const translation = useMemo(
() => ({ enabled, bottomClearance, keyboardGap }),
[bottomClearance, enabled, keyboardGap],
);
const scope = useKeyboardTranslationScope(frameRef, translation);
const keyboardStyle = useAnimatedStyle(() => ({
transform: [{ translateY: -resolveKeyboardLift({ shift: shift.value, ...translation }) }],
@@ -10,30 +10,40 @@ interface KeyboardTranslateViewProps extends ViewProps {
enabled?: boolean;
/** Space below the view's resting position that the keyboard may fill before the view moves. */
bottomClearance?: number;
/**
* Distance the view rises beyond the keyboard's overlap of the safe area, so content
* resting on the safe-area boundary keeps this gap above the open keyboard.
*/
keyboardGap?: number;
}
export function KeyboardTranslateView({
children,
enabled = true,
bottomClearance = 0,
keyboardGap = 0,
style,
...props
}: KeyboardTranslateViewProps) {
const insets = useSafeAreaInsets();
const { height, progress } = useKeyboardAnimation();
const translateY = useMemo(
() =>
Animated.add(
Animated.add(height, Animated.multiply(progress, insets.bottom)),
bottomClearance,
).interpolate({
inputRange: [-1, 0],
outputRange: [-1, 0],
extrapolateLeft: "extend",
extrapolateRight: "clamp",
}),
[bottomClearance, height, insets.bottom, progress],
);
// Mirrors resolveKeyboardLift on the native driver: `overlap` is the negated shift.
const translateY = useMemo(() => {
const overlap = Animated.add(height, Animated.multiply(progress, insets.bottom));
const lift = Animated.add(overlap, bottomClearance).interpolate({
inputRange: [-1, 0],
outputRange: [-1, 0],
extrapolateLeft: "extend",
extrapolateRight: "clamp",
});
if (!(keyboardGap > 0)) return lift;
const gap = overlap.interpolate({
inputRange: [-keyboardGap, 0],
outputRange: [-keyboardGap, 0],
extrapolate: "clamp",
});
return Animated.add(lift, gap);
}, [bottomClearance, height, insets.bottom, keyboardGap, progress]);
// Fullscreen resizes this view while disabling translation. iOS Fabric can
// reapply its previous transform during that layout commit. Keep the graph
// attached and apply the mode on the native animation frame; a static zero or
@@ -57,7 +67,10 @@ export function KeyboardTranslateView({
);
const frameRef = useRef<View>(null);
const translation = useMemo(() => ({ enabled, bottomClearance }), [bottomClearance, enabled]);
const translation = useMemo(
() => ({ enabled, bottomClearance, keyboardGap }),
[bottomClearance, enabled, keyboardGap],
);
const scope = useKeyboardTranslationScope(frameRef, translation);
return (
@@ -5,12 +5,14 @@ interface KeyboardTranslateViewProps extends ViewProps {
children: ReactNode;
enabled?: boolean;
bottomClearance?: number;
keyboardGap?: number;
}
export function KeyboardTranslateView({
children,
enabled: _enabled,
bottomClearance: _bottomClearance,
keyboardGap: _keyboardGap,
...props
}: KeyboardTranslateViewProps) {
return <View {...props}>{children}</View>;
@@ -3,19 +3,36 @@ import { describe, expect, it } from "vitest";
import { resolveMobilePanelGestureIntent } from "./gesture-intent";
describe("mobile panel gesture intent", () => {
it("rejects a pending swipe when presentation changes before activation", () => {
expect(
resolveMobilePanelGestureIntent({
enabled: false,
deltaX: 40,
deltaY: 2,
direction: 1,
openGesturesBlocked: false,
openEdge: null,
}),
).toBe("fail");
});
it("blocks both panel-opening directions while the active surface owns horizontal dragging", () => {
expect([
resolveMobilePanelGestureIntent({
enabled: true,
deltaX: 40,
deltaY: 2,
direction: 1,
openGesturesBlocked: true,
openEdge: null,
}),
resolveMobilePanelGestureIntent({
enabled: true,
deltaX: -40,
deltaY: 2,
direction: -1,
openGesturesBlocked: true,
openEdge: null,
}),
]).toEqual(["fail", "fail"]);
});
@@ -23,17 +40,68 @@ describe("mobile panel gesture intent", () => {
it("keeps ordinary horizontal panel gestures available when unblocked", () => {
expect([
resolveMobilePanelGestureIntent({
enabled: true,
deltaX: 40,
deltaY: 2,
direction: 1,
openGesturesBlocked: false,
openEdge: null,
}),
resolveMobilePanelGestureIntent({
enabled: true,
deltaX: -40,
deltaY: 2,
direction: -1,
openGesturesBlocked: false,
openEdge: null,
}),
]).toEqual(["activate", "activate"]);
});
it("opens phone web drawers only from the edge they slide in from", () => {
const swipe = { deltaY: 2, openGesturesBlocked: false };
expect([
resolveMobilePanelGestureIntent({
enabled: true,
...swipe,
deltaX: 40,
direction: 1,
openEdge: { startX: 200, windowWidth: 400 },
}),
resolveMobilePanelGestureIntent({
enabled: true,
...swipe,
deltaX: 40,
direction: 1,
openEdge: { startX: 12, windowWidth: 400 },
}),
resolveMobilePanelGestureIntent({
enabled: true,
...swipe,
deltaX: -40,
direction: -1,
openEdge: { startX: 200, windowWidth: 400 },
}),
resolveMobilePanelGestureIntent({
enabled: true,
...swipe,
deltaX: -40,
direction: -1,
openEdge: { startX: 388, windowWidth: 400 },
}),
]).toEqual(["fail", "activate", "fail", "activate"]);
});
it("opens from anywhere when the layout does not require an edge start", () => {
expect(
resolveMobilePanelGestureIntent({
enabled: true,
deltaX: 40,
deltaY: 2,
direction: 1,
openGesturesBlocked: false,
openEdge: null,
}),
).toBe("activate");
});
});
@@ -1,14 +1,39 @@
export type MobilePanelGestureDirection = -1 | 1;
export type MobilePanelGestureIntent = "activate" | "fail" | "wait";
/** How far from the window edge an edge-only open gesture may start. */
export const MOBILE_PANEL_OPEN_EDGE_WIDTH = 32;
/**
* Where an edge-only open gesture started. Phone web opens drawers only from the edge they slide in
* from: interior horizontal scrollers (terminals, code blocks, diffs) keep their swipes there.
*/
export interface MobilePanelOpenEdge {
startX: number;
windowWidth: number;
}
function startsAtOpenEdge(edge: MobilePanelOpenEdge, direction: MobilePanelGestureDirection) {
"worklet";
return direction === 1
? edge.startX <= MOBILE_PANEL_OPEN_EDGE_WIDTH
: edge.startX >= edge.windowWidth - MOBILE_PANEL_OPEN_EDGE_WIDTH;
}
export function resolveMobilePanelGestureIntent(input: {
enabled: boolean;
deltaX: number;
deltaY: number;
direction: MobilePanelGestureDirection;
openGesturesBlocked: boolean;
/** Required start edge for opening gestures, or `null` when they may start anywhere. */
openEdge: MobilePanelOpenEdge | null;
}): MobilePanelGestureIntent {
"worklet";
if (input.openGesturesBlocked) {
if (!input.enabled || input.openGesturesBlocked) {
return "fail";
}
if (input.openEdge && !startsAtOpenEdge(input.openEdge, input.direction)) {
return "fail";
}
const directedDelta = input.deltaX * input.direction;
+55 -17
View File
@@ -1,16 +1,16 @@
import { createContext, useContext, useRef, type RefObject, useCallback, useMemo } from "react";
import { Gesture, type GestureType } from "react-native-gesture-handler";
import { useSharedValue } from "react-native-reanimated";
import { Gesture, PointerType, type GestureType } from "react-native-gesture-handler";
import { useDerivedValue, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
import { usePanelStore } from "@/stores/panel-store";
import { canBeginMobilePanelGesture, isMobilePanelGestureCurrent } from "./model";
import { useMobilePanelsRuntime } from "./provider";
import { resolveMobilePanelGestureIntent } from "./gesture-intent";
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
function isCurrentSelection(startedRevision: number): boolean {
return usePanelStore.getState().mobilePanel.revision === startedRevision;
}
@@ -23,6 +23,15 @@ function useGestureState() {
};
}
/**
* Gates a sidebar pan on the UI thread. The sidebar flips between the dock and the overlay at
* runtime, and on Android flipping the handler's `.enabled()` config from false back to true left
* it inert until the app reloaded (RNGH 2.32), so `.enabled()` only carries the device capability.
*/
function useGestureGate(enabled: boolean) {
return useDerivedValue(() => enabled, [enabled]);
}
function useRevisionCommit(action: () => void) {
return useCallback(
(revision: number) => {
@@ -39,24 +48,35 @@ export function useOpenAgentListGesture(enabled: boolean) {
beginGesture,
finishGesture,
leftOpenGestureRef,
leftPanelWidth: width,
motionState,
openGesturesBlocked,
position,
updateGesture,
windowWidth,
} = useMobilePanelsRuntime();
const { touchScreen: hasTouchScreen } = usePointerCapabilities();
// Phone web keeps interior swipes for the page's horizontal scrollers; the wide overlay opens
// from anywhere in the workspace.
const isCompact = useIsCompactFormFactor();
const openFromEdgeOnly = isWeb && isCompact;
const horizontalScroll = useHorizontalScrollOptional();
const { startedRevision, touchStartX, touchStartY } = useGestureState();
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const commit = useRevisionCommit(showMobileAgentList);
const gate = useGestureGate(enabled);
return useMemo(
() =>
Gesture.Pan()
.withRef(leftOpenGestureRef)
.enabled(enabled)
.enabled(hasTouchScreen)
.manualActivation(true)
.onTouchesDown((event) => {
.onTouchesDown((event, stateManager) => {
if (!gate.value || event.pointerType !== PointerType.TOUCH) {
stateManager.fail();
return;
}
const touch = event.changedTouches[0];
if (touch) {
touchStartX.value = touch.absoluteX;
@@ -76,15 +96,16 @@ export function useOpenAgentListGesture(enabled: boolean) {
}
const panIntent = resolveMobilePanelGestureIntent({
enabled: gate.value,
deltaX,
deltaY,
direction: 1,
openGesturesBlocked: openGesturesBlocked.value,
openEdge: openFromEdgeOnly ? { startX: touchStartX.value, windowWidth } : null,
});
if (
!canBeginMobilePanelGesture(motionState.value, "agent", position.value) ||
horizontalScroll?.isAnyScrolledRight.value ||
(isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) ||
panIntent === "fail"
) {
stateManager.fail();
@@ -98,10 +119,10 @@ export function useOpenAgentListGesture(enabled: boolean) {
startedRevision.value = beginGesture({ origin: "agent" });
})
.onUpdate((event) => {
updateGesture(startedRevision.value, -event.translationX / windowWidth);
updateGesture(startedRevision.value, -event.translationX / width);
})
.onEnd((event, success) => {
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
const shouldOpen = event.translationX > width / 3 || event.velocityX > 500;
const result = finishGesture({
startedRevision: startedRevision.value,
target: shouldOpen ? "agent-list" : "agent",
@@ -113,43 +134,53 @@ export function useOpenAgentListGesture(enabled: boolean) {
}),
[
commit,
enabled,
beginGesture,
gate,
finishGesture,
hasTouchScreen,
horizontalScroll?.isAnyScrolledRight,
leftOpenGestureRef,
motionState,
openFromEdgeOnly,
openGesturesBlocked,
position,
startedRevision,
touchStartX,
touchStartY,
updateGesture,
width,
windowWidth,
],
);
}
export function useCloseAgentListGesture() {
export function useCloseAgentListGesture(enabled = true) {
const {
beginGesture,
finishGesture,
leftCloseGestureRef,
leftPanelWidth: width,
motionState,
position,
updateGesture,
windowWidth,
} = useMobilePanelsRuntime();
const { touchScreen: hasTouchScreen } = usePointerCapabilities();
const { startedRevision, touchStartX, touchStartY } = useGestureState();
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const commit = useRevisionCommit(showMobileAgent);
const gate = useGestureGate(enabled);
const gesture = useMemo(
() =>
Gesture.Pan()
.withRef(leftCloseGestureRef)
.enabled(hasTouchScreen)
.manualActivation(true)
.onTouchesDown((event) => {
.onTouchesDown((event, stateManager) => {
if (!gate.value || event.pointerType !== PointerType.TOUCH) {
stateManager.fail();
return;
}
const touch = event.changedTouches[0];
if (touch) {
touchStartX.value = touch.absoluteX;
@@ -169,10 +200,12 @@ export function useCloseAgentListGesture() {
}
const panIntent = resolveMobilePanelGestureIntent({
enabled: gate.value,
deltaX,
deltaY,
direction: -1,
openGesturesBlocked: false,
openEdge: null,
});
if (
!canBeginMobilePanelGesture(motionState.value, "agent-list", position.value) ||
@@ -191,10 +224,10 @@ export function useCloseAgentListGesture() {
});
})
.onUpdate((event) => {
updateGesture(startedRevision.value, -1 - event.translationX / windowWidth);
updateGesture(startedRevision.value, -1 - event.translationX / width);
})
.onEnd((event, success) => {
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
const shouldClose = event.translationX < -width / 3 || event.velocityX < -500;
const result = finishGesture({
startedRevision: startedRevision.value,
target: shouldClose ? "agent" : "agent-list",
@@ -207,6 +240,8 @@ export function useCloseAgentListGesture() {
[
beginGesture,
commit,
gate,
hasTouchScreen,
finishGesture,
leftCloseGestureRef,
motionState,
@@ -215,7 +250,7 @@ export function useCloseAgentListGesture() {
touchStartX,
touchStartY,
updateGesture,
windowWidth,
width,
],
);
@@ -269,14 +304,15 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer
}
const panIntent = resolveMobilePanelGestureIntent({
enabled: enabled,
deltaX,
deltaY,
direction: -1,
openGesturesBlocked: openGesturesBlocked.value,
openEdge: isWeb ? { startX: touchStartX.value, windowWidth } : null,
});
if (
!canBeginMobilePanelGesture(motionState.value, "agent", position.value) ||
(isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) ||
panIntent === "fail"
) {
stateManager.fail();
@@ -363,10 +399,12 @@ export function useCloseFileExplorerGesture(enabled = true) {
}
const panIntent = resolveMobilePanelGestureIntent({
enabled: enabled,
deltaX,
deltaY,
direction: 1,
openGesturesBlocked: false,
openEdge: null,
});
if (
!canBeginMobilePanelGesture(motionState.value, "file-explorer", position.value) ||
@@ -17,6 +17,7 @@ class MobilePanelsScenario {
private startedRevision = -1;
private state: MobilePanelMotionState;
readonly commits: MobilePanelCommit[] = [];
readonly animations: MobilePanelView[] = [];
constructor(target: MobilePanelView = "agent") {
this.state = createMobilePanelMotionState({ target, revision: this.nextRevision });
@@ -74,6 +75,9 @@ class MobilePanelsScenario {
if (transition.commit) {
this.commits.push(transition.commit);
}
if (transition.animationTarget) {
this.animations.push(transition.animationTarget);
}
}
}
@@ -237,6 +241,29 @@ describe("mobile panel ownership", () => {
expect(panels.commits).toEqual([]);
});
it("returns an interrupted drag to the anchor when the command keeps its target", () => {
const panels = new MobilePanelsScenario();
panels.beginGesture("agent").command("agent").finishGesture("agent-list");
expect(panels.animations).toEqual(["agent"]);
expect(panels.snapshot()).toEqual({
target: "agent",
motionTarget: "agent",
settledTarget: "agent",
revision: 1,
});
expect(panels.commits).toEqual([]);
});
it("lets the command for a completed drag adopt the drag's finishing motion", () => {
const panels = new MobilePanelsScenario();
panels.beginGesture("agent").finishGesture("agent-list").command("agent-list");
expect(panels.animations).toEqual(["agent-list"]);
});
it("keeps the latest rapid command when position reaches a stale anchor", () => {
const panels = new MobilePanelsScenario();
+3 -1
View File
@@ -78,7 +78,9 @@ export function transitionMobilePanel(
if (event.selection.revision <= state.revision) {
return { state };
}
const continuesCurrentMotion = state.motionTarget === event.selection.target;
// An interrupted drag left the position between anchors, so it moves back even when the
// target is unchanged. Without a drag, the command adopts motion already heading there.
const continuesCurrentMotion = !state.gesture && state.motionTarget === event.selection.target;
return {
animationTarget: continuesCurrentMotion ? undefined : event.selection.target,
state: {
@@ -29,26 +29,27 @@ export function MobilePanelOverlay({
dockVisible = false,
frameStyle,
}: MobilePanelOverlayProps) {
const { position, windowWidth } = useMobilePanelsRuntime();
const { leftPanelWidth, position, windowWidth } = useMobilePanelsRuntime();
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const overlayOpen = useIsMobilePanelActive(panel);
const docked = presentation === "dock";
const isOpen = docked ? dockVisible : overlayOpen;
const hostId = useId();
const isLeft = panel === "agent-list";
const width = isLeft ? leftPanelWidth : windowWidth;
const sidebarAnimatedStyle = useAnimatedStyle(() => {
if (docked) return { transform: [{ translateX: 0 }] };
const frame = getMobilePanelFrame(position.value, windowWidth);
const frame = getMobilePanelFrame(position.value, width);
return {
transform: [{ translateX: isLeft ? frame.leftTranslateX : frame.rightTranslateX }],
};
}, [docked, isLeft, windowWidth]);
}, [docked, isLeft, width]);
const backdropAnimatedStyle = useAnimatedStyle(() => {
const frame = getMobilePanelFrame(position.value, windowWidth);
const frame = getMobilePanelFrame(position.value, width);
return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity };
}, [isLeft, windowWidth]);
}, [isLeft, width]);
const overlayAnimatedStyle = useAnimatedStyle(() => {
if (docked) return { opacity: 1, display: "flex" as const, pointerEvents: "box-none" as const };
@@ -73,11 +74,11 @@ export function MobilePanelOverlay({
() => [
docked ? styles.dockedPanel : styles.panel,
!docked && positionedPanelStyle,
!docked && { width: windowWidth },
!docked && { width },
panelStyle,
sidebarAnimatedStyle,
],
[docked, panelStyle, positionedPanelStyle, sidebarAnimatedStyle, windowWidth],
[docked, panelStyle, positionedPanelStyle, sidebarAnimatedStyle, width],
);
let overlayPointerEvents: "auto" | "box-none" | "none";
if (!isWeb) {
+43 -5
View File
@@ -1,3 +1,6 @@
import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
import { subscribeMobilePanelSelection } from "./selection";
import {
createContext,
useCallback,
@@ -20,7 +23,10 @@ import {
withTiming,
type SharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import {
usePanelStore,
@@ -45,6 +51,7 @@ interface MobilePanelsRuntime {
beginGesture: (input: BeginGestureInput) => number;
finishGesture: (input: FinishGestureInput) => MobilePanelCommit | null;
leftCloseGestureRef: RefObject<GestureType | undefined>;
leftPanelWidth: number;
leftOpenGestureRef: RefObject<GestureType | undefined>;
motionState: SharedValue<MobilePanelMotionState>;
openGesturesBlocked: SharedValue<boolean>;
@@ -68,8 +75,36 @@ interface FinishGestureInput {
const MobilePanelsContext = createContext<MobilePanelsRuntime | null>(null);
const MobilePanelActiveContext = createContext<MobilePanelView>("agent");
/**
* The left sidebar's width: the full window for the compact drawer, the saved sidebar width
* clamped to the safe viewport on wide layouts. The wide dock, its overlay, and the gestures that
* drag it all read this one value.
*/
function useResolvedLeftPanelWidth(windowWidth: number): number {
const isCompact = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const { height: windowHeight } = useWindowDimensions();
const divisionLayout = resolveDesktopDivisionLayout({
geometry: useWindowLayout(),
windowWidth,
windowHeight,
leftInset: insets.left,
rightInset: insets.right,
});
if (isCompact) return windowWidth;
return (
divisionLayout?.sidebarWidth ??
resolveDesktopSidebarWidth({
requestedWidth: sidebarWidth,
viewportWidth: windowWidth - insets.left - insets.right,
})
);
}
export function MobilePanelsProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const leftPanelWidth = useResolvedLeftPanelWidth(windowWidth);
const initialSelection = useRef(usePanelStore.getState().mobilePanel).current;
const position = useSharedValue(getMobilePanelAnchor(initialSelection.target));
const motionState = useSharedValue(createMobilePanelMotionState(initialSelection));
@@ -152,11 +187,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
);
useEffect(() => {
return usePanelStore.subscribe((state, previousState) => {
const selection = state.mobilePanel;
if (selection === previousState.mobilePanel) {
return;
}
return subscribeMobilePanelSelection((selection) => {
scheduleOnUI(applySelection, selection);
});
}, [applySelection]);
@@ -217,6 +248,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
finishGesture,
leftCloseGestureRef,
leftOpenGestureRef,
leftPanelWidth,
motionState,
openGesturesBlocked,
position,
@@ -228,6 +260,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
[
beginGesture,
finishGesture,
leftPanelWidth,
motionState,
openGesturesBlocked,
position,
@@ -255,6 +288,11 @@ export function useMobilePanelsRuntime(): MobilePanelsRuntime {
return context;
}
/** The resolved left sidebar width shared by the dock, the overlay, and their gestures. */
export function useLeftPanelWidth(): number {
return useMobilePanelsRuntime().leftPanelWidth;
}
export function useIsMobilePanelActive(panel: MobilePanelView): boolean {
return useContext(MobilePanelActiveContext) === panel;
}
@@ -0,0 +1,14 @@
import { usePanelStore, type MobilePanelSelection } from "@/stores/panel-store";
/** Observe panel commands, including layout commands published before subscription. */
export function subscribeMobilePanelSelection(
onSelection: (selection: MobilePanelSelection) => void,
) {
const unsubscribe = usePanelStore.subscribe((state, previousState) => {
if (state.mobilePanel !== previousState.mobilePanel) {
onSelection(state.mobilePanel);
}
});
onSelection(usePanelStore.getState().mobilePanel);
return unsubscribe;
}
@@ -177,6 +177,7 @@ describe("resolveStartupRoute", () => {
workspaceSelectionStatus: "unknown" as const,
isWorkspaceSelectionLoaded: true,
hasGivenUpWaitingForHost: false,
isWaitingForSetup: false,
};
const baseHostInput = {
route: { kind: "host" as const, serverId: "server-saved" },
@@ -278,6 +279,28 @@ describe("resolveStartupRoute", () => {
).toEqual({ kind: "redirect", href: "/h/srv-desktop" });
});
it("holds the splash over the empty home while setup may still open", () => {
expect(
resolveStartupRoute({
...baseIndexInput,
hosts: [{ serverId: "srv-desktop" }],
anyOnlineHostServerId: "srv-desktop",
isWaitingForSetup: true,
}),
).toEqual({ kind: "splash" });
});
it("restores a saved workspace without waiting for setup", () => {
expect(
resolveStartupRoute({
...baseIndexInput,
hosts: [{ serverId: "server-1" }],
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
isWaitingForSetup: true,
}),
).toEqual({ kind: "redirect", href: "/h/server-1" });
});
it("keeps a known connecting host in app-owned routing instead of showing welcome", () => {
expect(
resolveStartupRoute({
@@ -117,6 +117,8 @@ export interface ResolveIndexStartupRouteInput extends ResolveStartupRouteBaseIn
workspaceSelectionStatus: WorkspaceSelectionStatus;
isWorkspaceSelectionLoaded: boolean;
hasGivenUpWaitingForHost: boolean;
/** Setup may still open over the empty home; see `useIsSetupDecisionPending`. */
isWaitingForSetup: boolean;
}
export interface ResolveHostStartupRouteInput extends ResolveStartupRouteBaseInput {
@@ -198,6 +200,12 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St
};
}
// Without a workspace to restore, startup lands on the empty home. A new user's setup opens
// over it, so hold the splash until that is known instead of flashing the home first.
if (input.isWaitingForSetup) {
return { kind: "splash" };
}
if (input.anyOnlineHostServerId) {
return { kind: "redirect", href: buildHostRootRoute(input.anyOnlineHostServerId) };
}
+4 -2
View File
@@ -2,8 +2,10 @@
* First-run setup: a one-time modal that walks a new user through the surfaces Settings
* already has — appearance, providers, projects, Paseo tools and phone pairing.
*
* The app shell mounts the host; Settings reopens it. Whether it opens, which steps it has,
* and how completion is remembered stay inside; import from `@/onboarding` only.
* The app shell mounts the host, startup waits for its decision, and Settings reopens it. Whether
* it opens, which steps it has, and how completion is remembered stay inside; import from
* `@/onboarding` only.
*/
export { OnboardingHost } from "./onboarding-host";
export { useOpenOnboarding } from "./use-open-onboarding";
export { useIsSetupDecisionPending } from "./use-setup-decision-pending";
@@ -0,0 +1,24 @@
import { useSyncExternalStore } from "react";
import { isNative } from "@/constants/platform";
import { useHostRuntimeConnectionStatus } from "@/runtime/host-runtime";
import { getOnboardingModel } from "./internal/onboarding-instance";
import { useSetupHostServerId } from "./internal/use-setup-host";
/**
* True while this launch may still open setup: it has not been decided, and the setup host is
* connecting, or connected and loading its workspaces. Startup keeps its loading screen up
* meanwhile, so a new user lands on setup rather than on the empty home first. False on phones,
* where setup never opens, when there is no setup host, and while the host is unreachable.
*/
export const useIsSetupDecisionPending: () => boolean = isNative
? () => false
: useIsSetupDecisionPendingOnDesktop;
function useIsSetupDecisionPendingOnDesktop(): boolean {
const model = getOnboardingModel();
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
const serverId = useSetupHostServerId();
const connectionStatus = useHostRuntimeConnectionStatus(serverId ?? "");
if (state.status !== "deciding" || !serverId) return false;
return connectionStatus !== "offline" && connectionStatus !== "error";
}
@@ -31,7 +31,7 @@ import {
import { useTranslation } from "react-i18next";
import type { PendingPermission } from "@/types/shared";
import type { StreamItem } from "@/types/stream";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { deriveSubagentStatusBucket } from "@/subagents";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import type { TurnPresentation } from "@/timeline/turn-liveness";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
@@ -107,10 +107,7 @@ function useProviderSubagentDescriptor(
titleState: descriptor ? "ready" : "loading",
icon: useProviderIcon(provider, context.serverId),
statusBucket: descriptor
? deriveSidebarStateBucket({
status: providerSubagentLifecycleStatus(descriptor.status),
requiresAttention: descriptor.status === "failed",
})
? deriveSubagentStatusBucket(providerSubagentLifecycleStatus(descriptor.status))
: null,
};
}

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