diff --git a/CLAUDE.md b/CLAUDE.md index c9b6e0db7c..1496bd407b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | | [docs/terminal-rendering.md](docs/terminal-rendering.md) | Native terminal view: xterm owns the grid, cluster placement, emoji widths, mixed-direction limits, render regression | | [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render | +| [docs/agent-stream-disclosure.md](docs/agent-stream-disclosure.md) | Expand/collapse in the chat timeline — hold contract, native anchor sequence, frame spacing, render-window cost | | [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints | | [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations | | [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison | diff --git a/docs/agent-stream-disclosure.md b/docs/agent-stream-disclosure.md new file mode 100644 index 0000000000..9cde8a44ff --- /dev/null +++ b/docs/agent-stream-disclosure.md @@ -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. diff --git a/docs/agent-stream-performance.md b/docs/agent-stream-performance.md index ec506ee99b..fb022895bc 100644 --- a/docs/agent-stream-performance.md +++ b/docs/agent-stream-performance.md @@ -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 diff --git a/docs/android.md b/docs/android.md index 6b10a56892..8b6201ae91 100644 --- a/docs/android.md +++ b/docs/android.md @@ -174,18 +174,18 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \ This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:` works directly; on Android you need `10.0.2.2` or `adb reverse`. -## Inverted timeline input +## Scroll view corrections -Android focus and selection visibility requests must not reposition inverted timelines. The -`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for -child-reveal scroll calculations when the view's transform matrix inverts the vertical axis. React -Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotation with positive -`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll -commands still work; non-inverted scroll views keep Android's default behavior. +`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which +keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the +chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections) +lists the corrections and the React Native issues they work around. Its instrumentation test, +`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager: -Mouse wheel axes also bypass the inversion transform. The same module reverses the vertical -wheel axis for inverted scroll views before delegating to Android, preserving native scroll -bounds and React Native's scroll events and disabled-scroll behavior. +```bash +cd packages/app/android +./gradlew :paseo-scroll:connectedDebugAndroidTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false +``` Register this package before React Native's core package through its Expo config plugin. Normal Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 4616a03815..9032a3d079 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -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 diff --git a/docs/markdown-renderer.md b/docs/markdown-renderer.md index 9a69631071..11b9f09405 100644 --- a/docs/markdown-renderer.md +++ b/docs/markdown-renderer.md @@ -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. diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md index 903244e3d9..3dbea66676 100644 --- a/docs/mobile-testing.md +++ b/docs/mobile-testing.md @@ -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. diff --git a/docs/native-text-selection.md b/docs/native-text-selection.md index dd7edcd79c..fb04571451 100644 --- a/docs/native-text-selection.md +++ b/docs/native-text-selection.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 5518ce485e..7e561ceb9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8142,6 +8142,26 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@legendapp/list": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.7.1.tgz", + "integrity": "sha512-zgIy84yG2JP93+kib61rZBTcYGZ6fULrkCQz/MF7WrjVJeJQa/rR4z+rXfM5oveirO5mYkro4IDGytARIjJe9w==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "react": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/@lezer/common": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", @@ -37466,6 +37486,7 @@ "@getpaseo/protocol": "*", "@gorhom/bottom-sheet": "^5.2.14", "@gorhom/portal": "^1.0.14", + "@legendapp/list": "^3.7.1", "@marijn/find-cluster-break": "^1.0.2", "@mattermost/react-native-paste-input": "2.0.1", "@react-native-async-storage/async-storage": "2.2.0", diff --git a/packages/app/modules/paseo-scroll/android/src/androidTest/AndroidManifest.xml b/packages/app/modules/paseo-scroll/android/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..2b3dae4d2a --- /dev/null +++ b/packages/app/modules/paseo-scroll/android/src/androidTest/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/MountingReactContext.java b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/MountingReactContext.java new file mode 100644 index 0000000000..fb9f65ced1 --- /dev/null +++ b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/MountingReactContext.java @@ -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 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 inert(Class 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; + } + })); + } +} diff --git a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt deleted file mode 100644 index 0ed8e69626..0000000000 --- a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt +++ /dev/null @@ -1,159 +0,0 @@ -package sh.paseo.scroll - -import android.graphics.Rect -import android.text.Selection -import android.text.Spannable -import android.view.View -import android.view.InputDevice -import android.view.MotionEvent -import android.os.SystemClock -import android.widget.FrameLayout -import android.widget.TextView -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.facebook.react.bridge.JavaOnlyArray -import com.facebook.react.bridge.JavaOnlyMap -import com.facebook.react.soloader.OpenSourceMergedSoMapping -import com.facebook.react.uimanager.DisplayMetricsHolder -import com.facebook.react.uimanager.ReactStylesDiffMap -import com.facebook.react.uimanager.ThemedReactContext -import com.facebook.react.views.scroll.ReactScrollView -import com.facebook.react.views.scroll.ReactScrollViewManager -import com.facebook.soloader.SoLoader -import org.junit.Assert.* -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ScrollSelectionTest { - @Test fun mouseWheelMovesInvertedViewportInVisualDirection() = withScroll(inverted = true) { scroll, _ -> - val before = scroll.scrollY - withWheel(1f) { event -> - assertTrue(scroll.dispatchGenericMotionEvent(event)) - assertTrue("Wheel up must move toward older messages: $before -> ${scroll.scrollY}", scroll.scrollY > before) - assertEquals("The caller's event must not change", 1f, event.getAxisValue(MotionEvent.AXIS_VSCROLL)) - } - val afterUp = scroll.scrollY - withWheel(-1f) { event -> - assertTrue(scroll.dispatchGenericMotionEvent(event)) - assertTrue("Wheel down must move toward newer messages: $afterUp -> ${scroll.scrollY}", scroll.scrollY < afterUp) - } - } - - @Test fun mouseWheelKeepsOrdinaryScrollDirection() = withScroll(inverted = false) { scroll, _ -> - val before = scroll.scrollY - withWheel(1f) { event -> - assertTrue(scroll.dispatchGenericMotionEvent(event)) - assertTrue(scroll.scrollY < before) - } - val afterUp = scroll.scrollY - withWheel(-1f) { event -> - assertTrue(scroll.dispatchGenericMotionEvent(event)) - assertTrue(scroll.scrollY > afterUp) - } - } - - @Test fun mouseWheelRespectsDisabledScrolling() = withScroll(inverted = true) { scroll, _ -> - scroll.setScrollEnabled(false) - val before = scroll.scrollY - withWheel(1f) { scroll.dispatchGenericMotionEvent(it) } - assertEquals(before, scroll.scrollY) - } - - @Test fun focusingSelectableTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text -> - val before = scroll.scrollY - assertTrue(text.requestFocus()) - assertSame(text, scroll.findFocus()) - assertEquals("Text focus must not move the inverted viewport", before, scroll.scrollY) - assertTrue(text.isTextSelectable) - } - - @Test fun selectingTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text -> - val before = scroll.scrollY - Selection.setSelection(text.text as Spannable, 0, 8) - text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true) - assertEquals("Selection visibility requests must not move the inverted viewport", before, scroll.scrollY) - assertEquals("Selectab", text.text.subSequence(text.selectionStart, text.selectionEnd).toString()) - } - - @Test fun explicitScrollCommandsStillMoveInvertedViewport() = withScroll(inverted = true) { scroll, _ -> - scroll.scrollTo(0, 600) - assertEquals(600, scroll.scrollY) - } - - @Test fun ordinaryScrollViewsStillRevealSelectedText() = withScroll(inverted = false) { scroll, text -> - val before = scroll.scrollY - text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true) - assertNotEquals(before, scroll.scrollY) - } - - @Test fun verticalScaleStillKeepsFocusAndSelectionInPlace() = withScroll(inverted = true, transform = "scaleY") { scroll, text -> - val before = scroll.scrollY - assertTrue(text.requestFocus()) - text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true) - assertEquals(before, scroll.scrollY) - } - - @Test fun horizontalScaleStillRevealsSelectedText() = withScroll(inverted = true, transform = "scaleX") { scroll, text -> - val before = scroll.scrollY - text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true) - assertNotEquals(before, scroll.scrollY) - } - - private fun withScroll(inverted: Boolean, transform: String = "scale", check: (ReactScrollView, TextView) -> Unit) { - val instrumentation = InstrumentationRegistry.getInstrumentation() - instrumentation.runOnMainSync { - val appContext = instrumentation.targetContext - DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext) - SoLoader.init(appContext, OpenSourceMergedSoMapping) - val reactContext = ScrollTestContext(appContext) - val context = ThemedReactContext(reactContext, appContext) - val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager - val scroll = manager.createViewInstance(context) - val content = FrameLayout(context) - val text = TextView(context).apply { - setText("Selectable timeline text", TextView.BufferType.SPANNABLE) - setTextIsSelectable(true) - } - content.addView(text, FrameLayout.LayoutParams(300, 100).apply { topMargin = 1400 }) - scroll.addView(content) - content.measure(exact(300), exact(2000)) - content.layout(0, 0, 300, 2000) - scroll.measure(exact(300), exact(300)) - scroll.layout(0, 0, 300, 300) - // Apply RN's actual Android inversion through the manager's transform decomposition. - val transforms = JavaOnlyArray.of(JavaOnlyMap.of(transform, if (inverted) -1.0 else 1.0)) - manager.updateProperties(scroll, ReactStylesDiffMap(JavaOnlyMap.of("transform", transforms))) - if (inverted && transform == "scale") { - assertEquals(1f, scroll.scaleY, 0f) - assertEquals(180f, kotlin.math.abs(scroll.rotation), 0f) - } - scroll.scrollTo(0, 400) - check(scroll, text) - } - } - - private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY) - - private fun withWheel(axis: Float, check: (MotionEvent) -> Unit) { - val time = SystemClock.uptimeMillis() - val properties = MotionEvent.PointerProperties().apply { - id = 0 - toolType = MotionEvent.TOOL_TYPE_MOUSE - } - val coordinates = MotionEvent.PointerCoords().apply { - x = 150f - y = 150f - setAxisValue(MotionEvent.AXIS_VSCROLL, axis) - } - val event = MotionEvent.obtain( - time, time, MotionEvent.ACTION_SCROLL, 1, arrayOf(properties), arrayOf(coordinates), - 0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_MOUSE, 0, - ) - try { - check(event) - } finally { - event.recycle() - } - } -} diff --git a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollTestContext.java b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollTestContext.java deleted file mode 100644 index d2e64c6339..0000000000 --- a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollTestContext.java +++ /dev/null @@ -1,19 +0,0 @@ -package sh.paseo.scroll; - -import android.content.Context; -import com.facebook.react.bridge.BridgeReactContext; -import com.facebook.react.uimanager.events.BlackHoleEventDispatcher; -import com.facebook.react.uimanager.events.EventDispatcher; -import com.facebook.react.uimanager.events.EventDispatcherProvider; - -/** Native-only tests use RN's dispatcher for a context without a JS instance. */ -final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider { - ScrollTestContext(Context context) { - super(context); - } - - @Override - public EventDispatcher getEventDispatcher() { - return BlackHoleEventDispatcher.INSTANCE; - } -} diff --git a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/VisibleContentPositionClampTest.kt b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/VisibleContentPositionClampTest.kt new file mode 100644 index 0000000000..c99241db47 --- /dev/null +++ b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/VisibleContentPositionClampTest.kt @@ -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() + + 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 + } +} diff --git a/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt b/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt index e887d67a31..617a017336 100644 --- a/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt +++ b/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt @@ -1,9 +1,5 @@ package sh.paseo.scroll -import android.graphics.Matrix -import android.graphics.Rect -import android.view.InputDevice -import android.view.MotionEvent import com.facebook.react.ReactPackage import com.facebook.react.ViewManagerOnDemandReactPackage import com.facebook.react.bridge.NativeModule @@ -28,51 +24,3 @@ private class PaseoScrollViewManager : ReactScrollViewManager() { override fun createViewInstance(context: ThemedReactContext): ReactScrollView = PaseoScrollView(context) } - -private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) { - private val transformValues = FloatArray(9) - - private fun isVerticallyInverted(): Boolean { - // Android FlatList uses scale: -1, which RN decomposes into a 180-degree - // rotation with positive scaleY. Read the resulting transform, not scaleY. - matrix.getValues(transformValues) - return transformValues[Matrix.MSCALE_Y] < 0 - } - - override fun onGenericMotionEvent(event: MotionEvent): Boolean { - if (!isVerticallyInverted() || event.actionMasked != MotionEvent.ACTION_SCROLL || - !event.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) { - return super.onGenericMotionEvent(event) - } - if (!scrollEnabled) return false - - // Android applies wheel axes directly to scrollY; the inversion transform only - // transforms pointer coordinates. Reverse the vertical axis before delegating so - // platform scroll bounds and scroll events still apply. - val properties = Array(event.pointerCount) { index -> - MotionEvent.PointerProperties().also { event.getPointerProperties(index, it) } - } - val coordinates = Array(event.pointerCount) { index -> - MotionEvent.PointerCoords().also { - event.getPointerCoords(index, it) - it.setAxisValue(MotionEvent.AXIS_VSCROLL, -it.getAxisValue(MotionEvent.AXIS_VSCROLL)) - } - } - val correctedEvent = MotionEvent.obtain( - event.downTime, event.eventTime, event.action, event.pointerCount, properties, coordinates, - event.metaState, event.buttonState, event.xPrecision, event.yPrecision, - event.deviceId, event.edgeFlags, event.source, event.flags, - ) - return try { - super.onGenericMotionEvent(correctedEvent) - } finally { - correctedEvent.recycle() - } - } - - // Both RN focus handling and Android selection visibility requests use this calculation. - // Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline - // movement to user gestures and explicit scroll commands. - override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int = - if (isVerticallyInverted()) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect) -} diff --git a/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollView.java b/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollView.java new file mode 100644 index 0000000000..5e2345830c --- /dev/null +++ b/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollView.java @@ -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); + } +} diff --git a/packages/app/modules/paseo-scroll/expo-module.config.json b/packages/app/modules/paseo-scroll/expo-module.config.json index ba00434407..47b7ac97b7 100644 --- a/packages/app/modules/paseo-scroll/expo-module.config.json +++ b/packages/app/modules/paseo-scroll/expo-module.config.json @@ -1 +1 @@ -{ "platforms": ["android"], "android": { "modules": [] } } +{ "platforms": ["android", "ios"], "android": { "modules": [] }, "ios": { "modules": [] } } diff --git a/packages/app/modules/paseo-scroll/ios/PaseoScroll.podspec b/packages/app/modules/paseo-scroll/ios/PaseoScroll.podspec new file mode 100644 index 0000000000..8ddbfd47b2 --- /dev/null +++ b/packages/app/modules/paseo-scroll/ios/PaseoScroll.podspec @@ -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 diff --git a/packages/app/modules/paseo-scroll/ios/PaseoScrollViewComponentView.mm b/packages/app/modules/paseo-scroll/ios/PaseoScrollViewComponentView.mm new file mode 100644 index 0000000000..6b9ca95435 --- /dev/null +++ b/packages/app/modules/paseo-scroll/ios/PaseoScrollViewComponentView.mm @@ -0,0 +1,148 @@ +#import +#import +#import +#import +#import +#import + +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(*_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(*_props); + UIScrollView *scrollView = self.scrollView; + if (!props.maintainVisibleContentPosition || scrollView.contentSize.width > self.frame.size.width) { + return nil; + } + NSArray *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 diff --git a/packages/app/package.json b/packages/app/package.json index 42d791fbf4..1447bef2f7 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/agent-stream/bottom-anchor-controller.test.ts b/packages/app/src/agent-stream/bottom-anchor-controller.test.ts index 17b6d44459..12ff114521 100644 --- a/packages/app/src/agent-stream/bottom-anchor-controller.test.ts +++ b/packages/app/src/agent-stream/bottom-anchor-controller.test.ts @@ -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, diff --git a/packages/app/src/agent-stream/bottom-anchor-controller.ts b/packages/app/src/agent-stream/bottom-anchor-controller.ts index 93b3c075e7..84535caaf5 100644 --- a/packages/app/src/agent-stream/bottom-anchor-controller.ts +++ b/packages/app/src/agent-stream/bottom-anchor-controller.ts @@ -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); }, diff --git a/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts b/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts index 1b4b8d18e8..876c615577 100644 --- a/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts +++ b/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts @@ -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; }, diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx index 890f6eaae7..1ca917f0b3 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx @@ -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 = { diff --git a/packages/app/src/agent-stream/disclosure.tsx b/packages/app/src/agent-stream/disclosure.tsx new file mode 100644 index 0000000000..b35e67e567 --- /dev/null +++ b/packages/app/src/agent-stream/disclosure.tsx @@ -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(applyInPlace); + +export function DisclosureRow({ + rowId, + viewportRef, + children, +}: { + rowId: string; + viewportRef: RefObject; + children: ReactNode; +}) { + const hold = useMemo( + () => (apply) => { + const viewport = viewportRef.current; + if (viewport) { + viewport.holdRow(rowId, apply); + } else { + apply(); + } + }, + [rowId, viewportRef], + ); + return {children}; +} + +/** Call with the toggle's state change: `hold(() => setExpanded(!expanded))`. */ +export function useDisclosureHold(): DisclosureHold { + return useContext(DisclosureHoldContext); +} diff --git a/packages/app/src/agent-stream/footer-spacing.test.ts b/packages/app/src/agent-stream/footer-spacing.test.ts index 341ce75583..e3e28ab0bc 100644 --- a/packages/app/src/agent-stream/footer-spacing.test.ts +++ b/packages/app/src/agent-stream/footer-spacing.test.ts @@ -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 { diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index a86817b6a7..fde9a3a342 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -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", () => { diff --git a/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx b/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx new file mode 100644 index 0000000000..cc52429b23 --- /dev/null +++ b/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx @@ -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 { + return act(() => new Promise((resolve) => setTimeout(resolve, ms))); +} + +const LIST_STYLE = { flex: 1 }; +const rowStyles = new Map(); + +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(); +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 ; +} + +function renderRow({ item }: { item: Row }) { + return ; +} + +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(); + const maintainVisibleContentPosition = holdOnly(heldRowId); + const render = (data: Row[]) => + act(() => + root.render( + , + ), + ); + const scrollElement = () => { + const scroller = [...host.querySelectorAll("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); +}); diff --git a/packages/app/src/agent-stream/model.test.ts b/packages/app/src/agent-stream/model.test.ts index 9c6ef3384d..bf6ce27f75 100644 --- a/packages/app/src/agent-stream/model.test.ts +++ b/packages/app/src/agent-stream/model.test.ts @@ -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, diff --git a/packages/app/src/agent-stream/native-disclosure-hold.test.ts b/packages/app/src/agent-stream/native-disclosure-hold.test.ts new file mode 100644 index 0000000000..b732edbb01 --- /dev/null +++ b/packages/app/src/agent-stream/native-disclosure-hold.test.ts @@ -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 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([]); + }); +}); diff --git a/packages/app/src/agent-stream/native-disclosure-hold.ts b/packages/app/src/agent-stream/native-disclosure-hold.ts new file mode 100644 index 0000000000..96b5087b98 --- /dev/null +++ b/packages/app/src/agent-stream/native-disclosure-hold.ts @@ -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; + }, + }; +} diff --git a/packages/app/src/agent-stream/native-end-pin.tsx b/packages/app/src/agent-stream/native-end-pin.tsx new file mode 100644 index 0000000000..dced2bf4a0 --- /dev/null +++ b/packages/app/src/agent-stream/native-end-pin.tsx @@ -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( + function EndPinnedScrollView({ pinEnd, children, maintainVisibleContentPosition, ...rest }, ref) { + return ( + + {pinEnd ? ( + + ) : null} + {children} + + ); + }, +); diff --git a/packages/app/src/agent-stream/native-scroll-animation.test.ts b/packages/app/src/agent-stream/native-scroll-animation.test.ts index 947b8c3d1d..10508154a8 100644 --- a/packages/app/src/agent-stream/native-scroll-animation.test.ts +++ b/packages/app/src/agent-stream/native-scroll-animation.test.ts @@ -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); }); }); diff --git a/packages/app/src/agent-stream/native-scroll-animation.ts b/packages/app/src/agent-stream/native-scroll-animation.ts index 72bc39108c..bd5a4966b6 100644 --- a/packages/app/src/agent-stream/native-scroll-animation.ts +++ b/packages/app/src/agent-stream/native-scroll-animation.ts @@ -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; } diff --git a/packages/app/src/agent-stream/near-bottom.ts b/packages/app/src/agent-stream/near-bottom.ts new file mode 100644 index 0000000000..19de04b11a --- /dev/null +++ b/packages/app/src/agent-stream/near-bottom.ts @@ -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); +} diff --git a/packages/app/src/agent-stream/new-row-estimates.test.ts b/packages/app/src/agent-stream/new-row-estimates.test.ts new file mode 100644 index 0000000000..d38c3fe3e0 --- /dev/null +++ b/packages/app/src/agent-stream/new-row-estimates.test.ts @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + estimates.noteRows([]); + const first = row("a"); + estimates.noteRows([first]); + estimates.noteMeasured(first, 34); + + expect(estimates.estimate(first)).toBeUndefined(); + }); +}); diff --git a/packages/app/src/agent-stream/new-row-estimates.ts b/packages/app/src/agent-stream/new-row-estimates.ts new file mode 100644 index 0000000000..f13a32697a --- /dev/null +++ b/packages/app/src/agent-stream/new-row-estimates.ts @@ -0,0 +1,47 @@ +interface EstimatedRow { + id: string; + kind: string; +} + +export interface NewRowEstimates { + /** 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(): NewRowEstimates { + let previousRowIds: ReadonlySet | null = null; + const unmeasuredRowIds = new Set(); + const firstSizeByKind = new Map(); + + 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); + }, + }; +} diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index 94140ca395..9a5ff6469e 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -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; diff --git a/packages/app/src/agent-stream/reading-anchor.test.ts b/packages/app/src/agent-stream/reading-anchor.test.ts index d7d1b2beb4..30d30ab1a0 100644 --- a/packages/app/src/agent-stream/reading-anchor.test.ts +++ b/packages/app/src/agent-stream/reading-anchor.test.ts @@ -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); diff --git a/packages/app/src/agent-stream/reading-anchor.ts b/packages/app/src/agent-stream/reading-anchor.ts index aa915c6ae7..ed2baa552a 100644 --- a/packages/app/src/agent-stream/reading-anchor.ts +++ b/packages/app/src/agent-stream/reading-anchor.ts @@ -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) { + anchor = { id: row.id, top: row.top }; + }, scroll(scrollTop: number) { const next = readingRow(scrollTop); anchor = next ? { id: next.id, top: next.top } : null; diff --git a/packages/app/src/agent-stream/render-strategy.test.ts b/packages/app/src/agent-stream/render-strategy.test.ts index 59a2f7aed3..5e5ab7f7a2 100644 --- a/packages/app/src/agent-stream/render-strategy.test.ts +++ b/packages/app/src/agent-stream/render-strategy.test.ts @@ -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", - ); }); }); diff --git a/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.test.ts b/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.test.ts index 9d22d2b90c..d459ca8ba8 100644 --- a/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.test.ts +++ b/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.test.ts @@ -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); }); }); diff --git a/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts b/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts index 73e0d19a76..374bb84762 100644 --- a/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts +++ b/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts @@ -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, }; } diff --git a/packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts b/packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts index fb78eea9b5..e0455687ea 100644 --- a/packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts +++ b/packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts @@ -18,7 +18,7 @@ type ScrollEvent = NativeSyntheticEvent; /** * 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() { diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 7dc6e8d521..f894880d23 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -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 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>(null); + const listRef = useRef(null); + // LegendList places the rows of each agent once, behind a hidden reveal (`dataKey`). + const [placedAgentId, setPlacedAgentId] = useState(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(null); + const isReleasedAtEndRef = useRef(false); const programmaticScrollEventBudgetRef = useRef(0); - const isNativeViewportSettlingRef = useRef(false); - const nativeViewportSettlingFrameIdRef = useRef(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()); + 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(() => 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) => , + [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) => { @@ -384,6 +472,17 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }, ); + const isScrollEventAtEnd = useStableEvent((event: NativeSyntheticEvent) => { + 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) => { 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) => { - 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): ReactElement | null => { - const rendered = renderHistoryMountedRow(item, index, historyItems); - return (rendered ?? null) as ReactElement | null; - }, + ({ item, index }: LegendListRenderItemProps): ReactElement => ( + + {liveRowIds.has(item.id) + ? renderLiveHeadRow(item, index - historyRows.length, segments.liveHead) + : renderHistoryMountedRow(item, index, historyItems)} + + ), ); - 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) => ( - {renderLiveHeadRow(item, index, segments.liveHead)} - )); + 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 ( - - {liveHeadRows} - {liveAuxiliary} - - ); - }, [ - boundary, - listEmptyComponent, - liveHeadRowRevision, - renderLiveAuxiliary, - renderLiveHeadRow, - segments.liveHead, - ]); + return {liveAuxiliary}; + }, [boundary, listEmptyComponent, liveRows.length, renderLiveAuxiliary]); - const historyFooterContent = useMemo(() => { + const historyStartContent = useMemo(() => { const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState); return ( @@ -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 ( - + 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) => , - 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; } diff --git a/packages/app/src/agent-stream/strategy-web.browser.test.tsx b/packages/app/src/agent-stream/strategy-web.browser.test.tsx new file mode 100644 index 0000000000..28fbd1e2a2 --- /dev/null +++ b/packages/app/src/agent-stream/strategy-web.browser.test.tsx @@ -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 { + 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(); + +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
{item.id}
; +} + +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(); + 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) => + root.render(strategy.render({ ...input, segments: { ...input.segments, ...segments } })); + act(() => render({})); + + const scrollContainer = host.querySelector('[data-testid="agent-chat-scroll"]'); + if (!scrollContainer) throw new Error("Expected agent chat scroll container"); + const rowElement = (id: string) => { + const element = host.querySelector(`[data-history-row-id="${id}"]`); + if (!element) throw new Error(`Expected row ${id}`); + return element; + }; + + return { + render: (segments: Partial) => 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); + }); +}); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 9dae42bcc0..904ab9c157 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -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("[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("[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", diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 364eba1043..d1d40d351f 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -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 | 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[]; diff --git a/packages/app/src/agent-stream/stream-row.browser.test.tsx b/packages/app/src/agent-stream/stream-row.browser.test.tsx new file mode 100644 index 0000000000..7608704d2d --- /dev/null +++ b/packages/app/src/agent-stream/stream-row.browser.test.tsx @@ -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 = () => {turnOpen ? "open" : "closed"}; + const viewportRef = createRef(); + 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"); +}); diff --git a/packages/app/src/agent-stream/stream-row.tsx b/packages/app/src/agent-stream/stream-row.tsx new file mode 100644 index 0000000000..874b032f1d --- /dev/null +++ b/packages/app/src/agent-stream/stream-row.tsx @@ -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; +}) { + return ( + + {renderStreamItem(item, layoutItem)} + + ); +}); + +export function renderStreamRow(input: { + item: StreamItem; + layoutItemById: Map; + renderStreamItem: RenderStreamItem; + viewportRef: RefObject; +}): ReactNode { + const layoutItem = input.layoutItemById.get(input.item.id); + if (!layoutItem) { + return null; + } + return ( + + ); +} diff --git a/packages/app/src/agent-stream/top-overlay.ts b/packages/app/src/agent-stream/top-overlay.ts index 24cd7f9592..12652580e2 100644 --- a/packages/app/src/agent-stream/top-overlay.ts +++ b/packages/app/src/agent-stream/top-overlay.ts @@ -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); diff --git a/packages/app/src/agent-stream/turn-collapse/model.test.ts b/packages/app/src/agent-stream/turn-collapse/model.test.ts index eca6d8757c..d46085f95d 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.test.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.test.ts @@ -41,6 +41,7 @@ function collapse(tail: StreamItem[], options: Partial = {}) isLatestTurnOpen: false, expandedHostIds: new Set(), getGroupedCalls: () => undefined, + previousTurnsByHostId: new Map(), ...options, }); } @@ -48,6 +49,37 @@ function collapse(tail: StreamItem[], options: Partial = {}) 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"); diff --git a/packages/app/src/agent-stream/turn-collapse/model.ts b/packages/app/src/agent-stream/turn-collapse/model.ts index a5985e600f..167a7cc219 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.ts @@ -27,6 +27,11 @@ export interface TurnCollapse { hostIdByFoldedMessageId: ReadonlyMap; /** Mounted rows replaced by a closed turn's host, for preserving the reading position. */ hostIdByHiddenRowId: ReadonlyMap; + /** + * 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; } export interface CollapseTurnsInput { @@ -36,6 +41,8 @@ export interface CollapseTurnsInput { expandedHostIds: ReadonlySet; /** 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; } const EMPTY_TURNS: ReadonlyMap = 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, b: Record): boolean { + const keys = Object.keys(a); + return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]); +} + +function findUpdatedHosts( + previous: ReadonlyMap, + next: ReadonlyMap, +): ReadonlySet { + const updated = new Set(); + 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, }; } diff --git a/packages/app/src/agent-stream/turn-collapse/summary-line.tsx b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx index 191ac706d5..22660b4606 100644 --- a/packages/app/src/agent-stream/turn-collapse/summary-line.tsx +++ b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx @@ -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 ( diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 72923b4212..15d9435fd7 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -74,6 +74,8 @@ import { TurnSummaryLine } from "./turn-collapse/summary-line"; import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model"; import { resolveStreamRenderStrategy } from "./strategy-resolver"; import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy"; +import { renderStreamRow, type RenderStreamItem } from "./stream-row"; +import { type NearBottom, useIsNearBottom, useNearBottom } from "./near-bottom"; import { ChatOutlineRail } from "@/agent-stream/chat-outline/rail"; import { useChatOutline } from "@/agent-stream/chat-outline/use-chat-outline"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; @@ -230,41 +232,6 @@ function renderListEmptyComponent(input: { ); } -// Live and retained rows use the same boundary so completion preserves the message -// subtree, including measured diagrams and selections. FlatList recreates history -// cells with fresh refs and shifted indexes on data changes. This boundary is -// what stops that churn: 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). -const StreamRow = memo(function StreamRow({ - layoutItem, - renderStreamItem, -}: { - item: StreamItem; - layoutItem: StreamLayoutItem; - renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode; -}) { - return <>{renderStreamItem(layoutItem)}; -}); - -function renderStreamRow(input: { - item: StreamItem; - layoutItemById: Map; - renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode; -}): ReactNode { - const layoutItem = input.layoutItemById.get(input.item.id); - if (!layoutItem) { - return null; - } - return ( - - ); -} - export interface AgentStreamViewHandle { scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void; prepareForViewportChange(): void; @@ -324,6 +291,47 @@ function resolveBottomOverlayControlOffset(clearance: number | undefined): numbe return Math.max(16, clearance ?? 0); } +// Keep entry/exit animations off on Android due to RN dispatchDraw crashes +// tracked in react-native-reanimated#8422. +const shouldDisableEntryExitAnimations = Platform.OS === "android"; +const scrollIndicatorFadeIn = shouldDisableEntryExitAnimations ? undefined : FadeIn.duration(200); +const scrollIndicatorFadeOut = shouldDisableEntryExitAnimations ? undefined : FadeOut.duration(200); + +function ScrollToBottomButton({ + nearBottom, + isTimelineDetached, + bottomOffset, + onPress, +}: { + nearBottom: NearBottom; + isTimelineDetached: boolean; + bottomOffset: number; + onPress: () => void; +}) { + const { t } = useTranslation(); + const isNearBottom = useIsNearBottom(nearBottom); + const containerStyle = useMemo( + () => [stylesheet.scrollToBottomContainer, { bottom: bottomOffset }], + [bottomOffset], + ); + if (isNearBottom && !isTimelineDetached) return null; + return ( + + + + + + + + ); +} + const AgentStreamViewComponent = forwardRef( function AgentStreamView( { @@ -365,7 +373,7 @@ const AgentStreamViewComponent = forwardRef>( new Set(), ); @@ -422,18 +430,7 @@ const AgentStreamViewComponent = forwardRef { - setIsNearBottom(true); setExpandedInlineToolCallIds(new Set()); setExpandedTurnHostIds(new Set()); }, [agentId]); @@ -675,9 +672,8 @@ const AgentStreamViewComponent = forwardRef { - // A turn opens below its summary line; keep that line under the reader's pointer. - viewportRef.current?.holdReadingPosition(); setExpandedTurnHostIds((previous) => { if (previous.has(hostId) === expanded) return previous; const next = new Set(previous); @@ -928,8 +924,7 @@ const AgentStreamViewComponent = forwardRef { - const item = layoutItem.item; + (item: StreamItem, layoutItem: StreamLayoutItem) => { switch (item.kind) { case "user_message": return renderUserMessageItem(layoutItem, item); @@ -990,9 +985,9 @@ const AgentStreamViewComponent = forwardRef { - const content = renderStreamItemContent(layoutItem); + const renderStreamItem = useCallback( + (item, layoutItem) => { + const content = renderStreamItemContent(item, layoutItem); return renderStreamItemWithTurnFooter({ content, layoutItem, @@ -1061,13 +1056,7 @@ const AgentStreamViewComponent = forwardRef [stylesheet.emptyState, stylesheet.contentWrapper], []); - const scrollToBottomContainerStyle = useMemo( - () => [ - stylesheet.scrollToBottomContainer, - { bottom: resolveBottomOverlayControlOffset(bottomOverlayControlClearance) }, - ], - [bottomOverlayControlClearance], - ); + const scrollToBottomOffset = resolveBottomOverlayControlOffset(bottomOverlayControlClearance); const listEmptyComponent = useMemo( () => renderListEmptyComponent({ @@ -1110,6 +1099,7 @@ const AgentStreamViewComponent = forwardRef(() => { @@ -1165,10 +1156,13 @@ const AgentStreamViewComponent = forwardRef ({ - contentById: presentation.historyGroupUpdatesByHostId, + contentById: { + has: (id: string) => + presentation.historyGroupUpdatesByHostId.has(id) || updatedTurnHostIds.has(id), + }, displayStateById: { // An opened lone call also marks its future group open; that row stays unrevised // until a group actually forms there. @@ -1186,6 +1180,7 @@ const AgentStreamViewComponent = forwardRef - {(!isNearBottom || isTimelineDetached) && ( - - - - - - - - )} + @@ -1789,10 +1775,6 @@ const stylesheet = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, }, - invertedWrapper: { - transform: [{ scaleY: -1 }], - width: "100%", - }, emptyStateText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 0ec5cbfb44..e73dd4171f 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1,3 +1,4 @@ +import { useDisclosureHold } from "@/agent-stream/disclosure"; import { MessagePreview, MessageSender } from "@/components/user-message"; import { useStableEvent } from "@/hooks/use-stable-event"; import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content"; @@ -1483,9 +1484,9 @@ export const TodoListCard = memo(function TodoListCard({ }; }, [activity, t]); - const handleToggle = useCallback(() => { - setIsExpanded((prev) => !prev); - }, []); + const hold = useDisclosureHold(); + const toggleExpanded = useCallback(() => setIsExpanded((prev) => !prev), []); + const handleToggle = useCallback(() => hold(toggleExpanded), [hold, toggleExpanded]); const renderDetails = useCallback(() => { return ( @@ -2279,6 +2280,7 @@ export const ToolCall = memo(function ToolCall({ const isMobile = useIsCompactFormFactor(); const shouldRenderInline = !isMobile || forceInline; + const hold = useDisclosureHold(); const effectiveDetail = useMemo(() => { if (detail) { @@ -2330,9 +2332,10 @@ export const ToolCall = memo(function ToolCall({ showLoadingSkeleton: presentation.isLoadingDetails, }); } else { - toggleExpanded(); + hold(toggleExpanded); } }, [ + hold, shouldRenderInline, toggleExpanded, openToolCall, diff --git a/packages/app/src/keyboard/shift/index.ts b/packages/app/src/keyboard/shift/index.ts index e54fc7ace9..e570bd3fee 100644 --- a/packages/app/src/keyboard/shift/index.ts +++ b/packages/app/src/keyboard/shift/index.ts @@ -1,5 +1,4 @@ export { KeyboardShiftProvider, useKeyboardShiftStyle } from "./internal/provider"; -export { useKeyboardShift, useSettledKeyboardShift } from "./internal/context"; +export { useKeyboardShift } from "./internal/context"; export { KeyboardTranslateView } from "./internal/translate"; export { useKeyboardAnchor } from "./internal/anchor"; -export { useKeyboardStreamInset } from "./internal/stream"; diff --git a/packages/app/src/keyboard/shift/internal/context.ts b/packages/app/src/keyboard/shift/internal/context.ts index f010c01255..bc1df21fb4 100644 --- a/packages/app/src/keyboard/shift/internal/context.ts +++ b/packages/app/src/keyboard/shift/internal/context.ts @@ -41,7 +41,6 @@ export const KeyboardTranslationContext = createContext(null); /** Read the app-wide keyboard inset without loading its native provider implementation. */ export function useKeyboardShift(): KeyboardShiftContextValue { @@ -51,12 +50,3 @@ export function useKeyboardShift(): KeyboardShiftContextValue { } return context; } - -/** Read the keyboard inset published after native keyboard motion has settled. */ -export function useSettledKeyboardShift(): number { - const context = useContext(SettledKeyboardShiftContext); - if (context === null) { - throw new Error("useSettledKeyboardShift must be used inside KeyboardShiftProvider"); - } - return context; -} diff --git a/packages/app/src/keyboard/shift/internal/policy.test.ts b/packages/app/src/keyboard/shift/internal/policy.test.ts index 6867f3ffa1..51387eba43 100644 --- a/packages/app/src/keyboard/shift/internal/policy.test.ts +++ b/packages/app/src/keyboard/shift/internal/policy.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { - resolveStreamKeyboardInset, shouldReconcileHiddenKeyboardEnd, resolveKeyboardLift, resolveKeyboardShift, @@ -29,29 +28,6 @@ describe("keyboard layout reservation", () => { }); }); -describe("resolveStreamKeyboardInset", () => { - it("uses the native scroll inset on iOS without changing content size", () => { - expect(resolveStreamKeyboardInset({ platform: "ios", settledShift: 311 })).toEqual({ - contentContainerPaddingBottom: 0, - contentInset: { bottom: 311 }, - }); - }); - - it("keeps Android's exact content-container padding behavior", () => { - expect(resolveStreamKeyboardInset({ platform: "android", settledShift: 311 })).toEqual({ - contentContainerPaddingBottom: 311, - contentInset: undefined, - }); - }); - - it("does not expose a negative inset", () => { - expect(resolveStreamKeyboardInset({ platform: "ios", settledShift: -1 })).toEqual({ - contentContainerPaddingBottom: 0, - contentInset: { bottom: 0 }, - }); - }); -}); - describe("resolveKeyboardShift", () => { it("keeps the existing open-keyboard offset behavior", () => { expect( diff --git a/packages/app/src/keyboard/shift/internal/policy.ts b/packages/app/src/keyboard/shift/internal/policy.ts index 4e618c8a49..c7d79bd9fa 100644 --- a/packages/app/src/keyboard/shift/internal/policy.ts +++ b/packages/app/src/keyboard/shift/internal/policy.ts @@ -10,26 +10,6 @@ export function reserveKeyboardLayoutShift(input: { return input.phase === "start" ? Math.max(input.current, input.target) : input.target; } -export function resolveStreamKeyboardInset(input: { - platform: "android" | "ios"; - settledShift: number; -}): { - contentContainerPaddingBottom: number; - contentInset: { bottom: number } | undefined; -} { - const settledShift = Math.max(0, input.settledShift); - if (input.platform === "ios") { - return { - contentContainerPaddingBottom: 0, - contentInset: { bottom: settledShift }, - }; - } - return { - contentContainerPaddingBottom: settledShift, - contentInset: undefined, - }; -} - export function resolveKeyboardShift(input: { rawKeyboardHeight: number; keyboardProgress: number; diff --git a/packages/app/src/keyboard/shift/internal/provider.ts b/packages/app/src/keyboard/shift/internal/provider.ts index 7b2ff46eae..a99a148aef 100644 --- a/packages/app/src/keyboard/shift/internal/provider.ts +++ b/packages/app/src/keyboard/shift/internal/provider.ts @@ -1,4 +1,4 @@ -import { createElement, useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { createElement, useEffect, useMemo, type ReactNode } from "react"; import { Platform } from "react-native"; import type { ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -8,19 +8,17 @@ import { } from "react-native-keyboard-controller"; import { useAnimatedStyle, - useAnimatedReaction, useDerivedValue, useSharedValue, type SharedValue, } from "react-native-reanimated"; -import { scheduleOnRN } from "react-native-worklets"; import { DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, resolveKeyboardShift, reserveKeyboardLayoutShift, shouldReconcileHiddenKeyboardEnd, } from "./policy"; -import { KeyboardShiftContext, SettledKeyboardShiftContext, useKeyboardShift } from "./context"; +import { KeyboardShiftContext, useKeyboardShift } from "./context"; type KeyboardShiftMode = "translate" | "padding"; @@ -31,11 +29,6 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { const isIos = Platform.OS === "ios"; const isMoving = useSharedValue(false); const layoutShift = useSharedValue(0); - const [settledShift, setSettledShift] = useState(0); - const publishSettledShift = useCallback((nextShift: number) => { - setSettledShift((currentShift) => (currentShift === nextShift ? currentShift : nextShift)); - }, []); - useEffect(() => { bottomInset.value = insets.bottom; }, [bottomInset, insets.bottom]); @@ -93,16 +86,6 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { [bottomInset, isIos, isMoving, keyboardHeight, keyboardProgress, layoutShift], ); - useAnimatedReaction( - () => ({ moving: isMoving.value, shift: shift.value }), - (current, previous) => { - if (!current.moving && (previous === null || previous.moving)) { - scheduleOnRN(publishSettledShift, current.shift); - } - }, - [isMoving, publishSettledShift, shift], - ); - const value = useMemo( () => ({ shift, @@ -113,11 +96,7 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { [bottomInset, isMoving, layoutShift, shift], ); - return createElement( - KeyboardShiftContext.Provider, - { value }, - createElement(SettledKeyboardShiftContext.Provider, { value: settledShift }, children), - ); + return createElement(KeyboardShiftContext.Provider, { value }, children); } export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): { diff --git a/packages/app/src/keyboard/shift/internal/stream.ts b/packages/app/src/keyboard/shift/internal/stream.ts deleted file mode 100644 index 45fe167fe4..0000000000 --- a/packages/app/src/keyboard/shift/internal/stream.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useMemo } from "react"; -import { Platform } from "react-native"; -import { useSettledKeyboardShift } from "./context"; -import { resolveStreamKeyboardInset } from "./policy"; - -/** Preserve the translated inverted list's far-end scroll range only after motion settles. */ -export function useKeyboardStreamInset() { - const settledShift = useSettledKeyboardShift(); - return useMemo( - () => - resolveStreamKeyboardInset({ - platform: Platform.OS === "ios" ? "ios" : "android", - settledShift, - }), - [settledShift], - ); -} diff --git a/packages/app/src/tool-calls/detail-level/overview/view.tsx b/packages/app/src/tool-calls/detail-level/overview/view.tsx index b11e82e4f7..b05fbe1edd 100644 --- a/packages/app/src/tool-calls/detail-level/overview/view.tsx +++ b/packages/app/src/tool-calls/detail-level/overview/view.tsx @@ -2,6 +2,7 @@ import React, { memo, useCallback, useRef, type ReactNode } from "react"; import { ScrollView } from "react-native"; import { Wrench } from "lucide-react-native"; import { StyleSheet } from "react-native-unistyles"; +import { useDisclosureHold } from "@/agent-stream/disclosure"; import { IndentRail } from "@/components/indent-rail"; import { ExpandableBadge } from "@/components/message"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -32,9 +33,13 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView const scrollToLatest = useCallback(() => { scrollRef.current?.scrollToEnd({ animated: false }); }, []); + const hold = useDisclosureHold(); const toggle = useCallback(() => { - onExpandedChange(group.run.id, !expanded); - }, [expanded, group.run.id, onExpandedChange]); + const apply = () => onExpandedChange(group.run.id, !expanded); + // Compact opens a sheet; nothing in the timeline moves. + if (isCompact) apply(); + else hold(apply); + }, [expanded, group.run.id, hold, isCompact, onExpandedChange]); const close = useCallback(() => { onExpandedChange(group.run.id, false); }, [group.run.id, onExpandedChange]); diff --git a/packages/app/test-stubs/lucide-react-native.ts b/packages/app/test-stubs/lucide-react-native.ts index a85976e1bf..e07f0b05e7 100644 --- a/packages/app/test-stubs/lucide-react-native.ts +++ b/packages/app/test-stubs/lucide-react-native.ts @@ -11,6 +11,7 @@ const StubIcon: LucideIcon = () => null; export const Activity = StubIcon; export const AlertTriangle = StubIcon; export const Archive = StubIcon; +export const ArrowDown = StubIcon; export const ArrowDownUp = StubIcon; export const ArrowLeft = StubIcon; export const ArrowLeftToLine = StubIcon; diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index f63a3b73c5..69360fa7ee 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -39,10 +39,12 @@ export default defineConfig({ name: "compile-react-components", enforce: "pre", transform(source, id) { - // Exercise compiler-sensitive labels and overlay lifetime in the same form as Expo. + // Exercise compiler-sensitive labels, overlay lifetime and timeline row revisions in the same + // form as Expo. const usageComponent = id.includes("/src/usage/") && id.endsWith(".tsx"); const overlayLayer = id.endsWith("/src/lib/overlay-root.ts"); - if ((!usageComponent && !overlayLayer) || id.includes(".test.")) return; + const streamRow = id.endsWith("/src/agent-stream/stream-row.tsx"); + if ((!usageComponent && !overlayLayer && !streamRow) || id.includes(".test.")) return; const result = transformSync(source, { filename: id, babelrc: false, @@ -95,6 +97,17 @@ export default defineConfig({ }, { extends: true, + resolve: { + alias: [ + // The app renders LegendList on native only, from the patched native build. Browser + // tests render that build too, instead of the package's separate web build. Unit tests + // load it outside Vite, where its react-native import would miss the web alias. + { + find: /^@legendapp\/list\/react-native$/, + replacement: path.resolve(rootNodeModules, "@legendapp/list/react-native.mjs"), + }, + ], + }, test: { name: "browser", fileParallelism: false, @@ -165,6 +178,8 @@ export default defineConfig({ "expo-router/react-navigation", "@testing-library/react", "@tanstack/react-query", + "@tanstack/react-virtual", + "@legendapp/list/react-native", "react-i18next", "i18next", "@marijn/find-cluster-break", diff --git a/patches/@legendapp+list+3.7.1.patch b/patches/@legendapp+list+3.7.1.patch new file mode 100644 index 0000000000..3a59c09550 --- /dev/null +++ b/patches/@legendapp+list+3.7.1.patch @@ -0,0 +1,548 @@ +# LegendList 3.7.1 for the native chat timeline (docs/agent-stream-disclosure.md). +# Upstream: https://github.com/LegendApp/legend-list/issues/593 covers every change below. +# react-native.js and react-native.mjs carry the same hunks; each has a "Paseo patch" comment. +# +# 1. getEstimatedItemSize (react-native.d.ts, getItemSize, LegendListInner props): a per-item +# estimate for an unmeasured row wins over the running average for its type. +# 2. MVCP anchor lock on native (canRestoreAnchorLock, resolveAnchorLock, updateAnchorLock, +# prepareMVCP): upstream enables it on web only. On native the lock ends when its row leaves +# the view or shouldRestorePosition rejects it, instead of after 300 ms. +# 3. Preserved initial end only with maintainScrollAtEnd (finishScrollTo, +# finishBootstrapInitialScrollWithoutScroll): otherwise every data change re-arms the initial +# scroll to the end. +# 4. Silent initial scroll retry in one task (checkFinishedScrollFallback): the nudge and the return +# to the target go out together, so Android does not reveal 1 dp short of the end. +# 5. Layout noise against the floored known size (isNativeLayoutNoise, updateOneItemSize): a +# one-pixel snap change is not a resize. +# 6. Content height from React state on the New Architecture (useContentSize, ContainersLayer): +# the Animated height mounts the estimated layout for a frame. +# 7. Header and footer revealed with the rows (RevealedLayoutView, ListComponent). +# 8. Top-padding shrink hold on iOS only (setPaddingTop): Android paints the held height as blank +# space at the end. +# 9. Rows cut off at the top count as in view (getIdsInVisibleRange): they follow the fully +# visible rows in idsInView, so a disclosure hold on a row whose top is above the viewport +# still anchors on that row instead of the row below it. +# 10. A size change keeps only a row shouldRestorePosition accepts in place (prepareMVCP), as a +# data change already does, so a disclosure that only grows (a todo card) holds the cut-off +# row instead of the first row in view. +# +# Upgrading: drop each hunk whose fix the new version ships, regenerate with +# `npx patch-package @legendapp/list`, and copy this header back on top. +diff --git a/node_modules/@legendapp/list/react-native.d.ts b/node_modules/@legendapp/list/react-native.d.ts +index 983e937..970ba51 100644 +--- a/node_modules/@legendapp/list/react-native.d.ts ++++ b/node_modules/@legendapp/list/react-native.d.ts +@@ -204,6 +204,11 @@ interface LegendListSpecificProps { + * Change extraData when the fixed sizes returned by this callback change. + */ + getFixedItemSize?: (item: ItemT, index: number, type: TItemType) => number | undefined; ++ /** ++ * Paseo patch: estimated size of an item that has not been measured yet. Return undefined to ++ * fall back to the average size for its type. ++ */ ++ getEstimatedItemSize?: (item: ItemT, index: number, type: TItemType) => number | undefined; + /** + * Returns a stable item type used for pooling and size estimation. + */ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index eeb3fb0..b1689ad 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -1497,7 +1497,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no + return renderedSize; + } + const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; +- if (useAverageSize && !scrollingTo) { ++ // Paseo patch: a caller estimate for an unmeasured item wins over the running average for its ++ // type, so a row new to the list can be laid out near its real size before it is measured. ++ if (state.props.getEstimatedItemSize) { ++ const estimate = state.props.getEstimatedItemSize(data, index, itemType); ++ if (estimate !== void 0) { ++ size = estimate + ctx.scrollAxisGap; ++ } ++ } ++ if (size === void 0 && useAverageSize && !scrollingTo) { + const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg; + if (averageSizeForType !== void 0) { + size = roundSize(averageSizeForType); +@@ -1570,6 +1578,12 @@ var MVCP_POSITION_EPSILON = 0.1; + var MVCP_ANCHOR_LOCK_TTL_MS = 300; + var MVCP_ANCHOR_LOCK_QUIET_PASSES_TO_RELEASE = 2; + var NATIVE_END_CLAMP_EPSILON = 1; ++function canRestoreAnchorLock(state, id) { ++ const shouldRestorePosition = state.props.maintainVisibleContentPosition.shouldRestorePosition; ++ const index = state.indexByKey.get(id); ++ const data = state.props.data; ++ return !shouldRestorePosition || index !== void 0 && data[index] !== void 0 && shouldRestorePosition(data[index], index, data); ++} + function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) { + if (!enableMVCPAnchorLock) { + state.mvcpAnchorLock = void 0; +@@ -1579,7 +1593,13 @@ function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) { + if (!lock) { + return void 0; + } +- const isExpired = now > lock.expiresAt; ++ // Paseo patch: native keeps the anchor of a data change through the size passes that follow, as ++ // web does. Upstream limits the lock to web (d24a9d93 "feat: (web) add MVCP anchor lock") without a ++ // native reason. Without it, the pass that applies the measured sizes of prepended rows re-anchors ++ // on the first row in view, which can be one of those rows, and its growth moves the rows below. ++ // A wall-clock expiry is wrong on native, where the measurements arrive after the native mount ++ // and a slow render; the lock ends when its row leaves the view or may not be restored instead. ++ const isExpired = Platform.OS === "web" ? now > lock.expiresAt : !state.idsInView.includes(lock.id) || !canRestoreAnchorLock(state, lock.id); + const isMissing = state.indexByKey.get(lock.id) === void 0; + if (isExpired || isMissing || !mvcpData) { + state.mvcpAnchorLock = void 0; +@@ -1588,7 +1608,7 @@ function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) { + return lock; + } + function updateAnchorLock(state, params) { +- if (Platform.OS === "web") { ++ { + const { anchorId, anchorPosition, dataChanged, now, positionDiff } = params; + const enableMVCPAnchorLock = !!dataChanged || !!state.mvcpAnchorLock; + const mvcpData = state.props.maintainVisibleContentPosition.data; +@@ -1713,7 +1733,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } = props; + const isWeb = Platform.OS === "web"; + const now = Date.now(); +- const enableMVCPAnchorLock = isWeb && (!!dataChanged || !!state.mvcpAnchorLock); ++ const enableMVCPAnchorLock = !!dataChanged || !!state.mvcpAnchorLock; + const scrollingTo = state.scrollingTo; + if ((scrollingTo == null ? void 0 : scrollingTo.key) !== void 0) { + const nextIndex = state.indexByKey.get(scrollingTo.key); +@@ -1723,7 +1743,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + state.mvcpAnchorLock = void 0; + return void 0; + } +- const anchorLock = isWeb ? resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) : void 0; ++ const anchorLock = resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now); + let prevPosition; + let targetId; + const idsInViewWithPositions = []; +@@ -1755,7 +1775,10 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } + targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ // Paseo patch: a size change keeps only a row shouldRestorePosition accepts in place, as a data ++ // change does. Upstream keeps the first row in view, so a disclosure hold on a row cut off at ++ // the top kept the row below it instead and moved the trigger by the row's size change. ++ targetId = idsInView.find((id) => indexByKey.get(id) !== void 0 && canRestoreAnchorLock(state, id)); + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +@@ -2371,7 +2394,10 @@ function finishScrollTo(ctx) { + }; + if (scrollingTo.isInitialScroll || state.initialScroll) { + const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; ++ // Paseo patch: only a list that keeps its end keeps landing on the initial end after the ++ // initial scroll. Without maintainScrollAtEnd, the preserved target re-arms the initial scroll ++ // on every data change, footer layout and viewport resize, and a stream never lets it clear. ++ const shouldPreserveResizeTarget = !!state.props.maintainScrollAtEnd && !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; + finishInitialScroll(ctx, { + onFinished: complete, + preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +@@ -2515,11 +2541,12 @@ function checkFinishedScrollFallback(ctx) { + const targetOffset = (_d = (_c = (_b = initialScrollWatchdog.get(state)) == null ? void 0 : _b.targetOffset) != null ? _c : isStillScrollingTo.targetOffset) != null ? _d : 0; + const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; + initialScrollCompletion.markSilentInitialScrollRetry(state); ++ // Paseo patch: send the nudge and the return to the target in one task, so native applies both before ++ // it draws and the pair still emits the scroll event this retry exists for. Upstream returns on the next ++ // frame, but the 16 ms check below can run first, finish the scroll and cancel that frame; the list then ++ // revealed 1 dp short of its end and moved into place a frame later. + scrollToFallbackOffset(ctx, jiggleOffset); +- state.scheduledWork.frame( +- () => scrollToFallbackOffset(ctx, targetOffset), +- "checkFinishedScrollRetryFrame" +- ); ++ scrollToFallbackOffset(ctx, targetOffset); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); +@@ -3378,7 +3405,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) { + var _a3; + const state = ctx.state; + clearBootstrapInitialScrollSession(state); +- const shouldPreserveResizeTarget = !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_a3 = state.initialScroll) == null ? void 0 : _a3.viewPosition) === 1; ++ // Paseo patch: see finishScrollTo; only a list that keeps its end preserves the initial end. ++ const shouldPreserveResizeTarget = !!state.props.maintainScrollAtEnd && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_a3 = state.initialScroll) == null ? void 0 : _a3.viewPosition) === 1; + finishInitialScroll(ctx, { + preserveTarget: shouldPreserveResizeTarget, + recalculateItems: true, +@@ -4569,6 +4597,17 @@ function getIdsInVisibleRange(state, range) { + const id = (_b = state.idCache[i]) != null ? _b : getId(state, i); + idsInView.push(id); + } ++ // Paseo patch: a row whose top is above the viewport is still in view. Upstream starts the ids at ++ // the first fully visible row, so a disclosure hold on a row cut off at the top finds no anchor ++ // (shouldRestorePosition rejects every other row) and the next size pass keeps the row below it ++ // in place instead. The partly visible rows go last, so the first fully visible row stays the ++ // anchor of every pass that does not restrict it. ++ if (range.startNoBuffer !== null && range.startNoBuffer < firstVisibleAnchorIndex) { ++ for (let i = range.startNoBuffer; i < firstVisibleAnchorIndex; i++) { ++ const id = (_b = state.idCache[i]) != null ? _b : getId(state, i); ++ idsInView.push(id); ++ } ++ } + } + return idsInView; + } +@@ -5176,8 +5215,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL + function isWithinEpsilon(delta) { + return Math.abs(delta) <= NATIVE_LAYOUT_MEASUREMENT_EPSILON; + } +-function isNativeLayoutNoise(delta) { +- return isWithinEpsilon(delta); ++// Paseo patch: sizesKnown holds roundSize values, floored to 1/8 dp, so the raw size behind a known ++// size lies in [known, known + 1/8). Upstream compares the raw size with the floored one, so a row ++// whose pixel-snapped height changes by one pixel, plus the floor, reads as a resize. On a cold open ++// that late resize moved every row a frame after the reveal. ++function isNativeLayoutNoise(rawSize, knownSize) { ++ const delta = rawSize - knownSize; ++ return delta >= -NATIVE_LAYOUT_MEASUREMENT_EPSILON && delta < NATIVE_LAYOUT_MEASUREMENT_EPSILON + 1 / 8; + } + + // src/core/updateItemSizes.ts +@@ -5414,7 +5458,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif + state.fixedItemSizeKeys.add(itemKey); + } + const prevSizeKnown = sizesKnown.get(itemKey); +- if (Platform.OS !== "web" && prevSizeKnown !== void 0 && isNativeLayoutNoise(rawSize - prevSizeKnown)) { ++ if (Platform.OS !== "web" && prevSizeKnown !== void 0 && isNativeLayoutNoise(rawSize, prevSizeKnown)) { + return 0; + } + const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize); +@@ -6283,6 +6327,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { + + // src/components/Containers.native.tsx + var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0; ++// Paseo patch: on the new architecture the content height comes from React state only. The ++// JS-driven Animated height writes through setNativeProps, which Fabric commits synchronously ++// mid-task, mounting React's estimated layout for a frame before the measured one. ++var useContentSize = IsNewArchitecture ? useCommittedMinSize : () => useValue$("totalSize"); + var ContainersLayer = typedMemo(function ContainersLayer2({ + children, + freshDataTransitionEpoch, +@@ -6290,7 +6338,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ + }) { + const ctx = useStateContext(); + const columnWrapperStyle = ctx.columnWrapperStyle; +- const size = useValue$("totalSize"); ++ const size = useContentSize(); + const minSize = useCommittedMinSize(); + const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); + const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); +@@ -6513,6 +6561,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont + null + ); + }); ++// Paseo patch: the header and footer reveal with the rows. Upstream hides only the containers until ++// readyToRender, so a cold open paints the header and footer at their estimated place a frame or more ++// before the rows appear around them. ++var RevealedLayoutView = function RevealedLayoutView2({ freshDataTransitionEpoch, style, ...rest }) { ++ const [readyToRender] = useArr$(["readyToRender"]); ++ const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); ++ return /* @__PURE__ */ React2__namespace.createElement(LayoutView, { ...rest, pointerEvents: isVisible ? "auto" : "none", style: isVisible ? style : [style, { opacity: 0 }] }); ++}; + var ListComponent = typedMemo(function ListComponent2({ + canRender, + freshDataTransitionEpoch, +@@ -6651,7 +6707,7 @@ var ListComponent = typedMemo(function ListComponent2({ + style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style + }, + /* @__PURE__ */ React2__namespace.createElement(ScrollAdjust, null), +- ListHeaderComponent && /* @__PURE__ */ React2__namespace.createElement(LayoutView, { onLayoutChange: onLayoutHeader, style: ListHeaderComponentStyle }, getComponent(ListHeaderComponent)), ++ ListHeaderComponent && /* @__PURE__ */ React2__namespace.createElement(RevealedLayoutView, { freshDataTransitionEpoch, onLayoutChange: onLayoutHeader, style: ListHeaderComponentStyle }, getComponent(ListHeaderComponent)), + ListEmptyComponent && getComponent(ListEmptyComponent), + shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2__namespace.createElement(AlignItemsAtEndSpacer, { horizontal }), + canRender && !ListEmptyComponent && /* @__PURE__ */ React2__namespace.createElement( +@@ -6665,7 +6721,7 @@ var ListComponent = typedMemo(function ListComponent2({ + stickyHeaderConfig + } + ), +- ListFooterComponent && /* @__PURE__ */ React2__namespace.createElement(LayoutView, { onLayoutChange: onLayoutFooterInternal, style: ListFooterComponentStyle }, getComponent(ListFooterComponent)), ++ ListFooterComponent && /* @__PURE__ */ React2__namespace.createElement(RevealedLayoutView, { freshDataTransitionEpoch, onLayoutChange: onLayoutFooterInternal, style: ListFooterComponentStyle }, getComponent(ListFooterComponent)), + IS_DEV && ENABLE_DEVMODE + ); + }); +@@ -7886,7 +7942,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) { + const state = ctx.state; + if (stylePaddingTop !== void 0) { + const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0; +- if (stylePaddingTop < prevStylePaddingTop) { ++ // Paseo patch: only iOS compensates a shrinking top padding with a scroll adjust, which this ++ // keeps the native clamp from doubling. Elsewhere the held height paints as blank space at the ++ // end until the timer fires, and a scroll view that keeps its end scrolls into it. ++ if (stylePaddingTop < prevStylePaddingTop && Platform.OS === "ios") { + let prevTotalSize = peek$(ctx, "totalSize") || 0; + set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop); + state.timeoutSetPaddingTop = setTimeout(() => { +@@ -7989,6 +8048,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + estimatedListSize, + extraData, + getFixedItemSize, ++ getEstimatedItemSize, + getItemType, + horizontal, + rtl, +@@ -8282,6 +8342,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + dataVersion, + drawDistance, + estimatedItemSize, ++ getEstimatedItemSize: useWrapIfItem(getEstimatedItemSize), + getFixedItemSize: useWrapIfItem(getFixedItemSize), + getItemType: useWrapIfItem(getItemType), + hasExternalScroll, +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index fe5f415..e61945e 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -1476,7 +1476,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no + return renderedSize; + } + const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; +- if (useAverageSize && !scrollingTo) { ++ // Paseo patch: a caller estimate for an unmeasured item wins over the running average for its ++ // type, so a row new to the list can be laid out near its real size before it is measured. ++ if (state.props.getEstimatedItemSize) { ++ const estimate = state.props.getEstimatedItemSize(data, index, itemType); ++ if (estimate !== void 0) { ++ size = estimate + ctx.scrollAxisGap; ++ } ++ } ++ if (size === void 0 && useAverageSize && !scrollingTo) { + const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg; + if (averageSizeForType !== void 0) { + size = roundSize(averageSizeForType); +@@ -1549,6 +1557,12 @@ var MVCP_POSITION_EPSILON = 0.1; + var MVCP_ANCHOR_LOCK_TTL_MS = 300; + var MVCP_ANCHOR_LOCK_QUIET_PASSES_TO_RELEASE = 2; + var NATIVE_END_CLAMP_EPSILON = 1; ++function canRestoreAnchorLock(state, id) { ++ const shouldRestorePosition = state.props.maintainVisibleContentPosition.shouldRestorePosition; ++ const index = state.indexByKey.get(id); ++ const data = state.props.data; ++ return !shouldRestorePosition || index !== void 0 && data[index] !== void 0 && shouldRestorePosition(data[index], index, data); ++} + function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) { + if (!enableMVCPAnchorLock) { + state.mvcpAnchorLock = void 0; +@@ -1558,7 +1572,13 @@ function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) { + if (!lock) { + return void 0; + } +- const isExpired = now > lock.expiresAt; ++ // Paseo patch: native keeps the anchor of a data change through the size passes that follow, as ++ // web does. Upstream limits the lock to web (d24a9d93 "feat: (web) add MVCP anchor lock") without a ++ // native reason. Without it, the pass that applies the measured sizes of prepended rows re-anchors ++ // on the first row in view, which can be one of those rows, and its growth moves the rows below. ++ // A wall-clock expiry is wrong on native, where the measurements arrive after the native mount ++ // and a slow render; the lock ends when its row leaves the view or may not be restored instead. ++ const isExpired = Platform.OS === "web" ? now > lock.expiresAt : !state.idsInView.includes(lock.id) || !canRestoreAnchorLock(state, lock.id); + const isMissing = state.indexByKey.get(lock.id) === void 0; + if (isExpired || isMissing || !mvcpData) { + state.mvcpAnchorLock = void 0; +@@ -1567,7 +1587,7 @@ function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) { + return lock; + } + function updateAnchorLock(state, params) { +- if (Platform.OS === "web") { ++ { + const { anchorId, anchorPosition, dataChanged, now, positionDiff } = params; + const enableMVCPAnchorLock = !!dataChanged || !!state.mvcpAnchorLock; + const mvcpData = state.props.maintainVisibleContentPosition.data; +@@ -1692,7 +1712,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } = props; + const isWeb = Platform.OS === "web"; + const now = Date.now(); +- const enableMVCPAnchorLock = isWeb && (!!dataChanged || !!state.mvcpAnchorLock); ++ const enableMVCPAnchorLock = !!dataChanged || !!state.mvcpAnchorLock; + const scrollingTo = state.scrollingTo; + if ((scrollingTo == null ? void 0 : scrollingTo.key) !== void 0) { + const nextIndex = state.indexByKey.get(scrollingTo.key); +@@ -1702,7 +1722,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + state.mvcpAnchorLock = void 0; + return void 0; + } +- const anchorLock = isWeb ? resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) : void 0; ++ const anchorLock = resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now); + let prevPosition; + let targetId; + const idsInViewWithPositions = []; +@@ -1734,7 +1754,10 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } + targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ // Paseo patch: a size change keeps only a row shouldRestorePosition accepts in place, as a data ++ // change does. Upstream keeps the first row in view, so a disclosure hold on a row cut off at ++ // the top kept the row below it instead and moved the trigger by the row's size change. ++ targetId = idsInView.find((id) => indexByKey.get(id) !== void 0 && canRestoreAnchorLock(state, id)); + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +@@ -2350,7 +2373,10 @@ function finishScrollTo(ctx) { + }; + if (scrollingTo.isInitialScroll || state.initialScroll) { + const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; ++ // Paseo patch: only a list that keeps its end keeps landing on the initial end after the ++ // initial scroll. Without maintainScrollAtEnd, the preserved target re-arms the initial scroll ++ // on every data change, footer layout and viewport resize, and a stream never lets it clear. ++ const shouldPreserveResizeTarget = !!state.props.maintainScrollAtEnd && !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; + finishInitialScroll(ctx, { + onFinished: complete, + preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +@@ -2494,11 +2520,12 @@ function checkFinishedScrollFallback(ctx) { + const targetOffset = (_d = (_c = (_b = initialScrollWatchdog.get(state)) == null ? void 0 : _b.targetOffset) != null ? _c : isStillScrollingTo.targetOffset) != null ? _d : 0; + const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; + initialScrollCompletion.markSilentInitialScrollRetry(state); ++ // Paseo patch: send the nudge and the return to the target in one task, so native applies both before ++ // it draws and the pair still emits the scroll event this retry exists for. Upstream returns on the next ++ // frame, but the 16 ms check below can run first, finish the scroll and cancel that frame; the list then ++ // revealed 1 dp short of its end and moved into place a frame later. + scrollToFallbackOffset(ctx, jiggleOffset); +- state.scheduledWork.frame( +- () => scrollToFallbackOffset(ctx, targetOffset), +- "checkFinishedScrollRetryFrame" +- ); ++ scrollToFallbackOffset(ctx, targetOffset); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); +@@ -3357,7 +3384,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) { + var _a3; + const state = ctx.state; + clearBootstrapInitialScrollSession(state); +- const shouldPreserveResizeTarget = !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_a3 = state.initialScroll) == null ? void 0 : _a3.viewPosition) === 1; ++ // Paseo patch: see finishScrollTo; only a list that keeps its end preserves the initial end. ++ const shouldPreserveResizeTarget = !!state.props.maintainScrollAtEnd && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_a3 = state.initialScroll) == null ? void 0 : _a3.viewPosition) === 1; + finishInitialScroll(ctx, { + preserveTarget: shouldPreserveResizeTarget, + recalculateItems: true, +@@ -4548,6 +4576,17 @@ function getIdsInVisibleRange(state, range) { + const id = (_b = state.idCache[i]) != null ? _b : getId(state, i); + idsInView.push(id); + } ++ // Paseo patch: a row whose top is above the viewport is still in view. Upstream starts the ids at ++ // the first fully visible row, so a disclosure hold on a row cut off at the top finds no anchor ++ // (shouldRestorePosition rejects every other row) and the next size pass keeps the row below it ++ // in place instead. The partly visible rows go last, so the first fully visible row stays the ++ // anchor of every pass that does not restrict it. ++ if (range.startNoBuffer !== null && range.startNoBuffer < firstVisibleAnchorIndex) { ++ for (let i = range.startNoBuffer; i < firstVisibleAnchorIndex; i++) { ++ const id = (_b = state.idCache[i]) != null ? _b : getId(state, i); ++ idsInView.push(id); ++ } ++ } + } + return idsInView; + } +@@ -5155,8 +5194,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL + function isWithinEpsilon(delta) { + return Math.abs(delta) <= NATIVE_LAYOUT_MEASUREMENT_EPSILON; + } +-function isNativeLayoutNoise(delta) { +- return isWithinEpsilon(delta); ++// Paseo patch: sizesKnown holds roundSize values, floored to 1/8 dp, so the raw size behind a known ++// size lies in [known, known + 1/8). Upstream compares the raw size with the floored one, so a row ++// whose pixel-snapped height changes by one pixel, plus the floor, reads as a resize. On a cold open ++// that late resize moved every row a frame after the reveal. ++function isNativeLayoutNoise(rawSize, knownSize) { ++ const delta = rawSize - knownSize; ++ return delta >= -NATIVE_LAYOUT_MEASUREMENT_EPSILON && delta < NATIVE_LAYOUT_MEASUREMENT_EPSILON + 1 / 8; + } + + // src/core/updateItemSizes.ts +@@ -5393,7 +5437,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif + state.fixedItemSizeKeys.add(itemKey); + } + const prevSizeKnown = sizesKnown.get(itemKey); +- if (Platform.OS !== "web" && prevSizeKnown !== void 0 && isNativeLayoutNoise(rawSize - prevSizeKnown)) { ++ if (Platform.OS !== "web" && prevSizeKnown !== void 0 && isNativeLayoutNoise(rawSize, prevSizeKnown)) { + return 0; + } + const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize); +@@ -6262,6 +6306,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { + + // src/components/Containers.native.tsx + var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0; ++// Paseo patch: on the new architecture the content height comes from React state only. The ++// JS-driven Animated height writes through setNativeProps, which Fabric commits synchronously ++// mid-task, mounting React's estimated layout for a frame before the measured one. ++var useContentSize = IsNewArchitecture ? useCommittedMinSize : () => useValue$("totalSize"); + var ContainersLayer = typedMemo(function ContainersLayer2({ + children, + freshDataTransitionEpoch, +@@ -6269,7 +6317,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ + }) { + const ctx = useStateContext(); + const columnWrapperStyle = ctx.columnWrapperStyle; +- const size = useValue$("totalSize"); ++ const size = useContentSize(); + const minSize = useCommittedMinSize(); + const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); + const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); +@@ -6492,6 +6540,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont + null + ); + }); ++// Paseo patch: the header and footer reveal with the rows. Upstream hides only the containers until ++// readyToRender, so a cold open paints the header and footer at their estimated place a frame or more ++// before the rows appear around them. ++var RevealedLayoutView = function RevealedLayoutView2({ freshDataTransitionEpoch, style, ...rest }) { ++ const [readyToRender] = useArr$(["readyToRender"]); ++ const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); ++ return /* @__PURE__ */ React2.createElement(LayoutView, { ...rest, pointerEvents: isVisible ? "auto" : "none", style: isVisible ? style : [style, { opacity: 0 }] }); ++}; + var ListComponent = typedMemo(function ListComponent2({ + canRender, + freshDataTransitionEpoch, +@@ -6630,7 +6686,7 @@ var ListComponent = typedMemo(function ListComponent2({ + style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style + }, + /* @__PURE__ */ React2.createElement(ScrollAdjust, null), +- ListHeaderComponent && /* @__PURE__ */ React2.createElement(LayoutView, { onLayoutChange: onLayoutHeader, style: ListHeaderComponentStyle }, getComponent(ListHeaderComponent)), ++ ListHeaderComponent && /* @__PURE__ */ React2.createElement(RevealedLayoutView, { freshDataTransitionEpoch, onLayoutChange: onLayoutHeader, style: ListHeaderComponentStyle }, getComponent(ListHeaderComponent)), + ListEmptyComponent && getComponent(ListEmptyComponent), + shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2.createElement(AlignItemsAtEndSpacer, { horizontal }), + canRender && !ListEmptyComponent && /* @__PURE__ */ React2.createElement( +@@ -6644,7 +6700,7 @@ var ListComponent = typedMemo(function ListComponent2({ + stickyHeaderConfig + } + ), +- ListFooterComponent && /* @__PURE__ */ React2.createElement(LayoutView, { onLayoutChange: onLayoutFooterInternal, style: ListFooterComponentStyle }, getComponent(ListFooterComponent)), ++ ListFooterComponent && /* @__PURE__ */ React2.createElement(RevealedLayoutView, { freshDataTransitionEpoch, onLayoutChange: onLayoutFooterInternal, style: ListFooterComponentStyle }, getComponent(ListFooterComponent)), + IS_DEV && ENABLE_DEVMODE + ); + }); +@@ -7865,7 +7921,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) { + const state = ctx.state; + if (stylePaddingTop !== void 0) { + const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0; +- if (stylePaddingTop < prevStylePaddingTop) { ++ // Paseo patch: only iOS compensates a shrinking top padding with a scroll adjust, which this ++ // keeps the native clamp from doubling. Elsewhere the held height paints as blank space at the ++ // end until the timer fires, and a scroll view that keeps its end scrolls into it. ++ if (stylePaddingTop < prevStylePaddingTop && Platform.OS === "ios") { + let prevTotalSize = peek$(ctx, "totalSize") || 0; + set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop); + state.timeoutSetPaddingTop = setTimeout(() => { +@@ -7968,6 +8027,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + estimatedListSize, + extraData, + getFixedItemSize, ++ getEstimatedItemSize, + getItemType, + horizontal, + rtl, +@@ -8261,6 +8321,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + dataVersion, + drawDistance, + estimatedItemSize, ++ getEstimatedItemSize: useWrapIfItem(getEstimatedItemSize), + getFixedItemSize: useWrapIfItem(getFixedItemSize), + getItemType: useWrapIfItem(getItemType), + hasExternalScroll, diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 3dbbeddcaf..0f4b96b19f 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -43,6 +43,11 @@ const patchedPackages = [ nodeModulesPath: "node_modules/react-native-reanimated", patchPrefix: "react-native-reanimated+", }, + // The native chat timeline. The patch's header lists its hunks and the upstream issue. + { + nodeModulesPath: "node_modules/@legendapp/list", + patchPrefix: "@legendapp+list+", + }, { nodeModulesPath: "node_modules/react-native-draggable-flatlist", patchPrefix: "react-native-draggable-flatlist+",