Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] df67f21403 test(terminal): reproduce missing mouse reports on initial attach
A raw-input terminal enables SGR mouse reporting before the web client attaches. A trusted finger drag should reach its stdin, as it does when the same mode is enabled after attachment, but no report arrives. This diagnostic intentionally remains red; it changes no production behavior.
2026-11-04 11:31:58 +00:00
paseo-bot[bot] 1c35da2bdb 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.
2026-11-04 11:25:47 +00:00
paseo-bot[bot]andsino a1cd62fbc9 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>
2026-11-04 11:09:38 +00:00
sino 78669b1f5b 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.
2026-11-04 10:58:56 +00: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
paseo-ai[bot] bfbd4d5175 fix: update lockfile signatures and Nix hash [skip ci] 2026-11-04 08:04:28 +00:00
Mohamed Boudra 52b84b7835 fix(ci): avoid incomplete pkijs signature attestations (#8749)
Pin pkijs to 3.4.2: SEA serves only provenance for 3.4.3. Hoist electron-builder so root overrides apply to its dependency tree.
2026-11-04 14:48:36 +07:00
Mohamed Boudra d68271565a Read agent history after its working directory is removed (#8737)
* fix(server): read agent history after working directory removal

* fix(server): preserve interactive residency when reading agent history

* fix(server): serialize history session upgrades

* fix(server): upgrade history sessions before changing prompt mode

* fix(server): keep archived history out of interactive residency

* fix(server): admit configuration changes through interactive residency

* fix(server): establish interactive residency before rewinding

* fix(server): preserve immediate rewind locking for interactive sessions
2026-11-04 14:10:27 +07:00
paseo-bot[bot]and3ae3ae ce7b14a143 Keep context details open while chat streams (#8742)
* fix(app): keep hover cards open while unrelated content scrolls

Hover cards closed on any scroll outside their content. A streaming
chat scrolls on every new line, so the context window details closed
while an agent was responding. Close only when the scrolled element
contains the trigger, which is the case that moves the card's anchor.

* test(app): confirm the turn is still streaming once the details open

* test(app): cover context clicks and trigger scrolling

Exercise the reported click path without replacing usage transport, and
preserve dismissal when a sidebar scroll moves its trigger.

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>

---------

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 07:05:49 +00:00
paseo-bot[bot] c7e137d4b4 test(server): settle missing-folder observation before cleanup (#8743)
Wait for metadata observation setup before deleting and restoring the fixture, and flush its diagnostic trace sink after service disposal so queued appends cannot recreate files during removal.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 06:51:15 +00:00
Mohamed Boudra d37262be56 Keep workspace updates independent of archived history (#8735)
* perf(server): scope workspace updates to indexed records

* fix(server): preserve parent context in scoped workspace queries

* test(server): update remaining scoped store adapters

* fix(server): preserve archived exact paths in archive requests

* fix(server): prefer active exact archive targets
2026-11-04 13:25:24 +07:00
Mohamed Boudra 0952ddbd4b fix(server): recover workspace Git watchers after setup timeouts
* fix(server): recover git watchers after setup timeouts

Setup failures entered degraded polling without scheduling the existing recovery ladder. Retry both watcher types so successful subscriptions stop fallback reads.

* fix(server): isolate callbacks from expired git watchers

An expired subscription can report an error after recovery. Ignore abandoned callbacks and callbacks that no longer own the active subscription so they cannot tear down a replacement.

* fix(server): refresh metadata when git observation recovers

* fix(server): settle watcher recovery during workspace teardown

* fix(server): reconcile exclusions when metadata watching recovers
2026-11-04 13:10:21 +07:00
67628b48fe Keep workspaces usable when mobile navigation overflows (#8727)
* fix(app): cap the compact sidebar nav block so plugins cant crowd out workspaces

The compact sidebar's top nav group (builtin rows plus plugin header
items) was a fixed block with no height bound. On short screens - a
foldable's cover display, for instance - enough plugin items pushed the
scrollable workspace list to zero height, leaving it unusable.

Wrap the nav rows in a bounded ScrollView capped at half the sidebar
height. Rows scroll inside the cap when they overflow; the separator
moves to the scroll frame so it stays fixed at the region's bottom. On
native the scroll view is the gesture-handler variant with the close
pan as a simultaneous handler, matching the workspace list's contract.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(app): keep the mobile nav separator theme-safe and disable iOS bounce

Review follow-ups: the border lived on the gesture-handler ScrollView,
which Unistyles never rewrites, so move the divider to a SidebarSeparator
sibling. On iOS the block rubber-banded under a fixed divider even when
every row fit, so disable alwaysBounceVertical. Inline
simultaneousHandlers now that GHScrollView types it, and tag the region
for Maestro.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(app): keep workspaces reachable with overflowing sidebar navigation

Reproduce the short compact sidebar failure with the existing ten-row plugin fixture. Check that navigation scrolls independently and both navigation actions and workspaces remain usable.

Co-authored-by: hugo <jaredwangcoode@gmail.com>

---------

Co-authored-by: hugo <jaredwangcoode@gmail.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 04:08:50 +00:00
paseo-bot[bot] eba5bdf810 test(server): drain agent snapshots before title fixture cleanup (#8716)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 03:27:40 +00:00
paseo-bot[bot] 66676d49e6 fix(pi): require Reload after the native process exits (#8718)
Reject new turns before accepting work into a known dead Pi runtime. Keep process liveness separate from explicit session cleanup so Reload can still release resources and resume the conversation.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 01:56:00 +00:00
paseo-bot[bot] 064243288b Recognize persisted parents when creating child sessions (#8714)
* fix(server): recognize persisted parents during child creation

Creation only needs the caller's identity and workspace. Read the existing stored record when the parent is unloaded after restart, rather than requiring its provider session to be opened first.

* test(server): run persisted caller regressions in integration CI

* test(server): retry fixture removal while Windows releases handles

* fix(server): keep archived parents ineligible for child creation

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 01:12:53 +00:00
Mohamed Boudra a880c9619d feat: background agents for hidden helpers (#8683)
* feat(server): add background agents

An agent can be created background in any workspace. It stays a durable,
addressable agent, but default discovery, workspace status and attention
leave it out. Effective background is agent.background ||
workspace.background.

- Protocol: `create_agent_request.background` now marks the agent itself
  and stays the workspace intent for a workspace the request creates.
  Snapshots carry `background`. The daemon advertises
  `features.backgroundAgents`. Clients advertise the `background_agents`
  capability.
- Daemon: the flag persists in the agent record, so restart and resume
  keep it. AgentDirectory owns visibility. Inclusive reads and
  subscriptions from sources without `background_agents` see only agents
  in background workspaces, because older apps would otherwise open a tab
  for each one.
- Inheritance: omitted on agent-scoped creation, children and new
  workspaces take the caller agent's background.
- MCP: create_agent accepts `background`.
- SDK: placing a background agent in an existing workspace, or under a
  caller, fails fast with "Update the host to use background agents." on
  older hosts.
- CLI: `ls -a` and `ls --background` add a BACKGROUND column.

* feat(app): hide background agents from tabs, track and status

* docs(plugins): point hidden helpers at background agents

* fix: keep background agents hidden across cache reload and connected hosts

* fix: keep remote creation working on older hosts and inherit foreign parents' background
2026-11-04 07:19:21 +07:00
Mohamed Boudra 6740d41fab Open plugin settings from a cog, with a tab per settings screen (#8675)
* feat(app): open plugin settings from a cog with a tab per screen

The plugin row shows a settings button next to its switch when the plugin
registers settings screens. The actions menu keeps Logs, Reload and Remove.
The settings page shows one tab per registered screen; a plugin with one
screen shows no tabs. Tabs swap screenId in place, so existing
plugins/[pluginId]/[screenId] links and openSettings keep working.

* fix(app): render settings tab icons inside the plugin's runtime

Component icons now mount under the installation provider, so plugin SDK
hooks such as useTranslation work. Each icon has its own error boundary;
an icon that throws leaves its tab with the label alone.

* feat(app): name only the plugin in its settings page header

The header shows the plugin's manifest name, or its id when it has none,
for single- and multi-screen plugins alike. The selected tab names the
screen. The Plugins page and the header share one plugin list query.

* feat(app): show plugin display names and a Settings tooltip on the cog

Plugin rows on Settings → Plugins show the manifest name, falling back to
the id; when a name is set, the id stays visible beneath it. The row's
controls, logs sheet, confirmations and feedback use the same name.
The cog is a ToolbarButton labeled Settings, with a matching tooltip.
2026-11-04 07:19:13 +07:00
Mohamed Boudra ca6b54fc2e feat(app): mark composer pills that open a menu and keep the pill track's space (#8673)
Plugin composer pills with a menu or popover now show an upward chevron at
every width, so a pill that opens a surface reads differently from a one-tap
action pill. Built-in Tasks and Subagents pills are unchanged.

The transcript's bottom inset used to be reserved only while some pill
existed. Plugins that add pills when a turn ends and remove them when the next
starts moved the transcript every turn. The inset is now reserved whenever the
agent has an active composer.
2026-11-04 07:19:06 +07:00
paseo-bot[bot] 4cd23a8f17 test(server): wait for worktree observation without racing filesystem setup (#8700)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 21:30:59 +00:00
3084f32852 Show effort controls for Claude settings models (#8699)
* fix(claude): show thinking options for models from settings.json

Models appended from Claude's settings.json were pushed to the catalog
without thinkingOptions, so the composer had no effort selector for them.
Resolve them like profile and additionalModels entries: manifest options for
built-in IDs, the standard Claude effort levels otherwise.

* docs(claude): clarify effort selection for settings models

Describe explicit effort selection without promising how every client initializes an unsaved value.

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
Co-authored-by: wangyahao <wangyahao@fxiaoke.com>

---------

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: wangyahao <wangyahao@fxiaoke.com>
2026-11-03 20:59:32 +00:00
paseo-bot[bot] ce8196f6c5 fix(opencode): keep reopened running conversations interruptible (#8698)
An execution adopted during session initialization emitted turn_started before the manager could subscribe. Deliver the current turn to a new subscriber so the manager owns the run and routes Stop to OpenCode.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 19:54:49 +00:00
paseo-bot[bot]andSeth Jeon 40b95f70f4 Fix Android chat mouse-wheel direction (#8693)
* fix(android): correct inverted chat wheel direction

* test(android): run wheel regressions with current React Native

Provide the native-only test context with React Native’s event dispatcher for an absent JavaScript instance, retaining real scroll handling.

Co-authored-by: Seth Jeon <paseo@sethjeon.com>

---------

Co-authored-by: Seth Jeon <paseo@sethjeon.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 19:25:40 +00:00
babo-jeongukandbabo-jeonguk 93b9ac9ded fix(claude): preserve restart recovery during graceful shutdown (#8686)
Separate runtime release from explicit turn cancellation, drain close-time cleanup before retirement, and reject late query creation. Add regressions for closure, pending tools and permissions, and asynchronous launch.

Co-authored-by: babo-jeonguk <278435975+babo-jeonguk@users.noreply.github.com>
2026-11-03 17:02:24 +00:00
paseo-ai[bot] eb8b5b8f94 fix: update lockfile signatures and Nix hash [skip ci] 2026-11-03 12:04:22 +00:00
Okan YILDIRIMandMichael Lawson 51b9ad7695 fix(npm): declare and ship Apache-2.0 licenses (#7816)
Co-authored-by: Michael Lawson <github@mdlawson.com>
2026-11-03 11:54:57 +00:00
1c4400194a Support hardware keyboard shortcuts across native and desktop apps (#8625)
* feat(keyboard): support hardware keyboards on iPad

Register app shortcuts as iOS UIKeyCommands so a paired hardware keyboard
drives the same bindings as web and desktop. Overlays (menus, comboboxes,
modals, command center) get first refusal on bare keys, and a focused
terminal keeps the keys it needs.

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

* docs(keyboard): record iOS hardware key observation spike

With a UITextView focused on iPad Pro M4/iOS 18.6, window and controller observers received Cmd+B, Cmd+P and Escape. Escape also invoked UIKeyCommand when registered, so the stream must be the sole emitter. Use observation for shortcuts and commands only for text-control ownership. Simulator intercepted Cmd+K as HID184; input capture blocked synthetic typing entirely. Keep capture off and verify the toggle via a rebind during simulator QA.

* refactor(keyboard): give overlays one key registration API

Overlay ownership now follows one layer-ordered registry on every runtime. Web retains focus trapping and restoration through its adapter; native registers the same key handler without a DOM scope. Picker navigation and dismissal no longer require a parallel native stack.

Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>

* feat(keyboard): route native hardware keys through the shared resolver

Observe each physical press once, including native modal windows, and derive focus from declared composer and terminal surfaces. Native bindings and rebinds now use the same resolver and overlay precedence as browser and Electron input.

Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>

* feat(keyboard): show shortcuts when hardware keyboards are present

Keep routing independent of compact layout and detect hardware capability for hints and settings. Rebind capture uses the shared overlay interface on native too. Keep the filtered sidebar model available for keyboard navigation while its panel is hidden.

Co-authored-by: Les Stroud <public@lesstroud.com>

Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>

* test(keyboard): allow shortcut hints in compact menu assertions

Compact menus now display hardware keyboard hints. Assert action labels, order and uniqueness without treating a trailing shortcut badge as part of the action label.

* fix(keyboard): retain an overlay's opening layer in state

Capture the registry's current top when an overlay opens and retain that layer through rerenders. React Compiler removed the previous useMemo cache after the registry getter moved across modules, allowing a parent modal to rise above its child and start an infinite focus loop.

Compile this hook in Vitest as Expo does. The new hook regression failed first with layer 220 instead of 20, then passed with explicit opening state. Focused unit and browser tests pass, and the previously failing onboarding E2E completes through the real Expo compiler. Typecheck, lint and format pass.

* refactor(keyboard): let overlays own native hardware input

Remove the TextInput key handlers that duplicate declared overlay arrows and Escape in Add Project and Command Center. Remove Add Project's hidden responder now that native window sources observe keys without it. Keep soft-keyboard submit editing behavior.

* fix(keyboard): close Add Project outside its state updater

Root Escape previously called the parent close action from a React state updater, producing a cross-component render warning when navigation was still queued. Compute the previous page in the event handler and close outside the updater.

The existing Escape/Back E2E now batches navigation and dismissal through the DOM key source. It failed first with the AddProjectFlowHost render warning, then passed after the correction. Typecheck, lint and format pass; native Escape checks pass on Android and iPad.

* fix(keyboard): preserve Android dead-key composition

* fix(keyboard): recognize focused native text inputs

* test(keyboard): replace JSDOM coverage with browser tests

* refactor(menu): spell out arrow navigation branches

* fix(composer): claim fullscreen Escape on native

* refactor(composer): one fullscreen Escape module for every platform

The shared registration lived in fullscreen-exit.shared.ts with two one-line
re-exports (fullscreen-exit.ts and fullscreen-exit.native.ts). Metro needs no
native override now that the Android back button is its own adapter, so the
implementation moves into fullscreen-exit.ts and the browser test imports the
module every platform resolves.

---------

Co-authored-by: Ubuntu <jeroenfrenken@icloud.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>
2026-11-03 18:08:27 +07:00
Dominic 1502b7f90f fix(acp): fail a prompt to a disconnected ACP agent instead of hanging (#8406)
When the ACP agent process exited while idle, the session kept its dead
connection and accepted the next prompt. The ACP SDK logs the failed
write but never settles the prompt request, so the turn stayed running
forever with no error.

startTurn now refuses a connection whose signal has aborted, and a turn
whose transport closes before its response fails with "ACP agent
disconnected" (with the exit code or signal when known). A response
that was already delivered still completes the turn, and a crash that
the exit handler reports keeps its "exited unexpectedly" error.

Fixes #7070
2026-11-03 10:08:51 +00:00
paseo-bot[bot] 16f688b726 test(server): stop pending setup before removing its fixture (#8624)
* test(server): diagnose recurring Windows setup timeout

* test(server): stop pending setup before removing its fixture

A test deadline does not cancel background setup. Abort and join it before deleting its working directory, and cover teardown with a deliberately live setup child. Keep the existing subdirectory shell assertions and timeout.

* test(server): preserve setup readiness filesystem errors

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 09:30:30 +00:00
paseo-bot[bot]andHomicideZero b507fc1986 fix(server): return 404 for missing well-known metadata (#8622)
* fix(server): return 404 for unserved well-known paths

The daemon web UI middleware fell back to the SPA's index.html for every
unknown GET, including /.well-known/* URIs. MCP clients that run OAuth
discovery (RFC 9728 protected resource metadata, RFC 8414 authorization
server metadata) received a 200 text/html body, treated it as malformed
metadata and retried instead of concluding the server has no OAuth.

Well-known paths now serve a real file from the dist directory if one
exists and otherwise return a JSON 404. Other paths keep the SPA fallback.

* fix(server): verify metadata routing through the daemon

Retain the contributed well-known routing fix with named resolver options and a full daemon regression that fails when metadata requests receive app HTML.

Co-authored-by: HomicideZero <219062565+HomicideZero@users.noreply.github.com>

---------

Co-authored-by: HomicideZero <219062565+HomicideZero@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 07:53:13 +00:00
375 changed files with 15222 additions and 4942 deletions
+2
View File
@@ -34,6 +34,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/keyboard-shortcuts.md](docs/keyboard-shortcuts.md) | The binding table, per-platform key sources, badges vs routing, iOS `UIKeyCommand` limits |
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA |
@@ -54,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 |
+30 -11
View File
@@ -183,23 +183,42 @@ Some providers can create their own child sessions inside one provider runtime.
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
## Background workspaces
## Background visibility
`workspace.background` controls discovery only. Background workspaces contain ordinary durable
agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery
keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot
change it. New workspaces default to false, inherit the caller workspace's value when an agent
creates one without an override, and accept explicit true or false.
An agent is effectively background when it or its workspace is background:
`agent.background || workspace.background`. Background controls discovery only. Background agents
and the agents in background workspaces are ordinary durable agents: provider history, plugin
hooks, parentage, finish notifications to a caller, archive, and restart recovery keep their
normal behavior. Both flags are fixed at creation.
The daemon derives agent discovery from workspace ownership. Default workspace and agent lists,
including History requests, exclude background work; `filter.includeBackground` includes it.
Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only
its sidebar with **Show background**, so direct navigation and restored tabs remain available.
- **Agent.** `create_agent_request.background` marks the agent. Use it for helpers the user does
not watch, such as a plugin's classifier in the user's open workspace. A background agent never
raises attention, adds nothing to its workspace's status bucket or Last prompt time, and stays
out of tabs, the parent's subagent track and agent lists in the app. A tab opened by id still
works.
- **Workspace.** `workspace.background` hides the workspace from the sidebar unless **Show
background** is on.
- **Inheritance.** When `background` is omitted, an agent created by a caller agent takes the
caller's own `background`. A new workspace takes the caller's effective background. A request
that creates its workspace applies `background` to both the agent and the workspace. Explicit
true or false always wins.
`AgentDirectory` (`packages/server/src/server/agent/directory.ts`) owns visibility for lists.
Default workspace and agent lists, including History requests, exclude background work;
`includeBackground` includes it. Exact-ID operations bypass discovery filtering. The app
replicates inclusively and filters at each surface.
Sources without the `background_agents` hello capability get workspace-only visibility on
inclusive reads and subscriptions: agents in background workspaces, but no agent-level background
agents. Older apps replicate inclusively and auto-open a tab for every root agent, so a classifier
run would otherwise open a tab each turn. That visibility has its own directory sync generation.
Sequenced directories keep complete canonical collections. A filtered read must never replace
those collections with its subset. Inclusive and default observations have distinct cursor
scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache.
Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`.
Clients gate background workspaces and inclusive discovery on `features.backgroundWorkspaces`.
They gate a background agent placed in an existing workspace or under a caller on
`features.backgroundAgents`.
### Private daemon helpers
+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
+11 -7
View File
@@ -174,14 +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 selection
## 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:
```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
+2
View File
@@ -777,6 +777,8 @@ The built-in `claude` provider appends concrete model IDs from `~/.claude/settin
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
Paseo's effort selector does not inherit `effortLevel` from Claude's settings.json. Select an effort in Paseo to choose a specific value.
### Gotcha: `extends: "claude"` with third-party endpoints
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
+5 -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. `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:
+2 -1
View File
@@ -593,7 +593,8 @@ 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 and report the URL to npm so they purge it.
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.
## ACP provider catalog versions
+2 -1
View File
@@ -96,7 +96,8 @@ hidden workspace can overwrite the remembered workspace before Settings or
History returns.
Plugin settings use the distinct `settings/hosts/[serverId]/plugins/[pluginId]/[screenId]` leaf;
Back returns to that host's Plugins page.
Back returns to that host's Plugins page. Its screen tabs change `screenId` with `router.setParams`,
so switching tabs adds no stack entries.
Settings detail routes are separate siblings on purpose. Keep
`settings/[section]`, the host routes, the projects index, and project detail as
+4 -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,14 @@ 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.
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
+1
View File
@@ -28,6 +28,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time.
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
- **Background agent** — An agent hidden from default discovery and from app surfaces that announce agents: tabs, the subagent track, agent lists, workspace status and attention. It is otherwise an ordinary durable agent, addressable by id. An agent is effectively background when it or its workspace is background. Fixed at creation. Code: `background` on `AgentSnapshotPayload` and `create_agent_request`. See [agent-lifecycle.md](agent-lifecycle.md#background-visibility). Don't confuse with background work (provider shells and monitors) or the daemon's private internal helpers, which are never public ([agent-lifecycle.md](agent-lifecycle.md#private-daemon-helpers)).
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy.
+86
View File
@@ -0,0 +1,86 @@
# Keyboard shortcuts
Define shortcuts in [the shared binding table](../packages/app/src/keyboard/keyboard-shortcuts.ts). Platforms contribute key sources, not separate action maps, so matching and user rebinds stay consistent across runtimes. Add a shortcut through a binding; add platform support through a key source.
## Badges and routing
Routing runs whenever the app mounts its key sources, independently of layout.
Use `useHardwareKeyboardPresent()` for hints and the Shortcuts settings section.
iOS reads `GCKeyboard.coalesced` and connection notifications. Android watches input
devices and hardware keyboard configuration. Web starts with a fine hover pointer and recognizes the first modified
keydown, including on touch devices. Compact Electron keeps its bindings.
Keyboard navigation needs the sidebar's filtered workspace model even when its visual
panel is hidden. Retain that model while keyboard navigation is active; gating it on
panel visibility leaves number shortcuts with no targets after a compact startup.
Composer send-key customization remains a separate capability. Native retains its
existing Return submission path; advertising its web-only customization would expose
settings the native composer does not implement.
## Native sources
Use the iOS window press stream for hardware keys. `UIKeyCommand` only takes keys
that must bypass the focused text control: composer Return and the top overlay's
Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned
press is still observed by the window.
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
`UITextView` first responder. Both `UIWindow.sendEvent` and the root controller's
`pressesBegan` received Cmd+B, Cmd+P and Escape. With Escape registered as a
`UIKeyCommand`, the window observed it and the command handler also ran.
Simulator reserves Cmd+K for Toggle Software Keyboard. Injecting it produced
HID 184 with empty characters rather than K; plain K, Cmd+B and Cmd+P arrived
normally. Keep Simulator input capture off: Send Keyboard Input to Device
prevented synthetic AppleScript input from arriving, including plain text.
For simulator QA, use Cmd+B/P to check delivery and rebind the command center to
Cmd+J to check toggle and rebind behavior. Cmd+K delivery needs a physical iPad.
Simulator also reserves Cmd+1…4, Cmd+Shift+A and Cmd+comma. Exercise those actions
through rebinds. For numeric injection, use AppleScript physical key codes (for example
25 for 9); `keystroke "9"` did not deliver a digit in the QA session.
Simulator exposes a virtual `GCKeyboard` even with Connect Hardware Keyboard off.
Verify the iOS absence case on a physical iPhone.
Never register Backspace: it would take deletion away from text fields. Changes
to the native module require a native rebuild; an over-the-air JavaScript update
cannot deliver them.
## Overlay ownership
Register keyboard handling with `useOverlayRegistration` from `@/keyboard`.
Pass the surface's open state, layer, handled keys and `onKeyDown`. The highest
layer gets first refusal; the latest registration wins a tie. Native registration
does not need a DOM ref. Web adds focus trapping and restoration through its adapter.
Capture a global overlay’s layer in state for its opening lifetime. Memo caches can
be discarded or removed by React Compiler; recomputing against the registry lets an
open modal rise above its own children and creates competing focus traps.
Declare Enter and arrow keys only while the surface owns them. Native sources
use the top overlay's declarations to take those keys before text controls edit.
Backspace stays with the focused input. Composition confirmation must not select
an overlay row or submit the composer.
Android modals have their own windows. Observe them through React Native's
`ExtraWindowEventListener` as well as the activity window. The dev client bypasses
Expo's root-container handler, so an activity root-view wrapper misses presses.
A modal can forward an unhandled event into the activity window. Observe the event
once across that nested delivery without retaining recycled Android `KeyEvent`s.
Keep an overlay-owned press consumed through key-up, even if its key-down closes
the overlay; otherwise the modal's default Escape handler can close a second layer.
iOS scene startup installs the observing window after Expo creates its root view.
Keep that integration in the hardware keyboard module's config plugin. Its window
owns both the press stream and the text-control key commands, including above modals.
## Focus scopes
The composer and terminal declare focus with `useKeyboardFocusSurface`. Native
scope derives from those declarations and command-center state. Releasing an old
surface must not clear a newer owner's declaration during a tab switch. Web keeps
its DOM-derived scope.
Escape interrupts an agent only in the message input or an unclaimed surface.
A focused terminal excludes that binding. The source leaves terminal Escape alone;
terminal Escape/Ctrl forwarding is a separate feature.
+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.
+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.
+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
+1 -1
View File
@@ -1 +1 @@
sha256-iAi82L6Lm75hdg/9tJYyYcHMhCJLtXGSpcNqgv9QQaw=
sha256-sLaLHD1u1eJRdGdflcwWBGxnnhRQpB1T74BlaWcmxbU=
+735 -615
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -131,6 +131,7 @@
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "44.2.0",
"electron-builder": "26.18.0",
"expo-module-scripts": "^5.0.8",
"get-port-cli": "^3.0.0",
"js-yaml": "^4.1.1",
@@ -151,6 +152,7 @@
"expo": "~57.0.30",
"expo-router": "~57.0.28",
"lightningcss": "1.30.1",
"pkijs": "3.4.2",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.3",
+1
View File
@@ -142,6 +142,7 @@ export default {
searchPaths: ["../../node_modules", "./node_modules"],
},
plugins: [
"./modules/paseo-hardware-keyboard/app.plugin",
"expo-router",
withPasteInput,
withAndroidScroll,
+18
View File
@@ -94,3 +94,21 @@ it("allows all orientations on iPhone and iPad", async () => {
await rm(root, { recursive: true, force: true });
}
}, 30_000);
// Scene startup is the integration boundary for the iOS keyboard window.
describe("hardware keyboard scene integration", () => {
it("installs observation after Expo creates the scene window and is idempotent", () => {
const {
configureHardwareKeyboardAppDelegate,
} = require("./modules/paseo-hardware-keyboard/app.plugin.js");
const source =
"import Expo\nclass Scene {\n super.scene(scene, willConnectTo: session, options: connectionOptions)\n}\n";
const configured = configureHardwareKeyboardAppDelegate(source);
expect(configured).toContain("import PaseoHardwareKeyboard");
expect(configured).toContain("PaseoHardwareKeyboardWindow.observing(window)");
expect(configured.indexOf("super.scene")).toBeLessThan(
configured.indexOf(".observing(window)"),
);
expect(configureHardwareKeyboardAppDelegate(configured)).toBe(configured);
});
});
@@ -15,6 +15,8 @@ import {
expectNewWorkspaceForAddedProject,
expectAddProjectResultVisible,
navigateAddProjectResults,
navigateAndDismissAddProject,
watchAddProjectRenderWarnings,
openAddProjectFlow,
openAddProjectHostSelection,
} from "../support/helpers/add-project-flow";
@@ -146,6 +148,7 @@ test.describe("Add Project command-center flow", () => {
test("Escape and Back restore searchable page input before closing at the root", async ({
page,
}) => {
const renderWarnings = watchAddProjectRenderWarnings(page);
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
@@ -183,8 +186,9 @@ test.describe("Add Project command-center flow", () => {
await page.keyboard.press("Escape");
await expectAddProjectPage(page, "host");
await page.keyboard.press("Escape");
await navigateAndDismissAddProject(page);
await expect(addProjectFlow(page)).not.toBeVisible();
expect(renderWarnings).toEqual([]);
});
test("New directory creates a Project on the selected remote host", async ({ page }) => {
@@ -0,0 +1,65 @@
import { test } from "../support/fixtures";
import {
expectWorkspaceShownIdle,
finishAndArchiveAgent,
startBackgroundAgent,
} from "../support/helpers/background-agents";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectSubagentRowGone,
expectSubagentRowVisible,
openSubagentsTrack,
seedParentWithSubagent,
seedSubagent,
} from "../support/helpers/subagents";
import { expectOnlyWorkspaceAgentTabsVisible } from "../support/helpers/workspace-tabs";
test.describe("Background agents", () => {
let workspace: SeededWorkspace;
test.beforeAll(async () => {
workspace = await seedWorkspace({
repoPrefix: "background-agents-",
title: "Classifier workspace",
});
});
test.afterAll(async () => {
await workspace?.cleanup();
});
test("a background agent runs in an open workspace without announcing itself", async ({
page,
}) => {
const agents = await seedParentWithSubagent(workspace, {
parentTitle: "Feature agent",
childTitle: "Visible helper",
});
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
await openSubagentsTrack(page);
await expectSubagentRowVisible(page, agents.child.id);
const classifier = await test.step("a plugin starts a background agent", () =>
startBackgroundAgent(workspace, { parentId: agents.parent.id, title: "Classifier" }));
// A visible child created after the classifier reaches the app on the same stream, so once
// its row shows, the app has processed the classifier too.
const marker = await seedSubagent(workspace, {
parentId: agents.parent.id,
title: "Ordering marker",
});
await test.step("it stays out of the track, the tabs and the sidebar status", async () => {
await expectSubagentRowVisible(page, marker.id);
await expectSubagentRowGone(page, classifier.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
await expectWorkspaceShownIdle(page, "Classifier workspace");
});
await test.step("the plugin reads its finish and archives it", async () => {
await finishAndArchiveAgent(workspace, classifier.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
await expectSubagentRowGone(page, classifier.id);
});
});
});
@@ -928,11 +928,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);
@@ -1,6 +1,7 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile } from "node:fs/promises";
import path from "node:path";
import type { Locator } from "@playwright/test";
import { test, expect, type Page } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
@@ -59,6 +60,14 @@ async function openCompactActionsMenu(page: Page, workspaceId: string) {
return menu;
}
async function expectActionsInOrder(menu: Locator, labels: readonly string[]) {
const items = menu.getByRole("menuitem");
await expect(items).toHaveCount(labels.length);
for (const [index, label] of labels.entries()) {
await expect(items.nth(index).getByText(label, { exact: true })).toBeVisible();
}
}
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
const repoPath = await createRemoteRepo("compact-menu-pull-");
cleanupTasks.push(async () => {
@@ -83,12 +92,7 @@ test("compact git actions menu lists a promoted Pull once", async ({ page }) =>
const menu = await openCompactActionsMenu(page, workspaceId);
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
await expect(menu.getByRole("menuitem")).toHaveText([
"Pull",
"Push",
"Pull and push",
"Archive workspace",
]);
await expectActionsInOrder(menu, ["Pull", "Push", "Pull and push", "Archive workspace"]);
});
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
@@ -106,10 +110,5 @@ test("compact git actions menu lists a promoted Push once", async ({ page }) =>
const menu = await openCompactActionsMenu(page, workspaceId);
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
await expect(menu.getByRole("menuitem")).toHaveText([
"Push",
"Pull",
"Pull and push",
"Archive workspace",
]);
await expectActionsInOrder(menu, ["Push", "Pull", "Pull and push", "Archive workspace"]);
});
@@ -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();
}
});
@@ -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();
}
});
});
@@ -429,6 +429,32 @@ test("context details support keyboard entry and dismiss when the viewport moves
await expect(details).toHaveCount(0);
});
test("context details stay open while the chat streams", async ({ page }) => {
test.setTimeout(120_000);
await page.setViewportSize(DESKTOP);
const session = await seedMockAgentWorkspace({
repoPrefix: "context-window-streaming-",
title: "Context details while streaming",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await submitMessageWithButton(page, "Stream while the context details are open.");
const stop = page.getByRole("button", { name: /stop|cancel/i }).first();
await expect(stop).toBeVisible();
const details = await pressContextWindowMeter(page, /^Context window/);
await expect(stop).toBeVisible();
await qaScreenshot(page, "context-details-while-streaming");
await expect(stop).toHaveCount(0, { timeout: 30_000 });
await expect(details).toBeVisible();
await qaScreenshot(page, "context-details-after-streaming");
} finally {
await session.cleanup();
}
});
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
@@ -10,6 +10,7 @@ test("plugin header and composer buttons share actions, menus, content, and upda
await buttons.verifyObservationLifetime();
await buttons.openWideMenusAndPopovers();
await buttons.runAndUpdateActions();
await buttons.keepTrackSpaceWithoutPills();
await buttons.openCompactSheets();
await buttons.useCompactWorkspaceMenu();
await buttons.hideAndUnloadOpenButtons();
@@ -16,6 +16,14 @@ import {
returnAndReopenSettings,
returnToPluginsPage,
expectSettingsUnavailable,
openPluginsPage,
expectActionsMenuWithoutSettings,
expectNoSettingsCogWithoutScreens,
openTabbedSettings,
selectSettingsTab,
openTabbedSettingsLink,
expectSettingsTooltip,
expectNamedPluginRow,
} from "../support/helpers/plugin-settings";
test("display preferences survive a reload", async ({ page }) => {
@@ -49,6 +57,32 @@ test("Back to plugins returns from a plugin settings screen", async ({ page }) =
await returnToPluginsPage(page);
});
test("plugin settings open from the cog and show a tab per screen", async ({ page }) => {
await openPluginsPage(page);
await test.step("plugin rows show the display name", async () => {
await expectNamedPluginRow(page);
});
await test.step("the cog explains itself", async () => {
await expectSettingsTooltip(page);
});
await test.step("the actions menu keeps only plugin actions", async () => {
await expectActionsMenuWithoutSettings(page);
});
await test.step("tabs switch between the plugin's settings screens", async () => {
await openTabbedSettings(page);
await selectSettingsTab(page, "Advanced");
await selectSettingsTab(page, "General");
await returnToPluginsPage(page);
});
await test.step("a plugin without settings screens shows no cog", async () => {
await expectNoSettingsCogWithoutScreens(page);
});
});
test("a settings screen link opens its tab", async ({ page }) => {
await openTabbedSettingsLink(page, "Advanced");
});
test.describe("compact plugin settings", () => {
test.use({ viewport: { width: 390, height: 844 } });
@@ -2,6 +2,7 @@ import path from "node:path";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { gotoAppShell } from "../support/helpers/app";
import {
BOTS_PLUGIN_ID,
COMPACT,
@@ -176,6 +177,37 @@ test.describe("Plugin sidebar items", () => {
await cleanup();
});
test("a short compact sidebar keeps workspaces usable while navigation scrolls", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 600 });
await gotoAppShell(page);
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
await expect(botRow(page, "status")).toBeAttached();
const workspace = visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`);
await expect(workspace).toBeInViewport({ ratio: 1 });
await test.step("scrolling navigation reaches its last row without moving workspaces", async () => {
const before = await boxOf(workspace);
await visibleTestId(page, "sidebar-global-new-workspace").hover();
await page.mouse.wheel(0, 800);
await expect(botRow(page, "status")).toBeInViewport({ ratio: 1 });
await expect(workspace).toBeInViewport({ ratio: 1 });
expect((await boxOf(workspace)).y).toBe(before.y);
await botRow(page, "status").click();
await expect(page.getByText("Bot status details", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Close bot status", exact: true }).click();
});
await test.step("the workspace still opens and closes the sidebar", async () => {
await workspace.click();
await expect(
page.getByRole("button", { name: "Open menu", exact: true }).first(),
).toBeVisible();
await expect(workspace).not.toBeInViewport();
});
});
test("a restored plugin screen waits for its catalog before rendering", async ({ page }) => {
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
@@ -285,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();
@@ -13,6 +13,7 @@ import {
movePointerAwayFromWorkspaceHoverCard,
openMobileAgentSidebar,
pinWorkspaceFromSidebar,
scrollWorkspaceList,
} from "../support/helpers/sidebar";
import { seedWorkspace } from "../support/helpers/seed-client";
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
@@ -582,6 +583,12 @@ test.describe("Half-screen desktop layout", () => {
await page.getByTestId("menu-button").click();
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
await expect(sidebarScroll).toHaveJSProperty("scrollTop", scrollTop);
await movePointerAwayFromWorkspaceHoverCard(page);
await focusWorkspaceRowWithKeyboard(page, lastWorkspaceId);
await expectWorkspaceHoverCardOpen(page);
await scrollWorkspaceList(page);
await expectWorkspaceHoverCardClosed(page);
} finally {
await workspace.cleanup();
}
@@ -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";
@@ -487,3 +488,175 @@ 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();
}
});
}
// Diagnostic-only: application modes emitted before the first attachment.
// The existing live-mode tests above are the control for this failure.
test.describe("mouse mode enabled before terminal attachment", () => {
test.use({ isMobile: true, hasTouch: true, viewport: { width: 390, height: 844 } });
test("preserves SGR wheel reports when opening a running terminal", async ({ page }) => {
await openPreAttachedMouseCapture(page);
await expectFingerNavigationInput(page, "mouse");
});
});
async function openPreAttachedMouseCapture(page: Page) {
await harness.createTerminal({
name: "Mouse mode before attachment",
command: process.execPath,
args: [
"-e",
`
process.stdin.setRawMode(true);
process.stdin.on("data", chunk => {
process.stdout.write("\\r\\nTOUCH_BYTES:" + chunk.toString("hex") + ":END\\r\\n");
});
process.stdout.write("\\x1b[?1000h\\x1b[?1006hTOUCH_INPUT_READY\\r\\n");
`,
],
});
await openPhoneTerminal(page);
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_INPUT_READY");
await expect
.poll(() => page.evaluate(() => window.__paseoTerminal!.modes.mouseTrackingMode))
.toBe("vt200");
}
@@ -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}\""
@@ -117,3 +117,25 @@ export async function expectAddProjectResultVisible(page: Page, text: string): P
export async function chooseAddHostFromProjectFlow(page: Page): Promise<void> {
await page.getByRole("button", { name: /Add host.*No connected hosts/ }).click();
}
export async function navigateAndDismissAddProject(page: Page): Promise<void> {
// Keep both keys in one task so Escape closes with a selection update still queued.
await page.evaluate(() => {
for (const key of ["ArrowDown", "Escape"]) {
window.dispatchEvent(
new KeyboardEvent("keydown", { key, code: key, bubbles: true, cancelable: true }),
);
}
});
}
export function watchAddProjectRenderWarnings(page: Page): string[] {
const warnings: string[] = [];
page.on("console", (message) => {
const text = message.text();
if (text.includes("Cannot update a component") && text.includes("AddProjectFlow")) {
warnings.push(text);
}
});
return warnings;
}
@@ -0,0 +1,48 @@
import { expect, type Page } from "@playwright/test";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { SeededWorkspace } from "./seed-client";
/**
* Starts a background agent the way a plugin helper does: in the workspace, under a parent,
* streaming for about ten seconds. Resolves once the daemon reports it running.
*/
export async function startBackgroundAgent(
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
input: { parentId: string; title: string },
): Promise<{ id: string }> {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: input.title,
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: "classify the last turn",
labels: { [PARENT_AGENT_ID_LABEL]: input.parentId },
background: true,
});
await workspace.client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running");
return { id: agent.id };
}
/** Waits for the agent's turn to end and archives it, as the plugin does with a helper's reply. */
export async function finishAndArchiveAgent(
workspace: Pick<SeededWorkspace, "client">,
agentId: string,
): Promise<void> {
const finished = await workspace.client.waitForFinish(agentId, 60_000);
expect(finished.status).toBe("idle");
await workspace.client.archiveAgent(agentId);
}
export async function expectWorkspaceShownIdle(page: Page, workspaceName: string): Promise<void> {
const row = page.getByRole("button", {
name: new RegExp(`^${escapeRegExp(workspaceName)}(?:,|$)`),
});
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByTestId("workspace-status-indicator-running")).toHaveCount(0);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -54,12 +54,15 @@ export async function leaveContextWindowMeter(page: Page): Promise<void> {
await expect(contextWindowDetails(page)).toHaveCount(0);
}
/** Compact screens open the meter's details in a sheet. */
export async function pressContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
/** Clicking the meter opens its details in the active layout. */
export async function pressContextWindowMeter(
page: Page,
name: string | RegExp = METER_NAME,
): Promise<Locator> {
await page.getByRole("button", { name, exact: true }).click({ timeout: 30_000 });
const sheet = contextWindowDetails(page);
await expect(sheet).toBeVisible();
return sheet;
const details = contextWindowDetails(page);
await expect(details).toBeVisible();
return details;
}
export async function closeContextWindowSheet(page: Page): Promise<void> {
@@ -4,6 +4,9 @@ import path from "node:path";
import type { Locator, TestInfo } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { scrollAgentChatToBottom, waitForScrollableChat } from "./agent-bottom-anchor";
import { expectTurnCopyButton } from "./agent-stream";
import { submitMessage } from "./composer";
import { buildAgentRoute } from "./mock-agent";
import { openCommandCenter } from "./command-center";
import {
@@ -88,7 +91,7 @@ export default function contribute(client) {
header("locked", { title: "Production locked", icon: "Lock", disabled: true, behavior: { kind: "action", onPress() {} } });
const review = pill("review", { title: "Run review", icon: "Scan", label: "Review", behavior: { kind: "action", async onPress() { await new Promise((resolve) => { finishReview = resolve; }); review.update({ label: "Reviewed" }); } } });
const pillMenu = pill("checks", { title: "Composer checks", icon: "ListChecks", label: "Checks", behavior: menu });
pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
const usage = pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
const command = (id, title, onSelect) => client.addCommandCenterItem({ id, title, icon: "Settings", context: "workspace", onSelect });
command("finish", "Finish deployment", () => finish());
command("finish-review", "Finish review", () => finishReview());
@@ -102,10 +105,16 @@ export default function contribute(client) {
command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false }));
command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true }));
const lifetime = new AbortController();
let pillsHidden = false;
const applyWorkspace = (workspace) => {
if (workspace.id !== workspaceId) return;
const hidePills = workspace.name === "No composer pills";
deploy.update({ visible: workspace.name !== "Compact checks" });
pillMenu.update({ visible: workspace.name !== "Hide composer menu" });
pillMenu.update({ visible: workspace.name !== "Hide composer menu" && !hidePills });
if (hidePills === pillsHidden) return;
pillsHidden = hidePills;
review.update({ visible: !hidePills });
usage.update({ visible: !hidePills });
};
void client.paseo.workspaces.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => {
subscription.subscribe({
@@ -244,6 +253,38 @@ async function captureSchemes(page: Page, info: TestInfo, name: string, subject:
await page.emulateMedia({ colorScheme: "light" });
}
const COMPOSER_PILLS = ["Run review", "Composer checks", "Composer status"] as const;
function chevronOf(page: Page, title: string): Locator {
return page
.getByRole("button", { name: title, exact: true })
.getByTestId("plugin-button-chevron");
}
/** Composer pills that open a menu or popover carry a chevron; a one-tap action does not. */
async function expectComposerPillChevrons(page: Page) {
await expect(chevronOf(page, "Composer checks")).toHaveCount(1);
await expect(chevronOf(page, "Composer status")).toHaveCount(1);
await expect(chevronOf(page, "Run review")).toHaveCount(0);
}
async function setComposerPillsShown(
page: Page,
showcase: { setTitle(title: string): Promise<unknown> },
shown: boolean,
) {
await showcase.setTitle(shown ? "Button showcase" : "No composer pills");
for (const title of COMPOSER_PILLS) {
await expect(page.getByRole("button", { name: title, exact: true })).toHaveCount(shown ? 1 : 0);
}
}
async function lastTurnTop(page: Page): Promise<number> {
const box = await page.getByRole("button", { name: "Copy turn" }).last().boundingBox();
expect(box).not.toBeNull();
return box!.y;
}
async function expectHeaderTextTruncated(page: Page) {
const title = page.getByTestId("workspace-header-title");
const subtitle = page.getByTestId("workspace-header-subtitle");
@@ -276,6 +317,7 @@ export async function withButtonShowcase(
run: (buttons: {
openWideMenusAndPopovers(): Promise<void>;
runAndUpdateActions(): Promise<void>;
keepTrackSpaceWithoutPills(): Promise<void>;
openCompactSheets(): Promise<void>;
useCompactWorkspaceMenu(): Promise<void>;
hideAndUnloadOpenButtons(): Promise<void>;
@@ -348,11 +390,7 @@ export async function withButtonShowcase(
.getByRole("button", { name: "Header checks", exact: true })
.getByTestId("plugin-button-chevron"),
).toHaveCount(1);
await expect(
page
.getByRole("button", { name: "Composer checks", exact: true })
.getByTestId("plugin-button-chevron"),
).toHaveCount(0);
await expectComposerPillChevrons(page);
await expectSurfaceVisible(page);
await captureSchemes(
page,
@@ -476,10 +514,31 @@ export async function withButtonShowcase(
"Review ready",
);
}),
keepTrackSpaceWithoutPills: () =>
test.step("the composer track keeps its space when every pill leaves and returns", async () => {
await page.setViewportSize(WIDE);
await submitMessage(page, "Stream a turn under the composer pills");
await expectTurnCopyButton(page);
await waitForScrollableChat(page, { minScrollableDistance: 200, timeout: 30_000 });
for (const viewport of [WIDE, COMPACT]) {
await page.setViewportSize(viewport);
await scrollAgentChatToBottom(page);
const top = await lastTurnTop(page);
await setComposerPillsShown(page, showcase, false);
expect(await lastTurnTop(page)).toBe(top);
await setComposerPillsShown(page, showcase, true);
expect(await lastTurnTop(page)).toBe(top);
}
await page.setViewportSize(WIDE);
}),
openCompactSheets: () =>
test.step("compact header leaves plugin actions to the workspace menu while composer triggers open sheets", async () => {
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
await expect(
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
).toHaveCount(0);
await expectComposerPillChevrons(page);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(2);
await expect(
page.getByRole("button", { name: "Composer checks", exact: true }),
).toContainText("Checks");
@@ -1,4 +1,7 @@
import { copyPluginExample } from "./plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { expect, test as base, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app";
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
@@ -9,6 +12,54 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const TABBED_PLUGIN_ID = "settings-tabs";
const TABBED_PLUGIN_NAME = "Tabbed settings";
/** Two settings screens, so the settings page shows a tab per screen. */
const TABBED_SOURCE = `import React from "react";
import { Text, View } from "react-native";
import { defineTranslations, useTranslation } from "@getpaseo/plugin/client";
const messages = defineTranslations({ resources: { en: { icon: "advanced-tab-icon" } } });
function General({ theme }) {
return <Text style={{ color: theme.colors.foreground }}>General settings body</Text>;
}
function Advanced({ theme }) {
return <Text style={{ color: theme.colors.foreground }}>Advanced settings body</Text>;
}
// Component icons run with the plugin's runtime; one that throws costs its tab only the icon.
function TranslatedIcon({ color, size }) {
const { t } = useTranslation(messages);
return <View testID={t("icon")} aria-hidden style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
}
function BrokenIcon() {
throw new Error("Settings tab icon exploded");
}
export default function contribute(client) {
client.addSettingsScreen({ id: "general", title: "General", icon: BrokenIcon, Component: General });
client.addSettingsScreen({ id: "advanced", title: "Advanced", icon: TranslatedIcon, Component: Advanced });
return () => {};
}
`;
async function writeTabbedPlugin() {
const directory = await mkdtemp(path.join(tmpdir(), `paseo-${TABBED_PLUGIN_ID}-`));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({
id: TABBED_PLUGIN_ID,
name: TABBED_PLUGIN_NAME,
requirements: pluginRequirements,
}),
);
await writeFile(path.join(directory, "index.client.tsx"), TABBED_SOURCE);
return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) };
}
export const test = base.extend<{ settingsPlugin: SettingsClient }>({
settingsPlugin: [
async ({ e2eWorker, page }, provide, testInfo) => {
@@ -16,16 +67,20 @@ export const test = base.extend<{ settingsPlugin: SettingsClient }>({
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const example = await copyPluginExample("settings");
const tabbed = await writeTabbedPlugin();
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await client.installDirectoryPlugin(tabbed.directory);
await provide(client);
await page.screenshot({ path: testInfo.outputPath("settings.png") });
} finally {
await client.removePlugin("settings-example");
await client.removePlugin(TABBED_PLUGIN_ID);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await example.cleanup();
await tabbed.cleanup();
}
},
{ auto: true },
@@ -44,9 +99,88 @@ export async function openCompactDisplaySettings(page: Page) {
}
async function openPluginScreen(page: Page) {
await openHostSection(page, getServerId(), "plugins");
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await openSettingsFromCog(page);
}
/** The cog on a plugin's row. Its label is "Settings"; the row tells plugins apart. */
function settingsButton(page: Page, pluginId: string) {
return page
.getByTestId(`plugin-row-${pluginId}`)
.filter({ visible: true })
.getByRole("button", { name: "Settings", exact: true });
}
async function openSettingsFromCog(page: Page) {
await settingsButton(page, "settings-example").click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
// A plugin with one settings screen shows no tabs, and the header names only the plugin.
await expect(page.getByTestId("plugin-settings-tabs")).toHaveCount(0);
await expectSettingsHeader(page, "settings-example");
}
export async function openPluginsPage(page: Page) {
await gotoAppShell(page);
await openSettings(page);
await openHostSection(page, getServerId(), "plugins");
}
export async function expectActionsMenuWithoutSettings(page: Page) {
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
await expect(page.getByRole("menuitem", { name: "Reload", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Display", exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
}
export async function expectNoSettingsCogWithoutScreens(page: Page) {
await page.getByRole("switch", { name: "settings-example: Disable" }).click();
await expect(page.getByRole("switch", { name: "settings-example: Enable" })).toBeVisible();
await expect(settingsButton(page, "settings-example")).toHaveCount(0);
await page.getByRole("switch", { name: "settings-example: Enable" }).click();
await expect(settingsButton(page, "settings-example")).toBeVisible();
}
export async function expectSettingsTooltip(page: Page) {
await settingsButton(page, "settings-example").hover();
await expect(page.getByRole("tooltip")).toHaveText("Settings");
await page.mouse.move(0, 0);
}
/** A manifest `name` titles the row and its controls; the id stays visible beneath it. */
export async function expectNamedPluginRow(page: Page) {
const row = page.getByTestId(`plugin-row-${TABBED_PLUGIN_ID}`).filter({ visible: true });
await expect(row.getByText(TABBED_PLUGIN_NAME, { exact: true })).toBeVisible();
await expect(row.getByText(TABBED_PLUGIN_ID, { exact: true })).toBeVisible();
await expect(
page.getByRole("switch", { name: `${TABBED_PLUGIN_NAME}: Disable`, exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: `Actions for ${TABBED_PLUGIN_NAME}`, exact: true }),
).toBeVisible();
}
export async function openTabbedSettings(page: Page) {
await settingsButton(page, TABBED_PLUGIN_ID).click();
await expectSettingsTab(page, "General");
// The Advanced icon reads the plugin's translations; the General icon throws and is dropped.
await expect(page.getByTestId("advanced-tab-icon")).toBeVisible();
}
function settingsTab(page: Page, title: string) {
return page.getByTestId("plugin-settings-tabs").getByRole("button", { name: title, exact: true });
}
export async function selectSettingsTab(page: Page, title: "General" | "Advanced") {
await settingsTab(page, title).click();
await expectSettingsTab(page, title);
}
export async function expectSettingsTab(page: Page, title: "General" | "Advanced") {
const other = title === "General" ? "Advanced" : "General";
await expect(settingsTab(page, title)).toHaveAttribute("aria-selected", "true");
await expect(page.getByText(`${title} settings body`, { exact: true })).toBeVisible();
await expect(page.getByText(`${other} settings body`, { exact: true })).toHaveCount(0);
// The header names the plugin by its manifest name; the tab names the screen.
await expectSettingsHeader(page, TABBED_PLUGIN_NAME);
await expectAppRoute(
page,
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
);
}
export async function openTabbedSettingsLink(page: Page, title: "General" | "Advanced") {
await gotoAppShell(page);
await page.goto(
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
);
await expectSettingsTab(page, title);
}
export async function groupByWorkspace(page: Page) {
await page.getByRole("button", { name: /^Group agents by:/ }).click();
@@ -101,11 +235,7 @@ export async function discardTitle(page: Page) {
}
export async function returnAndReopenSettings(page: Page) {
await goBackInSettings(page);
const actions = page.getByRole("button", { name: "Actions for settings-example", exact: true });
await expect(actions).toBeVisible();
await actions.click();
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
await openSettingsFromCog(page);
}
export async function returnToPluginsPage(page: Page) {
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
@@ -114,6 +244,10 @@ export async function returnToPluginsPage(page: Page) {
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
).toBeVisible();
}
async function expectSettingsHeader(page: Page, title: string) {
// Settings keeps the Plugins page mounted beneath, so read the visible title only.
await expect(page.getByText(title, { exact: true }).filter({ visible: true })).toHaveCount(1);
}
export async function expectSettingsUnavailable(page: Page) {
await expect(
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
@@ -118,6 +118,7 @@ export interface SeedDaemonClient {
featureValues?: Record<string, unknown>;
initialPrompt?: string;
labels?: Record<string, string>;
background?: boolean;
}): Promise<{ id: string; status: string }>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{
@@ -71,6 +71,15 @@ export async function focusWorkspaceRowWithKeyboard(
await expect(row).toBeFocused();
}
export async function scrollWorkspaceList(page: Page): Promise<void> {
const list = page.getByTestId("sidebar-project-workspace-list-scroll");
const before = await list.evaluate((element) => element.scrollTop);
await list.evaluate((element) =>
element.scrollBy({ top: element.scrollTop > 0 ? -40 : 40, behavior: "instant" }),
);
await expect.poll(() => list.evaluate((element) => element.scrollTop)).not.toBe(before);
}
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
}
@@ -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,16 @@
apply plugin: 'com.android.library'
apply plugin: 'expo-module-gradle-plugin'
group = 'sh.paseo.keyboard'
version = '0.1.0'
android {
namespace 'sh.paseo.keyboard'
defaultConfig {
versionCode 1
versionName '0.1.0'
}
}
dependencies {
implementation 'com.facebook.react:react-android'
testImplementation 'junit:junit:4.13.2'
}
@@ -0,0 +1,7 @@
package sh.paseo.keyboard
import android.view.KeyCharacterMap
internal fun hardwareCharacterKey(character: Int): String =
if (character and KeyCharacterMap.COMBINING_ACCENT != 0) "Dead"
else String(Character.toChars(character))
@@ -0,0 +1,164 @@
package sh.paseo.keyboard
import android.content.ComponentCallbacks
import android.content.res.Configuration
import android.hardware.input.InputManager
import android.os.Handler
import android.os.Looper
import android.view.InputDevice
import android.view.KeyEvent
import android.view.Window
import android.widget.EditText
import android.view.inputmethod.BaseInputConnection
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.bridge.ReactContext
import com.facebook.react.interfaces.ExtraWindowEventListener
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
internal object HardwareKeyboard {
var module: PaseoHardwareKeyboardModule? = null
@Volatile var overlayKeys: Set<String> = emptySet()
private val windows = mutableMapOf<Window, KeyboardWindowCallback>()
val dispatcher = WindowKeyDispatcher<KeyEvent>()
private val consumedPresses = mutableSetOf<Int>()
fun observe(window: Window) {
if (windows.containsKey(window)) return
val callback = KeyboardWindowCallback(window, window.callback)
windows[window] = callback
window.callback = callback
}
fun release(window: Window) {
val callback = windows.remove(window) ?: return
if (window.callback === callback) window.callback = callback.previous
}
fun releaseAll() {
windows.keys.toList().forEach { release(it) }
consumedPresses.clear()
}
fun dispatch(event: KeyEvent, composing: Boolean): Boolean {
if (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0) return false
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
val code = code(event.keyCode)
if (code.isEmpty()) return false
val textModifiers = event.metaState and KeyEvent.META_CTRL_MASK.inv() and KeyEvent.META_META_MASK.inv()
val character = event.getUnicodeChar(textModifiers)
val named = !code.startsWith("Key") && !code.startsWith("Digit")
val key = when {
code.startsWith("Control") -> "Control"
code.startsWith("Meta") -> "Meta"
code.startsWith("Shift") -> "Shift"
code.startsWith("Alt") -> "Alt"
named && (character == 0 || code == "Enter" || code == "Tab" || code == "Backspace") -> code
character != 0 -> hardwareCharacterKey(character)
else -> code
}
module?.emit(mapOf(
"key" to key, "code" to code,
"altKey" to event.isAltPressed, "ctrlKey" to event.isCtrlPressed,
"metaKey" to event.isMetaPressed, "shiftKey" to event.isShiftPressed,
"repeat" to (event.repeatCount > 0), "isComposing" to composing,
"phase" to if (event.action == KeyEvent.ACTION_UP) "up" else "down"
))
if (event.action == KeyEvent.ACTION_UP) return consumedPresses.remove(event.keyCode)
val overlayOwnsKey = key != "Dead" && key in overlayKeys && !event.isCtrlPressed && !event.isMetaPressed && !event.isAltPressed && !composing
if (overlayOwnsKey) consumedPresses.add(event.keyCode)
return overlayOwnsKey
}
private fun code(keyCode: Int): String = when (keyCode) {
in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> "Key" + ('A'.code + keyCode - KeyEvent.KEYCODE_A).toChar()
in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> "Digit" + (keyCode - KeyEvent.KEYCODE_0)
in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12 -> "F" + (keyCode - KeyEvent.KEYCODE_F1 + 1)
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> "Enter"
KeyEvent.KEYCODE_ESCAPE -> "Escape"
KeyEvent.KEYCODE_DEL -> "Backspace"
KeyEvent.KEYCODE_FORWARD_DEL -> "Delete"
KeyEvent.KEYCODE_TAB -> "Tab"
KeyEvent.KEYCODE_SPACE -> "Space"
KeyEvent.KEYCODE_DPAD_UP -> "ArrowUp"
KeyEvent.KEYCODE_DPAD_DOWN -> "ArrowDown"
KeyEvent.KEYCODE_DPAD_LEFT -> "ArrowLeft"
KeyEvent.KEYCODE_DPAD_RIGHT -> "ArrowRight"
KeyEvent.KEYCODE_CTRL_LEFT -> "ControlLeft"
KeyEvent.KEYCODE_CTRL_RIGHT -> "ControlRight"
KeyEvent.KEYCODE_META_LEFT -> "MetaLeft"
KeyEvent.KEYCODE_META_RIGHT -> "MetaRight"
KeyEvent.KEYCODE_SHIFT_LEFT -> "ShiftLeft"
KeyEvent.KEYCODE_SHIFT_RIGHT -> "ShiftRight"
KeyEvent.KEYCODE_ALT_LEFT -> "AltLeft"
KeyEvent.KEYCODE_ALT_RIGHT -> "AltRight"
KeyEvent.KEYCODE_COMMA -> "Comma"
KeyEvent.KEYCODE_PERIOD -> "Period"
KeyEvent.KEYCODE_MINUS -> "Minus"
KeyEvent.KEYCODE_EQUALS -> "Equal"
KeyEvent.KEYCODE_LEFT_BRACKET -> "BracketLeft"
KeyEvent.KEYCODE_RIGHT_BRACKET -> "BracketRight"
KeyEvent.KEYCODE_BACKSLASH -> "Backslash"
KeyEvent.KEYCODE_SEMICOLON -> "Semicolon"
KeyEvent.KEYCODE_APOSTROPHE -> "Quote"
KeyEvent.KEYCODE_GRAVE -> "Backquote"
KeyEvent.KEYCODE_SLASH -> "Slash"
else -> ""
}
}
private class KeyboardWindowCallback(private val window: Window, val previous: Window.Callback) : Window.Callback by previous {
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val editable = (window.currentFocus as? EditText)?.text
val composing = editable != null && BaseInputConnection.getComposingSpanStart(editable) >= 0
return HardwareKeyboard.dispatcher.dispatch(event, { HardwareKeyboard.dispatch(event, composing) }, { previous.dispatchKeyEvent(event) })
}
}
class PaseoHardwareKeyboardModule : Module(), ExtraWindowEventListener, InputManager.InputDeviceListener, ComponentCallbacks {
private var inputManager: InputManager? = null
private var registeredReactContext: ReactContext? = null
private fun keyboardPresent(): Boolean {
val config = registeredReactContext?.resources?.configuration
return (config?.keyboard == Configuration.KEYBOARD_QWERTY && config.hardKeyboardHidden != Configuration.HARDKEYBOARDHIDDEN_YES) ||
InputDevice.getDeviceIds().any { id -> InputDevice.getDevice(id)?.let { !it.isVirtual && it.keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC } == true }
}
private fun presenceChanged() { sendEvent("onHardwareKeyboardPresenceChanged", emptyMap<String, Any>()) }
override fun onInputDeviceAdded(id: Int) { presenceChanged() }
override fun onInputDeviceRemoved(id: Int) { presenceChanged() }
override fun onInputDeviceChanged(id: Int) { presenceChanged() }
override fun onConfigurationChanged(config: Configuration) { presenceChanged() }
override fun onLowMemory() {}
override fun onExtraWindowCreate(window: Window) { HardwareKeyboard.observe(window) }
override fun onExtraWindowDestroy(window: Window) { HardwareKeyboard.release(window) }
override fun definition() = ModuleDefinition {
Name("PaseoHardwareKeyboard")
Events("onHardwareKeyboardShortcut", "onHardwareKeyboardPresenceChanged")
OnCreate {
HardwareKeyboard.module = this@PaseoHardwareKeyboardModule
val context = appContext.reactContext
require(context is ReactContext) { "Hardware keyboard observation requires a React context" }
registeredReactContext = context
context.registerComponentCallbacks(this@PaseoHardwareKeyboardModule)
inputManager = context.getSystemService(InputManager::class.java)
inputManager?.registerInputDeviceListener(this@PaseoHardwareKeyboardModule, Handler(Looper.getMainLooper()))
context.addExtraWindowEventListener(this@PaseoHardwareKeyboardModule)
UiThreadUtil.runOnUiThread { appContext.currentActivity?.window?.let { HardwareKeyboard.observe(it) } }
}
OnDestroy {
inputManager?.unregisterInputDeviceListener(this@PaseoHardwareKeyboardModule)
inputManager = null
registeredReactContext?.unregisterComponentCallbacks(this@PaseoHardwareKeyboardModule)
registeredReactContext?.removeExtraWindowEventListener(this@PaseoHardwareKeyboardModule)
registeredReactContext = null
if (HardwareKeyboard.module === this@PaseoHardwareKeyboardModule) {
HardwareKeyboard.module = null
HardwareKeyboard.overlayKeys = emptySet()
UiThreadUtil.runOnUiThread { if (HardwareKeyboard.module == null) HardwareKeyboard.releaseAll() }
}
}
Function("getHardwareKeyboardPresent") { keyboardPresent() }
Function("setOverlayKeys") { keys: List<String> ->
HardwareKeyboard.overlayKeys = keys.filter { it in setOf("Escape", "Enter", "ArrowUp", "ArrowDown") }.toSet()
}
}
internal fun emit(event: Map<String, Any>) { sendEvent("onHardwareKeyboardShortcut", event) }
}
@@ -0,0 +1,17 @@
package sh.paseo.keyboard
import android.app.Activity
import android.content.Context
import android.os.Bundle
import expo.modules.core.interfaces.Package
import expo.modules.core.interfaces.ReactActivityLifecycleListener
class PaseoHardwareKeyboardPackage : Package {
override fun createReactActivityLifecycleListeners(activityContext: Context): List<ReactActivityLifecycleListener> = listOf(
object : ReactActivityLifecycleListener {
override fun onCreate(activity: Activity, savedInstanceState: Bundle?) { HardwareKeyboard.observe(activity.window) }
override fun onResume(activity: Activity) { HardwareKeyboard.observe(activity.window) }
override fun onDestroy(activity: Activity) { HardwareKeyboard.release(activity.window) }
}
)
}
@@ -0,0 +1,11 @@
package sh.paseo.keyboard
// A modal can forward the same physical event into the activity window. Keep
// observation outside that nested delivery, without retaining recycled KeyEvents.
internal class WindowKeyDispatcher<Event : Any> {
private val delivering = java.util.Collections.newSetFromMap(java.util.IdentityHashMap<Event, Boolean>())
fun dispatch(event: Event, observe: () -> Boolean, deliver: () -> Boolean): Boolean {
if (!delivering.add(event)) return deliver()
return try { observe() || deliver() } finally { delivering.remove(event) }
}
}
@@ -0,0 +1,11 @@
package sh.paseo.keyboard
import org.junit.Assert.assertEquals
import org.junit.Test
class HardwareCharacterKeyTest {
@Test fun deadAccentUsesDomKeyWithoutThrowing() {
assertEquals("Dead", hardwareCharacterKey(Int.MIN_VALUE or 0xB4))
}
@Test fun ordinaryUnicodeRemainsText() {
assertEquals("é", hardwareCharacterKey(0xE9))
}
}
@@ -0,0 +1,27 @@
package sh.paseo.keyboard
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class WindowKeyDispatcherTest {
@Test fun observesOnePressAcrossNestedWindows() {
val dispatcher = WindowKeyDispatcher<Any>()
val event = Any()
var observations = 0
var deliveries = 0
dispatcher.dispatch(event, { observations++; false }, {
dispatcher.dispatch(event, { observations++; false }, { deliveries++; false })
})
assertEquals(1, observations)
assertEquals(1, deliveries)
dispatcher.dispatch(event, { observations++; false }, { false })
assertEquals(2, observations)
}
@Test fun consumedOverlayKeyDoesNotReachTheView() {
val dispatcher = WindowKeyDispatcher<Any>()
var deliveries = 0
assertTrue(dispatcher.dispatch(Any(), { true }, { deliveries++; false }))
assertEquals(0, deliveries)
}
}
@@ -0,0 +1,29 @@
const { withAppDelegate } = require("expo/config-plugins");
const SCENE_START = "super.scene(scene, willConnectTo: session, options: connectionOptions)";
function configureHardwareKeyboardAppDelegate(source) {
source = source.replace(
/^import PaseoHardwareKeyboard$/m,
"internal import PaseoHardwareKeyboard",
);
if (source.includes("PaseoHardwareKeyboardWindow.observing(window)")) return source;
if (!source.includes(SCENE_START)) {
throw new Error("Hardware keyboard observation requires the app's Expo scene delegate");
}
const installation = `${SCENE_START}
if let window {
let observedWindow = PaseoHardwareKeyboardWindow.observing(window)
self.window = observedWindow
(UIApplication.shared.delegate as? ExpoReactNativeFactoryProvider)?.window = observedWindow
}`;
return "internal import PaseoHardwareKeyboard\n" + source.replace(SCENE_START, installation);
}
module.exports = function withHardwareKeyboardWindow(config) {
return withAppDelegate(config, (modConfig) => {
modConfig.modResults.contents = configureHardwareKeyboardAppDelegate(
modConfig.modResults.contents,
);
return modConfig;
});
};
module.exports.configureHardwareKeyboardAppDelegate = configureHardwareKeyboardAppDelegate;
@@ -1,7 +1,9 @@
{
"platforms": ["ios"],
"platforms": ["ios", "android"],
"ios": {
"modules": ["PaseoHardwareKeyboardModule"],
"reactDelegateHandlers": ["PaseoHardwareKeyboardReactDelegateHandler"]
"modules": ["PaseoHardwareKeyboardModule"]
},
"android": {
"modules": ["sh.paseo.keyboard.PaseoHardwareKeyboardModule"]
}
}
@@ -1,53 +1,159 @@
import ExpoModulesCore
import UIKit
import GameController
private let hardwareSubmitEventName = "onHardwareKeyboardSubmit"
private let hardwareShortcutEventName = "onHardwareKeyboardShortcut"
private weak var activeModule: PaseoHardwareKeyboardModule?
private var isHardwareSubmitEnabled = false
private var overlayKeys: Set<String> = []
@objc
public class PaseoHardwareKeyboardReactDelegateHandler: ExpoReactDelegateHandler {
public override func createRootViewController() -> UIViewController? {
return PaseoHardwareKeyboardRootViewController()
/// UIKit spells non-character keys as sentinel input strings rather than as the
/// character they would type. JS sends the DOM `code` and the mapping lives
/// here, so the constants stay in the one place that owns UIKit vocabulary.
private let namedKeyInputs: [String: String] = [
"Escape": UIKeyCommand.inputEscape,
"ArrowUp": UIKeyCommand.inputUpArrow,
"ArrowDown": UIKeyCommand.inputDownArrow,
"Enter": "\r",
]
/// Whether the first responder is mid-IME-composition.
///
/// A committing key has to stay out of the way while a CJK candidate is being
/// chosen: Enter confirms the candidate, and consuming it as a key command
/// would submit the half-composed text instead.
private func isComposingText() -> Bool {
guard let responder = UIResponder.paseoCurrentFirstResponder as? UITextInput else {
return false
}
return responder.markedTextRange != nil
}
public class PaseoHardwareKeyboardModule: Module {
private var keyboardObservers: [NSObjectProtocol] = []
public func definition() -> ModuleDefinition {
Name("PaseoHardwareKeyboard")
Events(hardwareSubmitEventName)
Events(hardwareSubmitEventName, hardwareShortcutEventName, "onHardwareKeyboardPresenceChanged")
OnCreate {
activeModule = self
for name in [Notification.Name.GCKeyboardDidConnect, Notification.Name.GCKeyboardDidDisconnect] {
self.keyboardObservers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
self?.sendEvent("onHardwareKeyboardPresenceChanged", [:])
})
}
}
Function("getHardwareKeyboardPresent") { GCKeyboard.coalesced != nil }
Function("setHardwareKeyboardSubmitEnabled") { (enabled: Bool) in
DispatchQueue.main.async {
isHardwareSubmitEnabled = enabled
}
}
Function("setOverlayKeys") { (keys: [String]) in
DispatchQueue.main.async {
overlayKeys = Set(keys).intersection(["Enter", "ArrowUp", "ArrowDown"])
UIMenuSystem.main.setNeedsRebuild()
}
}
OnDestroy {
self.keyboardObservers.forEach(NotificationCenter.default.removeObserver)
self.keyboardObservers = []
if activeModule === self {
activeModule = nil
isHardwareSubmitEnabled = false
overlayKeys = []
}
isHardwareSubmitEnabled = false
}
}
fileprivate func emitHardwareKeyboardSubmit() {
sendEvent(hardwareSubmitEventName, [:])
}
fileprivate func emitHardwareKeyboardPress(_ press: UIPress, isRepeat: Bool) {
guard let key = press.key else { return }
let code = hardwareKeyCode(key)
let characters = key.characters
let named = namedKeyInputs.first { $0.value == characters }?.key
let keyName: String
if code.hasPrefix("Meta") { keyName = "Meta" }
else if code.hasPrefix("Control") { keyName = "Control" }
else if code.hasPrefix("Shift") { keyName = "Shift" }
else if code.hasPrefix("Alt") { keyName = "Alt" }
else { keyName = named ?? (characters.isEmpty ? code : characters) }
guard !code.isEmpty else { return }
sendEvent(hardwareShortcutEventName, [
"key": keyName,
"code": code,
"altKey": key.modifierFlags.contains(.alternate),
"ctrlKey": key.modifierFlags.contains(.control),
"metaKey": key.modifierFlags.contains(.command),
"shiftKey": key.modifierFlags.contains(.shift),
"repeat": isRepeat,
"isComposing": isComposingText(),
"phase": press.phase == .ended || press.phase == .cancelled ? "up" : "down",
])
}
}
private final class PaseoHardwareKeyboardRootViewController: UIViewController {
override var keyCommands: [UIKeyCommand]? {
guard isHardwareSubmitEnabled && UIDevice.current.userInterfaceIdiom == .pad else {
return super.keyCommands
}
public final class PaseoHardwareKeyboardWindow: UIWindow {
private var pressedCodes: Set<Int> = []
public static func observing(_ original: UIWindow) -> UIWindow {
if original is PaseoHardwareKeyboardWindow { return original }
guard let scene = original.windowScene else { return original }
let observed = PaseoHardwareKeyboardWindow(windowScene: scene)
observed.windowLevel = original.windowLevel
observed.backgroundColor = original.backgroundColor
let controller = original.rootViewController
original.rootViewController = nil
original.isHidden = true
observed.rootViewController = controller
observed.makeKeyAndVisible()
return observed
}
public override func sendEvent(_ event: UIEvent) {
if let event = event as? UIPressesEvent {
for press in event.allPresses where press.phase == .began || press.phase == .ended || press.phase == .cancelled {
guard let key = press.key else { continue }
let raw = Int(key.keyCode.rawValue)
let isRepeat = press.phase == .began && pressedCodes.contains(raw)
if press.phase == .began { pressedCodes.insert(raw) }
else { pressedCodes.remove(raw) }
activeModule?.emitHardwareKeyboardPress(press, isRepeat: isRepeat)
}
}
super.sendEvent(event)
}
public override var keyCommands: [UIKeyCommand]? {
var commands = super.keyCommands ?? []
if isHardwareSubmitEnabled && !isPlainReturnClaimed && !isComposingText()
&& UIDevice.current.userInterfaceIdiom == .pad
{
commands.append(makeSubmitCommand())
}
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
return commands
}
/// Whether something registered has already asked for an unmodified Return.
///
/// The submit command is registered for as long as the composer holds focus,
/// which includes while a picker is open over it. Registering both would put
/// two commands on one press, and the composer would win a press the picker
/// asked for — Enter would create the workspace instead of choosing a project.
private var isPlainReturnClaimed: Bool {
overlayKeys.contains("Enter")
}
private func makeSubmitCommand() -> UIKeyCommand {
let command = UIKeyCommand(
input: "\r",
modifierFlags: [],
@@ -56,7 +162,14 @@ private final class PaseoHardwareKeyboardRootViewController: UIViewController {
if #available(iOS 15.0, *) {
command.wantsPriorityOverSystemBehavior = true
}
return (super.keyCommands ?? []) + [command]
return command
}
private func makeShortcutCommand(_ key: String) -> UIKeyCommand? {
guard let input = namedKeyInputs[key] else { return nil }
let command = UIKeyCommand(input: input, modifierFlags: [], action: #selector(handleHardwareKeyboardShortcut(_:)))
if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true }
return command
}
@objc
@@ -67,6 +180,9 @@ private final class PaseoHardwareKeyboardRootViewController: UIViewController {
activeModule?.emitHardwareKeyboardSubmit()
}
@objc
private func handleHardwareKeyboardShortcut(_ sender: UIKeyCommand) {}
private func canSubmitCurrentTextInput() -> Bool {
guard let responder = UIResponder.paseoCurrentFirstResponder else {
return false
@@ -97,3 +213,25 @@ private extension UIResponder {
UIResponder.currentFirstResponder = self
}
}
private func hardwareKeyCode(_ key: UIKey) -> String {
let raw = key.keyCode.rawValue
if raw >= 4 && raw <= 29 {
let letter = UnicodeScalar(Int(raw) + 61)!
return "Key" + String(letter)
}
if raw >= 30 && raw <= 38 { return "Digit" + String(raw - 29) }
if raw == 39 { return "Digit0" }
let codes: [Int: String] = [
40: "Enter", 41: "Escape", 42: "Backspace", 43: "Tab", 44: "Space",
45: "Minus", 46: "Equal", 47: "BracketLeft", 48: "BracketRight",
49: "Backslash", 51: "Semicolon", 52: "Quote", 53: "Backquote",
54: "Comma", 55: "Period", 56: "Slash", 76: "Delete", 79: "ArrowRight",
80: "ArrowLeft", 81: "ArrowDown", 82: "ArrowUp", 224: "ControlLeft",
225: "ShiftLeft", 226: "AltLeft", 227: "MetaLeft", 228: "ControlRight",
229: "ShiftRight", 230: "AltRight", 231: "MetaRight",
]
if raw >= 58 && raw <= 69 { return "F" + String(raw - 57) }
return codes[Int(raw)] ?? ""
}
@@ -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,101 +0,0 @@
package sh.paseo.scroll
import android.graphics.Rect
import android.text.Selection
import android.text.Spannable
import android.view.View
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.BridgeReactContext
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 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 = BridgeReactContext(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)
}
@@ -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,7 +1,5 @@
package sh.paseo.scroll
import android.graphics.Matrix
import android.graphics.Rect
import com.facebook.react.ReactPackage
import com.facebook.react.ViewManagerOnDemandReactPackage
import com.facebook.react.bridge.NativeModule
@@ -26,17 +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)
// 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 {
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
matrix.getValues(transformValues)
if (transformValues[Matrix.MSCALE_Y] < 0) return 0
return 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,110 @@
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 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
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 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) }
let payload: [String: Any] = [
"bounds": rect(bounds),
"verticalBarEdge": barEdge,
"verticalBar": barPayload,
"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
@@ -40,7 +40,7 @@
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
"build:apk": "npm --prefix ../.. run build:apk",
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :paseo-hardware-keyboard:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
},
"dependencies": {
"@codemirror/commands": "6.10.4",
@@ -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);
},
@@ -15,7 +15,7 @@ import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } f
import { Button } from "@/components/ui/button";
import { usePaneFocus } from "@/panels/pane-context";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { hasActiveWebOverlay } from "@/lib/overlay-root";
import { hasActiveOverlay } from "@/keyboard";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { planTimelinePromptJump } from "@/timeline/timeline-sync-plan";
@@ -104,7 +104,7 @@ export function ChatFind({
useEffect(() => {
if (!active || !isInteractive) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
if (event.defaultPrevented || hasActiveOverlay() || isImeComposingKeyboardEvent(event))
return;
if (isFindShortcut(event, findShortcutPlatform())) {
event.preventDefault();
@@ -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);
});
});
@@ -2,8 +2,7 @@
* Pure state machine for the chat history's flick-to-dismiss gesture.
*
* Native keyboardDismissMode cannot express this interaction: "on-drag"
* dismisses on the first pixel, while "interactive" behaves incorrectly on an
* inverted list. We therefore classify the list's existing scroll gesture at
* dismisses on the first pixel, while "interactive" is iOS-only. We therefore classify the list's existing scroll gesture at
* release instead of introducing a competing pan recognizer.
*/
@@ -114,11 +113,12 @@ export function releaseDrag(
spanStartY = samples.previousSampleY;
}
// The list runs oldest to newest, so moving toward older messages lowers the offset.
const spanDurationMs = releaseTs - spanStartTs;
const releaseVelocity = spanDurationMs > 0 ? (releaseY - spanStartY) / spanDurationMs : 0;
const towardOlderVelocity = spanDurationMs > 0 ? (spanStartY - releaseY) / spanDurationMs : 0;
return {
gesture: IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
shouldDismiss: releaseVelocity > DISMISS_VELOCITY_POINTS_PER_MS,
shouldDismiss: towardOlderVelocity > DISMISS_VELOCITY_POINTS_PER_MS,
};
}
@@ -18,7 +18,7 @@ type ScrollEvent = NativeSyntheticEvent<NativeScrollEvent>;
/**
* Owns the chat history's flick-to-dismiss behavior. The native stream only
* forwards the FlatList scroll lifecycle; removing this hook and those three
* forwards the LegendList scroll lifecycle; removing this hook and those three
* calls removes the feature completely.
*/
export function useScrollKeyboardDismiss() {
+292 -186
View File
@@ -1,38 +1,42 @@
import {
Fragment,
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
FlatList,
Platform,
View,
type LayoutChangeEvent,
type ListRenderItemInfo,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ScrollView,
type ScrollViewProps,
type ViewStyle,
} from "react-native";
import {
LegendList,
type LegendListRef,
type LegendListRenderItemProps,
useSyncLayout,
} from "@legendapp/list/react-native";
import { withUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useKeyboardStreamInset } from "@/keyboard/shift";
import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
import { useRevisedHistoryRows } from "./history-row-revision";
import { useBottomAnchorController } from "./bottom-anchor-controller";
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
import { createNativeDisclosureHold } from "./native-disclosure-hold";
import { EndPinnedScrollView } from "./native-end-pin";
import { createNewRowEstimates } from "./new-row-estimates";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import {
createStreamStrategy,
isNearBottomForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
} from "./strategy";
import { createStreamStrategy, isNearBottomForStreamRenderStrategy } from "./strategy";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
@@ -49,11 +53,6 @@ import {
type HistoryStartSettleScheduler,
} from "./history-start-settle-scheduler";
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
minIndexForVisible: 0,
autoscrollToTopThreshold: 0,
});
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
@@ -65,7 +64,50 @@ const historyStartSlotStyle: ViewStyle = {
flexShrink: 0,
};
const HISTORY_START_SETTLE_FRAMES = 2;
const NATIVE_STREAM_WINDOW_SIZE = 10;
// Rendered distance on each side of the viewport. Smaller values blank the viewport while a fling
// crosses rows that were never measured (docs/agent-stream-disclosure.md).
const NATIVE_STREAM_DRAW_DISTANCE = 2000;
// Following resumes after a disclosure or a user scroll only from the end, so resuming never moves
// the rows the user is looking at. Web resumes within the same distance (strategy-web.tsx).
const AT_END_OFFSET_PX = 1;
// A disclosure settles after this many frames without an item size change.
const DISCLOSURE_QUIET_FRAMES = 2;
const BOTTOM_ANCHOR_TRANSPORT_BEHAVIOR = Object.freeze({
verificationDelayFrames: 2,
verificationRetryMode: "recheck" as const,
});
type RowLayoutSyncRegistry = Map<string, () => void>;
// A disclosure toggle changes a row from the row's own state, so LegendList's container does not
// re-render and would learn the new height from onLayout a task later, a frame after the change
// painted. The row's measurement trigger, called next to the toggle, batches the measurement and
// the corrected positions into the toggle's own commit.
function RowLayoutSync(props: {
rowId: string;
registry: RowLayoutSyncRegistry;
children: ReactNode;
}) {
const { rowId, registry } = props;
const syncLayout = useSyncLayout();
useLayoutEffect(() => {
registry.set(rowId, syncLayout);
return () => {
if (registry.get(rowId) === syncLayout) {
registry.delete(rowId);
}
};
}, [registry, rowId, syncLayout]);
return props.children;
}
function distanceFromEnd(input: {
offsetY: number;
contentHeight: number;
viewportHeight: number;
}): number {
return Math.max(0, input.contentHeight - input.viewportHeight - input.offsetY);
}
function keyExtractor(item: { id: string }): string {
return item.id;
@@ -94,7 +136,12 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
strategy,
} = props;
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
const flatListRef = useRef<FlatList<StreamItem>>(null);
const listRef = useRef<LegendListRef>(null);
// LegendList places the rows of each agent once, behind a hidden reveal (`dataKey`).
const [placedAgentId, setPlacedAgentId] = useState<string | null>(null);
const isListPlaced = placedAgentId === agentId;
const isListPlacedRef = useRef(isListPlaced);
isListPlacedRef.current = isListPlaced;
const streamViewportMetricsRef = useRef({
containerKey: "native-virtualized",
contentHeight: 0,
@@ -107,11 +154,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const scrollOffsetYRef = useRef(0);
const isUserScrollActiveRef = useRef(false);
const scrollKeyboardDismiss = useScrollKeyboardDismiss();
const streamKeyboardInset = useKeyboardStreamInset();
const userScrollEndFrameIdRef = useRef<number | null>(null);
const isReleasedAtEndRef = useRef(false);
const programmaticScrollEventBudgetRef = useRef(0);
const isNativeViewportSettlingRef = useRef(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
@@ -126,13 +171,28 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
return [...segments.historyVirtualized, ...segments.historyMounted];
}, [segments.historyMounted, segments.historyVirtualized]);
const historyRows = useRevisedHistoryRows(historyItems, historyRowRevision);
// Live rows are the tail of the list, so a row that finishes keeps its id, container and measured
// size when it moves into history. A fresh identity per live-head change re-renders every live
// row, which is how they pick up neighbour and expansion changes (`liveHeadRowRevision`).
const liveRows = useMemo(() => {
void liveHeadRowRevision;
return segments.liveHead.map((item) => ({ ...item }));
}, [segments.liveHead, liveHeadRowRevision]);
const liveRowIds = useMemo(() => new Set(liveRows.map((item) => item.id)), [liveRows]);
const [newRowEstimates] = useState(() => createNewRowEstimates<StreamItem>());
const rows = useMemo(() => {
const nextRows = [...historyRows, ...liveRows];
newRowEstimates.noteRows(nextRows);
return nextRows;
}, [newRowEstimates, historyRows, liveRows]);
const getEstimatedItemSize = useStableEvent((item: StreamItem) => newRowEstimates.estimate(item));
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => {
const metrics = streamViewportMetricsRef.current;
const hasMeasuredViewport =
metrics.viewportMeasuredForKey === metrics.containerKey &&
metrics.contentMeasuredForKey === metrics.containerKey;
return {
distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY,
distanceFromHistoryStart: metrics.offsetY,
hasOlderHistory,
isLoadingOlderHistory,
isReady: historyStartReadyRef.current && hasMeasuredViewport,
@@ -196,13 +256,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scheduler.schedule();
});
const clearNativeViewportSettling = useCallback(() => {
if (nativeViewportSettlingFrameIdRef.current !== null) {
cancelAnimationFrame(nativeViewportSettlingFrameIdRef.current);
nativeViewportSettlingFrameIdRef.current = null;
}
}, []);
const clearPendingUserScrollEnd = useCallback(() => {
if (userScrollEndFrameIdRef.current !== null) {
cancelAnimationFrame(userScrollEndFrameIdRef.current);
@@ -210,49 +263,30 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
}, []);
const markNativeViewportSettling = useCallback(() => {
clearNativeViewportSettling();
isNativeViewportSettlingRef.current = true;
let remainingFrames = 4;
const tick = () => {
if (remainingFrames <= 0) {
nativeViewportSettlingFrameIdRef.current = null;
isNativeViewportSettlingRef.current = false;
return;
}
remainingFrames -= 1;
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
};
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
}, [clearNativeViewportSettling]);
const getBottomAnchorTransportBehavior = useCallback(
() =>
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: isNativeViewportSettlingRef.current,
}),
[strategy],
);
const getBottomAnchorTransportBehavior = useCallback(() => BOTTOM_ANCHOR_TRANSPORT_BEHAVIOR, []);
const scrollToBottom = useCallback(
(animated: boolean) => {
programmaticScrollEventBudgetRef.current = 3;
flatListRef.current?.scrollToOffset({
offset: 0,
const metrics = streamViewportMetricsRef.current;
// The native scroll view finds its end from the content it has mounted, and the end pin holds
// it from there. LegendList's own scrollToEnd keeps re-aiming at the end its JS sizes predict
// until it lands, which runs ahead of the mount and scrolls the end out of view.
const list = listRef.current;
list?.cancelScroll();
// The scroll component is `EndPinnedScrollView`, a ScrollView.
(list?.getNativeScrollRef() as ScrollView | undefined)?.scrollToEnd({
animated:
animated &&
shouldAnimateNativeScrollToBottom({
offsetY: scrollOffsetYRef.current,
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
windowSize: NATIVE_STREAM_WINDOW_SIZE,
distanceFromEnd: distanceFromEnd(metrics),
viewportHeight: metrics.viewportHeight,
drawDistance: NATIVE_STREAM_DRAW_DISTANCE,
}),
});
scrollOffsetYRef.current = 0;
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
offsetY: 0,
};
const endOffsetY = Math.max(0, metrics.contentHeight - metrics.viewportHeight);
scrollOffsetYRef.current = endOffsetY;
streamViewportMetricsRef.current = { ...metrics, offsetY: endOffsetY };
onNearBottomChange(true);
},
[onNearBottomChange],
@@ -262,7 +296,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
agentId,
routeRequest: routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
renderStrategy: "inverted-stream",
renderStrategy: "legend-stream",
getTransportBehavior: getBottomAnchorTransportBehavior,
getMeasurementState: () => streamViewportMetricsRef.current,
isNearBottom: () => {
@@ -277,39 +311,76 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
},
scrollToBottom,
});
// Android's maintainVisibleContentPosition ignores the list inversion transform and
// fights the controller's offset-zero correction while the live header grows.
const maintainVisibleContentPosition =
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
? undefined
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
const topOverlayInset = useStreamTopOverlayInset();
// The list is inverted, so its content's bottom padding is the visual top.
const listContentContainerStyle = useMemo(
() => [
baseListContentContainerStyle,
{
paddingBottom: streamKeyboardInset.contentContainerPaddingBottom + topOverlayInset,
},
],
[
baseListContentContainerStyle,
streamKeyboardInset.contentContainerPaddingBottom,
topOverlayInset,
],
const [rowLayoutSyncRegistry] = useState<RowLayoutSyncRegistry>(() => new Map());
const isAtEnd = useStableEvent(
() => distanceFromEnd(streamViewportMetricsRef.current) <= AT_END_OFFSET_PX,
);
const listInsetProps = useMemo(
() =>
streamKeyboardInset.contentInset
? {
automaticallyAdjustContentInsets: false,
automaticallyAdjustsScrollIndicatorInsets: false,
contentInsetAdjustmentBehavior: "never" as const,
contentInset: streamKeyboardInset.contentInset,
scrollIndicatorInsets: streamKeyboardInset.contentInset,
}
: {},
[streamKeyboardInset.contentInset],
const releaseDisclosure = useStableEvent(() => {
bottomAnchorController.releaseDisclosure({ isAtEnd: isAtEnd() });
});
const [disclosureHold] = useState(() =>
createNativeDisclosureHold({
quietFrames: DISCLOSURE_QUIET_FRAMES,
requestFrame: requestAnimationFrame,
cancelFrame: cancelAnimationFrame,
onRelease: releaseDisclosure,
}),
);
useEffect(() => () => disclosureHold.cancel(), [disclosureHold]);
// The hold detaches the controller in the same event as `apply`, so the toggle's commit already
// has end following off and LegendList anchoring on the held row. The row's measurement trigger
// makes that same commit carry the row's new height.
const holdRow = useStableEvent((rowId: string, apply: () => void) => {
disclosureHold.hold(rowId);
bottomAnchorController.holdForDisclosure();
apply();
rowLayoutSyncRegistry.get(rowId)?.();
});
const handleItemSizeChanged = useStableEvent(
(info: { size: number; previous: number; itemKey: string; itemData: StreamItem }) => {
newRowEstimates.noteMeasured(info.itemData, info.size);
disclosureHold.noteItemSizeChanged();
},
);
const shouldRestorePosition = useStableEvent((item: StreamItem) => {
const heldRowId = disclosureHold.heldRowId();
return heldRowId === null || item.id === heldRowId;
});
// One owner keeps the scroll offset at any moment:
// - Before the list has placed its rows for the agent: LegendList's initial scroll to the end. It
// keeps the rows hidden until they are measured and in place, so neither the pin nor the
// controller acts; the content does not count as measured yet (`handleContentSizeChange`).
// - Following the end: the end pin. Rows added, rows resized and footer changes never scroll from
// JS; the pin holds the end in the frame the change mounts. The controller scrolls only for a
// viewport resize (the composer growing or shrinking), which the pin cannot see, and for an
// explicit jump, and both go to the native end (`scrollToBottom`). The keyboard does not resize
// the viewport: it translates the list with the composer. LegendList's end keeping stays off: it
// scrolls to the end its JS sizes predict, which runs ahead of the native mount, so it lands
// short of the real end and the controller reads that as the user scrolling away.
// - A disclosure hold: the held row. The hold detaches the controller, which removes the pin, and
// LegendList keeps only the held row in place.
// - A user scroll: the finger. The controller detaches for the gesture, so streamed lines grow below
// the rows the user is dragging, and resumes following if the gesture ends at the end.
// - Detached: the visible rows. LegendList keeps the first visible row in place, so history loading
// above or rows growing below leave what the user is reading where it is.
const isFollowingEnd = bottomAnchorController.mode === "sticky-bottom";
const pinsEnd = isFollowingEnd && isListPlaced;
const maintainVisibleContentPosition = useMemo(
() => (isFollowingEnd ? false : { data: true, size: true, shouldRestorePosition }),
[isFollowingEnd, shouldRestorePosition],
);
// A new identity re-renders LegendList's scroll component, which is how the pin follows the mode.
const renderScrollComponent = useCallback(
(scrollProps: ScrollViewProps) => <EndPinnedScrollView {...scrollProps} pinEnd={pinsEnd} />,
[pinsEnd],
);
const topOverlayInset = useStreamTopOverlayInset();
// No keyboard inset: one committed after the keyboard settles moves every row when the end pin is
// not holding the offset (docs/agent-stream-disclosure.md).
const listContentContainerStyle = useMemo(
() => [baseListContentContainerStyle, { paddingTop: topOverlayInset }],
[baseListContentContainerStyle, topOverlayInset],
);
useEffect(() => {
@@ -325,8 +396,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollOffsetYRef.current = 0;
isUserScrollActiveRef.current = false;
clearPendingUserScrollEnd();
clearNativeViewportSettling();
isNativeViewportSettlingRef.current = false;
disclosureHold.cancel();
historyStartReadyRef.current = false;
const initialHistoryStartState = createHistoryStartPaginationState();
historyStartPaginationStateRef.current = initialHistoryStartState;
@@ -341,13 +411,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
useEffect(() => () => clearNativeViewportSettling(), [clearNativeViewportSettling]);
useEffect(() => {
bottomAnchorController.prepareForStickyContentChange();
}, [bottomAnchorController, historyRows, segments.liveHead]);
}, [agentId, clearPendingUserScrollEnd, disclosureHold, evaluateHistoryStart]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -359,9 +423,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
},
prepareForViewportChange: () => {
bottomAnchorController.prepareForStickyViewportChange();
markNativeViewportSettling();
},
holdReadingPosition: () => bottomAnchorController.detachByUser(),
holdRow,
};
viewportRef.current = handle;
return () => {
@@ -369,7 +432,32 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportRef.current = null;
}
};
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
}, [agentId, bottomAnchorController, holdRow, viewportRef]);
// The scroll-to-bottom button shows whenever following would not resume on its own. A detached
// list resumes only from the end, so the button shows anywhere short of it. While following, the
// offset can trail content growth for a frame, so the controller's wider threshold keeps the
// button from flashing.
const publishNearBottom = useStableEvent(() => {
const metrics = streamViewportMetricsRef.current;
onNearBottomChange(
bottomAnchorController.mode === "detached"
? distanceFromEnd(metrics) <= AT_END_OFFSET_PX
: isNearBottomForStreamRenderStrategy({
strategy,
offsetY: metrics.offsetY,
threshold: 32,
contentHeight: metrics.contentHeight,
viewportHeight: metrics.viewportHeight,
}),
);
});
// Following starts and stops without a scroll, for example when a disclosure hold begins short of
// the end, and the button follows it.
useEffect(() => {
publishNearBottom();
}, [isFollowingEnd, publishNearBottom]);
const isScrollEventNearBottom = useStableEvent(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
@@ -384,6 +472,17 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
},
);
const isScrollEventAtEnd = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
return (
distanceFromEnd({
offsetY: contentOffset.y,
contentHeight: contentSize.height,
viewportHeight: layoutMeasurement.height,
}) <= AT_END_OFFSET_PX
);
});
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const previousOffsetY = scrollOffsetYRef.current;
@@ -401,14 +500,18 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
};
const nearBottom = isScrollEventNearBottom(event);
onNearBottomChange(nearBottom);
publishNearBottom();
evaluateHistoryStart();
if (
!isUserScrollActiveRef.current &&
programmaticScrollEventBudgetRef.current > 0 &&
contentOffset.y <= 8
distanceFromEnd({
offsetY: contentOffset.y,
contentHeight: contentSize.height,
viewportHeight: layoutMeasurement.height,
}) <= 8
) {
programmaticScrollEventBudgetRef.current -= 1;
} else {
@@ -424,6 +527,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
scrollKeyboardDismiss.onScrollBeginDrag(event);
// The gesture takes the viewport from a disclosure hold. Size changes from a streaming row can
// keep the hold's quiet frames from ever passing, and a held row that scrolls away would leave
// no row LegendList may anchor a history page on. The gesture's end decides following.
disclosureHold.cancel();
bottomAnchorController.beginUserScroll();
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
if (rearmed !== historyStartPaginationStateRef.current) {
@@ -433,17 +540,22 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
});
// Defer drag end so momentum can take ownership, but capture the terminal
// gesture position now because layout may move the viewport in the meantime.
// A gesture ends at the end when the finger lifts at or past it, or when its momentum stops there.
// A release past the end bounces back to it on iOS, and lines that land during the bounce scroll
// in as they do while following. Momentum stopping short of the end, or output that arrived below
// the viewport before the decision, leaves the list detached.
// Drag end is deferred a frame so momentum can take ownership.
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const isNearBottom = isScrollEventNearBottom(event);
scrollKeyboardDismiss.onScrollEndDrag(event);
isReleasedAtEndRef.current = isScrollEventAtEnd(event);
clearPendingUserScrollEnd();
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
userScrollEndFrameIdRef.current = null;
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll({ isNearBottom });
bottomAnchorController.endUserScroll({
isAtEnd: isReleasedAtEndRef.current || isAtEnd(),
});
});
});
@@ -458,10 +570,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
if (!isUserScrollActiveRef.current) {
return;
}
const isNearBottom = isScrollEventNearBottom(event);
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll({ isNearBottom });
bottomAnchorController.endUserScroll({
isAtEnd: isReleasedAtEndRef.current || isScrollEventAtEnd(event),
});
},
);
@@ -470,9 +583,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
const viewportWidth = Math.max(0, event.nativeEvent.layout.width);
const viewportHeight = Math.max(0, event.nativeEvent.layout.height);
const viewportChanged =
(previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) ||
(previousViewportHeight > 0 && previousViewportHeight !== viewportHeight);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
containerKey: "native-virtualized",
@@ -480,9 +590,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportHeight,
viewportMeasuredForKey: "native-virtualized",
};
if (viewportChanged) {
markNativeViewportSettling();
}
bottomAnchorController.handleViewportMetricsChange({
previousViewportWidth,
viewportWidth,
@@ -493,24 +600,37 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const handleContentSizeChange = useStableEvent((_width: number, height: number) => {
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
const nextContentHeight = Math.max(0, height);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
containerKey: "native-virtualized",
contentHeight: nextContentHeight,
contentMeasuredForKey: "native-virtualized",
contentMeasuredForKey: isListPlacedRef.current ? "native-virtualized" : null,
};
bottomAnchorController.handleContentSizeChange({
previousContentHeight,
contentHeight: nextContentHeight,
});
bottomAnchorController.handleContentSizeChange();
// Content growing below a detached viewport moves the end away without a scroll event.
if (
bottomAnchorController.mode === "detached" &&
streamViewportMetricsRef.current.viewportHeight > 0
) {
publishNearBottom();
}
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartSettle();
}
});
const handleListPlaced = useStableEvent(() => {
isListPlacedRef.current = true;
setPlacedAgentId(agentId);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
contentMeasuredForKey: "native-virtualized",
};
bottomAnchorController.handleContentSizeChange();
});
useEffect(() => {
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
@@ -524,45 +644,35 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scheduleHistoryStartSettle,
]);
// LegendList re-renders a mounted row only when its item or `extraData` changes, not when
// renderItem does. Item identity carries content and display changes (`useRevisedHistoryRows`);
// `extraData` carries the history renderer, whose identity changes with the history layout (a
// reply gaining its completed footer when the next prompt arrives) and keeps its row's item.
// StreamRow's memo limits that pass to the rows whose layout item changed.
const renderItem = useStableEvent(
({ item, index }: ListRenderItemInfo<StreamItem>): ReactElement | null => {
const rendered = renderHistoryMountedRow(item, index, historyItems);
return (rendered ?? null) as ReactElement | null;
},
({ item, index }: LegendListRenderItemProps<StreamItem>): ReactElement => (
<RowLayoutSync rowId={item.id} registry={rowLayoutSyncRegistry}>
{liveRowIds.has(item.id)
? renderLiveHeadRow(item, index - historyRows.length, segments.liveHead)
: renderHistoryMountedRow(item, index, historyItems)}
</RowLayoutSync>
),
);
const liveHeaderContent = useMemo(() => {
// Stable render events read the latest expansion state; this revision makes
// the memo invoke them again when that state changes.
void liveHeadRowRevision;
const liveHeadRows = segments.liveHead.map((item, index) => (
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
));
const liveAuxiliaryContent = useMemo(() => {
const liveAuxiliary = renderLiveAuxiliary();
if (
liveHeadRows.length === 0 &&
liveRows.length === 0 &&
!liveAuxiliary &&
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory
) {
return (listEmptyComponent ?? null) as ReactElement | null;
}
return (
<Fragment>
{liveHeadRows}
{liveAuxiliary}
</Fragment>
);
}, [
boundary,
listEmptyComponent,
liveHeadRowRevision,
renderLiveAuxiliary,
renderLiveHeadRow,
segments.liveHead,
]);
return <View>{liveAuxiliary}</View>;
}, [boundary, listEmptyComponent, liveRows.length, renderLiveAuxiliary]);
const historyFooterContent = useMemo(() => {
const historyStartContent = useMemo(() => {
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
return (
<View style={historyStartSlotStyle} testID="older-history-slot">
@@ -575,21 +685,21 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
);
}, [historyStartPaginationState]);
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when
// data or the live header changes, preserving the row identities above.
return (
<FlatList
{...listInsetProps}
ref={flatListRef}
data={historyRows}
<LegendList<StreamItem>
ref={listRef}
data={rows}
renderItem={renderItem}
extraData={renderHistoryMountedRow}
keyExtractor={keyExtractor}
strictMode
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={liveHeaderContent ?? undefined}
ListFooterComponent={historyFooterContent ?? undefined}
ListHeaderComponent={historyStartContent}
ListFooterComponent={liveAuxiliaryContent ?? undefined}
contentContainerStyle={listContentContainerStyle}
automaticallyAdjustContentInsets={false}
automaticallyAdjustsScrollIndicatorInsets={false}
contentInsetAdjustmentBehavior="never"
style={listStyle}
onLayout={handleListLayout}
onScroll={handleScroll}
@@ -599,16 +709,18 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
onItemSizeChanged={handleItemSizeChanged}
onReady={handleListPlaced}
dataKey={agentId}
maintainVisibleContentPosition={maintainVisibleContentPosition}
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial
// render small, then refill a deeper window in batches large enough to keep up.
initialNumToRender={2}
maxToRenderPerBatch={10}
windowSize={NATIVE_STREAM_WINDOW_SIZE}
removeClippedSubviews={false}
renderScrollComponent={renderScrollComponent}
recycleItems={false}
alignItemsAtEnd
initialScrollAtEnd
drawDistance={NATIVE_STREAM_DRAW_DISTANCE}
getEstimatedItemSize={getEstimatedItemSize}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
inverted
/>
);
}
@@ -616,26 +728,20 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
export function createNativeStreamStrategy(): StreamStrategy {
const strategy = createStreamStrategy({
render: (renderInput) => <NativeStreamViewport {...renderInput} strategy={strategy} />,
orderTailReverse: true,
orderHeadReverse: true,
assistantTurnTraversalStep: 1,
edgeSlot: "header",
historyLiveBoundaryEdge: "first",
liveHeadHistoryBoundaryEdge: "last",
frameChildOrder: "footer-then-content",
flatListInverted: true,
overlayScrollbarInverted: true,
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
bottomAnchorTransportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
orderTailReverse: false,
orderHeadReverse: false,
assistantTurnTraversalStep: -1,
edgeSlot: "footer",
historyLiveBoundaryEdge: "last",
liveHeadHistoryBoundaryEdge: "first",
frameChildOrder: "content-then-footer",
bottomAnchorTransportBehavior: BOTTOM_ANCHOR_TRANSPORT_BEHAVIOR,
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: false,
animateManualScrollToBottom: true,
useVirtualizedList: true,
isNearBottom: (input) => input.offsetY <= input.threshold,
getBottomOffset: () => 0,
isNearBottom: (input) => distanceFromEnd(input) <= input.threshold,
getBottomOffset: (metrics) => Math.max(0, metrics.contentHeight - metrics.viewportHeight),
});
return strategy;
}
@@ -0,0 +1,158 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
import type { StreamItem } from "@/types/stream";
import type { StreamRenderInput, StreamViewportHandle } from "./strategy";
import { createWebStreamStrategy } from "./strategy-web";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const VIEWPORT_HEIGHT = 100;
const ROW_HEIGHT = 40;
// A row's height comes from its text, so a disclosure that opens or grows a row is a new item
// for that row, as it is in the app.
function row(index: number, height = ROW_HEIGHT): Extract<StreamItem, { kind: "user_message" }> {
return {
kind: "user_message",
id: `message-${index}`,
text: String(height),
timestamp: new Date(`2026-04-20T00:00:${String(index % 60).padStart(2, "0")}.000Z`),
};
}
const rowStyleByHeight = new Map<number, { height: number }>();
function renderRow(item: StreamItem) {
const height = item.kind === "user_message" ? Number(item.text) : ROW_HEIGHT;
let style = rowStyleByHeight.get(height);
if (!style) {
style = { height };
rowStyleByHeight.set(height, style);
}
return <div style={style}>{item.id}</div>;
}
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
host = document.createElement("div");
host.style.cssText = `display:flex;flex-direction:column;width:500px;height:${VIEWPORT_HEIGHT}px`;
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(() => {
act(() => root.unmount());
host.remove();
});
function mountTimeline(historyMounted: StreamItem[]) {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const input: StreamRenderInput = {
agentId: "agent",
segments: { historyVirtualized: [], historyMounted, liveHead: [] },
boundary: { hasVirtualizedHistory: false, hasMountedHistory: true, hasLiveHead: false },
renderers: {
renderHistoryVirtualizedRow: renderRow,
renderHistoryMountedRow: renderRow,
renderLiveHeadRow: renderRow,
renderLiveAuxiliary: () => null,
},
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {},
onNearHistoryStart: () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
const render = (segments: Partial<StreamRenderInput["segments"]>) =>
root.render(strategy.render({ ...input, segments: { ...input.segments, ...segments } }));
act(() => render({}));
const scrollContainer = host.querySelector<HTMLElement>('[data-testid="agent-chat-scroll"]');
if (!scrollContainer) throw new Error("Expected agent chat scroll container");
const rowElement = (id: string) => {
const element = host.querySelector<HTMLElement>(`[data-history-row-id="${id}"]`);
if (!element) throw new Error(`Expected row ${id}`);
return element;
};
return {
render: (segments: Partial<StreamRenderInput["segments"]>) => act(() => render(segments)),
hold: (rowId: string, apply: () => void) =>
act(() => viewportRef.current!.holdRow(rowId, apply)),
rowTop: (id: string) =>
rowElement(id).getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
distanceFromEnd: () =>
scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop,
// Resize observers and the hold's settle run on animation frames.
settle: () =>
act(async () => {
for (let frame = 0; frame < 4; frame += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
}),
};
}
describe("web disclosure hold", () => {
it("keeps the trigger's top when an anchored toggle at the bottom opens rows below it, and stops following", async () => {
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
const timeline = mountTimeline(history);
await timeline.settle();
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
const triggerTop = timeline.rowTop("message-5");
timeline.hold("message-5", () =>
timeline.render({ historyMounted: [...history.slice(0, 5), row(7, 200), history[5]!] }),
);
await timeline.settle();
expect(timeline.rowTop("message-5")).toBe(triggerTop);
timeline.render({ liveHead: [row(8)] });
await timeline.settle();
expect(timeline.rowTop("message-5")).toBe(triggerTop);
});
it("keeps the trigger's top when its row grows while following output", async () => {
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
const timeline = mountTimeline(history);
await timeline.settle();
const triggerTop = timeline.rowTop("message-5");
timeline.hold("message-5", () =>
timeline.render({ historyMounted: [...history.slice(0, 4), row(5, 240), history[5]!] }),
);
await timeline.settle();
expect(timeline.rowTop("message-5")).toBe(triggerTop);
});
it("resumes following when collapsing an anchored row lands back at the end", async () => {
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
const timeline = mountTimeline(history);
await timeline.settle();
timeline.hold("message-5", () =>
timeline.render({ historyMounted: [...history.slice(0, 4), row(5, 240), history[5]!] }),
);
await timeline.settle();
timeline.hold("message-5", () => timeline.render({ historyMounted: history }));
await timeline.settle();
timeline.render({ liveHead: [row(8)] });
await timeline.settle();
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
});
});
+29 -16
View File
@@ -363,6 +363,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const wasFollowOutputLayoutActiveRef = useRef(false);
const resumedUnchangedLayoutRef = useRef(false);
const pendingResumeGeometryCheckRef = useRef(false);
// Set by holdRow; the next reading reconcile sees the disclosure's layout.
const disclosurePendingRef = useRef(false);
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
const {
renderHistoryVirtualizedRow,
@@ -728,22 +730,27 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
onNearBottomChange,
});
const reconcileReadingPosition = useStableEvent(() => {
// Row tops in content coordinates, which survive scrolling.
const measureRows = useStableEvent(() => {
const container = scrollContainerRef.current;
const content = contentRef.current;
if (!isActiveRef.current || !container || !content) return;
if (!container || !content) return null;
const viewportTop = container.getBoundingClientRect().top;
const scrollTop = container.scrollTop;
const width = content.getBoundingClientRect().width;
const rows = Array.from(
content.querySelectorAll<HTMLElement>("[data-history-row-id]"),
(element) => {
const bounds = element.getBoundingClientRect();
const id = element.dataset.historyRowId!;
measuredRowHeights.current.set(id, { width, height: bounds.height });
return { id, top: bounds.top - viewportTop + scrollTop, height: bounds.height };
},
);
return Array.from(content.querySelectorAll<HTMLElement>("[data-history-row-id]"), (element) => {
const bounds = element.getBoundingClientRect();
const id = element.dataset.historyRowId!;
measuredRowHeights.current.set(id, { width, height: bounds.height });
return { id, top: bounds.top - viewportTop + scrollTop, height: bounds.height };
});
});
const reconcileReadingPosition = useStableEvent(() => {
const container = scrollContainerRef.current;
if (!isActiveRef.current || !container) return;
const rows = measureRows();
if (!rows) return;
const shouldAnchor = !followOutputRef.current && !isJumpSettling();
if (!shouldAnchor) readingAnchor.reset();
const correctedTop = readingAnchor.reconcile(
@@ -763,6 +770,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
// this layout so the reader does not get a frame containing only the pin.
virtualOffsetListener.current?.(container.scrollTop, false);
}
// A disclosure that closes back to the end of the list rejoins the output.
if (disclosurePendingRef.current) {
disclosurePendingRef.current = false;
if (isScrollContainerAtBottom(container)) setFollowOutput(true);
}
});
// Apply row positions and their scroll correction in the same paint, including
@@ -1182,10 +1194,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
scheduleStickToBottom();
},
holdReadingPosition: () => {
holdRow: (rowId, apply) => {
cancelPendingStickToBottom();
setFollowOutput(false);
readingAnchor.scroll(scrollContainerRef.current?.scrollTop ?? 0);
const row = measureRows()?.find((candidate) => candidate.id === rowId);
if (row) readingAnchor.pin(row);
disclosurePendingRef.current = true;
apply();
},
scrollToMessage,
};
@@ -1199,6 +1214,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [
cancelPendingStickToBottom,
forceStickToBottom,
measureRows,
readingAnchor,
scheduleStickToBottom,
scrollToMessage,
@@ -1392,9 +1408,6 @@ export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): St
historyLiveBoundaryEdge: "last",
liveHeadHistoryBoundaryEdge: "first",
frameChildOrder: "content-then-footer",
flatListInverted: false,
overlayScrollbarInverted: false,
maintainVisibleContentPosition: undefined,
bottomAnchorTransportBehavior: {
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
+5 -35
View File
@@ -14,11 +14,6 @@ type NeighborRelation = "above" | "below";
type AssistantTurnTraversalStep = -1 | 1;
export type StreamFrameChildOrder = "content-then-footer" | "footer-then-content";
export type MaintainVisibleContentPositionConfig = Readonly<{
minIndexForVisible: number;
autoscrollToTopThreshold: number;
}>;
export type BottomAnchorTransportBehavior = Readonly<{
verificationDelayFrames: number;
verificationRetryMode: "rescroll" | "recheck";
@@ -56,8 +51,11 @@ export interface StreamViewportHandle {
focus?: () => void;
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void;
/** Stop following output so the rows on screen stay put while rows below them open or close. */
holdReadingPosition: () => void;
/**
* Run a disclosure's state change so the row that owns its trigger keeps its visual top.
* Following output stops; it resumes if the change leaves the list at its end.
*/
holdRow: (rowId: string, apply: () => void) => void;
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
}
@@ -125,14 +123,11 @@ export interface StreamStrategy {
component: ReactElement | ComponentType<unknown> | null,
gapSize: number,
) => StreamEdgeSlotProps;
getMaintainVisibleContentPosition: () => MaintainVisibleContentPositionConfig | undefined;
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
getHistoryLiveBoundaryIndex: (history: StreamItem[]) => number | null;
getLiveHeadHistoryBoundaryIndex: (liveHead: StreamItem[]) => number | null;
getLatestItemIndex: (items: StreamItem[]) => number | null;
getFrameChildOrder: () => StreamFrameChildOrder;
getFlatListInverted: () => boolean;
getOverlayScrollbarInverted: () => boolean;
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
shouldAnchorBottomOnContentSizeChange: () => boolean;
shouldAnimateManualScrollToBottom: () => boolean;
@@ -148,9 +143,6 @@ interface StreamStrategyConfig {
historyLiveBoundaryEdge: "first" | "last";
liveHeadHistoryBoundaryEdge: "first" | "last";
frameChildOrder: StreamFrameChildOrder;
flatListInverted: boolean;
overlayScrollbarInverted: boolean;
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
bottomAnchorTransportBehavior: BottomAnchorTransportBehavior;
disableParentScrollOnInlineDetailsExpansion: boolean;
anchorBottomOnContentSizeChange: boolean;
@@ -160,8 +152,6 @@ interface StreamStrategyConfig {
getBottomOffset: (metrics: StreamViewportMetrics) => number;
}
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
export function createStreamStrategy(config: StreamStrategyConfig): StreamStrategy {
return {
render: config.render,
@@ -216,7 +206,6 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
ListFooterComponentStyle: { marginTop: gapSize },
};
},
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior,
getHistoryLiveBoundaryIndex: (history) => {
if (history.length === 0) {
@@ -237,8 +226,6 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
return config.historyLiveBoundaryEdge === "first" ? 0 : items.length - 1;
},
getFrameChildOrder: () => config.frameChildOrder,
getFlatListInverted: () => config.flatListInverted,
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
config.disableParentScrollOnInlineDetailsExpansion,
shouldAnchorBottomOnContentSizeChange: () => config.anchorBottomOnContentSizeChange,
@@ -247,23 +234,6 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
};
}
export function resolveBottomAnchorTransportBehavior(input: {
strategy: StreamStrategy;
isViewportSettling: boolean;
}): BottomAnchorTransportBehavior {
const baseBehavior = input.strategy.getBottomAnchorTransportBehavior();
if (!input.isViewportSettling || !input.strategy.getFlatListInverted()) {
return baseBehavior;
}
return {
verificationDelayFrames: Math.max(
baseBehavior.verificationDelayFrames,
NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES,
),
verificationRetryMode: "recheck",
};
}
export function orderTailForStreamRenderStrategy(params: {
strategy: StreamStrategy;
streamItems: StreamItem[];
@@ -0,0 +1,69 @@
import { act, createRef } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import type { StreamLayoutItem } from "./layout";
import type { StreamViewportHandle } from "./strategy";
import { renderStreamRow } from "./stream-row";
// stream-row.tsx is compiled with the React Compiler here, as Expo compiles it.
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(() => {
act(() => root.unmount());
host.remove();
});
const turnHost: StreamItem = {
kind: "user_message",
id: "host",
text: "Fix the build",
timestamp: new Date(0),
};
const turnHostLayout: StreamLayoutItem = {
item: turnHost,
aboveItem: null,
// The turn's rows land below another row, so opening it leaves the host's layout unchanged.
belowItem: null,
gapBelow: 0,
assistantSpacing: "default",
completedFooter: null,
toolSequence: "none",
isFirstInUserGroup: true,
isLastInUserGroup: true,
isLastInToolSequence: false,
frameOrder: "content-then-footer",
phase: "complete",
};
it("renders a revised row again when its layout is unchanged", () => {
// The turn's open state lives outside the row, as `getCollapsedTurn` reads it in view.tsx.
let turnOpen = false;
const renderStreamItem = () => <span>{turnOpen ? "open" : "closed"}</span>;
const viewportRef = createRef<StreamViewportHandle>();
const layoutItemById = new Map([[turnHost.id, turnHostLayout]]);
const renderRow = (item: StreamItem) =>
act(() =>
root.render(renderStreamRow({ item, layoutItemById, renderStreamItem, viewportRef })),
);
renderRow(turnHost);
expect(host.textContent).toBe("closed");
turnOpen = true;
// useRevisedHistoryRows hands the row a fresh item when its display state changes.
renderRow({ ...turnHost });
expect(host.textContent).toBe("open");
});
@@ -0,0 +1,58 @@
import { memo, type ReactNode, type RefObject } from "react";
import type { StreamItem } from "@/types/stream";
import { DisclosureRow } from "./disclosure";
import type { StreamLayoutItem } from "./layout";
import type { StreamViewportHandle } from "./strategy";
/**
* Renders a timeline row from the item the row was handed. The item is a revision of
* `layoutItem.item` with the same content: render from it, so a revision that leaves the layout
* unchanged still renders the row again.
*/
export type RenderStreamItem = (item: StreamItem, layoutItem: StreamLayoutItem) => ReactNode;
// Live and retained rows use the same boundary so completion preserves the message
// subtree, including measured diagrams and selections. LegendList renders every mounted row
// again when `extraData` (the history renderer) changes, and history rows shift index when older
// history loads. This boundary limits that pass: a row renders again only when its stream item
// identity, its layout item identity, or the renderer itself changes. Item identity is the
// revision signal the strategy already uses (`useRevisedHistoryRows` clones items whose content or
// display state changed). The row passes the item to the renderer, so the React Compiler's cache
// of the rendered content keys on it too.
const StreamRow = memo(function StreamRow({
item,
layoutItem,
renderStreamItem,
viewportRef,
}: {
item: StreamItem;
layoutItem: StreamLayoutItem;
renderStreamItem: RenderStreamItem;
viewportRef: RefObject<StreamViewportHandle | null>;
}) {
return (
<DisclosureRow rowId={item.id} viewportRef={viewportRef}>
{renderStreamItem(item, layoutItem)}
</DisclosureRow>
);
});
export function renderStreamRow(input: {
item: StreamItem;
layoutItemById: Map<string, StreamLayoutItem>;
renderStreamItem: RenderStreamItem;
viewportRef: RefObject<StreamViewportHandle | null>;
}): ReactNode {
const layoutItem = input.layoutItemById.get(input.item.id);
if (!layoutItem) {
return null;
}
return (
<StreamRow
item={input.item}
layoutItem={layoutItem}
renderStreamItem={input.renderStreamItem}
viewportRef={input.viewportRef}
/>
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { createContext, useContext } from "react";
import { isNative } from "@/constants/platform";
/** The native inverted viewport reserves visual-top clearance; web stays in flow. */
/** The native viewport reserves top clearance under the overlay; web stays in flow. */
export const supportsStreamTopOverlay = isNative;
const StreamTopOverlayContext = createContext(0);
@@ -41,6 +41,7 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
isLatestTurnOpen: false,
expandedHostIds: new Set(),
getGroupedCalls: () => undefined,
previousTurnsByHostId: new Map(),
...options,
});
}
@@ -48,6 +49,37 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
const ids = (items: StreamItem[]) => items.map((item) => item.id);
describe("collapseTurns", () => {
// A history row renders again only when its item changes, so a prompt that is already retained
// when its turn closes needs its row revised to show the summary line.
it("reports the hosts whose summary line appeared, changed or went away", () => {
const open = collapse([user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2")], {
isLatestTurnOpen: true,
});
expect([...open.updatedHostIds]).toEqual(["u1"]);
const closed = collapse(
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")],
{
previousTurnsByHostId: open.turnsByHostId,
},
);
expect([...closed.updatedHostIds]).toEqual(["u2"]);
const unchanged = collapse(
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")],
{
previousTurnsByHostId: closed.turnsByHostId,
},
);
expect(unchanged.updatedHostIds.size).toBe(0);
const moreWork = collapse(
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), shell("c3"), reply("r2")],
{ previousTurnsByHostId: unchanged.turnsByHostId },
);
expect([...moreWork.updatedHostIds]).toEqual(["u2"]);
const rewound = collapse([user("u1"), shell("c1"), reply("r1")], {
previousTurnsByHostId: moreWork.turnsByHostId,
});
expect([...rewound.updatedHostIds]).toEqual(["u2"]);
});
it("maps hidden rows only while their turn is closed", () => {
const result = collapse([user("u1"), shell("c1"), reply("final")]);
expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1");
@@ -27,6 +27,11 @@ export interface TurnCollapse {
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
/** Mounted rows replaced by a closed turn's host, for preserving the reading position. */
hostIdByHiddenRowId: ReadonlyMap<string, string>;
/**
* Hosts whose summary line appeared, changed or went away since `previousTurnsByHostId`. A
* history row renders again only when its item changes, so these rows are revised.
*/
updatedHostIds: ReadonlySet<string>;
}
export interface CollapseTurnsInput {
@@ -36,6 +41,8 @@ export interface CollapseTurnsInput {
expandedHostIds: ReadonlySet<string>;
/** The calls a tool-call group row stands for; undefined for a single call. */
getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined;
/** The previous fold of the same history, the baseline for `updatedHostIds`. */
previousTurnsByHostId: ReadonlyMap<string, CollapsedTurn>;
}
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
@@ -59,6 +66,36 @@ function hasWork(summary: TurnSummary): boolean {
return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0);
}
function isSameTurn(a: CollapsedTurn, b: CollapsedTurn): boolean {
const { paseoActions: actionsA, ...countsA } = a.summary.tools;
const { paseoActions: actionsB, ...countsB } = b.summary.tools;
return (
a.expanded === b.expanded &&
hasSameCounts(countsA, countsB) &&
hasSameCounts(actionsA, actionsB)
);
}
function hasSameCounts(a: Record<string, number>, b: Record<string, number>): boolean {
const keys = Object.keys(a);
return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]);
}
function findUpdatedHosts(
previous: ReadonlyMap<string, CollapsedTurn>,
next: ReadonlyMap<string, CollapsedTurn>,
): ReadonlySet<string> {
const updated = new Set<string>();
for (const [hostId, turn] of next) {
const before = previous.get(hostId);
if (!before || !isSameTurn(before, turn)) updated.add(hostId);
}
for (const hostId of previous.keys()) {
if (!next.has(hostId)) updated.add(hostId);
}
return updated.size === 0 ? EMPTY_IDS : updated;
}
function staysVisibleWhenCollapsed(item: StreamItem): boolean {
return isTurnTrigger(item) || item.kind === "compaction";
}
@@ -137,6 +174,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
}
flushRun(tail.length);
const updatedHostIds = findUpdatedHosts(input.previousTurnsByHostId, turnsByHostId);
if (turnsByHostId.size === 0) {
return {
tail,
@@ -144,6 +182,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
nestedItemIds: EMPTY_IDS,
hostIdByFoldedMessageId: EMPTY_HOSTS,
hostIdByHiddenRowId: EMPTY_HOSTS,
updatedHostIds,
};
}
return {
@@ -155,5 +194,6 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
nestedItemIds,
hostIdByFoldedMessageId,
hostIdByHiddenRowId,
updatedHostIds,
};
}
@@ -1,6 +1,7 @@
import { memo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Summary } from "lucide-react-native";
import { useDisclosureHold } from "@/agent-stream/disclosure";
import { ExpandableBadge } from "@/components/message";
import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label";
import type { CollapsedTurn } from "./model";
@@ -18,9 +19,10 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({
}) {
const { t } = useTranslation();
const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details");
const hold = useDisclosureHold();
const toggle = useCallback(
() => onExpandedChange(turn.hostId, !turn.expanded),
[onExpandedChange, turn.expanded, turn.hostId],
() => hold(() => onExpandedChange(turn.hostId, !turn.expanded)),
[hold, onExpandedChange, turn.expanded, turn.hostId],
);
return (

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