Compare commits

...
Author SHA1 Message Date
Mohamed Boudra c4e5a0e739 feat(plugin): observe terminals across connected hosts
Share host-wide terminal observations across plugin surfaces and gate them on terminalCollection. Keep directory observations compatible, and carry creation time in worker v6 state so v5 terminals can drain with an unknown timestamp.
2026-11-06 09:22:19 +01:00
Mohamed Boudra 62bd21214b feat(plugin): add sidebar view examples and ref actions
Keep sidebar layout and gestures inside the plugin while routing entity handles to their owning hosts. Examples cover empty projects, pinned workspaces, task sections, nested agents and device-local label folders.
2026-11-06 09:22:19 +01:00
Mohamed Boudra dc309586f0 feat(plugin): let users select a sidebar view
An exclusive view replaces the sidebar content while the host retains navigation, callouts, and footer. The device remembers the selection, unavailable views fall back to the default, and throwing views provide recovery. Device-scoped plugin settings persist locally with schema validation and revision conflicts. Web E2E and Android tablet QA cover selection, preserved chrome, disabled-view fallback, and error recovery.
2026-11-06 09:22:19 +01:00
Mohamed Boudra e4106b4e2d feat(plugin): expose stable sidebar entity data across hosts
Collections return interned references and preserve their arrays when membership and order stay unchanged. Entity selectors share one hook with identity defaults for full snapshots and shallow defaults for selections. The app caches host projections and shares underlying store and host subscriptions. Browser render counts exercise the real session store and plugin provider without memoized rows. Terminal hooks consume the collection source port; daemon-wide terminal discovery is the separate terminal phase.
2026-11-06 09:22:19 +01:00
Mohamed Boudra 6355b339c8 fix(app): paint a smaller send and stop disc inside the touch hit frame (#8891)
Under touch sizing the composer's send and stop buttons filled their whole
44pt hit frame with color, so they outweighed every unpainted control in the
toolbar. The trigger keeps the 44pt frame and now paints a 36pt disc
(TOOLBAR_DISC_SIZE) centered inside it. Pointer sizing paints 28 inside 28, so
desktop is unchanged. The voice button's recording fill follows the same rule.
2026-11-06 10:28:42 +07:00
Mohamed Boudra 8b9c42c266 Keep every sidebar footer action reachable in a narrow sidebar (#8885)
* fix(app): move sidebar footer actions that don't fit behind a caret

Touch sizing makes each footer icon a 44pt target, so five of them plus
the row's padding need 236pt. A docked sidebar can be 200pt and a fold
column about as narrow, so Settings was cut off at the sidebar's edge.

The row now measures itself and keeps as many full-size buttons as fit;
the rest move behind a caret that opens a menu-engine popover. Help and
Usage leave first, Settings and Add project last, and Hosts and Help
open as pages of the overflow menu rather than second popovers.

* fix(app): keep the sidebar footer empty until it has measured

Laying the footer out before its first onLayout put all five buttons in
the row, so a narrow sidebar painted Settings clipped for a few frames
before the caret replaced it. The unmeasured row now places nothing and
holds its height, so the first painted row is already the measured one.

* fix(app): keep the footer overflow menu on a narrow, short screen

The overflow menu asked for 240pt, and edge clamping can move a surface
but not shrink it, so on a 220pt screen its right edge and submenu
arrows hung off the display. Anchored surfaces now cap their width to
the display inside the same edge padding that clamping uses.

The Hosts page lists every host, so the overflow menu is scrollable:
on a short screen the last hosts and Add host were below the fold with
no way to reach them.
2026-11-06 10:28:38 +07:00
Okan YILDIRIM a68dc55490 fix(app): refresh visible usage summaries periodically (#7806) 2026-11-06 03:03:57 +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
paseo-ai[bot] 4407283a13 fix: update lockfile signatures and Nix hash [skip ci] 2026-11-04 10:31:18 +00:00
Mohamed Boudra 4f7813d3aa Keep the tapped line in place when chat disclosures expand or collapse, with the native timeline on LegendList (#8717)
* Keep a disclosure's trigger in place on web

Expanding or collapsing a turn summary, tool-call group, tool call details
or todo badge now holds the row that owns the trigger. Rows inside the
timeline get a disclosure context; each toggle runs its state change through
`useDisclosureHold()`, and the viewport's new `holdRow(rowId, apply)` replaces
`holdReadingPosition()`.

On web the hold pins the reading anchor to that row, stops following output,
and resumes following when the change leaves the list at its end. Native keeps
its previous behaviour (detach, then apply) until the anchored toggle lands.

* Render the native chat timeline with LegendList in forward order

The native timeline was an inverted FlatList. Disclosure toggles moved their
trigger, and a cold open settled visibly. It is now a forward-order LegendList
that stays virtualized, and one owner moves the scroll offset at a time:

- Before placement: LegendList's initial scroll, behind its hidden reveal.
- While following: the end pin (native-end-pin.tsx). This is a zero-height first
  child at the bottom of the content, which native maintainVisibleContentPosition
  keeps in place. A content shrink or growth never scrolls from JS; only a
  viewport resize or an explicit jump does.
- During a disclosure hold and while detached: LegendList MVCP with
  shouldRestorePosition (native-disclosure-hold.ts), so a toggle keeps its
  trigger at the same y and paints one layout change.
- Every newly mounted row starts at a per-kind estimate (new-row-estimates.ts).

PaseoScrollView defers React Native's content clamp while MVCP is set. A shrink
below an unmoved anchor no longer draws a frame past the end (RN #58578,
PR #58695). VisibleContentPositionClampTest covers this.

LegendList 3.7.1 patch, one "Paseo patch" comment per hunk:
- The content height comes from React state on the new architecture.
- Rows get a per-row size estimate.
- The MVCP anchor lock runs on native too. Upstream gated it to web in d24a9d93
  without a native reason. Without the lock, the size pass for prepended history
  re-anchors on a prepended row and moves the rows on screen. Native ends the
  lock when its row leaves the view, not on the 300 ms clock.
- Header and footer reveal together with the rows.
- Only lists that keep their end preserve the initial end.
- A remeasure within one pixel plus the 1/8 dp floor of the known size is
  noise. Upstream compares the raw size with the floored one, so the live
  turn's assistant row (1380 -> 1381 px) read as a resize and moved every row
  a frame after the reveal.
- The Android silent initial scroll retry sends its 1 dp nudge and the return
  to the target in one task. Upstream returns on the next frame, but its 16 ms
  check can finish the scroll first and cancel that frame, so the list revealed
  3 px short of its end and moved into place a frame later.
- The top-padding shrink hold applies on iOS only.

Android, emulator. Evidence is in .artifacts/legend-impl.
- J1, J2 at the end, J3, J4, J5, J6, J7: pass.
- J2 while streaming: not reachable on the Ten second stream fixture; its turns
  never fold.
- J8: no blank frames over 10 fresh flings, at host load 32-37.

J4: still. Before these two hunks, 6 of 7 cold opens moved every row one frame
after the reveal. After them, 9 valid cold opens show no shift. 5 of those
were traced through the nudge path. Runs that hit the dev client's startup
SIGSEGV in MountingCoordinator::pullTransaction were left out. That crash is
in the emulator's dropbox from before this work.

* Revise a history row when its turn summary line changes

LegendList renders a row again only when its item changes. The forward-order
timeline in d1f2ae352 folds a finished turn into its host user message, but the
host's item does not change when the fold appears, so a turn that folded live
lost its summary line until something else revised the row.

collapseTurns now reports the hosts whose summary line appeared, changed or went
away since the previous fold (updatedHostIds), and the history row revision
treats them as changed content.

* Stop following the end while a user scroll owns the viewport

The bottom anchor controller kept sticky-bottom for the whole of a drag and its momentum and only
decided at the end. While it is sticky the native list mounts the end pin, so a drag started during
a stream had every new line move the rows under the finger: on the iOS simulator the offset jumped
by each line's height (+22, +35, +22 pt) mid-drag, and Android does the same with the same code.

beginUserScroll now detaches. endUserScroll resumes following when the gesture ends at the end
(scrolling there without animation) and otherwise stays detached and drops any pending anchor, as
before. The re-stick is not animated: on Android a line that mounts during a smooth scroll cancels
it, and the list then followed 40 pt short of the end until the controller detached it.

Measured after the change, frame by frame:
- iOS and Android, drag up mid-stream: the content tracks the finger and momentum with no line
  steps; the stream grows below; tapping the pill re-sticks and follows again.
- A short drag at the end, mid-stream: following resumes; the release lands on the end in one
  frame (the drag distance, here 8 pt on iOS and 21 dp on Android).

A line already mounting when the drag begins can still move the rows once in its first frame,
because the detach reaches native one commit after the touch.

* Correct the iOS scroll view under maintainVisibleContentPosition

Phase 2 of the LegendList timeline. The native timeline relies on maintainVisibleContentPosition
for the end pin and the disclosure hold, and React Native 0.86 on iOS gets two cases wrong.
modules/paseo-scroll now registers PaseoScrollViewComponentView, a subclass of
RCTScrollViewComponentView, for the iOS scroll view, like PaseoScrollView on Android.

- A mount that removes its own anchor: detaching removes the end pin in the same mount that opens
  the tapped disclosure, and the offset jumped by the removed view's frame. The subclass keeps the
  offset when the anchor's tag is no longer registered, the check React Native 0.88 adds in
  https://github.com/react/react-native/pull/57294. That PR's extra superview check is not copied
  because it skips anchors detached by removeClippedSubviews
  (https://github.com/react/react-native/issues/58910).
- A shrink at the end: UIKit clamps the offset to the new end during the mount, then React Native
  adds the anchor's movement, so a turn folding while following landed above the end
  (https://github.com/react/react-native/issues/58578). The subclass restores the pre-mount offset
  for the adjustment and clamps after it, as
  https://github.com/react/react-native/pull/58695 does.

The dev client was rebuilt with the module. Verified on the iPhone 16 Pro simulator, frame by frame:
the summary far up and the latest turn's summary while following each keep the trigger at 0 px
with one layout change per toggle, the todo card the same, the cold open lands still, older
history pages keep the visible rows in place, and the keyboard, send-time shrink and streaming
stay pinned to the end.

Freshly mounted markdown on iOS paints one frame after its layout, because enriched-markdown
renders asynchronously (https://github.com/software-mansion/enriched-markdown/issues/1131). A
synchronous first render removed that frame but raised fling frame time at p90 and p99, so it is
not part of this change.

docs/agent-stream-disclosure.md now describes the clamp correction on both platforms.

* Keep the rows under the finger at a drag's start and its release

Two one-frame row movements remained around a user scroll while the timeline streams.

A drag's first frames. The end pin stays mounted until JS stops following, one round trip after
the drag begins, so a line mounting in those frames scrolled the rows under the finger by its
height. Both scroll view subclasses now skip the content-position correction while the user drags
and the anchor is the end pin, which they recognise by its native ID ("paseo-end-pin"). Android
overrides scrollToPreservingMomentum, the position keeper's only scroll call, from the begin-drag
event until the finger lifts; iOS skips the adjustment while the scroll view is dragging. The skip
is limited to the pin: LegendList's own anchoring for the detached list also uses the native
correction, and a history page landing mid-drag needs it. It starts at the drag rather than at
touch-down: a tap or long press while following would otherwise leave the list stranded short of
its end, still following, once the finger lifts.

The release. A gesture ending within 32 pt of the end re-stuck with an instant scroll, a snap of up
to 56 px. Following now resumes only when the finger lifts at or past the end, or momentum stops
there, within 1 px, the distance web resumes at (strategy-web.tsx). Anything else stays detached with the
pill. The check runs when the gesture is decided, so output that arrived below meanwhile counts. A
release past the end counts as at the end: on iOS it bounces back, and a line landing during the
bounce scrolls in as following would. endUserScroll takes isAtEnd, like releaseDisclosure.

Tests: VisibleContentPositionClampTest gains growth at the end during a drag (red before the
change: the offset moved by the 120 px growth, 352 -> 472) and growth without a touch (the pin
still follows); the module suite passes on emulator-5558, 11 tests.
bottom-anchor-controller.test.ts: a release near but short of the end stays detached.

Device, frame scans while streaming (dev clients rebuilt):
- Android, 8 slow drags with a pill tap between: no frame moves against the finger once a drag
  starts; the stream follows between drags. Three short releases 50 px short of the end stay put
  until the pill; three drags back past the end resume following.
- iOS simulator, 6 drags: no line step during any drag or its momentum. Two short releases stay
  detached with no snap; three drags past the end resume following within 0.5 s of the bounce.

* Document the LegendList timeline and remove what the inverted list left

The native section of docs/agent-stream-disclosure.md described the FlatList hold (anchor
neighbour, native child index, frame spacing, render window). It now covers why the timeline is
forward order, who owns the offset in each state, the hold and its sync layout trigger, user
scroll and release, draw distance numbers, the LegendList patch and the scroll view corrections.

Draw distance stays at 2000. The spike measured it on a chat with a 15,000 px turn expanded: 500
blanked the viewport 1.0 to 1.5 s per fling and 2000 not at all. A re-measure on the collapsed chat
(500, 1000, 1500, 2000, interleaved) blanked at no value and its jank followed host load, which
stayed between 19 and 40, so it could not separate 1000 or 1500 from 2000.

The patch file now opens with a header listing its eight hunks by purpose and linking
LegendApp/legend-list#593, which reports each one upstream. The markdown doc records the iOS
first-paint delay (software-mansion/enriched-markdown#1131).

Removed with the inverted list: PaseoScrollView's child-reveal override (a no-op without the
inverting transform) and ScrollSelectionTest, the invertedWrapper style, and the inverted-list
passages in android.md, native-text-selection.md, mobile-testing.md, floating-panels.md and
agent-stream-performance.md. model.test, layout.test and footer-spacing.test still asserted the
inverted native order and failed; they now assert forward order, and footer-spacing uses the
real strategies instead of an inverted stub.

* fix(app): show a reply's footer when the next prompt arrives on native

LegendList re-renders a mounted row only when its item or extraData changes.
When a new prompt arrives, the previous reply's footer moves from the live
auxiliary slot into that reply's layout item (completedFooter). The reply's
StreamItem keeps its identity, and renderItem is a stable event, so the row never
rendered its new layout and the footer (copy, timing, fork) disappeared until the
row remounted. FlatList had the same gap.

The history renderer is now LegendList's extraData. Its identity changes with the
history layout map, and StreamRow's memo limits the pass to rows whose layout
item changed.

* test(app): run the web disclosure hold cases in a real browser

The three hold cases simulated layout in jsdom: hand-written row heights,
scroll clamping and resize callbacks. They could pass while Chromium paints
the trigger somewhere else. They now run in Vitest browser mode with real
layout, ResizeObserver and scroll clamping. The two hold cases fail when the
hold is bypassed.

The browser project pre-bundles @tanstack/react-virtual, so its first import no
longer reloads the test mid-run. The lucide stub gains ArrowDown, which
pane-find imports.

* fix(app): let a drag end a disclosure hold on native

A drag detached the bottom controller but kept the disclosure hold. Every item
size change restarts the hold's quiet frames, including a streaming row's, so
the hold can outlive the toggle. While it lasts, shouldRestorePosition accepts
only the held row. Once the drag scrolls that row away, a history page arriving
mid-gesture gets no anchor, and the rows the reader was on move by the page's
height.

The drag now cancels the hold without releasing it. The controller keeps no hold
state of its own beyond detached, and the gesture's end decides whether following
resumes.

* perf(app): keep the scroll-to-bottom button's state out of the timeline

Opening a tool call or turn at the end of the chat now holds its trigger in
place, so the timeline detaches and the scroll-to-bottom button appears. That
visibility was useState in AgentStreamView, so showing the button re-rendered
the whole timeline view for a change only the button reads. The viewport now
writes near-bottom into a per-agent store that only the button subscribes to.

* fix(app): show the scroll-to-bottom button on native whenever following has stopped

A gesture now resumes following only when it ends within 1 px of the end, but
the button still hid within 32 px. A release 2-32 px short of the end left the
list detached with no button to return. While detached the button now hides
only at the end; while following it keeps the 32 px tolerance so content
growth that the offset trails for a frame does not flash it. The button also
updates when following starts or stops without a scroll.

* fix(app): stop the timeline jumping when the keyboard opens or closes on native

The keyboard's settled height was committed to the list after the motion, as
paddingTop on Android and contentInset.top on iOS, to keep the top of the
history reachable under the translated dock. While following, the end pin
absorbed it in the same mount. While detached or holding a disclosure, nothing
did: Android's native anchor is LegendList's ScrollAdjust view, which padding
does not move, and LegendList compensates a padding change only on iOS. Every
row jumped by the keyboard height 0.2-1 s after the keyboard settled, and back
after it closed. On send, the same commit raced the jump to the end.

The keyboard now only translates the list with the composer. The cost: while
the keyboard is open, the composer viewport clips the top keyboard-height of
the list, so the start of a short conversation is hidden until it closes.

* chore(app): describe the native timeline as LegendList in stream comments

* fix(app): keep a disclosure's trigger in place when its row is cut off at the top on native

LegendList's idsInView starts at the first fully visible row. When a turn host's prompt bubble
sat above the viewport, a disclosure hold found no anchor in the data pass (shouldRestorePosition
rejects every other row), and the size pass anchored on the row below the host instead, so the
trigger moved by the host's size change. The patch appends the partly visible rows to idsInView
after the fully visible ones, so the held row anchors while every pass that does not restrict the
anchor keeps the first fully visible row.

The browser test renders the patched native build, which vitest now aliases instead of the
package's unpatched web build.

* fix(app): let a turn's summary line collapse again after it opens

The React Compiler cached StreamRow's `renderStreamItem(layoutItem)` on the layout item and the
renderer. The revised item that useRevisedHistoryRows hands a row whose display state changed only
fed the row id, so the row reused its cached content. Opening a turn usually inserts its rows
directly below the host, which also gives the host a new layout item. When another row sits
between them, such as a compaction marker, the host kept its layout item, its summary line stayed
collapsed, and every later tap asked to open the turn again.

The renderer now takes the item it renders, so the compiler's cache keys on the revision. StreamRow
moves to its own module, which the tests compile the way Expo does.

* test(app): alias the patched LegendList build in browser tests only

Unit tests load @legendapp/list outside Vite, so the native build's react-native import skipped
the react-native-web alias and reached React Native's Flow source, failing every unit suite that
imports the native strategy.

* fix(app): keep a size-only disclosure's trigger in place when its row is cut off at the top on native

LegendList's prepareMVCP picks the anchor for a size change with no data change as the first row
in view, without asking shouldRestorePosition. A disclosure that only grows its own row (a todo
card) while that row's top was above the viewport therefore held the row below it, and the trigger
moved by the row's size change. The patch now applies shouldRestorePosition to size changes as it
already does to data changes.

* test(app): render the timeline row revision test in a real browser

The repo's unit tests do not mount components in JSDOM. The test still compiles stream-row.tsx with
the React Compiler, so it fails when a revised row ignores the item it was handed.
2026-11-04 17:21:31 +07:00
Mohamed BoudraandElsa Granger 4991efddf8 feat(app): follow iPhone Duo vertical bars and fold divisions (#8741)
* feat(app): follow system vertical bars and fold divisions

Keep custom navigation controls outside system content areas and use active division regions for temporary panel allocation. Normal sidebar preferences resume when the division disappears.

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

* fix(app): show window actions in the active portal scope

The bottom-sheet provider creates its own portal context. Register the window action host within that context so symbols reach the root host.

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

* fix(app): hide workspace bar actions behind compact panels

Compact panels keep the workspace route focused while covering its content. Hide its portal actions until the workspace panel is active again.

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

* fix(app): continue the sidebar surface across divisions

Keep the fold free of controls while painting it as part of the sidebar, with the shared divider at the workspace edge.

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

* fix(app): omit tab count beside the full tab rail

The full tab rail already exposes navigation. Use the same compact/full header selection for the vertical action list.

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

* fix(app): preserve folded navigation and bar action lifetime

Use resolved sidebar visibility for desktop activity, and keep bar controls mounted while compact panels cover them so pending menu work survives. Carry circular button chrome inside the portal and exercise the rendered boundary in Chromium.

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

* fix(app): own script actions across header placement changes

Keep one script action instance on the workspace screen so pending restarts survive removal of either menu. Pass activity through the bar portal so covered menus close their overlays without taking workflow state with them.

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

* test(app): exercise script ownership through the workspace

Replace synthetic ownership fixtures with a real workspace, script menu and isolated terminal worker. Hold terminal exit until the compact header is removed so moving pending state back into the menu fails the regression. Exercise portal activity with production menu and header controls.

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

---------

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
2026-11-04 11:05:45 +01:00
Mohamed Boudra fcbfb4f0d3 Size controls for touch on iPad and other touch screens (#8748)
* feat(app): size controls for touch on any touch device, not just compact layouts

One gate picks touch or pointer sizing: touch when the layout is compact or
the device has a touch screen. The touch fact is read once, so attaching a
mouse or keyboard to a tablet never shrinks its controls. Stylesheets take
`bySizing({ touch, pointer })`, which resolves to a breakpoint value, so the
existing `{ xs, md }` consumers follow the gate without call-site changes.

Touch values follow the iOS HIG 44pt hit region: controls, icon buttons,
rows, bars and menu rows are 44 on touch; `sm` controls are 32. Glyphs move
to a touch ladder (14/18/20/24). Pointer sizes are unchanged.

Merges duplicated header control heights, density's private icon ladder and
the compact-only icon button sizes into control-geometry.

* feat(app): size the composer for touch through control geometry

The composer toolbar, attach / dictation / realtime / send buttons, context
meter and model triggers take TOOLBAR_CONTROL_SIZES (28 pointer, 44 touch)
instead of private 28 constants. The density budget, button glyphs and the
fullscreen glyph rail offset resolve the same gate, replacing the isWeb /
isNative glyph branches. Track rows join the menu row height.

Desktop geometry is unchanged. On touch the toolbar steps down density
earlier: iPad portrait goes from condensed to tight.

* feat(app): fold composer controls before the model label truncates

The composer toolbar walks one width budget: full, condensed, tight,
compact, then icon-only. Tight keeps the model at its family label;
below that, thinking, mode and features fold into the model control's
sheet (the phone toolbar), and only then does the model drop its label.
A touch iPad in portrait with an attachment now reads
"GPT-6-Astra Medium" instead of "GP…".

The context meter glyph and combobox rows and icons take the density
ladder. Desktop is pixel-identical in the content area.

* refactor(app): keep the sizing gate free of the theme

constants/layout sized its bars through control-geometry, which loads the
theme and runs Platform.select at import. The gate (resolveControlSizing,
bySizing, TOUCH_TARGET) moves to control-sizing.ts with no theme import;
control-geometry re-exports it, so its callers are unchanged.

* test(app): expect touch sizing in the compact Changes and plugin sidebar specs

The compact Changes options button is a 44pt icon button with a 20pt glyph,
and its framed actions trigger is 32 tall. In the compact sidebar the nav
block is capped at half the height, so 44pt rows put the plugin header row
below the cap; the spec scrolls it into view as a user would.

* fix(app): close composer menus when the controls fold or unfold

Folding swaps the toolbar controls for the model sheet. A menu opened on the
separate controls stayed marked open across the fold, so widening the
composer again reopened Thinking without a click.
2026-11-04 11:04:52 +01:00
Monarch MaisuriyaandClaude Opus 5.5 6221b0c8b7 Fork from earlier replies when a plugin card was updated later (#8746)
The fork checkpoint check refused a fork when any projected row spanned the
selected reply. A plugin card re-appended with the same id keeps its first
position and stretches to its latest update, so it spanned every reply in
between, although the fork never copies plugin cards. The check now looks only
at the row kinds the fork copies, taken from the same constant as the copy.

Closes #8745

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:02:25 +00:00
392 changed files with 15422 additions and 3046 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 会在原有会话中恢复工作。
+1
View File
@@ -55,6 +55,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/terminal-rendering.md](docs/terminal-rendering.md) | Native terminal view: xterm owns the grid, cluster placement, emoji widths, mixed-direction limits, render regression |
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
| [docs/agent-stream-disclosure.md](docs/agent-stream-disclosure.md) | Expand/collapse in the chat timeline — hold contract, native anchor sequence, frame spacing, render-window cost |
| [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints |
| [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations |
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
+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`.
+163
View File
@@ -0,0 +1,163 @@
# Agent stream disclosures
Expanding or collapsing something in the chat timeline (a turn's summary line, a tool-call group,
a tool call's inline details, a todo card) keeps the tapped trigger at the same screen y in the
first painted frame. No animation.
## The contract
A disclosure component knows nothing about the list. It wraps its state change:
```tsx
const hold = useDisclosureHold();
const handleToggle = () => hold(() => setExpanded((value) => !value));
```
`view.tsx` wraps every timeline row in `DisclosureRow`, which routes `hold` to the strategy's
`holdRow(rowId, apply)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a timeline row,
`apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place.
Following output:
- A hold always detaches the bottom anchor controller (`holdForDisclosure`).
- When the hold ends, the controller re-sticks only if the list sits at its end and no user scroll
owns the viewport (`releaseDisclosure({ isAtEnd })`). Expanding at the bottom therefore leaves
the list detached with the scroll-to-bottom pill showing; collapsing back to the end resumes
following.
- Scroll events during the hold never re-stick: the correction is a programmatic scroll that
would otherwise read as "back at the bottom".
The trigger can only stay put when the list has scroll room for the change. When the loaded
content fits the viewport, or a collapse at the end would need an offset past the end, the list
ends at its end and the trigger moves by the difference.
## Web
`holdRow` pins the row in the reading anchor and runs `apply`; the next reading reconcile restores
the row's top after layout, before paint (`strategy-web.tsx`).
## Native
On compact layouts, tool calls and tool-call groups open a sheet instead of expanding, so they do
not hold. Summary lines and todo cards expand inline.
The timeline is a LegendList in forward order: one `data` array of history rows followed by the
live head, oldest first (`strategy-native.tsx`). LegendList keeps a row in place across a size
change in the commit that makes it, so a hold needs no anchor neighbour, native child index or
render window stretch. The inverted FlatList it replaced needed all three, and three commits per
toggle; its applied commit took up to 3.5 s of JS on a long turn.
### Who owns the offset
One owner keeps the scroll offset at any moment:
| State | Owner |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. |
| Following the end | The end pin (`native-end-pin.tsx`). |
| Disclosure hold | LegendList's `maintainVisibleContentPosition`, restoring only the held row. |
| User scroll | The finger. The controller detaches at drag start. |
| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. |
| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. |
The end pin is a zero-height view at the bottom of the content and the content's first native
child, with React Native's `maintainVisibleContentPosition` set to `{ minIndexForVisible: 0 }`. The
native correction runs on the UI thread around each mount, so a row that grows or lands at the end
scrolls in the frame it lays out. The controller scrolls only for a viewport resize (the composer
growing or shrinking) and an explicit jump, both through the native `scrollToEnd`.
Do not use LegendList's `maintainScrollAtEnd` instead. It scrolls from JS after the mount to the end
its JS sizes predict: growth paints below the viewport for a frame, and the scroll lands short of
the native end, which the controller reads as the user scrolling away.
The keyboard never changes the list's size, padding or offset. `KeyboardTranslateView` moves the list
and the composer together on the UI thread, and the composer viewport clips the top of the list
while the keyboard is open. Do not add a keyboard-sized inset to keep that content reachable: it can
only commit after the motion, and while detached or holding nothing corrects for it, so every row
jumps by the keyboard height after the keyboard settles.
### The hold
`holdRow(rowId, apply)` runs in the toggle's event:
1. Mark the row held and detach the controller (`holdForDisclosure`). The pin goes away and
LegendList's `maintainVisibleContentPosition` takes over, restoring only the held row
(`shouldRestorePosition`). Upstream LegendList anchors only rows from the first fully visible
one down, and a size change with no data change keeps the first row in view whatever
`shouldRestorePosition` says. A row cut off at the top would then let the row below it hold
instead, moving the trigger by the row's size change. The LegendList patch counts rows cut off
at the top as in view and applies `shouldRestorePosition` to size changes too.
2. Run `apply`.
3. Call the row's `useSyncLayout` trigger. The toggle changes the row from its own state, so
LegendList's container does not re-render and would learn the new height from `onLayout` a task
later, after the change painted. The trigger puts the measurement and the corrected positions
in the toggle's own commit.
The hold ends two frames after the last item size change, since the list measures the toggled row
and any rows it inserted over several frames (`native-disclosure-hold.ts`). The controller then
re-sticks only if the list is within 1 px of its end, the same distance web resumes from.
### User scroll
The controller detaches at `onScrollBeginDrag`, so lines that stream in during a drag grow below
the rows under the finger. The detach reaches native a JS round trip after the drag begins, so for
those frames the pin is still mounted. The scroll view subclasses skip the pin's correction while
the user drags (see [scroll view corrections](#scroll-view-corrections)). They skip only the pin's:
in the detached state LegendList uses the same native correction, and a history page that lands
mid-drag needs it. They key on the drag, not the touch, so a tap or long press while following
keeps following.
Following resumes only for a gesture that ends within 1 px of the end, either when the finger lifts
or when momentum stops. A release past the end counts: iOS bounces back to the end, and lines that
land during the bounce must keep scrolling in. A release anywhere short of the end stays detached
with the scroll-to-bottom pill, so the rows the user let go of never move.
### Draw distance
`drawDistance` is 2000 px on each side of the viewport. A fling over rows that were never measured
blanks the viewport until LegendList renders and measures them. Twelve flings over a chat with a
15,000 px turn expanded, Android emulator, dev build:
| List | Blank viewport | Janky frames | p50 / p90 / p99 |
| ----------------------------- | -------------------------- | ------------ | ---------------- |
| Inverted FlatList | 1.3 to 1.7 s over 5 flings | 10.8% | 27 / 48 / 109 ms |
| LegendList, drawDistance 500 | 1.0 to 1.5 s per fling | | |
| LegendList, drawDistance 2000 | none | 1.7% | 24 / 32 / 48 ms |
With every turn collapsed, flinging from a cold open into history did not blank at any value from
500 to 2000, and jank followed host load (19 to 40 on 16 cores) rather than draw distance. Measure
1000 and 1500 on the expanded chat, on a host under load 10, before lowering it.
### LegendList patch
`patches/@legendapp+list+3.7.1.patch` carries ten changes for the timeline. Its header lists
them by purpose, and
[legend-list #593](https://github.com/LegendApp/legend-list/issues/593) reports each upstream.
Drop a hunk when the installed LegendList ships its fix.
### Scroll view corrections
`modules/paseo-scroll` replaces React Native's scroll view on both platforms with a subclass:
Android's `PaseoScrollView` and iOS's `PaseoScrollViewComponentView`.
`VisibleContentPositionClampTest` covers Android; iOS has no native test harness.
**Content clamp.** A mount that shrinks the content while `maintainVisibleContentPosition` is set
applies the shrink twice on both platforms: the scroll view clamps its offset to the new end during
the mount, and React Native then adds the anchor's movement to the clamped offset
([RN #58578](https://github.com/react/react-native/issues/58578), fix in
[PR #58695](https://github.com/react/react-native/pull/58695)). A turn that folds while the list
follows its end lands above the end. Android defers the clamp to the pre-draw pass, and iOS restores
the pre-mount offset for the adjustment and clamps after it.
**Removed anchor (iOS).** A hold removes the end pin in the same mount that opens the tapped
disclosure, and React Native 0.86 on iOS moves the offset by the removed view's frame. The iOS
subclass keeps the offset. Android keeps a removed view's last frame, so it keeps the offset already.
React Native 0.88 skips the adjustment for a removed anchor
([PR #57294](https://github.com/react/react-native/pull/57294)).
**End pin during a drag.** Both subclasses recognize the pin by its native ID, `paseo-end-pin`,
and skip its correction while the user drags ([user scroll](#user-scroll)). This one has no React
Native counterpart; it stays while the timeline uses the pin.
Remove the content clamp and removed-anchor overrides once the installed React Native includes
their fixes.
+4 -4
View File
@@ -34,10 +34,10 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
- **A history row re-renders only when its item or layout item identity changes.** The inverted
FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a
memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of
JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
- **A history row re-renders only when its item or layout item identity changes.** Every
coalesced tick hands the list a new layout. Without a memo boundary each tick re-rendered every
mounted row (measured on the inverted FlatList native used before LegendList: about 50 rows on a
phone, 100–250 ms of JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
`useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state,
or breakpoint changed, and `StreamRow` memoizes on both. Every viewport runs its history
through that hook; the web viewport once skipped it and history hosts of a live tool group went
+10 -10
View File
@@ -174,18 +174,18 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
## Inverted timeline input
## Scroll view corrections
Android focus and selection visibility requests must not reposition inverted timelines. The
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
child-reveal scroll calculations when the view's transform matrix inverts the vertical axis. React
Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotation with positive
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
commands still work; non-inverted scroll views keep Android's default behavior.
`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which
keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the
chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections)
lists the corrections and the React Native issues they work around. Its instrumentation test,
`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager:
Mouse wheel axes also bypass the inversion transform. The same module reverses the vertical
wheel axis for inverted scroll views before delegating to Android, preserving native scroll
bounds and React Native's scroll events and disabled-scroll behavior.
```bash
cd packages/app/android
./gradlew :paseo-scroll:connectedDebugAndroidTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false
```
Register this package before React Native's core package through its Expo config plugin. Normal
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
+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) | |
+7 -3
View File
@@ -68,9 +68,11 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px in the compact layout, so every phone control is a full touch target; the breakpoint, not the caller, makes that choice. `sm` is 28px with `fontSize.sm` labels and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. The hit region is not the painted area: a filled toolbar control such as the composer's send or stop keeps the 44pt frame and paints a 36pt disc inside it (`TOOLBAR_DISC_SIZE`), so it does not outweigh its unpainted neighbours. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `<Switch>` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 in the compact layout, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size.
Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `<Switch>` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 where controls are touch-sized, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
@@ -150,7 +152,7 @@ One row off the rail makes the whole card look unconsidered.
Compact-first. The small case is designed; the large case adds chrome around it.
Use `md: 600` for both the compact/wide layout boundary and responsive density, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint. Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
Use `md: 600` for the compact/wide layout boundary, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint; touch sizing comes from the control sizing gate in [Buttons](#4-buttons). Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) share the canonical list+detail navigation pattern:
@@ -167,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
+9 -6
View File
@@ -157,8 +157,7 @@ and the stream keeps its existing flick-to-dismiss gesture.
The dock's private viewport and capacity model are extracted from chat. The
app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams,
terminal, explorer, and dialogs. Import only its public entry point: the provider,
shift hooks, shift style, translate view, keyboard anchor, settled stream inset, and explorer padding
policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
shift hooks, shift style, translate view, and keyboard anchor. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
at its root mount point outside the portal host when changing dock hosts.
`KeyboardTranslateView` owns visual keyboard motion inside `@/keyboard/shift`. Android uses the
@@ -171,15 +170,19 @@ animation. Keep this platform choice inside the keyboard module's private platfo
`KeyboardTranslateView`. The panel root clips it at the header edge, so rows
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 inverted list updates its viewport.
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
mounted layouts arrived in bursts every 2–4 frames. A transform does not dirty
layout. Preserve the translated dock's history scroll range with a far-end
content inset on the inverted stream list. Update that inset only when keyboard
motion settles; never drive it per frame.
layout. The stream list gets no keyboard inset either; see
[who owns the offset](agent-stream-disclosure.md#who-owns-the-offset).
The translated dock's height is not the composer's available height. Bound the
composer against the stationary space below the header inside
+2
View File
@@ -26,6 +26,8 @@ Native code blocks use the library's copy control and compiled grammars. Web cod
The package patch also keeps overflowing native horizontal blocks in control of their drags: Android claims parent interception at touch-down and releases it for vertical drags so the chat list can scroll; iOS keeps its scroll recognizer active for outward drags at the leading edge. This prevents compact sidebar pans from taking block scrolling without changing sidebar swipes on prose or non-overflowing blocks. Native changes require a client rebuild.
On iOS a newly mounted Markdown view paints empty for a frame or more after it is laid out at its measured height: the library parses even a view's first content on a background queue and applies it in a later main-queue turn. Expanding a turn in chat therefore shows its rows in place with the text arriving a frame later (165 ms when the mount keeps the main thread busy). Android paints the text in the first frame. Rendering a view's first content synchronously fixes the frame but costs frame time when a fling mounts history rows, so the app keeps the library's behavior until [enriched-markdown #1131](https://github.com/software-mansion/enriched-markdown/issues/1131) is resolved.
Android measurements use a size-only, 512-entry LRU across view unmounts, matching the library’s existing iOS cache. Scrolling back through history can reuse layout sizes without parsing those rows again. Content, layout props and width identify each entry; font-scale changes and native layout invalidation discard affected sizes. The cache retains no views or spans.
The library determines how incomplete Markdown appears during streaming. Completed text uses the same component and engine as history. Find and scroll-to-message address source message ids; selection-copy understands semantic HTML and the web code component's copy metadata.
+3 -2
View File
@@ -226,7 +226,8 @@ its own.
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
inactive, and that inactive commit is where the surface unmounts.
- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover
card, and are unit-tested. Fix positioning bugs there, not at a call site.
- Anchoring, flipping, edge clamping, and capping a surface's width to the display live in
`components/ui/anchor.ts`, shared with the hover card, and are unit-tested. Clamping moves a
surface but cannot shrink it, so the width cap is what keeps a wide menu on a narrow screen. Fix positioning bugs there, not at a call site.
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
+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.
+1 -10
View File
@@ -452,18 +452,9 @@ Regular `View` components can safely use Unistyles dynamic styles — the confli
## Native Chat Stream Layout
The native agent stream uses an inverted `FlatList`, so chat layout has three coordinate systems:
- chronological stream order
- strategy-ordered array order
- native inverted cell visual order
Do not compute stream neighbors, history/live-head seams, turn footer ownership, assistant block spacing, or tool sequence endings inside React render loops. Those policies live in `packages/app/src/agent-stream/layout.ts` and are unit-tested without React Native rendering.
Platform-specific stream edges belong on `StreamStrategy`:
- forward web uses the last history item as the history/live-head boundary and renders content before a footer
- native inverted uses the first history item as the history/live-head boundary and compensates for inverted cell child order
Platform-specific stream edges belong on `StreamStrategy`. Web and native both render in chronological order, so both use the last history item as the history/live-head boundary and render content before a footer.
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
-9
View File
@@ -2,15 +2,6 @@
Keep completed assistant prose in one native text view so selection crosses paragraphs, headings and lists. The shared [Markdown renderer](markdown-renderer.md) owns that view.
## Android inverted chat
Keep selection visibility handling in `modules/paseo-scroll`. Android's visibility
rectangles ignore list inversion. React Native decomposes Android's `scale: -1`
into rotation with positive `scaleY`, so test the composed matrix when deciding
whether those requests should move the viewport. Native tests must apply the
list's transform through React Native's view manager; assigning `scaleY` directly
misses that decomposition.
## Diagrams in lists and quotes
Selection stops at a Mermaid diagram. Native extracts Mermaid fences into separate views because the library exposes no native block renderer override. A diagram inside a list or quote therefore appears outside that container, and its following prose starts a separate Markdown fragment.
+7
View File
@@ -570,6 +570,13 @@ Register a usage source from `index.server.ts` with `server.registerUsageSource(
When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
## Replace sidebar content
The sidebar view slot is global across hosts, while header and footer contributions retain their
host selection. The slot owns selection loading, fallback and the error boundary; the plugin owns
its layout. This keeps plugin failures from removing navigation or recovery controls. The
[sidebar view guide](../public-docs/plugins/sidebar-views.md) owns the author API.
## Contribute sidebar items
Sidebar header and footer items are plugin components, not descriptors. The
+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
+4 -3
View File
@@ -95,9 +95,10 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
The daemon validates the epoch and locates the projected item at the selected position. A fork
includes projected items through that checkpoint. If an item spans the checkpoint and changed
afterward, the daemon refuses that fork with an actionable error: discarded historical payloads
cannot be reconstructed from sequence metadata. Forking the current context remains available.
includes projected items through that checkpoint. If an item the fork copies spans the checkpoint
and changed afterward, the daemon refuses that fork with an actionable error: discarded historical
payloads cannot be reconstructed from sequence metadata. Plugin cards are not copied, so a card
updated after the checkpoint does not block it. Forking the current context remains available.
## Resume behavior
@@ -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-p97CaALgkY+x9XVvS4/ILqmbwYSX0fSbVUgQl35BjiU=
sha256-wO0vJVhh1urkt4q9NiQ0RSXUj++WWuHTneWSPelU+JQ=
+55 -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": [
@@ -8142,6 +8142,26 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@legendapp/list": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.7.1.tgz",
"integrity": "sha512-zgIy84yG2JP93+kib61rZBTcYGZ6fULrkCQz/MF7WrjVJeJQa/rR4z+rXfM5oveirO5mYkro4IDGytARIjJe9w==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.5.0"
},
"peerDependencies": {
"react": "*"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
@@ -15068,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": {
@@ -35080,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": {
@@ -37448,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",
@@ -37466,6 +37486,7 @@
"@getpaseo/protocol": "*",
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@legendapp/list": "^3.7.1",
"@marijn/find-cluster-break": "^1.0.2",
"@mattermost/react-native-paste-input": "2.0.1",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -39616,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",
@@ -39869,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"
},
@@ -39885,7 +39906,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.15.0",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40128,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",
@@ -40174,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",
@@ -40204,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",
@@ -40212,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",
@@ -40223,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"
@@ -40246,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",
@@ -40274,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",
@@ -40496,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",
@@ -41453,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",
@@ -43063,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();
}
});
});
+131 -5
View File
@@ -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);
@@ -928,11 +1032,11 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
throw new Error("Compact Changes toolbar geometry could not be measured");
}
expect(actionsBox.width).toBe(48);
expect(actionsBox.height).toBe(28);
expect(optionsBox.width).toBe(32);
expect(optionsBox.height).toBe(32);
expect(glyphBox.width).toBe(18);
expect(glyphBox.height).toBe(18);
expect(actionsBox.height).toBe(32);
expect(optionsBox.width).toBe(44);
expect(optionsBox.height).toBe(44);
expect(glyphBox.width).toBe(20);
expect(glyphBox.height).toBe(20);
await expect(actions).not.toContainText("Commit");
await expect(actions.locator("svg")).toHaveCount(2);
@@ -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" }],
@@ -1,5 +1,6 @@
import type { Page, TestInfo } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { pauseWorkspaceTerminalWorker } from "../support/helpers/workspace-setup";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
@@ -190,3 +191,49 @@ test("compact header menu runs, shows, and stops workspace scripts", async ({ pa
await workspace.cleanup();
}
});
async function restartScriptAcrossHeaderRemoval(page: Page, terminalId: string) {
await openRunningScriptPage(page, "lint");
const resumeWorker = await pauseWorkspaceTerminalWorker(terminalId);
try {
await menuRow(page, "Restart").click();
await page.setViewportSize({ width: 1280, height: 844 });
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveCount(0);
await expect(page.getByTestId("workspace-scripts-button")).toBeVisible();
} finally {
resumeWorker();
}
}
async function expectScriptTerminalReplaced(
workspace: Awaited<ReturnType<typeof openWorkspaceWithScripts>>,
previousId: string,
) {
await expect
.poll(
async () => {
const after = await workspace.client.listTerminals(undefined, undefined, {
workspaceId: workspace.workspaceId,
});
return after.terminals.filter((terminal) => terminal.id !== previousId).length;
},
{ timeout: 15_000 },
)
.toBe(1);
}
test("pending script restart survives removal of the compact header", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const workspace = await openWorkspaceWithScripts(page);
try {
await runScriptFromScriptsPage(page, "lint");
const before = await workspace.client.listTerminals(undefined, undefined, {
workspaceId: workspace.workspaceId,
});
expect(before.terminals).toHaveLength(1);
await restartScriptAcrossHeaderRemoval(page, before.terminals[0]!.id);
await expectScriptTerminalReplaced(workspace, before.terminals[0]!.id);
} finally {
await workspace.cleanup();
}
});
@@ -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();
}
});
});
@@ -5,6 +5,7 @@ import {
recordComposerToolbarFrames,
} from "../support/helpers/composer-control-density";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
const SETTLE_MS = 1_000;
@@ -92,3 +93,34 @@ test.describe("Composer control density across tab switches", () => {
}
});
});
test.describe("Composer controls folding on a touch device", () => {
test.use({ viewport: { width: 1194, height: 834 }, isMobile: true, hasTouch: true });
test.describe.configure({ timeout: 180_000 });
test("an open menu stays closed after the controls fold and unfold", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-density-fold-",
title: "Folding chat",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, session);
const thinking = page.getByTestId("agent-thinking-selector").filter({ visible: true });
const thinkingMenu = page.getByTestId("combobox-desktop-container");
await thinking.click();
await expect(thinkingMenu).toBeVisible();
// Still a wide layout, but too narrow for separate controls: they fold into the model control.
await page.setViewportSize({ width: 640, height: 834 });
await expect(thinking).toHaveCount(0);
await page.setViewportSize({ width: 1194, height: 834 });
await expect(thinking).toBeVisible();
await expect(thinkingMenu).toHaveCount(0);
} finally {
await session.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);
@@ -317,7 +317,10 @@ test.describe("Plugin sidebar items", () => {
const compactSync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
await expect(compactSync).toBeInViewport();
await expectFooterRow(page, compactSync);
await expect(headerRow(page, SHOWCASE_PLUGIN_ID, "deploys")).toBeInViewport();
// The compact nav block is capped at half the sidebar and scrolls past it.
const compactDeploys = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
await compactDeploys.scrollIntoViewIfNeeded();
await expect(compactDeploys).toBeInViewport();
await qaScreenshot(page, "phase2-compact-sidebar");
await qaScreenshot(page, "phase5-compact-footer", sidebarFooter(page));
await compactSync.click();
@@ -0,0 +1,127 @@
import { expect, test } from "../support/fixtures";
import { addProjectFlow } from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
chooseFooterOverflowAction,
expectFooterLine,
expectFooterMenusInsideViewport,
expectFooterNeverClipped,
expectFooterOverflowActions,
footerOverflowMenu,
openFooterOverflow,
seedDockedSidebarWidth,
watchFooterClipping,
} from "../support/helpers/sidebar-footer-overflow";
import { addOfflineHostsAndReload } from "../support/helpers/hosts";
import { openCompactSidebar } from "../support/helpers/usage-sidebar-item";
const TOUCH_CONTROL = 44;
const MIN_SIDEBAR_WIDTH = 200;
const ALL_INLINE = [
"sidebar-add-project",
"sidebar-usage-icon",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-settings",
];
// Five 44pt buttons and the row's padding need 236pt; 200pt holds three and the caret.
const NARROW_TOUCH_LINE = [
"sidebar-add-project",
"sidebar-hosts-trigger",
"sidebar-footer-overflow",
"sidebar-settings",
];
test.describe("Sidebar footer on a touch screen", () => {
test.use({ viewport: { width: 1280, height: 800 }, hasTouch: true });
test("a narrow docked sidebar moves the actions it can't fit behind a caret", async ({
page,
}) => {
await seedDockedSidebarWidth(page, MIN_SIDEBAR_WIDTH);
await watchFooterClipping(page);
await gotoAppShell(page);
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
await expectFooterNeverClipped(page);
await test.step("Help opens as a page of the overflow menu", async () => {
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
await chooseFooterOverflowAction(page, "Help and support");
await expect(page.getByRole("menuitem", { name: "What's new" })).toBeVisible();
await expect(page.getByTestId("sidebar-help-menu")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(footerOverflowMenu(page)).toBeHidden();
});
await test.step("Usage opens from the overflow menu", async () => {
await openFooterOverflow(page);
await chooseFooterOverflowAction(page, "Usage");
await expect(footerOverflowMenu(page)).toBeHidden();
await expect(page.getByTestId("usage-modal")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("usage-modal")).toBeHidden();
});
await test.step("Add project and Settings shortcuts still work", async () => {
await page.keyboard.press("Control+o");
await expect(addProjectFlow(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).toBeHidden();
await page.keyboard.press("Control+,");
await expect(page).toHaveURL(/\/settings\/general$/);
});
});
test("the default sidebar width keeps all five actions inline", async ({ page }) => {
await gotoAppShell(page);
await expectFooterLine(page, { testIDs: ALL_INLINE, controlSize: TOUCH_CONTROL });
});
});
test.describe("Sidebar footer in a compact overlay narrower than the footer", () => {
test.use({ viewport: { width: 220, height: 800 }, hasTouch: true });
test("the overflow menu reaches the hidden actions", async ({ page }) => {
await watchFooterClipping(page);
await gotoAppShell(page);
await openCompactSidebar(page);
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
await expectFooterNeverClipped(page);
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
await expectFooterMenusInsideViewport(page);
});
});
test.describe("Sidebar footer on a short, narrow phone", () => {
// 164pt for buttons holds Settings, Add project and the caret, so Hosts moves behind it.
test.use({ viewport: { width: 180, height: 360 }, hasTouch: true });
test("the overflow Hosts page scrolls to its last host and Add host", async ({ page }) => {
await gotoAppShell(page);
await addOfflineHostsAndReload(
page,
Array.from({ length: 10 }, (_, index) => ({
serverId: `overflow-host-${index}`,
label: `Overflow host ${index}`,
})),
);
await openCompactSidebar(page);
await expectFooterLine(page, {
testIDs: ["sidebar-add-project", "sidebar-footer-overflow", "sidebar-settings"],
controlSize: TOUCH_CONTROL,
});
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Hosts", "Help and support"]);
await expectFooterMenusInsideViewport(page);
await chooseFooterOverflowAction(page, "Hosts");
await expectFooterMenusInsideViewport(page);
const addHost = page.getByRole("menuitem", { name: "Add host" });
await addHost.scrollIntoViewIfNeeded();
await expect(page.getByRole("menuitem", { name: "Overflow host 9" })).toBeVisible();
await expect(addHost).toBeInViewport();
await addHost.click();
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
});
});
@@ -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);
@@ -0,0 +1,18 @@
import { test } from "../support/fixtures";
import { withSidebarViews } from "../support/helpers/sidebar-views";
test("sidebar views replace the content, preserve chrome, and recover to the default", async ({
page,
}) => {
test.setTimeout(150_000);
await withSidebarViews(page, async (views) => {
await views.expectDefault();
await views.chooseWorkspaces();
await views.exerciseExamples();
await views.exerciseTerminals();
await views.restoreDefault();
await views.recoverFromFailure();
await views.chooseWorkspaces();
await views.disableChosenView();
});
});
@@ -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 }) => {
@@ -1,3 +1,4 @@
import { getServerId } from "../support/helpers/server-id";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { spawnSync } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
@@ -413,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",
@@ -487,3 +490,142 @@ test("keeps the shared Find controls usable in a compact dark browser", async ({
await page.getByRole("button", { name: "Close Find" }).click();
await expect(input(page)).toBeFocused();
});
// A phone browser installs xterm's document-level touch gesture listener too.
// Keep this journey touch-enabled so it catches duplicate scroll handling.
test.describe("terminal history with a finger", () => {
test.use({ isMobile: true, hasTouch: true, viewport: { width: 390, height: 844 } });
test("scrolls history once per finger drag", async ({ page }) => {
await openPhoneTerminalHistory(page);
await expectSingleFingerScroll(page, 24);
await expectSingleFingerScroll(page, 1);
await expectMouseWheelScrollsHistory(page);
});
for (const mode of ["alternate", "mouse"] as const) {
test(`preserves finger navigation in ${mode} input mode`, async ({ page }) => {
await openPhoneInputCapture(page, mode);
await expectFingerNavigationInput(page, mode);
});
}
});
async function expectFingerNavigationInput(page: Page, mode: "alternate" | "mouse") {
await dragTerminalWithFinger(page, 24, async () => {
const prefix = mode === "alternate" ? "TOUCH_BYTES:1b4f41" : "TOUCH_BYTES:1b5b3c3634";
await expect.poll(() => getTerminalBufferText(page)).toContain(prefix);
});
}
async function openPhoneTerminalHistory(page: Page) {
await harness.createTerminal({
name: "Finger scroll history",
command: "bash",
args: [
"--noprofile",
"--norc",
"-c",
"for i in $(seq 1 150); do echo HISTORY_$i; done; exec sleep 300",
],
});
await openPhoneTerminal(page);
await expect.poll(() => getTerminalBufferText(page)).toContain("HISTORY_150");
}
async function openPhoneInputCapture(page: Page, mode: "alternate" | "mouse") {
const escapes = mode === "alternate" ? "\x1b[?1049h\x1b[?1h" : "\x1b[?1000h\x1b[?1006h";
await harness.createTerminal({
name: "Touch application input",
command: process.execPath,
args: [
"-e",
`
process.stdin.setRawMode(true);
process.stdin.once("data", () => {
process.stdin.on("data", chunk => {
process.stdout.write("\\r\\nTOUCH_BYTES:" + chunk.toString("hex") + ":END\\r\\n");
});
process.stdout.write(${JSON.stringify(escapes)} + "TOUCH_INPUT_READY\\r\\n");
});
process.stdout.write("TOUCH_START_READY\\r\\n");
`,
],
});
await openPhoneTerminal(page);
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_START_READY");
await input(page).press("x");
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_INPUT_READY");
await expect
.poll(() => page.evaluate(() => window.__paseoTerminal!.buffer.active.type))
.toBe(mode === "alternate" ? "alternate" : "normal");
await expect
.poll(() => page.evaluate(() => window.__paseoTerminal!.modes.mouseTrackingMode))
.toBe(mode === "mouse" ? "vt200" : "none");
}
async function openPhoneTerminal(page: Page) {
await page.goto("/");
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${harness.workspaceId}`).click();
await expect(page.getByRole("textbox", { name: "Terminal input" })).toBeVisible();
await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toBeVisible();
}
async function expectMouseWheelScrollsHistory(page: Page) {
const beforeWheel = (await viewport(page))!;
await page.mouse.move(200, 300);
await page.mouse.wheel(0, -120);
await expect.poll(() => viewport(page)).toBeLessThan(beforeWheel);
}
async function expectSingleFingerScroll(page: Page, moves: number) {
await dragTerminalWithFinger(page, moves, async ({ lineHeight, before }) => {
await expect.poll(async () => before - (await viewport(page))!).toBeGreaterThan(0);
const moved = before - (await viewport(page))!;
await test.info().attach("finger-scroll", {
body: JSON.stringify({ moves, distancePx: 120, lineHeight, rowsMoved: moved }),
contentType: "application/json",
});
expect(moved, "one gesture must not scroll twice its physical distance").toBeLessThanOrEqual(
Math.ceil(120 / lineHeight),
);
});
}
async function dragTerminalWithFinger(
page: Page,
moves: number,
inspect: (state: { lineHeight: number; before: number }) => Promise<void>,
) {
await test.step(`Drag history with ${moves} touch moves`, async () => {
const screen = page
.getByTestId("terminal-surface")
.filter({ visible: true })
.locator(".xterm-screen");
const box = await screen.boundingBox();
if (!box) throw new Error("Terminal screen is not visible");
const rows = await page.evaluate(() => window.__paseoTerminal!.rows);
const lineHeight = box.height / rows;
const before = (await viewport(page))!;
const session = await page.context().newCDPSession(page);
try {
const start = { x: box.x + box.width / 2, y: box.y + box.height / 3 };
await session.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ ...start, id: 1 }],
});
for (let step = 1; step <= moves; step++) {
await session.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: start.x, y: start.y + (120 * step) / moves, id: 1 }],
});
}
// Check before lifting the finger to exclude inertial scrolling.
await inspect({ lineHeight, before });
} finally {
await session.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await session.detach();
}
});
}
@@ -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.
@@ -8,7 +8,7 @@ import {
openProjectViaDaemon,
} from "../support/helpers/new-workspace";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
import { openCompactSettings, openSettingsHostSection } from "../support/helpers/settings";
import {
expectFooterItemSetting,
expectFooterSeparator,
@@ -42,6 +42,7 @@ import {
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
import { createTempGitRepo } from "../support/helpers/workspace";
import { buildOpenProjectRoute } from "@/utils/host-routes";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
@@ -70,7 +71,112 @@ async function footerClip(page: Page) {
return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!;
}
async function setPageVisibility(page: Page, visibility: DocumentVisibilityState): Promise<void> {
await page.evaluate((state) => {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
Object.defineProperty(document, "hidden", {
configurable: true,
get: () => state === "hidden",
});
document.dispatchEvent(new Event("visibilitychange"));
}, visibility);
}
test.describe("Usage item", () => {
test("visible summary refreshes every five minutes without opening Usage", async ({ page }) => {
let usedPct = 31;
const usage = await installUsageReportsFixture(page, {
lists: [
() => [
{
...claudeAndCodexReports()[0]!,
report: {
status: "available",
windows: [
{ id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct },
],
},
},
],
],
});
await page.clock.install();
await page.setViewportSize(WIDE);
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h"]);
const startingUrl = page.url();
await qaScreenshot(page, "usage-auto-refresh-initial", { kind: "footer" });
const requestsBeforeRefresh = usage.listRequests();
usedPct = 42;
await page.clock.fastForward(5 * 60_000);
await qaScreenshot(page, "usage-auto-refresh-after-interval", { kind: "footer" });
await expectPinnedUsage(page, ["42% 5h"]);
await expect(usageModal(page)).toHaveCount(0);
await expect(page).toHaveURL(startingUrl);
expect(usage.listRequests()).toEqual([
...requestsBeforeRefresh,
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
]);
await setPageVisibility(page, "hidden");
const requestsBeforeBackgrounding = usage.listRequests();
usedPct = 47;
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeBackgrounding);
await expectPinnedUsage(page, ["42% 5h"]);
await setPageVisibility(page, "visible");
await page.clock.fastForward(5 * 60_000);
await expectPinnedUsage(page, ["47% 5h"]);
expect(usage.listRequests()).toEqual([
...requestsBeforeBackgrounding,
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
]);
await page.setViewportSize(COMPACT);
await expect(usageItem(page)).toHaveCount(0);
const requestsBeforeHiding = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeHiding);
await openCompactSidebar(page);
await expectPinnedUsage(page, ["47% 5h"]);
await expect(page.getByTestId("agent-list-backdrop")).toBeVisible();
usedPct = 53;
await page.clock.fastForward(5 * 60_000);
await expectPinnedUsage(page, ["53% 5h"]);
await expect(usageModal(page)).toHaveCount(0);
await page.setViewportSize(WIDE);
await openSidebarNavSettings(page);
await setFooterItemVisible(page, "usage", false);
await leaveSettings(page);
await expectNoUsageItem(page);
const requestsBeforeDisabling = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeDisabling);
});
test("Usage settings do not poll reports while the sidebar is inactive", async ({ page }) => {
const usage = await installUsageReportsFixture(page, {
lists: [() => claudeAndCodexReports()],
});
await page.clock.install();
await page.setViewportSize(COMPACT);
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openSettingsHostSection(page, getServerId(), "usage");
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
await expect(usageItem(page)).toHaveCount(0);
const requestsBeforeWaiting = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeWaiting);
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
});
test("both compact footer entry points open Usage as a sheet", async ({ page }) => {
await page.setViewportSize(COMPACT);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
@@ -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-" });
@@ -6,6 +6,11 @@ env APP_ID=sh.paseo.debug
# Hold past drag arming (180ms), then release.
open "${APP_ID}"
press "label=\"Open menu\""
wait "id=\"sidebar-close\"" 10000
press "id=\"sidebar-close\""
# The close target must clear system controls, including a trailing status column.
wait "label=\"Open menu\"" 5000
press "label=\"Open menu\""
wait "id=\"${WORKSPACE_ROW}\"" 10000
longpress "id=\"${WORKSPACE_ROW}\"" 250
press "id=\"${WORKSPACE_ROW}\""
+25 -11
View File
@@ -22,15 +22,27 @@ export async function addOfflineHostAndReload(
page: Page,
input: { serverId: string; label: string; primaryLabel?: string },
): Promise<void> {
const offlineHost = buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: "127.0.0.1:59999",
nowIso: new Date().toISOString(),
});
await addOfflineHostsAndReload(page, [input], { primaryLabel: input.primaryLabel });
}
/** Adds several unreachable hosts with one reload. See `addOfflineHostAndReload`. */
export async function addOfflineHostsAndReload(
page: Page,
hosts: Array<{ serverId: string; label: string }>,
options: { primaryLabel?: string } = {},
): Promise<void> {
const nowIso = new Date().toISOString();
const offlineHosts = hosts.map((host) =>
buildSeededHost({
serverId: host.serverId,
label: host.label,
endpoint: "127.0.0.1:59999",
nowIso,
}),
);
await page.evaluate(
({ host, keys, primaryLabel }) => {
({ hosts: added, keys, primaryLabel }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
@@ -40,20 +52,22 @@ export async function addOfflineHostAndReload(
if (primaryLabel && registry[0]) {
registry[0].label = primaryLabel;
}
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
for (const host of added) {
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
host: offlineHost,
hosts: offlineHosts,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
primaryLabel: input.primaryLabel,
primaryLabel: options.primaryLabel,
},
);
@@ -17,6 +17,8 @@ type NewWorkspaceDaemonClient = Pick<
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "createTerminal"
| "killTerminal"
| "fetchAgents"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
@@ -34,6 +36,7 @@ type NewWorkspaceDaemonClient = Pick<
| "removePlugin"
| "reloadPlugin"
| "setWorkspaceTitle"
| "setWorkspaceLabel"
>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
@@ -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");
}
@@ -0,0 +1,127 @@
import { expect, type Locator, type Page } from "@playwright/test";
/** Every footer control that can sit in the icon line, caret included. */
const FOOTER_LINE_TEST_IDS = [
"sidebar-add-project",
"sidebar-usage-icon",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-footer-overflow",
"sidebar-settings",
];
// Browser layout boxes include floating-point rounding, even for whole-pixel styles.
const GEOMETRY_TOLERANCE = 0.01;
/** Open the app with the docked sidebar at `width`, as if the user had dragged it there. */
export async function seedDockedSidebarWidth(page: Page, width: number): Promise<void> {
await page.addInitScript((sidebarWidth) => {
localStorage.setItem("panel-state", JSON.stringify({ version: 17, state: { sidebarWidth } }));
}, width);
}
/**
* From the first frame, record any frame in which a footer button reaches past its row, so a row
* laid out before it measured shows up even if it lasted one frame.
*/
export async function watchFooterClipping(page: Page): Promise<void> {
await page.addInitScript((testIDs) => {
const clippedFrames: string[] = [];
Object.assign(window, { __footerClippedFrames: clippedFrames });
const sample = () => {
for (const row of document.querySelectorAll('[data-testid="sidebar-footer-bottom-line"]')) {
const rowRect = row.getBoundingClientRect();
if (rowRect.width === 0) continue;
for (const testID of testIDs) {
const button = row.querySelector(`[data-testid="${testID}"]`);
if (button && button.getBoundingClientRect().right > rowRect.right + 0.5) {
clippedFrames.push(testID);
}
}
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}, FOOTER_LINE_TEST_IDS);
}
export async function expectFooterNeverClipped(page: Page): Promise<void> {
const clipped = await page.evaluate(
() => (window as unknown as { __footerClippedFrames: string[] }).__footerClippedFrames,
);
expect(clipped).toEqual([]);
}
function footerLine(page: Page): Locator {
return page.locator('[data-testid="sidebar-footer-bottom-line"]:visible');
}
export function footerOverflowMenu(page: Page): Locator {
return page.getByTestId("sidebar-footer-overflow-menu");
}
/**
* The icon line shows exactly `testIDs`, in order, each `controlSize` square and wholly inside the
* line's box, so no button is cut off by the sidebar's edge.
*/
export async function expectFooterLine(
page: Page,
input: { testIDs: string[]; controlSize: number },
): Promise<void> {
const line = footerLine(page);
await expect(line).toHaveCount(1);
await expect
.poll(async () =>
line
.locator(FOOTER_LINE_TEST_IDS.map((testID) => `[data-testid="${testID}"]`).join(","))
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid"))),
)
.toEqual(input.testIDs);
const { row, buttons } = await line.evaluate((element, testIDs) => {
const rect = (node: Element) => {
const { x, width, height } = node.getBoundingClientRect();
return { x, width, height };
};
return {
row: rect(element),
buttons: testIDs.map((testID) => rect(element.querySelector(`[data-testid="${testID}"]`)!)),
};
}, input.testIDs);
for (const button of buttons) {
expect(Math.abs(button.width - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
expect(Math.abs(button.height - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
expect(button.x).toBeGreaterThanOrEqual(row.x - GEOMETRY_TOLERANCE);
expect(button.x + button.width).toBeLessThanOrEqual(row.x + row.width + GEOMETRY_TOLERANCE);
}
}
export async function openFooterOverflow(page: Page): Promise<void> {
await footerLine(page).getByTestId("sidebar-footer-overflow").click();
await expect(footerOverflowMenu(page)).toBeVisible();
}
/** The overflow menu lists exactly these actions, in row order. */
export async function expectFooterOverflowActions(page: Page, names: string[]): Promise<void> {
await expect(footerOverflowMenu(page).getByRole("menuitem")).toHaveText(names);
}
export async function chooseFooterOverflowAction(page: Page, name: string): Promise<void> {
await footerOverflowMenu(page).getByRole("menuitem", { name }).click();
}
/** Every row of the open overflow menu, and of the page it opened, is inside the viewport's width. */
export async function expectFooterMenusInsideViewport(page: Page): Promise<void> {
const viewport = page.viewportSize()!;
const rows = page.getByRole("menuitem");
await expect(rows.first()).toBeVisible();
const boxes = await rows.evaluateAll((elements) =>
elements.map((element) => {
const { x, width } = element.getBoundingClientRect();
return { x, width };
}),
);
for (const box of boxes) {
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
}
}
@@ -0,0 +1,191 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { copyPluginExample } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import { gotoWorkspace } from "./launcher";
import { openSidebarNavSettings, leaveSettings } from "./sidebar-nav-settings";
import { visibleTestId } from "./plugin-sidebar-items";
import { getServerId } from "./server-id";
export async function withSidebarViews(
page: Page,
run: (views: ReturnType<typeof sidebarViews>) => Promise<void>,
) {
const example = await copyPluginExample("sidebar-views");
const entry = path.join(example.directory, "index.client.tsx");
const original = await readFile(entry, "utf8");
await writeFile(
entry,
original.replace(
"const cleanups = [",
'client.addSidebarView({ id: "broken", title: "Broken example", Component() { throw new Error("View failed"); } });\n const cleanups = [',
),
);
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const workspace = await seedWorkspace({
repoPrefix: "sidebar-views-",
title: "Sidebar view workspace",
});
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await run(sidebarViews(page, workspace.workspaceId, client));
} finally {
await client.removePlugin("sidebar-views");
await client.close();
await workspace.cleanup();
await example.cleanup();
}
}
function sidebarViews(
page: Page,
workspaceId: string,
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
) {
const builtinRow = () =>
visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`);
async function choose(title: string) {
await openSidebarNavSettings(page);
await page.getByRole("button", { name: /^View:/ }).click();
await page.getByRole("menuitem", { name: title, exact: true }).click();
await expect(page.getByRole("button", { name: `View: ${title}`, exact: true })).toBeVisible();
await leaveSettings(page);
}
async function expectDefault() {
await expect(builtinRow()).toBeVisible();
}
async function expectPlugin() {
await expect(
visibleTestId(page, "plugin-sidebar-view").getByText("Sidebar view workspace", {
exact: true,
}),
).toBeVisible();
await expect(builtinRow()).toHaveCount(0);
await expect(visibleTestId(page, "sidebar-global-new-workspace")).toBeVisible();
await expect(visibleTestId(page, "sidebar-settings")).toBeVisible();
}
return {
expectDefault,
async chooseWorkspaces() {
await choose("Example workspaces");
await expectPlugin();
},
async exerciseExamples() {
await choose("Example agents");
await expect(
visibleTestId(page, "plugin-sidebar-view").getByText("Respond", { exact: true }),
).toBeVisible();
await choose("Example nested agents");
await expectPlugin();
await choose("Example label folders");
const slot = visibleTestId(page, "plugin-sidebar-view");
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toContain("Inbox");
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Later", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Later"]);
await client.setWorkspaceLabel({
workspaceId,
label: { name: "Unlabelled", color: "red" },
assigned: true,
});
await client.setWorkspaceLabel({
workspaceId,
label: { name: "Later", color: "red" },
assigned: false,
});
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Unlabelled"]);
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Inbox"]);
},
async exerciseTerminals() {
await choose("Example terminals");
const slot = visibleTestId(page, "plugin-sidebar-view");
await slot
.getByRole("button", { name: `Terminals on ${getServerId()}`, exact: true })
.click();
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
const workspace = (await client.fetchWorkspaces()).entries.find(
(item) => item.id === workspaceId,
)!;
const created = await client.createTerminal(
workspace.workspaceDirectory,
"Sidebar terminal",
undefined,
{ workspaceId },
);
if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed");
try {
await expect(
slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`),
).toBeVisible();
await slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`).click();
await expect(page.getByTestId("terminal-surface").first()).toBeVisible();
await slot.getByTestId(`sidebar-terminal-kill-${created.terminal.id}`).click();
await expect
.poll(async () => (await client.listTerminals()).terminals.map((item) => item.id))
.not.toContain(created.terminal!.id);
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
} finally {
await client.killTerminal(created.terminal.id);
}
},
async restoreDefault() {
await choose("Default view");
await expectDefault();
},
async recoverFromFailure() {
await choose("Broken example");
await expect(page.getByText("Sidebar view failed to load.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Use default view", exact: true }).click();
await expectDefault();
},
async disableChosenView() {
await client.disablePlugin("sidebar-views");
await expectDefault();
await openSidebarNavSettings(page);
await expect(
page.getByText("The selected view is unavailable or disabled. Showing Default view.", {
exact: true,
}),
).toBeVisible();
await leaveSettings(page);
},
};
}
+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);
@@ -1,3 +1,5 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { realpathSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -380,3 +382,21 @@ export function observeSetupPanelTraffic(page: Page) {
export function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
return detail.commands.map((command) => command.log).join("\n");
}
/** Hold this isolated daemon's terminal worker so script exit cannot race a layout change. */
export async function pauseWorkspaceTerminalWorker(terminalId: string): Promise<() => void> {
const home = process.env.E2E_PASEO_HOME;
if (!home) throw new Error("Missing isolated E2E home");
const directory = path.join(home, "terminal-worker");
for (const name of await readdir(directory)) {
if (!name.endsWith(".json")) continue;
const record = JSON.parse(await readFile(path.join(directory, name), "utf8")) as {
pid: number;
terminals: Array<{ id: string }>;
};
if (!record.terminals.some((terminal) => terminal.id === terminalId)) continue;
process.kill(record.pid, "SIGSTOP");
return () => process.kill(record.pid, "SIGCONT");
}
throw new Error(`No isolated terminal worker owns ${terminalId}`);
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Hosts a scroll view in a real window, so layout clamps and draws run as they do on device. -->
<activity android:name="android.app.Activity" android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,77 @@
package sh.paseo.scroll;
import android.content.Context;
import com.facebook.react.bridge.BridgeReactContext;
import com.facebook.react.bridge.UIManager;
import com.facebook.react.bridge.UIManagerListener;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcherProvider;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.List;
/**
* A React context with a running Fabric UIManager, so scroll views find the mount listener
* registry and event dispatcher the way they do in the app. The UIManager only records mount
* listeners and the dispatcher drops events; every other call returns a default. Java, because
* {@link EventDispatcherProvider} is internal to React Native's Kotlin module.
*/
final class MountingReactContext extends BridgeReactContext implements EventDispatcherProvider {
final List<UIManagerListener> mountListeners = new ArrayList<>();
final UIManager uiManager;
private final EventDispatcher eventDispatcher = inert(EventDispatcher.class, null);
MountingReactContext(Context base) {
super(base);
uiManager = inert(UIManager.class, this);
}
/** Runs one Fabric mount: listeners see willMountItems, then the change, then didMountItems. */
void mount(Runnable change) {
for (UIManagerListener listener : new ArrayList<>(mountListeners)) listener.willMountItems(uiManager);
change.run();
for (UIManagerListener listener : new ArrayList<>(mountListeners)) listener.didMountItems(uiManager);
}
@Override
public boolean hasActiveReactInstance() {
return true;
}
@Override
public UIManager getFabricUIManager() {
return uiManager;
}
@Override
public EventDispatcher getEventDispatcher() {
return eventDispatcher;
}
private static <T> T inert(Class<T> type, MountingReactContext owner) {
return type.cast(
Proxy.newProxyInstance(
type.getClassLoader(),
new Class<?>[] {type},
(proxy, method, args) -> {
switch (method.getName()) {
case "addUIManagerEventListener":
owner.mountListeners.add((UIManagerListener) args[0]);
return null;
case "removeUIManagerEventListener":
owner.mountListeners.remove((UIManagerListener) args[0]);
return null;
case "getEventDispatcher":
return owner.eventDispatcher;
default:
Class<?> result = method.getReturnType();
if (result == boolean.class) return false;
if (result == int.class) return 0;
if (result == long.class) return 0L;
if (result == float.class) return 0f;
if (result == double.class) return 0.0;
return null;
}
}));
}
}
@@ -1,159 +0,0 @@
package sh.paseo.scroll
import android.graphics.Rect
import android.text.Selection
import android.text.Spannable
import android.view.View
import android.view.InputDevice
import android.view.MotionEvent
import android.os.SystemClock
import android.widget.FrameLayout
import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.bridge.JavaOnlyArray
import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.react.uimanager.DisplayMetricsHolder
import com.facebook.react.uimanager.ReactStylesDiffMap
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.scroll.ReactScrollView
import com.facebook.react.views.scroll.ReactScrollViewManager
import com.facebook.soloader.SoLoader
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ScrollSelectionTest {
@Test fun mouseWheelMovesInvertedViewportInVisualDirection() = withScroll(inverted = true) { scroll, _ ->
val before = scroll.scrollY
withWheel(1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue("Wheel up must move toward older messages: $before -> ${scroll.scrollY}", scroll.scrollY > before)
assertEquals("The caller's event must not change", 1f, event.getAxisValue(MotionEvent.AXIS_VSCROLL))
}
val afterUp = scroll.scrollY
withWheel(-1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue("Wheel down must move toward newer messages: $afterUp -> ${scroll.scrollY}", scroll.scrollY < afterUp)
}
}
@Test fun mouseWheelKeepsOrdinaryScrollDirection() = withScroll(inverted = false) { scroll, _ ->
val before = scroll.scrollY
withWheel(1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue(scroll.scrollY < before)
}
val afterUp = scroll.scrollY
withWheel(-1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue(scroll.scrollY > afterUp)
}
}
@Test fun mouseWheelRespectsDisabledScrolling() = withScroll(inverted = true) { scroll, _ ->
scroll.setScrollEnabled(false)
val before = scroll.scrollY
withWheel(1f) { scroll.dispatchGenericMotionEvent(it) }
assertEquals(before, scroll.scrollY)
}
@Test fun focusingSelectableTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text ->
val before = scroll.scrollY
assertTrue(text.requestFocus())
assertSame(text, scroll.findFocus())
assertEquals("Text focus must not move the inverted viewport", before, scroll.scrollY)
assertTrue(text.isTextSelectable)
}
@Test fun selectingTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text ->
val before = scroll.scrollY
Selection.setSelection(text.text as Spannable, 0, 8)
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
assertEquals("Selection visibility requests must not move the inverted viewport", before, scroll.scrollY)
assertEquals("Selectab", text.text.subSequence(text.selectionStart, text.selectionEnd).toString())
}
@Test fun explicitScrollCommandsStillMoveInvertedViewport() = withScroll(inverted = true) { scroll, _ ->
scroll.scrollTo(0, 600)
assertEquals(600, scroll.scrollY)
}
@Test fun ordinaryScrollViewsStillRevealSelectedText() = withScroll(inverted = false) { scroll, text ->
val before = scroll.scrollY
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
assertNotEquals(before, scroll.scrollY)
}
@Test fun verticalScaleStillKeepsFocusAndSelectionInPlace() = withScroll(inverted = true, transform = "scaleY") { scroll, text ->
val before = scroll.scrollY
assertTrue(text.requestFocus())
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
assertEquals(before, scroll.scrollY)
}
@Test fun horizontalScaleStillRevealsSelectedText() = withScroll(inverted = true, transform = "scaleX") { scroll, text ->
val before = scroll.scrollY
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
assertNotEquals(before, scroll.scrollY)
}
private fun withScroll(inverted: Boolean, transform: String = "scale", check: (ReactScrollView, TextView) -> Unit) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.runOnMainSync {
val appContext = instrumentation.targetContext
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
SoLoader.init(appContext, OpenSourceMergedSoMapping)
val reactContext = ScrollTestContext(appContext)
val context = ThemedReactContext(reactContext, appContext)
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
val scroll = manager.createViewInstance(context)
val content = FrameLayout(context)
val text = TextView(context).apply {
setText("Selectable timeline text", TextView.BufferType.SPANNABLE)
setTextIsSelectable(true)
}
content.addView(text, FrameLayout.LayoutParams(300, 100).apply { topMargin = 1400 })
scroll.addView(content)
content.measure(exact(300), exact(2000))
content.layout(0, 0, 300, 2000)
scroll.measure(exact(300), exact(300))
scroll.layout(0, 0, 300, 300)
// Apply RN's actual Android inversion through the manager's transform decomposition.
val transforms = JavaOnlyArray.of(JavaOnlyMap.of(transform, if (inverted) -1.0 else 1.0))
manager.updateProperties(scroll, ReactStylesDiffMap(JavaOnlyMap.of("transform", transforms)))
if (inverted && transform == "scale") {
assertEquals(1f, scroll.scaleY, 0f)
assertEquals(180f, kotlin.math.abs(scroll.rotation), 0f)
}
scroll.scrollTo(0, 400)
check(scroll, text)
}
}
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
private fun withWheel(axis: Float, check: (MotionEvent) -> Unit) {
val time = SystemClock.uptimeMillis()
val properties = MotionEvent.PointerProperties().apply {
id = 0
toolType = MotionEvent.TOOL_TYPE_MOUSE
}
val coordinates = MotionEvent.PointerCoords().apply {
x = 150f
y = 150f
setAxisValue(MotionEvent.AXIS_VSCROLL, axis)
}
val event = MotionEvent.obtain(
time, time, MotionEvent.ACTION_SCROLL, 1, arrayOf(properties), arrayOf(coordinates),
0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_MOUSE, 0,
)
try {
check(event)
} finally {
event.recycle()
}
}
}
@@ -1,19 +0,0 @@
package sh.paseo.scroll;
import android.content.Context;
import com.facebook.react.bridge.BridgeReactContext;
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcherProvider;
/** Native-only tests use RN's dispatcher for a context without a JS instance. */
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
ScrollTestContext(Context context) {
super(context);
}
@Override
public EventDispatcher getEventDispatcher() {
return BlackHoleEventDispatcher.INSTANCE;
}
}
@@ -0,0 +1,271 @@
package sh.paseo.scroll
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Canvas
import android.os.SystemClock
import android.view.MotionEvent
import android.view.View
import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.react.uimanager.DisplayMetricsHolder
import com.facebook.react.uimanager.ReactStylesDiffMap
import com.facebook.react.uimanager.RootView
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.scroll.ReactScrollView
import com.facebook.react.views.scroll.ReactScrollViewManager
import com.facebook.react.views.view.ReactViewGroup
import com.facebook.soloader.SoLoader
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* A Fabric mount that shrinks the content of a scroll view with `maintainVisibleContentPosition`
* set. The mount runs through React Native's real position keeper, driven by the same
* willMountItems/didMountItems calls Fabric makes, inside a real window so layout clamps and draws
* happen as they do on a device.
*/
@RunWith(AndroidJUnit4::class)
class VisibleContentPositionClampTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private var activity: Activity? = null
@After fun finish() {
activity?.finish()
}
@Test fun shrinkAtTheEndLandsAtTheNewEnd() {
val timeline = openTimeline(anchor = Anchor.END)
timeline.shrinkInOneMount()
assertEquals("Offset after the mount", NEW_END, timeline.scrollY())
assertEquals("Offset in the first frame drawn after the mount", NEW_END, timeline.firstDrawnScrollY())
}
@Test fun shrinkBelowAnUnmovedAnchorIsClampedBeforeTheFrameDraws() {
val timeline = openTimeline(anchor = Anchor.TOP_ROW)
timeline.shrinkInOneMount()
assertEquals("Offset in the first frame drawn after the mount", NEW_END, timeline.firstDrawnScrollY())
assertEquals("Offset after the mount", NEW_END, timeline.scrollY())
}
@Test fun shrinkDuringATouchDragLeavesNoOffsetPastTheEnd() {
val timeline = openTimeline(anchor = Anchor.TOP_ROW)
timeline.beginDrag()
assertTrue("The drag owns the viewport", timeline.scrollY() < CONTENT_HEIGHT - VIEWPORT)
timeline.shrinkInOneMount()
val drawn = timeline.firstDrawnScrollY()
timeline.endDrag()
val settled = timeline.settledScrollY()
assertTrue("Offset $drawn drawn past the end $NEW_END", drawn <= NEW_END)
assertTrue("Offset $settled settled past the end $NEW_END", settled <= NEW_END)
timeline.awaitFrames(10)
assertEquals("Offset kept moving after the drag ended", settled, timeline.scrollY())
}
@Test fun growthAtTheEndFollowsWithoutATouch() {
val timeline = openTimeline(anchor = Anchor.END)
timeline.growInOneMount()
assertEquals("Offset after the mount", CONTENT_HEIGHT + GROWTH - VIEWPORT, timeline.scrollY())
}
// The drag begins before JS stops following and removes the pin, so a line mounting in the drag's
// first frames must not scroll the rows the finger is on.
@Test fun growthAtTheEndDuringATouchDragKeepsTheRowsUnderTheFinger() {
val timeline = openTimeline(anchor = Anchor.END)
timeline.beginDrag()
val dragged = timeline.scrollY()
assertTrue("The drag owns the viewport", dragged < CONTENT_HEIGHT - VIEWPORT)
timeline.growInOneMount()
assertEquals("Offset after the mount", dragged, timeline.scrollY())
assertEquals("Offset in the first frame drawn after the mount", dragged, timeline.firstDrawnScrollY())
timeline.endDrag()
}
private enum class Anchor {
/** A zero-height view at the bottom of the content, first child: the end pin. */
END,
/** A row at the top of the content that reaches into the viewport and does not move. */
TOP_ROW,
}
private inner class Timeline(
val scroll: ReactScrollView,
private val content: ReactViewGroup,
private val endPin: View,
private val probe: DrawProbe,
private val reactContext: MountingReactContext,
) {
private var dragY = 0f
private var downTime = 0L
fun scrollY(): Int {
var y = 0
instrumentation.runOnMainSync { y = scroll.scrollY }
return y
}
fun shrinkInOneMount() = resizeInOneMount(CONTENT_HEIGHT - SHRINK)
fun growInOneMount() = resizeInOneMount(CONTENT_HEIGHT + GROWTH)
private fun resizeInOneMount(height: Int) {
instrumentation.runOnMainSync {
probe.drawnScrollY.clear()
reactContext.mount { layoutContent(height) }
probe.invalidate()
}
}
fun firstDrawnScrollY(): Int {
val deadline = SystemClock.uptimeMillis() + 2000
while (SystemClock.uptimeMillis() < deadline) {
instrumentation.waitForIdleSync()
var drawn: Int? = null
instrumentation.runOnMainSync { drawn = probe.drawnScrollY.firstOrNull() }
drawn?.let { return it }
SystemClock.sleep(16)
}
throw AssertionError("No frame was drawn after the mount")
}
fun beginDrag() {
downTime = SystemClock.uptimeMillis()
dragY = 300f
touch(MotionEvent.ACTION_DOWN)
// Finger moves down, so the content scrolls up from the end and the drag owns the viewport.
repeat(6) {
dragY += 12f
touch(MotionEvent.ACTION_MOVE)
}
}
fun endDrag() {
dragY += 4f
touch(MotionEvent.ACTION_MOVE)
touch(MotionEvent.ACTION_UP)
}
fun settledScrollY(): Int {
var previous = scrollY()
repeat(60) {
awaitFrames(2)
val current = scrollY()
if (current == previous) return current
previous = current
}
throw AssertionError("Offset never settled")
}
fun awaitFrames(count: Int) {
repeat(count) {
instrumentation.waitForIdleSync()
SystemClock.sleep(16)
}
}
fun layoutContent(height: Int) {
content.layout(0, 0, WIDTH, height)
endPin.layout(0, height, 0, height)
}
private fun touch(action: Int) {
instrumentation.runOnMainSync {
val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, WIDTH / 2f, dragY, 0)
scroll.dispatchTouchEvent(event)
event.recycle()
}
}
}
/** The React surface root a scroll view reports its native gestures to. */
private class SurfaceRoot(context: Context) : FrameLayout(context), RootView {
override fun onChildStartedNativeGesture(childView: View?, ev: MotionEvent) {}
override fun onChildEndedNativeGesture(childView: View, ev: MotionEvent) {}
override fun handleException(t: Throwable) {
throw t
}
}
private class DrawProbe(context: Context) : View(context) {
val drawnScrollY = mutableListOf<Int>()
override fun onDraw(canvas: Canvas) {
drawnScrollY += (parent.parent as View).scrollY
}
}
private fun openTimeline(anchor: Anchor): Timeline {
val started =
instrumentation.startActivitySync(
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
activity = started
lateinit var timeline: Timeline
instrumentation.runOnMainSync {
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(started)
SoLoader.init(started, OpenSourceMergedSoMapping)
val reactContext = MountingReactContext(started.applicationContext)
val context = ThemedReactContext(reactContext, started)
val manager =
PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS)
as ReactScrollViewManager
val scroll = manager.createViewInstance(context)
val content = ReactViewGroup(context)
val endPin = View(context).apply { setTag(com.facebook.react.R.id.view_tag_native_id, END_PIN_NATIVE_ID) }
val probe = DrawProbe(context)
if (anchor == Anchor.END) {
content.addView(endPin)
content.addView(probe)
} else {
content.addView(probe)
content.addView(endPin)
}
scroll.addView(content)
manager.updateProperties(
scroll,
ReactStylesDiffMap(
JavaOnlyMap.of("maintainVisibleContentPosition", JavaOnlyMap.of("minIndexForVisible", 0))
),
)
started.setContentView(SurfaceRoot(started).apply { addView(scroll, FrameLayout.LayoutParams(WIDTH, VIEWPORT)) })
timeline = Timeline(scroll, content, endPin, probe, reactContext)
timeline.layoutContent(CONTENT_HEIGHT)
probe.layout(0, 0, WIDTH, PROBE_HEIGHT)
}
timeline.awaitFrames(2)
instrumentation.runOnMainSync { timeline.scroll.scrollTo(0, CONTENT_HEIGHT - VIEWPORT) }
timeline.awaitFrames(2)
assertEquals("Starts at the end", CONTENT_HEIGHT - VIEWPORT, timeline.scrollY())
return timeline
}
private companion object {
const val WIDTH = 300
const val VIEWPORT = 600
const val CONTENT_HEIGHT = 1000
const val SHRINK = 240
const val GROWTH = 120
const val END_PIN_NATIVE_ID = "paseo-end-pin"
const val NEW_END = CONTENT_HEIGHT - SHRINK - VIEWPORT
const val PROBE_HEIGHT = 700
}
}
@@ -1,9 +1,5 @@
package sh.paseo.scroll
import android.graphics.Matrix
import android.graphics.Rect
import android.view.InputDevice
import android.view.MotionEvent
import com.facebook.react.ReactPackage
import com.facebook.react.ViewManagerOnDemandReactPackage
import com.facebook.react.bridge.NativeModule
@@ -28,51 +24,3 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
override fun createViewInstance(context: ThemedReactContext): ReactScrollView =
PaseoScrollView(context)
}
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
private val transformValues = FloatArray(9)
private fun isVerticallyInverted(): Boolean {
// Android FlatList uses scale: -1, which RN decomposes into a 180-degree
// rotation with positive scaleY. Read the resulting transform, not scaleY.
matrix.getValues(transformValues)
return transformValues[Matrix.MSCALE_Y] < 0
}
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
if (!isVerticallyInverted() || event.actionMasked != MotionEvent.ACTION_SCROLL ||
!event.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) {
return super.onGenericMotionEvent(event)
}
if (!scrollEnabled) return false
// Android applies wheel axes directly to scrollY; the inversion transform only
// transforms pointer coordinates. Reverse the vertical axis before delegating so
// platform scroll bounds and scroll events still apply.
val properties = Array(event.pointerCount) { index ->
MotionEvent.PointerProperties().also { event.getPointerProperties(index, it) }
}
val coordinates = Array(event.pointerCount) { index ->
MotionEvent.PointerCoords().also {
event.getPointerCoords(index, it)
it.setAxisValue(MotionEvent.AXIS_VSCROLL, -it.getAxisValue(MotionEvent.AXIS_VSCROLL))
}
}
val correctedEvent = MotionEvent.obtain(
event.downTime, event.eventTime, event.action, event.pointerCount, properties, coordinates,
event.metaState, event.buttonState, event.xPrecision, event.yPrecision,
event.deviceId, event.edgeFlags, event.source, event.flags,
)
return try {
super.onGenericMotionEvent(correctedEvent)
} finally {
correctedEvent.recycle()
}
}
// Both RN focus handling and Android selection visibility requests use this calculation.
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
// movement to user gestures and explicit scroll commands.
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int =
if (isVerticallyInverted()) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect)
}
@@ -0,0 +1,142 @@
package sh.paseo.scroll;
import android.content.Context;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import androidx.annotation.Nullable;
import com.facebook.react.R;
import com.facebook.react.views.scroll.MaintainVisibleScrollPositionHelper;
import com.facebook.react.views.scroll.ReactScrollView;
import com.facebook.react.views.scroll.ReactScrollViewHelper;
/**
* React Native's vertical scroll view with two corrections. Java, because the content-position
* config type is internal to React Native's Kotlin module.
*/
final class PaseoScrollView extends ReactScrollView {
// The chat timeline's end pin, packages/app/src/agent-stream/native-end-pin.tsx.
private static final String END_PIN_NATIVE_ID = "paseo-end-pin";
private final ViewTreeObserver.OnPreDrawListener clampBeforeDraw =
() -> {
clampToContentEnd();
return true;
};
private @Nullable MaintainVisibleScrollPositionHelper.Config visibleContentPosition = null;
private boolean isClampPending = false;
private boolean isUserDragging = false;
PaseoScrollView(Context context) {
super(context);
}
@Override
public void setMaintainVisibleContentPosition(
@Nullable MaintainVisibleScrollPositionHelper.Config config) {
super.setMaintainVisibleContentPosition(config);
visibleContentPosition = config;
}
// While the timeline follows its end, its end pin anchors the content position, so every line that
// mounts scrolls the content by its height. A drag owns the offset from the frame it begins, and
// JS removes the pin only a round trip later. While the user drags, the correction for the pin is
// skipped, so the line grows below the rows under the finger. iOS's PaseoScrollViewComponentView
// does the same.
// MaintainVisibleScrollPositionHelper is the only caller: it moves the offset by its anchor's move.
@Override
public void scrollToPreservingMomentum(int x, int y) {
if (isUserDragging && isEndPinTheAnchor()) return;
super.scrollToPreservingMomentum(x, y);
}
// The drag begins where React Native emits onScrollBeginDrag.
@Override
protected void handleInterceptedTouchEvent(MotionEvent ev) {
super.handleInterceptedTouchEvent(ev);
isUserDragging = true;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
boolean handled = super.onTouchEvent(ev);
int action = ev.getActionMasked();
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
isUserDragging = false;
}
return handled;
}
// The helper anchors on the first content child from minIndexForVisible that reaches into the
// viewport. The pin is the first child and sits at the bottom of the content, so it always does.
private boolean isEndPinTheAnchor() {
if (visibleContentPosition == null || visibleContentPosition.getMinIndexForVisible() != 0) {
return false;
}
View content = getChildAt(0);
if (!(content instanceof ViewGroup) || ((ViewGroup) content).getChildCount() == 0) return false;
Object nativeId = ((ViewGroup) content).getChildAt(0).getTag(R.id.view_tag_native_id);
return END_PIN_NATIVE_ID.equals(nativeId);
}
// With maintainVisibleContentPosition set, a mount that shrinks the content past the offset would
// apply the shrink twice:
// 1. MaintainVisibleScrollPositionHelper.willMountItems records the anchor's frame.
// 2. The mount lays out the shorter content, and ReactScrollView.onLayoutChange clamps the offset
// to the new end.
// 3. MaintainVisibleScrollPositionHelper.didMountItems adds the anchor's move to the clamped
// offset, so the shrink moves the offset a second time (to the top, for a shrink at the end).
// The clamp waits for the pre-draw pass instead, which runs after didMountItems and before the
// frame draws: the anchor's move applies to the offset the content had before the mount, and the
// clamp only catches what the anchor did not account for.
// https://github.com/react/react-native/issues/58578 tracks it; remove this override once
// https://github.com/react/react-native/pull/58695 ships in the React Native version we use.
@Override
public void onLayoutChange(
View v,
int left,
int top,
int right,
int bottom,
int oldLeft,
int oldTop,
int oldRight,
int oldBottom) {
if (visibleContentPosition == null) {
super.onLayoutChange(v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom);
return;
}
if (!isClampPending) {
isClampPending = true;
getViewTreeObserver().addOnPreDrawListener(clampBeforeDraw);
}
ReactScrollViewHelper.emitLayoutChangeEvent(this);
}
@Override
protected void onDetachedFromWindow() {
cancelPendingClamp();
isUserDragging = false;
super.onDetachedFromWindow();
}
private void clampToContentEnd() {
cancelPendingClamp();
View content = getChildAt(0);
if (content == null || content.getWidth() == 0 || content.getHeight() == 0 || !isShown()) {
return;
}
int viewportHeight = getHeight() - getPaddingTop() - getPaddingBottom();
int maxScrollY = Math.max(0, content.getHeight() - viewportHeight);
if (getScrollY() > maxScrollY) {
scrollTo(getScrollX(), maxScrollY);
}
}
private void cancelPendingClamp() {
if (!isClampPending) return;
isClampPending = false;
getViewTreeObserver().removeOnPreDrawListener(clampBeforeDraw);
}
}
@@ -1 +1 @@
{ "platforms": ["android"], "android": { "modules": [] } }
{ "platforms": ["android", "ios"], "android": { "modules": [] }, "ios": { "modules": [] } }
@@ -0,0 +1,16 @@
Pod::Spec.new do |s|
s.name = 'PaseoScroll'
s.version = '0.1.0'
s.summary = 'Scroll view corrections for Paseo'
s.description = 'Scroll view corrections for Paseo'
s.license = 'Apache-2.0'
s.author = 'Paseo'
s.homepage = 'https://paseo.sh'
s.platforms = { :ios => '15.1' }
s.source = { :path => '.' }
s.static_framework = true
s.source_files = "**/*.{h,mm}"
install_modules_dependencies(s)
end
@@ -0,0 +1,148 @@
#import <React/RCTComponentViewFactory.h>
#import <React/RCTCustomPullToRefreshViewProtocol.h>
#import <React/RCTScrollViewComponentView.h>
#import <React/RCTViewComponentView.h>
#import <react/renderer/components/scrollview/ScrollViewProps.h>
#import <react/renderer/components/scrollview/ScrollViewShadowNode.h>
using namespace facebook::react;
/**
* React Native's scroll view with three maintainVisibleContentPosition corrections.
*
* An anchor removed by the mount it anchors. React Native picks the anchor before the mount and
* moves the offset by the anchor's movement after it. When the mount removes that view, its frame
* after the mount no longer describes the content, and the offset jumps by an unrelated amount.
* Android keeps a removed view's last frame, so it keeps the offset. This class keeps the offset on
* iOS too.
* The chat timeline hits this when it stops following its end: the same mount removes the end pin
* and opens the disclosure the reader tapped.
* React Native 0.88 skips the adjustment for a removed anchor
* (https://github.com/react/react-native/pull/57294). Its view-registry tag check matches the check
* here; its superview check also skips anchors that removeClippedSubviews detached
* (https://github.com/react/react-native/issues/58910). Remove this part once the installed React
* Native includes the tag check.
*
* The chat timeline's end pin during a drag. While the timeline follows its end, a zero-height view
* at the bottom of the content (native ID "paseo-end-pin", packages/app/src/agent-stream/
* native-end-pin.tsx) is the anchor, so every line that mounts scrolls the content by its height.
* A drag owns the offset from the frame it begins, and JS removes the pin only a round trip later.
* While the user drags, a mount anchored on the pin keeps the offset, so the line grows below the
* rows under the finger. Android's PaseoScrollView does the same.
*
* A shrink at the end. A mount that shrinks the content sets the smaller contentSize, and UIKit
* clamps contentOffset to the new end. React Native then adds the anchor's movement to the clamped
* offset, so the shrink is applied twice and the viewport lands above the content it kept.
* The chat timeline hits this when a finished turn folds while the end pin follows the latest row.
* https://github.com/react/react-native/issues/58578
* Remove once https://github.com/react/react-native/pull/58695 ships; it keeps the pre-mount
* offset and clamps after the adjustment, as this class does.
*/
@interface PaseoScrollViewComponentView : RCTScrollViewComponentView
@end
@implementation PaseoScrollViewComponentView {
CGPoint _offsetBeforeMount;
__weak UIView *_anchorBeforeMount;
NSInteger _anchorTagBeforeMount;
}
// Replaces React Native's class for the ScrollView component. The factory looks a component up the
// first time it mounts, so the registration has to come before the first surface.
+ (void)load
{
[[RCTComponentViewFactory currentComponentViewFactory] registerComponentViewClass:self];
}
- (void)mountingTransactionWillMount:(const MountingTransaction &)transaction
withSurfaceTelemetry:(const SurfaceTelemetry &)surfaceTelemetry
{
_offsetBeforeMount = self.scrollView.contentOffset;
_anchorBeforeMount = [self visibleContentAnchor];
_anchorTagBeforeMount = _anchorBeforeMount.tag;
[super mountingTransactionWillMount:transaction withSurfaceTelemetry:surfaceTelemetry];
}
- (void)mountingTransactionDidMount:(const MountingTransaction &)transaction
withSurfaceTelemetry:(const SurfaceTelemetry &)surfaceTelemetry
{
// The view registry sets a removed view's tag to 0; a released view reads nil here.
BOOL isAnchorRemoved = _anchorTagBeforeMount != 0 && _anchorBeforeMount.tag != _anchorTagBeforeMount;
if (isAnchorRemoved || (self.scrollView.isDragging && [self isEndPin:_anchorBeforeMount])) {
// Without the content-position props, React Native's mount completes without moving the offset.
SharedViewProps props = _props;
_props = ScrollViewShadowNode::defaultSharedProps();
[super mountingTransactionDidMount:transaction withSurfaceTelemetry:surfaceTelemetry];
_props = props;
return;
}
if (![self didClampOffsetDuringMount]) {
[super mountingTransactionDidMount:transaction withSurfaceTelemetry:surfaceTelemetry];
return;
}
UIScrollView *scrollView = self.scrollView;
scrollView.contentOffset = _offsetBeforeMount;
[super mountingTransactionDidMount:transaction withSurfaceTelemetry:surfaceTelemetry];
CGFloat y = MAX(MIN(scrollView.contentOffset.y, [self maxOffsetY]), -scrollView.adjustedContentInset.top);
if (y != scrollView.contentOffset.y) {
scrollView.contentOffset = CGPointMake(scrollView.contentOffset.x, y);
}
}
// UIKit moved the offset to the new end while the mount set a smaller contentSize. A user scroll
// never gets here: React Native keeps the offset through contentSize changes while the user scrolls.
- (BOOL)didClampOffsetDuringMount
{
const auto &props = static_cast<const ScrollViewProps &>(*_props);
if (!props.maintainVisibleContentPosition) {
return NO;
}
CGFloat y = self.scrollView.contentOffset.y;
return y < _offsetBeforeMount.y - 0.5 && ABS(y - [self maxOffsetY]) <= 0.5;
}
// The view React Native anchors on for vertical content: the first content child from
// minIndexForVisible that reaches into the viewport, or the last one.
- (UIView *)visibleContentAnchor
{
const auto &props = static_cast<const ScrollViewProps &>(*_props);
UIScrollView *scrollView = self.scrollView;
if (!props.maintainVisibleContentPosition || scrollView.contentSize.width > self.frame.size.width) {
return nil;
}
NSArray<UIView *> *children = [self contentView].subviews;
NSUInteger first = props.maintainVisibleContentPosition.value().minIndexForVisible;
for (NSUInteger index = first; index < children.count; ++index) {
UIView *child = children[index];
if (CGRectGetMaxY(child.frame) > scrollView.contentOffset.y || index == children.count - 1) {
return child;
}
}
return nil;
}
- (BOOL)isEndPin:(UIView *)view
{
return [view isKindOfClass:[RCTViewComponentView class]] &&
[((RCTViewComponentView *)view).nativeId isEqualToString:@"paseo-end-pin"];
}
- (UIView *)contentView
{
for (UIView *child in self.containerView.subviews) {
if (![child conformsToProtocol:@protocol(RCTCustomPullToRefreshViewProtocol)]) {
return child;
}
}
return nil;
}
- (CGFloat)maxOffsetY
{
UIScrollView *scrollView = self.scrollView;
return MAX(
scrollView.contentSize.height - scrollView.bounds.size.height + scrollView.adjustedContentInset.bottom,
-scrollView.adjustedContentInset.top);
}
@end
@@ -0,0 +1,4 @@
{
"platforms": ["ios"],
"ios": { "modules": ["PaseoWindowLayoutModule"] }
}
@@ -0,0 +1,15 @@
Pod::Spec.new do |s|
s.name = 'PaseoWindowLayout'
s.version = '0.1.0'
s.summary = 'System window layout geometry for Paseo'
s.description = 'Publishes system bar and division geometry'
s.license = 'Apache-2.0'
s.author = 'Paseo'
s.homepage = 'https://paseo.sh'
s.platforms = { :ios => '16.4' }
s.swift_version = '5.4'
s.source = { :path => '.' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.source_files = '**/*.swift'
end
@@ -0,0 +1,124 @@
import ExpoModulesCore
import UIKit
public final class PaseoWindowLayoutModule: Module {
public func definition() -> ModuleDefinition {
Name("PaseoWindowLayout")
View(WindowLayoutView.self) {
Events("onGeometryChange")
}
}
}
final class WindowLayoutView: ExpoView {
let onGeometryChange = EventDispatcher()
private var updateScheduled = false
private var previousPayload: [String: Any]?
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) {
super.init(appContext: appContext)
isUserInteractionEnabled = false
#if compiler(>=6.4)
if #available(iOS 27.1, *) {
registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) {
(view: WindowLayoutView, _) in
view.setNeedsUpdateConstraints()
view.scheduleUpdate()
}
}
#endif
}
override func didMoveToWindow() {
super.didMoveToWindow()
previousPayload = nil
bottomBarGuide = nil
setNeedsUpdateConstraints()
scheduleUpdate()
}
override func safeAreaInsetsDidChange() {
super.safeAreaInsetsDidChange()
setNeedsUpdateConstraints()
scheduleUpdate()
}
override func layoutSubviews() {
super.layoutSubviews()
if bounds != measuredBounds {
measuredBounds = bounds
setNeedsUpdateConstraints()
}
scheduleUpdate()
}
override func updateConstraints() {
#if compiler(>=6.4)
if #available(iOS 27.1, *) {
// UIKit tracks these reads and invalidates constraints when regions change.
divisions = reservedRegions(kind: .division).compactMap { $0.isActive ? $0.frame : nil }
let edge: String
switch traitCollection.verticalBarEdge {
case .leading: edge = "leading"
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(
for: .bar(onEdge: edge == "trailing" ? .right : .left, extent: 44)
)
}
}
#endif
super.updateConstraints()
scheduleUpdate()
}
private func scheduleUpdate() {
guard !updateScheduled else { return }
updateScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.updateScheduled = false
self.publish()
}
}
private func publish() {
guard window != nil, bounds.width > 0, bounds.height > 0 else { return }
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 }
previousPayload = payload
onGeometryChange(payload)
}
private func rect(_ frame: CGRect) -> [String: CGFloat] {
["x": frame.minX, "y": frame.minY, "width": frame.width, "height": frame.height]
}
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.15.0",
"version": "0.15.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -59,6 +59,7 @@
"@getpaseo/protocol": "*",
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@legendapp/list": "^3.7.1",
"@marijn/find-cluster-break": "^1.0.2",
"@mattermost/react-native-paste-input": "2.0.1",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -263,7 +263,7 @@ describe("bottom anchor controller driver", () => {
expect(harness.scrollAttempts).toHaveLength(0);
harness.driver.endUserScroll({ isNearBottom: true });
harness.driver.endUserScroll({ isAtEnd: true });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
@@ -293,7 +293,7 @@ describe("bottom anchor controller driver", () => {
});
harness.context.authoritativeReady = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.endUserScroll({ isNearBottom: true });
harness.driver.endUserScroll({ isAtEnd: true });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
@@ -321,7 +321,7 @@ describe("bottom anchor controller driver", () => {
nextIsNearBottom: false,
scrollDelta: 48,
});
harness.driver.endUserScroll({ isNearBottom: false });
harness.driver.endUserScroll({ isAtEnd: false });
harness.context.authoritativeReady = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.reevaluate();
@@ -340,10 +340,7 @@ describe("bottom anchor controller driver", () => {
const harness = createDriverHarness();
harness.driver.detachByUser();
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1500,
});
harness.driver.handleContentSizeChange();
harness.driver.handleViewportMetricsChange({
previousViewportWidth: 800,
viewportWidth: 640,
@@ -356,7 +353,9 @@ describe("bottom anchor controller driver", () => {
expect(harness.scrollAttempts).toHaveLength(0);
});
it("pauses sticky maintenance while a user scroll owns the viewport", () => {
// While following, the list keeps its end through every content change, so a drag that kept
// following would see each streamed line move the rows under the finger.
it("stops following while a user scroll owns the viewport", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
@@ -364,12 +363,8 @@ describe("bottom anchor controller driver", () => {
},
});
harness.driver.prepareForStickyContentChange();
harness.driver.beginUserScroll();
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.driver.handleContentSizeChange();
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
@@ -377,19 +372,29 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.driver.getSnapshot().mode).toBe("detached");
harness.driver.endUserScroll({ isNearBottom: false });
harness.driver.endUserScroll({ isAtEnd: false });
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
it("restores sticky maintenance when a user scroll returns to the bottom", () => {
// Resuming would scroll by the distance left to the end in one frame, right after the release.
it("stays detached when a user scroll ends near the end but short of it", () => {
const harness = createDriverHarness();
harness.driver.beginUserScroll();
harness.context.nearBottom = true;
harness.driver.handleContentSizeChange();
harness.driver.endUserScroll({ isAtEnd: false });
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot().mode).toBe("detached");
expect(harness.scrollAttempts).toHaveLength(0);
});
it("resumes following when a user scroll ends at the end", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
@@ -403,16 +408,13 @@ describe("bottom anchor controller driver", () => {
nextIsNearBottom: false,
scrollDelta: 48,
});
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.driver.handleContentSizeChange();
harness.context.nearBottom = true;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: true,
scrollDelta: -48,
});
harness.driver.endUserScroll({ isNearBottom: true });
harness.driver.endUserScroll({ isAtEnd: true });
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot()).toMatchObject({
@@ -420,7 +422,55 @@ describe("bottom anchor controller driver", () => {
pendingRequest: null,
pendingVerification: null,
});
expect(harness.scrollAttempts).toHaveLength(1);
// Following stopped for the gesture, so content that arrived meanwhile left the end below it.
expect(harness.scrollAttempts).toEqual([false]);
});
it("holds a disclosure detached and resumes following when it is released at the end", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
});
harness.driver.holdForDisclosure();
harness.driver.handleContentSizeChange();
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: true,
scrollDelta: -12,
});
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot().mode).toBe("detached");
expect(harness.scrollAttempts).toHaveLength(0);
harness.driver.releaseDisclosure({ isAtEnd: true });
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
});
it("stays detached when a disclosure is released away from the end", () => {
const harness = createDriverHarness();
harness.driver.holdForDisclosure();
harness.driver.releaseDisclosure({ isAtEnd: false });
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot().mode).toBe("detached");
expect(harness.scrollAttempts).toHaveLength(0);
});
it("leaves a disclosure release to a user scroll that owns the viewport", () => {
const harness = createDriverHarness();
harness.driver.holdForDisclosure();
harness.driver.beginUserScroll();
harness.driver.releaseDisclosure({ isAtEnd: true });
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
@@ -441,7 +491,7 @@ describe("bottom anchor controller driver", () => {
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
});
it("schedules sticky maintenance on viewport and content growth", () => {
it("scrolls for a viewport resize while following", () => {
const harness = createDriverHarness();
harness.driver.handleViewportMetricsChange({
@@ -452,13 +502,44 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1600,
expect(harness.scrollAttempts).toHaveLength(1);
});
it("never scrolls for a content change while following", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
});
harness.context.measurementState.contentHeight = 1600;
harness.driver.handleContentSizeChange();
harness.context.measurementState.contentHeight = 1250;
harness.driver.handleContentSizeChange();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(2);
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
});
it("detaches when the user scrolls away after a content change", () => {
const harness = createDriverHarness();
harness.context.measurementState.contentHeight = 1600;
harness.driver.handleContentSizeChange();
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 4,
});
expect(harness.driver.getSnapshot().mode).toBe("detached");
expect(harness.scrollAttempts).toHaveLength(0);
});
it("keeps a pending request blocked when stale container measurements arrive", () => {
@@ -556,10 +637,7 @@ describe("bottom anchor controller driver", () => {
harness.context.measurementState.contentHeight = 14804;
harness.context.nearBottom = false;
harness.driver.handleContentSizeChange({
previousContentHeight: 14322,
contentHeight: 14804,
});
harness.driver.handleContentSizeChange();
harness.scheduler.flushFrame();
@@ -628,10 +706,7 @@ describe("bottom anchor controller driver", () => {
harness.context.measurementState.contentHeight = 16230;
harness.context.nearBottom = false;
harness.driver.handleContentSizeChange({
previousContentHeight: 14322,
contentHeight: 16230,
});
harness.driver.handleContentSizeChange();
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
@@ -685,136 +760,6 @@ describe("bottom anchor controller driver", () => {
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
it("keeps sticky-bottom during streaming growth until bottom is re-verified", () => {
const harness = createDriverHarness();
harness.context.nearBottom = false;
harness.setScrollToBottomBehavior(() => {
harness.context.measurementState.offsetY = 900;
});
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(4);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 0,
});
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
harness.context.nearBottom = true;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: true,
scrollDelta: 0,
});
harness.scheduler.flushAll();
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 64,
});
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
it("keeps initial native content growth anchored before layout scroll events arrive", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
measurementState: createMeasurementState({
containerKey: "native-virtualized",
viewportWidth: 0,
viewportHeight: 0,
contentHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null,
contentMeasuredForKey: null,
}),
});
harness.setScrollToBottomBehavior(() => {
harness.context.measurementState.offsetY = 0;
});
harness.context.measurementState.contentHeight = 1348;
harness.context.measurementState.contentMeasuredForKey = "native-virtualized";
harness.driver.handleContentSizeChange({
previousContentHeight: 0,
contentHeight: 1348,
});
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingVerification: {
requestId: null,
},
});
harness.context.measurementState.viewportWidth = 390;
harness.context.measurementState.viewportHeight = 546;
harness.context.measurementState.viewportMeasuredForKey = "native-virtualized";
harness.context.measurementState.offsetY = 50;
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 50,
});
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
});
it("keeps native sticky content changes anchored when measured height is unchanged", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
measurementState: createMeasurementState({
containerKey: "native-virtualized",
viewportWidth: 390,
viewportHeight: 546,
contentHeight: 546,
offsetY: 0,
viewportMeasuredForKey: "native-virtualized",
contentMeasuredForKey: "native-virtualized",
}),
});
harness.setScrollToBottomBehavior(() => {
harness.context.measurementState.offsetY = 0;
harness.context.nearBottom = true;
});
harness.driver.prepareForStickyContentChange();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingVerification: {
requestId: null,
},
});
harness.context.measurementState.offsetY = 50;
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 50,
});
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
});
});
describe("controller helper predicates", () => {
@@ -864,13 +809,6 @@ describe("controller helper predicates", () => {
it("suppresses auto-anchor helpers while detached", () => {
const mode: BottomAnchorMode = "detached";
expect(
__private__.shouldRestickOnContentChange({
mode,
previousContentHeight: 1000,
contentHeight: 1100,
}),
).toBe(false);
expect(
__private__.shouldRestickOnViewportChange({
mode,
@@ -69,20 +69,18 @@ interface BottomAnchorControllerDriver {
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
beginUserScroll: () => void;
endUserScroll: (params: { isNearBottom: boolean }) => void;
endUserScroll: (params: { isAtEnd: boolean }) => void;
detachByUser: () => void;
holdForDisclosure: () => void;
releaseDisclosure: (params: { isAtEnd: boolean }) => void;
handleViewportMetricsChange: (params: {
previousViewportWidth: number;
viewportWidth: number;
previousViewportHeight: number;
viewportHeight: number;
}) => void;
handleContentSizeChange: (params: {
previousContentHeight: number;
contentHeight: number;
}) => void;
handleContentSizeChange: () => void;
prepareForStickyViewportChange: () => void;
prepareForStickyContentChange: () => void;
handleScrollNearBottomChange: (params: {
nextIsNearBottom: boolean;
scrollDelta: number;
@@ -415,6 +413,7 @@ function createBottomAnchorControllerDriver(
| "content_size_change"
| "scroll_near_bottom_change"
| "user_scroll_end"
| "disclosure_released"
| "history_readiness_change"
| "manual_reevaluate"
| "retry_scroll",
@@ -505,13 +504,19 @@ function createBottomAnchorControllerDriver(
requestLocalAnchor(request) {
createRequest(request);
},
// The gesture owns the viewport, so following stops until it ends: content growing at the end
// must not move the rows under the finger. The gesture's end decides whether following resumes.
beginUserScroll() {
isUserScrollActive = true;
cancelPendingAttempt();
setModeInternal("detached");
},
// Following resumes only for a gesture that ends at the end. Resuming from short of it would
// scroll the rows the finger just let go of, so a release above the end, or with output that
// arrived below it during the gesture, stays detached with the pill.
endUserScroll(params) {
isUserScrollActive = false;
if (params.isNearBottom) {
if (params.isAtEnd) {
if (mode === "detached") {
setModeInternal("sticky-bottom");
pendingVerification = { requestId: null, retries: 0 };
@@ -533,9 +538,8 @@ function createBottomAnchorControllerDriver(
markStickyMeasurementVerified();
return;
}
if (mode === "sticky-bottom") {
this.detachByUser();
}
cancelPendingRequest("user_scrolled_away");
setModeInternal("detached");
},
detachByUser() {
if (mode === "detached") {
@@ -544,6 +548,21 @@ function createBottomAnchorControllerDriver(
cancelPendingRequest("user_scrolled_away");
setModeInternal("detached");
},
// A disclosure keeps its trigger in place, so following output stops while it applies.
holdForDisclosure() {
cancelPendingRequest("disclosure");
setModeInternal("detached");
},
// Following resumes only if the applied change left the list at its end. Scroll events
// during the hold never re-stick: before the change lands the list is still at the end.
releaseDisclosure(params) {
if (isUserScrollActive || mode !== "detached" || !params.isAtEnd) {
return;
}
setModeInternal("sticky-bottom");
pendingVerification = { requestId: null, retries: 0 };
evaluate(false, "disclosure_released");
},
handleViewportMetricsChange(params) {
if (
params.previousViewportWidth !== params.viewportWidth ||
@@ -568,30 +587,13 @@ function createBottomAnchorControllerDriver(
evaluate(false, "viewport_change");
}
},
handleContentSizeChange(params) {
if (params.previousContentHeight !== params.contentHeight) {
markStickyMeasurementChanged();
}
if (isUserScrollActive) {
// The list keeps its own end through content changes while following, so a change in content
// height never scrolls from here. It only gives a pending jump another look at the new geometry.
handleContentSizeChange() {
if (isUserScrollActive || !pendingRequest) {
return;
}
const shouldRestick = __private__.shouldRestickOnContentChange({
mode,
previousContentHeight: params.previousContentHeight,
contentHeight: params.contentHeight,
});
if (shouldRestick && !pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
if (attemptHandle) {
input.cancelFrame(attemptHandle);
attemptHandle = null;
}
runAttempt(false);
return;
}
if (shouldRestick || pendingRequest) {
evaluate(false, "content_size_change");
}
evaluate(false, "content_size_change");
},
prepareForStickyViewportChange() {
if (mode !== "sticky-bottom") {
@@ -599,25 +601,6 @@ function createBottomAnchorControllerDriver(
}
markStickyMeasurementChanged();
},
prepareForStickyContentChange() {
if (mode !== "sticky-bottom") {
return;
}
markStickyMeasurementChanged();
if (isUserScrollActive) {
return;
}
if (!pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
if (attemptHandle) {
input.cancelFrame(attemptHandle);
attemptHandle = null;
}
runAttempt(false);
return;
}
evaluate(false, "content_size_change");
},
handleScrollNearBottomChange(params) {
const { nextIsNearBottom, scrollDelta } = params;
if (isUserScrollActive) {
@@ -695,13 +678,6 @@ export const __private__ = {
input.previousViewportWidth !== input.viewportWidth))
);
},
shouldRestickOnContentChange(input: {
mode: BottomAnchorMode;
previousContentHeight: number;
contentHeight: number;
}): boolean {
return input.mode === "sticky-bottom" && input.contentHeight > input.previousContentHeight;
},
shouldDetachFromScrollAway(input: {
mode: BottomAnchorMode;
nextIsNearBottom: boolean;
@@ -792,12 +768,18 @@ export function useBottomAnchorController(input: {
beginUserScroll() {
driverRef.current?.beginUserScroll();
},
endUserScroll(params: { isNearBottom: boolean }) {
endUserScroll(params: { isAtEnd: boolean }) {
driverRef.current?.endUserScroll(params);
},
detachByUser() {
driverRef.current?.detachByUser();
},
holdForDisclosure() {
driverRef.current?.holdForDisclosure();
},
releaseDisclosure(params: { isAtEnd: boolean }) {
driverRef.current?.releaseDisclosure(params);
},
handleViewportLayout() {},
handleViewportMetricsChange(params: {
previousViewportWidth: number;
@@ -807,15 +789,12 @@ export function useBottomAnchorController(input: {
}) {
driverRef.current?.handleViewportMetricsChange(params);
},
handleContentSizeChange(params: { previousContentHeight: number; contentHeight: number }) {
driverRef.current?.handleContentSizeChange(params);
handleContentSizeChange() {
driverRef.current?.handleContentSizeChange();
},
prepareForStickyViewportChange() {
driverRef.current?.prepareForStickyViewportChange();
},
prepareForStickyContentChange() {
driverRef.current?.prepareForStickyContentChange();
},
handleScrollNearBottomChange(params: { nextIsNearBottom: boolean; scrollDelta: number }) {
driverRef.current?.handleScrollNearBottomChange(params);
},
@@ -24,7 +24,9 @@ function viewport(root: HTMLElement, capture: Capture) {
const handle: StreamViewportHandle = {
scrollToBottom() {},
prepareForViewportChange() {},
holdReadingPosition() {},
holdRow(_rowId, apply) {
apply();
},
scrollToMessage(_messageId, occurrence) {
if (occurrence) capture.occurrence = occurrence;
},
@@ -265,7 +265,7 @@ describe("useChatOutline", () => {
const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
holdReadingPosition: vi.fn(),
holdRow: vi.fn(),
scrollToMessage,
};
const viewportRef = { current: viewport };
@@ -317,7 +317,7 @@ describe("useChatOutline", () => {
current: {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
holdReadingPosition: vi.fn(),
holdRow: vi.fn(),
scrollToMessage,
},
};
@@ -373,7 +373,7 @@ describe("useChatOutline", () => {
const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
holdReadingPosition: vi.fn(),
holdRow: vi.fn(),
scrollToMessage,
};
const livePrompt = {
@@ -0,0 +1,40 @@
import { createContext, useContext, useMemo, type ReactNode, type RefObject } from "react";
import type { StreamViewportHandle } from "./strategy";
/**
* Runs a disclosure's state change. Inside a timeline row, the row that owns the
* trigger keeps its screen position through the change; elsewhere `apply` runs as is.
*/
export type DisclosureHold = (apply: () => void) => void;
const applyInPlace: DisclosureHold = (apply) => apply();
const DisclosureHoldContext = createContext<DisclosureHold>(applyInPlace);
export function DisclosureRow({
rowId,
viewportRef,
children,
}: {
rowId: string;
viewportRef: RefObject<StreamViewportHandle | null>;
children: ReactNode;
}) {
const hold = useMemo<DisclosureHold>(
() => (apply) => {
const viewport = viewportRef.current;
if (viewport) {
viewport.holdRow(rowId, apply);
} else {
apply();
}
},
[rowId, viewportRef],
);
return <DisclosureHoldContext.Provider value={hold}>{children}</DisclosureHoldContext.Provider>;
}
/** Call with the toggle's state change: `hold(() => setExpanded(!expanded))`. */
export function useDisclosureHold(): DisclosureHold {
return useContext(DisclosureHoldContext);
}
@@ -3,32 +3,13 @@ import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import { layoutStream } from "./layout";
import type { StreamStrategy } from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
function strategyFor(platform: "web" | "native"): StreamStrategy {
const inverted = platform === "native";
const neighborStep = inverted ? 1 : -1;
const getHistoryBoundaryIndex = (items: StreamItem[]) => {
if (items.length === 0) return null;
return inverted ? 0 : items.length - 1;
};
const getLiveHeadBoundaryIndex = (items: StreamItem[]) => {
if (items.length === 0) return null;
return inverted ? items.length - 1 : 0;
};
return {
orderTail: (items) => (inverted ? [...items].toReversed() : items),
orderHead: (items) => (inverted ? [...items].toReversed() : items),
getNeighborIndex: (index, relation) =>
relation === "above" ? index + neighborStep : index - neighborStep,
getNeighborItem: (items, index, relation) => {
const neighborIndex = relation === "above" ? index + neighborStep : index - neighborStep;
return items[neighborIndex];
},
getHistoryLiveBoundaryIndex: getHistoryBoundaryIndex,
getLiveHeadHistoryBoundaryIndex: getLiveHeadBoundaryIndex,
getLatestItemIndex: getHistoryBoundaryIndex,
getFrameChildOrder: () => (inverted ? "footer-then-content" : "content-then-footer"),
} as StreamStrategy;
return resolveStreamRenderStrategy({
platform: platform === "native" ? "android" : "web",
isMobileBreakpoint: false,
});
}
function userMessage(id: string): Extract<StreamItem, { kind: "user_message" }> {
+1 -1
View File
@@ -289,7 +289,7 @@ describe("layoutStream", () => {
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(assistantRow.completedFooter?.itemId).toBe(assistant.id);
expect(assistantRow.belowItem?.id).toBe("u2");
expect(assistantRow.frameOrder).toBe("footer-then-content");
expect(assistantRow.frameOrder).toBe("content-then-footer");
});
it("keeps an accepted steer inline while its canonical turn is active", () => {
@@ -0,0 +1,171 @@
import { LegendList, type LegendListRef } from "@legendapp/list/react-native";
import React, { act, useSyncExternalStore } from "react";
import { createRoot, type Root } from "react-dom/client";
import { View } from "react-native";
import { afterEach, beforeEach, expect, it } from "vitest";
// The native chat timeline runs on the patched LegendList (patches/@legendapp+list+3.7.1.patch).
// A disclosure hold keeps only the held row in place: `shouldRestorePosition` accepts no other row.
// The native build applies its scroll adjustments through native scroll props that
// react-native-web ignores, so these tests read the offset LegendList holds, not the DOM's.
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const VIEWPORT_HEIGHT = 100;
interface Row {
id: string;
height: number;
}
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
host = document.createElement("div");
host.style.cssText = `display:flex;flex-direction:column;width:300px;height:${VIEWPORT_HEIGHT}px`;
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(() => {
act(() => root.unmount());
grownHeights.clear();
host.remove();
});
function wait(ms: number): Promise<void> {
return act(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));
}
const LIST_STYLE = { flex: 1 };
const rowStyles = new Map<number, { height: number }>();
function rowStyle(height: number) {
let style = rowStyles.get(height);
if (!style) {
style = { height };
rowStyles.set(height, style);
}
return style;
}
// A row that changes its own height without a data change, like a todo card toggling its details.
const grownHeights = new Map<string, number>();
const heightListeners = new Set<() => void>();
function subscribeToHeights(listener: () => void) {
heightListeners.add(listener);
return () => heightListeners.delete(listener);
}
function growRow(id: string, height: number) {
grownHeights.set(id, height);
for (const listener of heightListeners) listener();
}
function RowView({ row }: { row: Row }) {
const height = useSyncExternalStore(
subscribeToHeights,
() => grownHeights.get(row.id) ?? row.height,
);
return <View style={rowStyle(height)} />;
}
function renderRow({ item }: { item: Row }) {
return <RowView row={item} />;
}
function holdOnly(heldRowId: string) {
return {
data: true,
size: true,
shouldRestorePosition: (item: Row) => item.id === heldRowId,
};
}
function keyExtractor(item: Row) {
return item.id;
}
function mountList(heldRowId: string) {
const listRef = React.createRef<LegendListRef>();
const maintainVisibleContentPosition = holdOnly(heldRowId);
const render = (data: Row[]) =>
act(() =>
root.render(
<LegendList
ref={listRef}
data={data}
renderItem={renderRow}
keyExtractor={keyExtractor}
estimatedItemSize={40}
recycleItems={false}
scrollEventThrottle={16}
maintainVisibleContentPosition={maintainVisibleContentPosition}
style={LIST_STYLE}
/>,
),
);
const scrollElement = () => {
const scroller = [...host.querySelectorAll<HTMLElement>("div")].find(
(element) => element.scrollHeight > element.clientHeight + 1,
);
if (!scroller) throw new Error("The list has no scroll element");
return scroller;
};
// Small steps: a large jump suspends LegendList's anchoring until the rows re-measure.
const scrollTo = async (offset: number) => {
for (let step = 20; step <= offset; step += 20) {
scrollElement().scrollTop = step;
await wait(150);
}
};
const heldRowTop = () => {
const state = listRef.current?.getState();
const position = state?.positionByKey(heldRowId);
if (state === undefined || position === undefined) {
throw new Error("The held row is not laid out");
}
return position - state.scroll;
};
return { render, scrollTo, heldRowTop };
}
it("anchors a data change on a held row whose top is above the viewport", async () => {
// The held row is a turn host whose prompt bubble is cut off at the top of the viewport, with a
// fully visible row below it. LegendList's ids in view start at that fully visible row.
const rows: Row[] = Array.from({ length: 10 }, (_, index) => ({
id: `row-${index}`,
height: index === 3 ? 60 : 40,
}));
const list = mountList("row-3");
list.render(rows);
await wait(150);
await list.scrollTo(140);
expect(list.heldRowTop()).toBe(-20);
const inserted: Row = { id: "inserted", height: 30 };
list.render([...rows.slice(0, 2), inserted, ...rows.slice(2)]);
await wait(150);
expect(list.heldRowTop()).toBe(-20);
});
it("anchors a size change on a held row whose top is above the viewport", async () => {
// The same cut-off row grows from its own state, so LegendList sees a size change only.
const rows: Row[] = Array.from({ length: 10 }, (_, index) => ({
id: `row-${index}`,
height: index === 3 ? 60 : 40,
}));
const list = mountList("row-3");
list.render(rows);
await wait(150);
await list.scrollTo(140);
expect(list.heldRowTop()).toBe(-20);
act(() => growRow("row-3", 260));
await wait(150);
expect(list.heldRowTop()).toBe(-20);
});
+4 -4
View File
@@ -47,7 +47,7 @@ describe("buildAgentStreamRenderModel", () => {
historyStart: 2,
});
expect(model.history.map((item) => item.id)).toEqual(["a3", "u3", "a2", "u2"]);
expect(model.history.map((item) => item.id)).toEqual(["u2", "a2", "u3", "a3"]);
expect(model.segments.historyVirtualized).toHaveLength(0);
});
@@ -98,7 +98,7 @@ describe("buildAgentStreamRenderModel", () => {
historyStart,
});
expect(model.history.map((item) => item.id)).toEqual(["visible-a", "visible-u"]);
expect(model.history.map((item) => item.id)).toEqual(["visible-u", "visible-a"]);
});
it("keeps head separate from committed history on desktop web", () => {
@@ -207,7 +207,7 @@ describe("buildAgentStreamRenderModel", () => {
});
});
it("derives the same timing for native inverted rendering", () => {
it("derives the same timing on native", () => {
const tail = [userMessage("u1", 1), assistantMessage("a1", 4)];
const model = buildAgentStreamRenderModel({
@@ -219,7 +219,7 @@ describe("buildAgentStreamRenderModel", () => {
isMobileBreakpoint: false,
});
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["u1", "a1"]);
expect(model.turnTiming.byItemId.get("a1")).toEqual({
completedAt: tail[1]?.timestamp,
durationMs: 3000,
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { createNativeDisclosureHold } from "./native-disclosure-hold";
function createFrames() {
let nextId = 1;
const pending = new Map<number, () => void>();
return {
requestFrame: (callback: () => void) => {
const id = nextId++;
pending.set(id, callback);
return id;
},
cancelFrame: (id: number) => {
pending.delete(id);
},
runFrame: () => {
const callbacks = [...pending.values()];
pending.clear();
for (const callback of callbacks) {
callback();
}
},
};
}
function createHarness() {
const frames = createFrames();
const releases: string[] = [];
const hold = createNativeDisclosureHold({
quietFrames: 2,
requestFrame: frames.requestFrame,
cancelFrame: frames.cancelFrame,
onRelease: () => releases.push("released"),
});
return { frames, hold, releases };
}
describe("native disclosure hold", () => {
it("holds the row until two frames pass without an item size change", () => {
const { frames, hold, releases } = createHarness();
hold.hold("turn-1");
frames.runFrame();
expect(hold.heldRowId()).toBe("turn-1");
expect(releases).toEqual([]);
frames.runFrame();
expect(hold.heldRowId()).toBeNull();
expect(releases).toEqual(["released"]);
});
it("restarts the quiet frames on every item size change", () => {
const { frames, hold, releases } = createHarness();
hold.hold("turn-1");
frames.runFrame();
hold.noteItemSizeChanged();
frames.runFrame();
expect(releases).toEqual([]);
frames.runFrame();
expect(releases).toEqual(["released"]);
});
it("ignores item size changes outside a hold", () => {
const { frames, hold, releases } = createHarness();
hold.noteItemSizeChanged();
frames.runFrame();
frames.runFrame();
expect(releases).toEqual([]);
});
it("moves the hold to the latest toggled row and releases once", () => {
const { frames, hold, releases } = createHarness();
hold.hold("turn-1");
frames.runFrame();
hold.hold("turn-2");
expect(hold.heldRowId()).toBe("turn-2");
frames.runFrame();
frames.runFrame();
expect(releases).toEqual(["released"]);
});
it("drops the hold without releasing when cancelled", () => {
const { frames, hold, releases } = createHarness();
hold.hold("turn-1");
hold.cancel();
frames.runFrame();
frames.runFrame();
expect(hold.heldRowId()).toBeNull();
expect(releases).toEqual([]);
});
});
@@ -0,0 +1,64 @@
/**
* The native viewport's disclosure hold. While a row is held, it is the only row the list may
* anchor on. The hold ends once item sizes stop changing for `quietFrames` frames: the list
* measures the toggled row and any rows it inserted over one or more frames, and end following
* must not resume between the change and the list's last layout correction.
*/
export interface NativeDisclosureHold {
heldRowId(): string | null;
hold(rowId: string): void;
noteItemSizeChanged(): void;
cancel(): void;
}
export function createNativeDisclosureHold(input: {
quietFrames: number;
requestFrame: (callback: () => void) => number;
cancelFrame: (id: number) => void;
onRelease: () => void;
}): NativeDisclosureHold {
let heldRowId: string | null = null;
let frameId: number | null = null;
let remainingFrames = 0;
const stopCountdown = () => {
if (frameId !== null) {
input.cancelFrame(frameId);
frameId = null;
}
};
const tick = () => {
remainingFrames -= 1;
if (remainingFrames > 0) {
frameId = input.requestFrame(tick);
return;
}
frameId = null;
heldRowId = null;
input.onRelease();
};
const restartCountdown = () => {
stopCountdown();
remainingFrames = input.quietFrames;
frameId = input.requestFrame(tick);
};
return {
heldRowId: () => heldRowId,
hold(rowId) {
heldRowId = rowId;
restartCountdown();
},
noteItemSizeChanged() {
if (heldRowId !== null) {
restartCountdown();
}
},
cancel() {
stopCountdown();
heldRowId = null;
},
};
}
@@ -0,0 +1,61 @@
import { forwardRef, type ReactNode } from "react";
import {
Animated,
type ScrollView,
type ScrollViewProps,
View,
type ViewStyle,
} from "react-native";
// While the stream follows its end, React Native's native content-position keeping holds a
// zero-height view at the bottom of the content still. It runs on the UI thread around each mount,
// so a row that grows or lands at the end scrolls in the same frame it lays out. LegendList's
// `maintainScrollAtEnd` scrolls from JS after the mount, which paints the growth below the viewport
// for a frame or more first.
//
// The anchor has to be the content's first native child: content-position keeping anchors on the
// first child at or after `minIndexForVisible` that reaches into the viewport, and every child
// LegendList renders can be flattened away. Absolute positioning keeps it out of the flow.
//
// A drag owns the offset from the frame it begins, but the pin stays until JS stops following a
// round trip later. The scroll view subclasses in `modules/paseo-scroll` recognise the pin by its
// native ID and skip its correction while the user drags, so a line mounting in those frames grows
// below the rows under the finger.
const END_PIN_NATIVE_ID = "paseo-end-pin";
const END_ANCHOR_STYLE: ViewStyle = {
position: "absolute",
bottom: 0,
left: 0,
height: 0,
width: 0,
};
const END_ANCHOR_POSITION = { minIndexForVisible: 0 };
interface EndPinnedScrollViewProps extends ScrollViewProps {
pinEnd: boolean;
children?: ReactNode;
}
export const EndPinnedScrollView = forwardRef<ScrollView, EndPinnedScrollViewProps>(
function EndPinnedScrollView({ pinEnd, children, maintainVisibleContentPosition, ...rest }, ref) {
return (
<Animated.ScrollView
{...rest}
ref={ref}
maintainVisibleContentPosition={
pinEnd ? END_ANCHOR_POSITION : maintainVisibleContentPosition
}
>
{pinEnd ? (
<View
collapsable={false}
nativeID={END_PIN_NATIVE_ID}
pointerEvents="none"
style={END_ANCHOR_STYLE}
/>
) : null}
{children}
</Animated.ScrollView>
);
},
);
@@ -4,31 +4,48 @@ import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
describe("native scroll-to-bottom animation", () => {
it("jumps across unmounted history instead of animating through blank rows", () => {
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 60_000, viewportHeight: 600, windowSize: 10 }),
shouldAnimateNativeScrollToBottom({
distanceFromEnd: 60_000,
viewportHeight: 600,
drawDistance: 2000,
}),
).toBe(false);
});
it("keeps near-bottom movement eligible for animation", () => {
it("keeps near-end movement eligible for animation", () => {
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 300, viewportHeight: 600, windowSize: 10 }),
shouldAnimateNativeScrollToBottom({
distanceFromEnd: 300,
viewportHeight: 600,
drawDistance: 2000,
}),
).toBe(true);
});
it("bounds travel by the bottom-side overscan, not the whole window", () => {
it("bounds travel by the rows mounted below the viewport", () => {
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 2700, viewportHeight: 600, windowSize: 10 }),
shouldAnimateNativeScrollToBottom({
distanceFromEnd: 1000,
viewportHeight: 600,
drawDistance: 2000,
}),
).toBe(true);
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 2701, viewportHeight: 600, windowSize: 10 }),
).toBe(false);
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 1500, viewportHeight: 600, windowSize: 5 }),
shouldAnimateNativeScrollToBottom({
distanceFromEnd: 1001,
viewportHeight: 600,
drawDistance: 2000,
}),
).toBe(false);
});
it("does not animate before the viewport has been measured", () => {
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 0, viewportHeight: 0, windowSize: 10 }),
shouldAnimateNativeScrollToBottom({
distanceFromEnd: 0,
viewportHeight: 0,
drawDistance: 2000,
}),
).toBe(false);
});
});
@@ -1,10 +1,10 @@
export function shouldAnimateNativeScrollToBottom(input: {
offsetY: number;
distanceFromEnd: number;
viewportHeight: number;
windowSize: number;
drawDistance: number;
}): boolean {
// VirtualizedList splits the extra window around the viewport. An animated
// jump farther than the bottom-side overscan crosses unmounted history.
const bottomOverscanHeight = ((input.windowSize - 1) / 2) * input.viewportHeight;
return input.viewportHeight > 0 && input.offsetY <= bottomOverscanHeight;
// Scrolled away from the end, LegendList keeps half its draw distance mounted below the
// viewport. An animated jump farther than that crosses rows it has not rendered.
const mountedBelowViewport = input.drawDistance / 2;
return input.viewportHeight > 0 && input.distanceFromEnd <= mountedBelowViewport;
}
@@ -0,0 +1,44 @@
import { useState, useSyncExternalStore } from "react";
/**
* Whether the reader is at the end of the timeline. The viewport writes it and only the
* scroll-to-bottom button reads it, so detaching from the end (a scroll, or a disclosure
* that opens below its trigger) re-renders the button and not the timeline.
*/
export interface NearBottom {
set: (value: boolean) => void;
subscribe: (listener: () => void) => () => void;
get: () => boolean;
}
function createNearBottom(): NearBottom {
let value = true;
const listeners = new Set<() => void>();
return {
set: (next) => {
if (next === value) return;
value = next;
for (const listener of listeners) listener();
},
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
get: () => value,
};
}
/** One per viewed agent; a newly viewed agent starts at the end. */
export function useNearBottom(agentId: string): NearBottom {
const [owner, setOwner] = useState(() => ({ agentId, nearBottom: createNearBottom() }));
if (owner.agentId === agentId) return owner.nearBottom;
const next = { agentId, nearBottom: createNearBottom() };
setOwner(next);
return next.nearBottom;
}
export function useIsNearBottom(nearBottom: NearBottom): boolean {
return useSyncExternalStore(nearBottom.subscribe, nearBottom.get, nearBottom.get);
}
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { createNewRowEstimates } from "./new-row-estimates";
interface Row {
id: string;
kind: string;
}
function row(id: string, kind = "assistant_message"): Row {
return { id, kind };
}
describe("new row estimates", () => {
it("leaves rows of the first list to the list's averages", () => {
const estimates = createNewRowEstimates<Row>();
const first = row("a");
estimates.noteRows([first]);
expect(estimates.estimate(first)).toBeUndefined();
});
it("estimates a new row at the first measured size of the last new row of its kind", () => {
const estimates = createNewRowEstimates<Row>();
estimates.noteRows([row("a")]);
const second = row("b");
estimates.noteRows([row("a"), second]);
expect(estimates.estimate(second)).toBeUndefined();
estimates.noteMeasured(second, 34);
estimates.noteMeasured(second, 56);
const third = row("c");
estimates.noteRows([row("a"), second, third]);
expect(estimates.estimate(third)).toBe(34);
});
it("keeps sizes per kind", () => {
const estimates = createNewRowEstimates<Row>();
estimates.noteRows([]);
const text = row("a");
const tool = row("b", "tool_call");
estimates.noteRows([text, tool]);
estimates.noteMeasured(text, 34);
estimates.noteMeasured(tool, 36);
const nextTool = row("c", "tool_call");
const nextText = row("d");
estimates.noteRows([text, tool, nextTool, nextText]);
expect(estimates.estimate(nextTool)).toBe(36);
expect(estimates.estimate(nextText)).toBe(34);
});
it("records the kind a row has when it is measured", () => {
const estimates = createNewRowEstimates<Row>();
estimates.noteRows([]);
estimates.noteRows([row("a", "thought")]);
estimates.noteMeasured(row("a", "tool_call"), 36);
const thought = row("b", "thought");
const tool = row("c", "tool_call");
estimates.noteRows([row("a", "tool_call"), thought, tool]);
expect(estimates.estimate(thought)).toBeUndefined();
expect(estimates.estimate(tool)).toBe(36);
});
it("estimates new rows anywhere in the list", () => {
const estimates = createNewRowEstimates<Row>();
estimates.noteRows([row("b"), row("d")]);
const appendedTool = row("e", "tool_call");
estimates.noteRows([row("b"), row("d"), appendedTool]);
estimates.noteMeasured(appendedTool, 36);
const older = row("a", "tool_call");
const revealed = row("c", "tool_call");
estimates.noteRows([older, row("b"), revealed, row("d"), appendedTool]);
expect(estimates.estimate(older)).toBe(36);
expect(estimates.estimate(revealed)).toBe(36);
expect(estimates.estimate(row("b", "tool_call"))).toBeUndefined();
});
it("stops estimating a measured row", () => {
const estimates = createNewRowEstimates<Row>();
estimates.noteRows([]);
const first = row("a");
estimates.noteRows([first]);
estimates.noteMeasured(first, 34);
expect(estimates.estimate(first)).toBeUndefined();
});
});
@@ -0,0 +1,47 @@
interface EstimatedRow {
id: string;
kind: string;
}
export interface NewRowEstimates<Row extends EstimatedRow> {
/** Called with every row list; a row whose id the previous list did not have is new. */
noteRows: (rows: readonly Row[]) => void;
/** Estimated size of an unmeasured new row, or undefined to use the list's average for its type. */
estimate: (row: Row) => number | undefined;
/** A row can change kind under the same id (a thought becomes a tool call); its current kind counts. */
noteMeasured: (row: Row, size: number) => void;
}
// A row that enters the list is a streamed row at the end, a tool call an expanded summary reveals,
// or an older row loaded at the start. Its first measured size is close to constant per kind: one
// line of text, a tool call header, a thought line. So a new row is estimated at the first measured
// size of the last new row of its kind, and is laid out near its real size before it is measured.
// The list's average for a type is the size of whole turns and is far off for any of these. Rows of
// the first list are not new; they keep the average until measured.
export function createNewRowEstimates<Row extends EstimatedRow>(): NewRowEstimates<Row> {
let previousRowIds: ReadonlySet<string> | null = null;
const unmeasuredRowIds = new Set<string>();
const firstSizeByKind = new Map<string, number>();
return {
noteRows(rows) {
const rowIds = new Set(rows.map((row) => row.id));
if (previousRowIds) {
for (const row of rows) {
if (!previousRowIds.has(row.id)) unmeasuredRowIds.add(row.id);
}
}
for (const rowId of unmeasuredRowIds) {
if (!rowIds.has(rowId)) unmeasuredRowIds.delete(rowId);
}
previousRowIds = rowIds;
},
estimate(row) {
return unmeasuredRowIds.has(row.id) ? firstSizeByKind.get(row.kind) : undefined;
},
noteMeasured(row, size) {
if (!unmeasuredRowIds.delete(row.id)) return;
firstSizeByKind.set(row.kind, size);
},
};
}
@@ -50,6 +50,7 @@ const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
isLatestTurnOpen: false,
expandedHostIds: new Set(),
getGroupedCalls: () => undefined,
previousTurnsByHostId: new Map(),
});
/** Source messages reach plugins before display projection. */
@@ -134,6 +135,7 @@ export function createStreamPresentation() {
isLatestTurnOpen,
expandedHostIds: input.expandedTurnHostIds,
getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls,
previousTurnsByHostId: collapsed?.turnsByHostId ?? NO_COLLAPSED_TURNS.turnsByHostId,
});
collapsedSource = projection.tail;
collapsedIsLatestTurnOpen = isLatestTurnOpen;
@@ -56,6 +56,16 @@ describe("reading anchor", () => {
).toBe(1100);
});
it("keeps a pinned row in place instead of the row under the reading line", () => {
const anchor = createReadingAnchor();
anchor.reconcile(600, rows);
anchor.pin(rows[2]!);
// The row being read grows; only the pinned row below it holds its position.
expect(
anchor.reconcile(600, [rows[0]!, { ...rows[1]!, height: 800 }, { ...rows[2]!, top: 1300 }]),
).toBe(900);
});
it("leaves expansion of the row being read in place", () => {
const anchor = createReadingAnchor();
anchor.reconcile(600, rows);
@@ -33,6 +33,11 @@ export function createReadingAnchor() {
reset() {
anchor = null;
},
// A disclosure names the row that owns its trigger. That row keeps its top
// through the toggle, even when another row sits under the reading line.
pin(row: Pick<RowGeometry, "id" | "top">) {
anchor = { id: row.id, top: row.top };
},
scroll(scrollTop: number) {
const next = readingRow(scrollTop);
anchor = next ? { id: next.id, top: next.top } : null;
@@ -12,7 +12,6 @@ import {
isNearBottomForStreamRenderStrategy,
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
} from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
@@ -64,8 +63,6 @@ describe("resolveStreamRenderStrategy", () => {
});
expect(strategy.shouldUseVirtualizedList()).toBe(false);
expect(strategy.getFlatListInverted()).toBe(false);
expect(strategy.getOverlayScrollbarInverted()).toBe(false);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(true);
expect(strategy.getBottomAnchorTransportBehavior()).toEqual({
verificationDelayFrames: 0,
@@ -73,57 +70,27 @@ describe("resolveStreamRenderStrategy", () => {
});
});
it("uses inverted_stream on native", () => {
it("uses a virtualized forward stream on native", () => {
const strategy = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
expect(strategy.shouldUseVirtualizedList()).toBe(true);
expect(strategy.getFlatListInverted()).toBe(true);
expect(strategy.getOverlayScrollbarInverted()).toBe(true);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(false);
expect(strategy.getBottomAnchorTransportBehavior()).toEqual({
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
});
});
it("delays native verification while viewport settling is in flight", () => {
const strategy = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
expect(
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
}),
).toEqual({
verificationDelayFrames: 4,
verificationRetryMode: "recheck",
});
});
it("does not inflate forward-stream verification delays during web resize", () => {
const strategy = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
expect(
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
}),
).toEqual({
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
});
});
});
const PLATFORMS = ["web", "ios", "android"] as const;
function strategyFor(platform: (typeof PLATFORMS)[number]) {
return resolveStreamRenderStrategy({ platform, isMobileBreakpoint: false });
}
describe("stream ordering", () => {
const streamItems: StreamItem[] = [
userMessage("u1", "user-1", 1),
@@ -131,11 +98,8 @@ describe("stream ordering", () => {
assistantMessage("a2", "assistant-2", 3),
];
it("keeps forward_stream order unchanged for tail and head", () => {
const strategy = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
it.each(PLATFORMS)("keeps oldest-to-newest order for tail and head on %s", (platform) => {
const strategy = strategyFor(platform);
const tail = orderTailForStreamRenderStrategy({ strategy, streamItems });
const head = orderHeadForStreamRenderStrategy({ strategy, streamHead: streamItems });
@@ -143,73 +107,31 @@ describe("stream ordering", () => {
expect(tail.map((item) => item.id)).toEqual(["u1", "a1", "a2"]);
expect(head.map((item) => item.id)).toEqual(["u1", "a1", "a2"]);
});
it("reverses inverted_stream order for tail and head", () => {
const strategy = resolveStreamRenderStrategy({
platform: "android",
isMobileBreakpoint: false,
});
const tail = orderTailForStreamRenderStrategy({ strategy, streamItems });
const head = orderHeadForStreamRenderStrategy({ strategy, streamHead: streamItems });
expect(tail.map((item) => item.id)).toEqual(["a2", "a1", "u1"]);
expect(head.map((item) => item.id)).toEqual(["a2", "a1", "u1"]);
});
});
describe("neighbor and traversal semantics", () => {
it("maps above/below indices for forward and inverted streams", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
it.each(PLATFORMS)("maps above/below indices on %s", (platform) => {
const strategy = strategyFor(platform);
expect(getStreamNeighborIndex({ strategy: forward, index: 3, relation: "above" })).toBe(2);
expect(getStreamNeighborIndex({ strategy: forward, index: 3, relation: "below" })).toBe(4);
expect(getStreamNeighborIndex({ strategy: inverted, index: 3, relation: "above" })).toBe(4);
expect(getStreamNeighborIndex({ strategy: inverted, index: 3, relation: "below" })).toBe(2);
expect(getStreamNeighborIndex({ strategy, index: 3, relation: "above" })).toBe(2);
expect(getStreamNeighborIndex({ strategy, index: 3, relation: "below" })).toBe(4);
});
it("collects assistant response content with strategy traversal direction", () => {
it.each(PLATFORMS)("collects assistant response content on %s", (platform) => {
const chronological: StreamItem[] = [
userMessage("u1", "user-1", 1),
assistantMessage("a1", "assistant-1", 2),
assistantMessage("a2", "assistant-2", 3),
userMessage("u2", "user-2", 4),
];
const strategy = strategyFor(platform);
const items = orderTailForStreamRenderStrategy({ strategy, streamItems: chronological });
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const forwardStartIndex = chronological.findIndex((item) => item.id === "a2");
expect(
collectAssistantResponseContentForStreamRenderStrategy({
strategy: forward,
items: chronological,
startIndex: forwardStartIndex,
}),
).toBe("assistant-1\n\nassistant-2");
const inverted = resolveStreamRenderStrategy({
platform: "android",
isMobileBreakpoint: false,
});
const invertedItems = orderTailForStreamRenderStrategy({
strategy: inverted,
streamItems: chronological,
});
const invertedStartIndex = invertedItems.findIndex((item) => item.id === "a2");
expect(
collectAssistantResponseContentForStreamRenderStrategy({
strategy: inverted,
items: invertedItems,
startIndex: invertedStartIndex,
strategy,
items,
startIndex: items.findIndex((item) => item.id === "a2"),
}),
).toBe("assistant-1\n\nassistant-2");
});
@@ -220,7 +142,7 @@ describe("neighbor and traversal semantics", () => {
{ ...toolCall("tool", 2), turnId: "turn-2" },
{ ...assistantMessage("a2", "second turn", 3), turnId: "turn-2" },
];
const strategy = resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false });
const strategy = strategyFor("web");
expect(
collectAssistantResponseContentForStreamRenderStrategy({
@@ -232,37 +154,17 @@ describe("neighbor and traversal semantics", () => {
});
it("returns undefined neighbor when index would be out of bounds", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const strategy = strategyFor("web");
const items: StreamItem[] = [userMessage("u1", "user-1", 1)];
expect(
getStreamNeighborItem({
strategy: forward,
items,
index: 0,
relation: "above",
}),
).toBeUndefined();
expect(
getStreamNeighborItem({
strategy: forward,
items,
index: 0,
relation: "below",
}),
).toBeUndefined();
expect(getStreamNeighborItem({ strategy, items, index: 0, relation: "above" })).toBeUndefined();
expect(getStreamNeighborItem({ strategy, items, index: 0, relation: "below" })).toBeUndefined();
});
});
describe("scroll/bottom calculations", () => {
it("computes near-bottom using forward_stream distance-from-bottom math", () => {
const strategy = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
it.each(PLATFORMS)("computes near-bottom from the distance to the end on %s", (platform) => {
const strategy = strategyFor(platform);
expect(
isNearBottomForStreamRenderStrategy({
@@ -284,48 +186,10 @@ describe("scroll/bottom calculations", () => {
).toBe(false);
});
it("computes near-bottom and scroll-to-bottom offset for inverted_stream", () => {
const strategy = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
expect(
isNearBottomForStreamRenderStrategy({
strategy,
offsetY: 12,
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
}),
).toBe(true);
expect(
isNearBottomForStreamRenderStrategy({
strategy,
offsetY: 40,
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
}),
).toBe(false);
it.each(PLATFORMS)("maps scroll-to-bottom to the max offset on %s", (platform) => {
expect(
getBottomOffsetForStreamRenderStrategy({
strategy,
viewportHeight: 300,
contentHeight: 1000,
}),
).toBe(0);
});
it("maps scroll-to-bottom to max offset for forward_stream", () => {
const strategy = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
expect(
getBottomOffsetForStreamRenderStrategy({
strategy,
strategy: strategyFor(platform),
viewportHeight: 320,
contentHeight: 1000,
}),
@@ -334,36 +198,15 @@ describe("scroll/bottom calculations", () => {
});
describe("edge slot semantics", () => {
it("uses footer slot for forward_stream and header slot for inverted_stream", () => {
it.each(PLATFORMS)("uses the footer slot on %s", (platform) => {
const EdgeSlot = () => null;
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "android",
isMobileBreakpoint: false,
});
const forwardProps = getStreamEdgeSlotProps({
strategy: forward,
component: EdgeSlot,
gapSize: 4,
});
const invertedProps = getStreamEdgeSlotProps({
strategy: inverted,
component: EdgeSlot,
gapSize: 4,
});
expect(forwardProps).toEqual({
expect(
getStreamEdgeSlotProps({ strategy: strategyFor(platform), component: EdgeSlot, gapSize: 4 }),
).toEqual({
ListFooterComponent: EdgeSlot,
ListFooterComponentStyle: { marginTop: 4 },
});
expect(invertedProps).toEqual({
ListHeaderComponent: EdgeSlot,
ListHeaderComponentStyle: { marginBottom: 4 },
});
});
});
@@ -373,81 +216,31 @@ describe("layout strategy edges", () => {
assistantMessage("a1", "assistant-1", 2),
];
it("uses the newest history edge as the history/live boundary", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "android",
isMobileBreakpoint: false,
});
it.each(PLATFORMS)(
"uses the newest history edge as the history/live boundary on %s",
(platform) => {
const strategy = strategyFor(platform);
const history = orderTailForStreamRenderStrategy({ strategy, streamItems });
const forwardHistory = orderTailForStreamRenderStrategy({ strategy: forward, streamItems });
const invertedHistory = orderTailForStreamRenderStrategy({ strategy: inverted, streamItems });
expect(getHistoryLiveBoundaryIndexForStreamRenderStrategy({ strategy, history })).toBe(1);
},
);
expect(
getHistoryLiveBoundaryIndexForStreamRenderStrategy({
strategy: forward,
history: forwardHistory,
}),
).toBe(1);
expect(
getHistoryLiveBoundaryIndexForStreamRenderStrategy({
strategy: inverted,
history: invertedHistory,
}),
).toBe(0);
});
it.each(PLATFORMS)(
"uses the oldest live-head edge as the live-head/history boundary on %s",
(platform) => {
const strategy = strategyFor(platform);
const liveHead = orderHeadForStreamRenderStrategy({ strategy, streamHead: streamItems });
it("uses the oldest live-head edge as the live-head/history boundary", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
expect(getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({ strategy, liveHead })).toBe(
0,
);
},
);
const forwardHead = orderHeadForStreamRenderStrategy({
strategy: forward,
streamHead: streamItems,
});
const invertedHead = orderHeadForStreamRenderStrategy({
strategy: inverted,
streamHead: streamItems,
});
expect(
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({
strategy: forward,
liveHead: forwardHead,
}),
).toBe(0);
expect(
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({
strategy: inverted,
liveHead: invertedHead,
}),
).toBe(1);
});
it("names the frame child order needed by native inverted cells", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "android",
isMobileBreakpoint: false,
});
expect(getFrameChildOrderForStreamRenderStrategy({ strategy: forward })).toBe(
it.each(PLATFORMS)("renders a cell's content before its footer on %s", (platform) => {
expect(getFrameChildOrderForStreamRenderStrategy({ strategy: strategyFor(platform) })).toBe(
"content-then-footer",
);
expect(getFrameChildOrderForStreamRenderStrategy({ strategy: inverted })).toBe(
"footer-then-content",
);
});
});
@@ -41,23 +41,23 @@ describe("scroll keyboard dismissal", () => {
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1040, y: 8 },
{ ts: 1080, y: 16 },
{ ts: 1120, y: 24 },
{ ts: 1040, y: -8 },
{ ts: 1080, y: -16 },
{ ts: 1120, y: -24 },
],
{ ts: 1160, y: 32 },
{ ts: 1160, y: -32 },
),
).toBe(false);
});
it("dismisses for an upward flick", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe(
it("dismisses for a flick toward older messages", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe(
true,
);
});
it("keeps the keyboard up when the inverted list moves toward newer messages", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe(
it("keeps the keyboard up when the list moves toward newer messages", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe(
false,
);
});
@@ -67,17 +67,17 @@ describe("scroll keyboard dismissal", () => {
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1100, y: 500 },
{ ts: 1200, y: 590 },
{ ts: 1270, y: 598 },
{ ts: 1100, y: -500 },
{ ts: 1200, y: -590 },
{ ts: 1270, y: -598 },
],
{ ts: 1300, y: 600 },
{ ts: 1300, y: -600 },
),
).toBe(false);
});
it("uses the whole drag when the gesture is too short to sample", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: 60 })).toBe(true);
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: -60 })).toBe(true);
});
it("steps back a sample when release lands immediately after one", () => {
@@ -85,22 +85,22 @@ describe("scroll keyboard dismissal", () => {
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1040, y: 120 },
{ ts: 1075, y: 225 },
{ ts: 1040, y: -120 },
{ ts: 1075, y: -225 },
],
{ ts: 1080, y: 240 },
{ ts: 1080, y: -240 },
),
).toBe(true);
});
it("keeps the keyboard up when release has no measurable time span", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: 90 })).toBe(false);
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: -90 })).toBe(false);
});
it("ignores scroll events that arrive before the minimum sample span", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: 1000 }], { ts: 1060, y: 1000 })).toBe(
true,
);
expect(
shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: -1000 }], { ts: 1060, y: -1000 }),
).toBe(true);
});
it("uses native gesture time when delayed callbacks arrive in a burst", () => {
@@ -108,32 +108,32 @@ describe("scroll keyboard dismissal", () => {
shouldDismiss(
{ ts: 5000, nativeTs: 1000, y: 0 },
[
{ ts: 5001, nativeTs: 1200, y: 40 },
{ ts: 5002, nativeTs: 1400, y: 80 },
{ ts: 5001, nativeTs: 1200, y: -40 },
{ ts: 5002, nativeTs: 1400, y: -80 },
],
{ ts: 5003, nativeTs: 1600, y: 120 },
{ ts: 5003, nativeTs: 1600, y: -120 },
),
).toBe(false);
});
it("falls back to synthetic event time when native time is absent", () => {
expect(
shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: 120 }], {
shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: -120 }], {
ts: 1080,
nativeTs: null,
y: 240,
y: -240,
}),
).toBe(true);
});
it("ignores scroll and release events without a matching drag", () => {
const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: 200 }));
const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: -200 }));
expect(idle).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
expect(releaseDrag(idle, event({ ts: 1010, y: 300 })).shouldDismiss).toBe(false);
expect(releaseDrag(idle, event({ ts: 1010, y: -300 })).shouldDismiss).toBe(false);
});
it("returns to idle after release", () => {
const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: 60 }));
const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: -60 }));
expect(release.gesture).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
});
});

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