From e951febefca26b1f805b6fe8d17145f275a86be1 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:30:50 +0000 Subject: [PATCH 1/5] feat(mobile): load earlier chat messages automatically as you scroll up The phone page's chat list is anchored at the newest message, so older history lands above the reader without moving them, mid-fling included. Fixes #31177 --- .../src/session/MobileNativeChatMessage.tsx | 2 +- .../src/session/MobileNativeChatPageList.tsx | 363 ++++++++++++++---- .../session/MobileNativeChatPageScroll.tsx | 25 +- .../session/MobileNativeChatReasoningRow.tsx | 2 +- .../src/session/MobileNativeChatRowList.tsx | 18 +- .../session/MobileNativeChatScrollHold.tsx | 268 ------------- .../MobileNativeChatScrollRootsBoundary.tsx | 46 +++ .../src/session/MobileNativeChatToolLine.tsx | 2 +- .../src/session/MobileNativeChatToolRun.tsx | 4 +- ...bileNativeChatView.harness.test-fixture.ts | 2 +- ...obileNativeChatView.history-paging.test.ts | 245 +++--------- ...ativeChatView.render-stubs.test-fixture.ts | 45 +-- mobile/src/session/MobileNativeChatView.tsx | 36 +- .../mobile-native-chat-list-header.tsx | 85 ++-- .../mobile-native-chat-page-heights.test.ts | 40 -- .../mobile-native-chat-page-heights.ts | 174 --------- .../session/mobile-native-chat-page-rows.ts | 118 ------ ...mobile-native-chat-page-window-coverage.ts | 66 ---- .../mobile-native-chat-page-window-state.ts | 66 ---- .../mobile-native-chat-reading-place.test.tsx | 187 ++++++--- .../mobile-native-chat-reading-place.ts | 158 ++++++++ ...=> mobile-native-chat-scroll-anchoring.ts} | 13 +- ...ts => mobile-native-chat-scroll-motion.ts} | 86 +---- ...bile-native-chat-scroll-ownership.test.tsx | 60 +-- .../mobile-native-chat-scroll-root-handle.ts | 8 +- .../mobile-native-chat-scroll-roots.ts | 29 +- .../session/mobile-native-chat-view-styles.ts | 31 +- .../use-mobile-native-chat-page-render.ts | 18 - .../use-mobile-native-chat-page-window.ts | 342 ----------------- .../use-mobile-native-chat-session.test.ts | 31 -- .../session/use-mobile-native-chat-session.ts | 156 ++++---- .../use-mobile-native-chat-tail-follow.ts | 161 ++------ .../use-mobile-structured-agent-state.ts | 122 +++--- 33 files changed, 1030 insertions(+), 1979 deletions(-) delete mode 100644 mobile/src/session/MobileNativeChatScrollHold.tsx create mode 100644 mobile/src/session/MobileNativeChatScrollRootsBoundary.tsx delete mode 100644 mobile/src/session/mobile-native-chat-page-heights.test.ts delete mode 100644 mobile/src/session/mobile-native-chat-page-heights.ts delete mode 100644 mobile/src/session/mobile-native-chat-page-rows.ts delete mode 100644 mobile/src/session/mobile-native-chat-page-window-coverage.ts delete mode 100644 mobile/src/session/mobile-native-chat-page-window-state.ts create mode 100644 mobile/src/session/mobile-native-chat-reading-place.ts rename mobile/src/session/{mobile-native-chat-page-anchoring.ts => mobile-native-chat-scroll-anchoring.ts} (78%) rename mobile/src/session/{mobile-native-chat-page-motion.ts => mobile-native-chat-scroll-motion.ts} (76%) delete mode 100644 mobile/src/session/use-mobile-native-chat-page-render.ts delete mode 100644 mobile/src/session/use-mobile-native-chat-page-window.ts diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 21247a007b65..d240eeccb4ae 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -25,7 +25,7 @@ import { MobileNativeChatGroupedThought, MobileNativeChatReasoningRow } from './MobileNativeChatReasoningRow' -import { mobileNativeChatRowId } from './MobileNativeChatScrollHold' +import { mobileNativeChatRowId } from './mobile-native-chat-reading-place' import { nativeChatWorkRunEntries } from '../../../src/shared/native-chat-work-run' import { nativeChatReasoningDisclosureKey } from '../../../src/shared/native-chat-reasoning-row' import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' diff --git a/mobile/src/session/MobileNativeChatPageList.tsx b/mobile/src/session/MobileNativeChatPageList.tsx index 8a0edc28e826..0747ff793313 100644 --- a/mobile/src/session/MobileNativeChatPageList.tsx +++ b/mobile/src/session/MobileNativeChatPageList.tsx @@ -1,116 +1,340 @@ import { Fragment, useCallback, + useEffect, useImperativeHandle, useLayoutEffect, + useMemo, useRef, + useState, type ReactElement, type Ref } from 'react' -import { - View, - type NativeScrollEvent, - type NativeSyntheticEvent, - type ScrollView, - type StyleProp, - type ViewStyle +import type { + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, + StyleProp, + ViewStyle } from 'react-native' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import type { MobileNativeChatRowKeys } from './mobile-native-chat-row-keys' -import { MobileNativeChatPageScroll, outOfAnchoring } from './MobileNativeChatPageScroll' +import { MobileNativeChatPageScroll } from './MobileNativeChatPageScroll' +import { + mobileNativeChatReadingDrift, + mobileNativeChatReadingPlace, + type MobileNativeChatReadingUnit, + type MobileNativeChatScrollNode +} from './mobile-native-chat-reading-place' import type { MobileNativeChatListHandle } from './use-mobile-native-chat-tail-follow' -import type { MobileNativeChatTurnRow } from './use-mobile-native-chat-turn-disclosure' -import type { MobileNativeChatWindowScroller } from './mobile-native-chat-page-window-state' -import { useMobileNativeChatPageWindow } from './use-mobile-native-chat-page-window' import type { MobileNativeChatScrollRoots } from './mobile-native-chat-scroll-roots' +/** Rows a chat opens with; more are drawn as the reader nears the top of what is drawn. */ +const FIRST_ROWS = 24 +const DRAW_BATCH = 12 +/** Rows above are drawn while the top of what is drawn is within this many screens. */ +const DRAW_AHEAD_SCREENS = 3 +/** An older page is asked for once fewer loaded rows than this are left to draw above, and the + * top of what is drawn is within reach: a phone flick crosses several screens. */ +const FETCH_AHEAD_ROWS = 40 +const FETCH_REACH_SCREENS = 10 +/** No scroll of the reader's and no touch for this long: a correction now moves nothing in motion. */ +const REST_MS = 300 + type Row = { item: NativeChatMessage; index: number } +type Listen = (type: string, listener: () => void, options: AddEventListenerOptions) => void +/** What the list reads of RN Web's scrollable node. */ +type Scroller = MobileNativeChatScrollNode & { + addEventListener: Listen + removeEventListener: Listen +} +/** What a scrolling reader sees below them, held until they rest. */ +type Held = { rows: readonly NativeChatMessage[]; footer: ReactElement | null } type Props = { roots?: MobileNativeChatScrollRoots ref: Ref - endAnchored: boolean isFollowing: () => boolean - pin: () => void + onEndDistance: (fromEnd: number, byReader: boolean) => void rows: readonly NativeChatMessage[] - rowKeys: Pick + rowKeys: Pick renderItem: (row: Row) => ReactElement - /** What a row draws besides itself; a height measured for other state no longer holds. */ - rowState: (index: number, row: NativeChatMessage) => MobileNativeChatTurnRow + /** How rows draw (Tools, text size): a change re-lays every row, so the place is kept mid-scroll. */ layoutKey: string contentContainerStyle: StyleProp - /** `measuring`: rows above are not measured yet, so the Load earlier slot shows loading. */ - header: (measuring: boolean) => ReactElement | null + header: ReactElement | null footer: ReactElement | null emptyStateView: ReactElement | null onScroll: (event: NativeSyntheticEvent) => void + /** Asks for an older page; absent while none may load. */ + onNearTop?: () => void } -/** The page's transcript in one keyed flow: RN Web's list draws by index after paint, so opens climbed - * and older pages went in its header, where iOS stopped scrolling at the seam. */ +/** Where the drawn rows start: by key, so rows streaming in below don't move it. */ +type Drawn = { scope: string; firstKey: string | null; count: number } + +/** The page's transcript, anchored at its end. Rows added above never move the reader, so older + * history lands and draws at any time, mid-fling included. What moves a parked reader is a change + * below them (a reply streaming in, Tools, text size, an image loading): the line they read goes + * back to where it was, once the scroll is at rest or at once for a regroup. */ export function MobileNativeChatPageList({ ref, roots, - endAnchored, isFollowing, - pin, - rows, + onEndDistance, + rows: liveRows, rowKeys, renderItem, - rowState, layoutKey, contentContainerStyle, header, - footer, + footer: liveFooter, emptyStateView, - onScroll + onScroll, + onNearTop }: Props): ReactElement { const scroll = useRef(null) - const scroller = useCallback( - (): MobileNativeChatWindowScroller | null | undefined => scroll.current?.getScrollableNode(), + const scroller = useCallback((): Scroller | null => { + const node: Scroller | null | undefined = scroll.current?.getScrollableNode() + return node ?? null + }, []) + + // A change below a scrolling reader would push the line they read, and putting it back mid-fling + // would stop the fling on iOS, or be lost. So what is below them holds until they rest; older rows + // still land above, where they move nothing. + // A ref, so holding costs no render: only letting the held changes in does. + const heldRef = useRef(null) + const [, release] = useState(0) + const held = heldRef.current + const rows = useMemo(() => { + const head = held?.rows[0] + if (!held || !head) { + return liveRows + } + const key = rowKeys.keyOf(head) + const at = liveRows.findIndex((row) => rowKeys.keyOf(row) === key) + return at === -1 ? liveRows : [...liveRows.slice(0, at), ...held.rows] + }, [held, liveRows, rowKeys]) + const footer = held ? held.footer : liveFooter + const keys = useMemo(() => rows.map(rowKeys.keyOf), [rows, rowKeys.keyOf]) + const indexOf = useMemo(() => new Map(keys.map((key, at) => [key, at])), [keys]) + const n = keys.length + const [drawn, setDrawn] = useState({ scope: rowKeys.scope, firstKey: null, count: 0 }) + const kept = drawn.scope === rowKeys.scope ? drawn : null + const opening = Math.max(0, n - FIRST_ROWS) + const firstKey = kept?.firstKey ?? null + const first = + firstKey === null + ? opening + : (indexOf.get(firstKey) ?? + indexOf.get(rowKeys.current(firstKey) ?? '') ?? + Math.max(0, n - (kept?.count ?? FIRST_ROWS))) + + const own = useRef(null) + const touching = useRef(false) + const readerScrolledAt = useRef(0) + const place = useRef(null) + const placedLayout = useRef(layoutKey) + const settleTimer = useRef | undefined>(undefined) + const live = useRef({ rows: liveRows, footer: liveFooter }) + const ask = useRef(onNearTop) + // Read at event time, so a scroll or resize between commits acts on the latest render. + const latest = useRef({ first, n, keys, layoutKey, rowKeys }) + useLayoutEffect(() => { + latest.current = { first, n, keys, layoutKey, rowKeys } + ask.current = onNearTop + live.current = { rows: liveRows, footer: liveFooter } + }) + + const write = useCallback((s: Scroller, to: number) => { + const top = Math.min(0, Math.max(to, s.clientHeight - s.scrollHeight)) + if (Math.abs(top - s.scrollTop) >= 0.5) { + s.scrollTop = top + own.current = s.scrollTop + } + }, []) + + const resting = useCallback( + () => !touching.current && Date.now() - readerScrolledAt.current >= REST_MS, [] ) - const { view, rowRef, list } = useMobileNativeChatPageWindow({ - roots, - rows, - rowKeys, - rowState, - following: endAnchored, - isFollowing, - layoutKey, - scroller + + // The line being read where the reader left it: every later correction is measured against it, so + // whole-pixel scroll writes can't add up to a drift. + const notePlace = useCallback( + (s: Scroller) => { + place.current = isFollowing() ? null : mobileNativeChatReadingPlace(s, latest.current.rowKeys) + placedLayout.current = latest.current.layoutKey + }, + [isFollowing] + ) + + const keepPlace = useCallback( + (s: Scroller) => { + const held = place.current + const regrouped = placedLayout.current !== latest.current.layoutKey + if (!held || isFollowing() || !(regrouped || resting())) { + notePlace(s) + return + } + const drift = mobileNativeChatReadingDrift(held, s, latest.current.rowKeys, roots) + if (Math.abs(drift) >= 0.5) { + write(s, s.scrollTop + drift) + } + // A regroup draws other parts: the line is re-read from them. + if (regrouped) { + notePlace(s) + } + }, + [isFollowing, notePlace, resting, roots, write] + ) + + // The reader moves while parked: hold what is below them, and let it in once they rest. + const settle = useRef(() => {}) + const unhold = useCallback(() => { + if (heldRef.current) { + heldRef.current = null + release((n) => n + 1) + } + }, []) + const hold = useCallback(() => { + if (!isFollowing()) { + heldRef.current ??= live.current + clearTimeout(settleTimer.current) + settleTimer.current = setTimeout(() => settle.current(), REST_MS) + } + }, [isFollowing]) + useLayoutEffect(() => { + settle.current = () => { + if (resting() || isFollowing()) { + unhold() + } else { + settleTimer.current = setTimeout(() => settle.current(), REST_MS) + } + } }) + useEffect(() => () => clearTimeout(settleTimer.current), []) + + const drawAndFetch = useCallback( + (s: Scroller) => { + const { first: at, keys: drawnKeys, n: total, rowKeys: current } = latest.current + const screens = (s.scrollHeight - s.clientHeight + s.scrollTop) / s.clientHeight + if (at > 0 && screens < DRAW_AHEAD_SCREENS) { + const next = Math.max(0, at - DRAW_BATCH) + setDrawn({ scope: current.scope, firstKey: drawnKeys[next]!, count: total - next }) + } else if (isFollowing() && at < total - FIRST_ROWS && screens > 3 * DRAW_AHEAD_SCREENS) { + // Back at the end, rows far above go: taking them away moves nothing. + setDrawn({ scope: current.scope, firstKey: null, count: 0 }) + } + if (at < FETCH_AHEAD_ROWS && screens < FETCH_REACH_SCREENS) { + ask.current?.() + } + }, + [isFollowing] + ) + useImperativeHandle( ref, () => ({ - getScrollableNode: () => scroll.current?.getScrollableNode(), - scrollToOffset: ({ offset, animated }) => { - list.wrote(offset) - scroll.current?.scrollTo({ y: offset, animated }) + scrollToEnd: () => { + const s = scroller() + if (s) { + write(s, 0) + } }, - scrollToEnd: (params) => scroll.current?.scrollToEnd(params), - ...list + // From the top, as a native list counts it. + scrollToOffset: ({ offset }) => { + const s = scroller() + if (s) { + write(s, offset - (s.scrollHeight - s.clientHeight)) + } + } }), - [list] + [scroller, write] ) - // After the handle attaches, so a chat's first paint is at its end; later growth the observer pins. - const opened = useRef('') - const shown = `${rowKeys.scope}:${rows.length > 0}` + + // Every commit: put the line back, then draw or ask for what the reader nears. useLayoutEffect(() => { - if (opened.current !== shown) { - opened.current = shown - pin() + const s = scroller() + if (s) { + keepPlace(s) + drawAndFetch(s) + } + }) + + // Changes no commit makes (an image loading, an inner box settling): the same rule, inner boxes first. + useEffect(() => { + const s = scroller() + const content: Element | null | undefined = scroll.current?.getInnerViewNode() + if (!s || !content || typeof ResizeObserver === 'undefined') { + return } - }, [shown, pin]) - - const drawRow = (item: NativeChatMessage, index: number) => { - const key = rowKeys.keyOf(item) - return ( - - {renderItem({ item, index })} - - ) - } + const observer = new ResizeObserver(() => { + roots?.restoreAll() + keepPlace(s) + drawAndFetch(s) + }) + observer.observe(content) + roots?.observeWith(observer) + return () => { + roots?.observeWith(null) + observer.disconnect() + } + }, [scroller, roots, keepPlace, drawAndFetch]) + + // A hand on the list is the reader's: nothing is put back under it. + useEffect(() => { + const s = scroller() + if (!s) { + return + } + // A release that hands over to a fling comes right after a scroll, which still counts. + const down = () => { + touching.current = true + hold() + } + const up = () => { + touching.current = false + } + const options = { capture: true, passive: true } + // Touch and pointer both: a finger fires either first, and one can go missing. + const downs = ['touchstart', 'pointerdown'] + const ups = ['touchend', 'touchcancel', 'pointerup', 'pointercancel'] + downs.forEach((type) => s.addEventListener(type, down, options)) + ups.forEach((type) => s.addEventListener(type, up, options)) + return () => { + downs.forEach((type) => s.removeEventListener(type, down, options)) + ups.forEach((type) => s.removeEventListener(type, up, options)) + } + }, [scroller, hold]) + + const scrolled = useCallback( + (event: NativeSyntheticEvent) => { + onScroll(event) + const s = scroller() + if (!s) { + return + } + const byReader = own.current === null || Math.abs(s.scrollTop - own.current) >= 1 + own.current = null + if (byReader) { + readerScrolledAt.current = Date.now() + } + onEndDistance(Math.max(0, -s.scrollTop), byReader) + if (isFollowing()) { + // At the end the newest text shows at once. + unhold() + } else if (byReader) { + hold() + } + if (byReader) { + notePlace(s) + } + drawAndFetch(s) + }, + [onScroll, scroller, onEndDistance, isFollowing, hold, unhold, notePlace, drawAndFetch] + ) + return ( - {/* Never what the browser's anchoring holds: rows land below it, and spacers stand in for rows. */} - {header(view.measuring)} + {header} {rows.length === 0 ? ( emptyStateView ) : ( - // Keyed by the chat, so no row carries into another. Rows unmount once scrolled past; what - // the reader opened in them is kept in the chat's disclosure store, above the list. + // Keyed by the chat, so no row carries into another. Rows above the drawn ones aren't in the + // document; what the reader opened in them is kept in the chat's disclosure store. - - {rows.slice(view.m0, view.m1).map((item, at) => drawRow(item, view.m0 + at))} - - {rows.slice(view.start, view.end).map((item, at) => drawRow(item, view.start + at))} - + {rows.slice(first).map((item, at) => ( + {renderItem({ item, index: first + at })} + ))} )} {footer} diff --git a/mobile/src/session/MobileNativeChatPageScroll.tsx b/mobile/src/session/MobileNativeChatPageScroll.tsx index edc833f5786d..39cd4415c58b 100644 --- a/mobile/src/session/MobileNativeChatPageScroll.tsx +++ b/mobile/src/session/MobileNativeChatPageScroll.tsx @@ -1,8 +1,9 @@ import type { Ref } from 'react' import { ScrollView, StyleSheet, View, type ScrollViewProps, type ViewStyle } from 'react-native' -/** The page's scroller. Where the browser has scroll anchoring it keeps a parked reader's place; - * the page turns it off on the scroller itself while it owns the scroll (following, a place-hold). */ +/** The page's scroller, anchored at its end: scrollTop is 0 at the newest message and negative above + * it, so rows added above the reader never move what they read and the end needs no pin. The + * browser's scroll anchoring is off: the list keeps a parked reader's place itself. */ export function MobileNativeChatPageScroll({ ref, children, @@ -10,19 +11,25 @@ export function MobileNativeChatPageScroll({ ...props }: ScrollViewProps & { ref?: Ref }) { return ( - + {children} ) } // RN Web passes it through to CSS; ViewStyle doesn't name it. -const anchorless: ViewStyle & { overflowAnchor: 'none' } = { overflowAnchor: 'none' } +const endAnchored: ViewStyle & { overflowAnchor: 'none' } = { + flexDirection: 'column-reverse', + overflowAnchor: 'none' +} const pageScroll = StyleSheet.create({ - fill: { flexGrow: 1 }, - anchorless + endAnchored, + // A short chat still fills the screen from the top. + content: { flexGrow: 1, flexShrink: 0 } }) - -/** Never the node the browser's anchoring holds. */ -export const outOfAnchoring = pageScroll.anchorless diff --git a/mobile/src/session/MobileNativeChatReasoningRow.tsx b/mobile/src/session/MobileNativeChatReasoningRow.tsx index 522b6aae334e..27d1365482e3 100644 --- a/mobile/src/session/MobileNativeChatReasoningRow.tsx +++ b/mobile/src/session/MobileNativeChatReasoningRow.tsx @@ -1,4 +1,4 @@ -import { mobileNativeChatMemberId } from './MobileNativeChatScrollHold' +import { mobileNativeChatMemberId } from './mobile-native-chat-reading-place' import { useState } from 'react' import { Pressable, Text, View } from 'react-native' import { MobileNativeChatOwnedScrollView } from './MobileNativeChatOwnedScrollView' diff --git a/mobile/src/session/MobileNativeChatRowList.tsx b/mobile/src/session/MobileNativeChatRowList.tsx index 4952bca5303c..fafd1dff84c2 100644 --- a/mobile/src/session/MobileNativeChatRowList.tsx +++ b/mobile/src/session/MobileNativeChatRowList.tsx @@ -14,31 +14,31 @@ type Props = Pick< | 'rows' | 'roots' | 'renderItem' - | 'rowState' | 'layoutKey' | 'contentContainerStyle' | 'header' | 'footer' | 'emptyStateView' | 'onScroll' + | 'onNearTop' > & { tail: MobileNativeChatTailFollow rowKeys: MobileNativeChatRowKeys - /** The native list's header; with one, the row below it anchors the visible content. */ - nativeHeader: ReactElement | null + /** With a header, the row below it anchors the native list's visible content. */ hasHeader: boolean } -/** The chat's rows: the page draws its own keyed window, a native list recycles through FlatList. */ +/** The chat's rows: the page's list is anchored at its end, a native list recycles through FlatList. */ export function MobileNativeChatRowList(props: Props): ReactElement { const { tail, rowKeys, rows, renderItem, contentContainerStyle, onScroll, footer } = props + const { header, emptyStateView } = props if (Platform.OS === 'web') { - const { roots, rowState, header, emptyStateView, layoutKey } = props + const { roots, layoutKey, onNearTop } = props return ( ) } @@ -61,9 +61,9 @@ export function MobileNativeChatRowList(props: Props): ReactElement { onContentSizeChange={tail.pinToTailAfterContentResize} onLayout={tail.pinToTail} maintainVisibleContentPosition={mobileNativeChatRowAnchor(props.hasHeader)} - ListHeaderComponent={props.nativeHeader} + ListHeaderComponent={header} ListFooterComponent={footer} - ListEmptyComponent={props.emptyStateView} + ListEmptyComponent={emptyStateView} /> ) } diff --git a/mobile/src/session/MobileNativeChatScrollHold.tsx b/mobile/src/session/MobileNativeChatScrollHold.tsx deleted file mode 100644 index a4f573708638..000000000000 --- a/mobile/src/session/MobileNativeChatScrollHold.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import { Component, type RefObject } from 'react' -import { Platform } from 'react-native' -import { scrollWritesStopMomentum } from './mobile-native-chat-page-motion' -import type { MobileNativeChatRowKeys } from './mobile-native-chat-row-keys' -import { - mobileNativeChatReadingParts, - mobileNativeChatScrollRootKey, - type MobileNativeChatScrollRoots -} from './mobile-native-chat-scroll-roots' - -type Box = { top: number; bottom: number } - -/** The parts of a DOM element this reads; on the page the list's scrollable node is one. */ -type Tagged = { - getBoundingClientRect: () => Box - getAttribute: (name: string) => string | null - querySelectorAll: (selector: string) => ArrayLike -} -type Scroller = Tagged & { scrollTop: number; scrollHeight: number; clientHeight: number } -export type MobileNativeChatScrollNode = Scroller - -/** Part of what the reader sees: a tool line or its run (`member`), else a row; where it was, and - * the message that placed its row. */ -type Unit = { key: string; top: number; member?: string; placed: string } - -type Props = { - children?: React.ReactNode - roots?: MobileNativeChatScrollRoots - runPlacements?: ReadonlyMap - rowKeys: Pick - /** How rows draw (tools shown, text size); a change regroups and re-lays them like a new source. */ - layoutKey?: string - /** Whether the reader follows the end, which then owns the scroll. */ - isFollowing: () => boolean - /** On the page, the list makes the correction (`keepInPlace`), mid-scroll only for a regroup. */ - listRef: RefObject<{ - getScrollableNode: () => Scroller | null | undefined - keepInPlace?: (drift: number, regrouped: boolean) => void - holdPlace?: (on: boolean) => void - } | null> -} - -const ROW = 'chat-row:' -const MEMBER = 'chat-member:' - -/** A message row's `testID`, which the page draws as `data-testid`. It sets message rows apart from - * the "Load earlier" control and the live status line, which don't move with the messages. */ -export function mobileNativeChatRowId(key: string): string { - return `${ROW}${key}` -} - -/** A tool line's `testID`, by its line identity, or the run's, inside a row. */ -export function mobileNativeChatMemberId(id: string): string { - return `${MEMBER}${id}` -} - -/** Tagged elements in document order; ids are compared, never put in a selector. */ -function tagged(node: Tagged, prefix: string): { id: string; node: Tagged }[] { - return Array.from(node.querySelectorAll(`[data-testid^="${prefix}"]`), (element) => ({ - id: element.getAttribute('data-testid')?.slice(prefix.length) ?? '', - node: element - })) -} - -function onScreen(box: Box, view: Box): boolean { - return box.bottom > view.top && box.top < view.bottom -} - -/** A row's tool lines on screen, else its run when the run's top is (below that top, lines can join - * or leave), else the row itself. */ -function unitsOf(row: Tagged, view: Box, self: Unit): Unit[] { - const shown: Unit[] = [] - const parts = - typeof Element !== 'undefined' && row instanceof Element - ? mobileNativeChatReadingParts(row).map((node) => ({ - node, - id: - mobileNativeChatScrollRootKey(node) !== undefined - ? `root:${mobileNativeChatScrollRootKey(node)}` - : (node.getAttribute('data-testid')?.slice(MEMBER.length) ?? '') - })) - : tagged(row, MEMBER) - for (const { id, node } of parts) { - const box = node.getBoundingClientRect() - if (box.top >= view.bottom) { - break - } - if (id === 'run' ? box.top >= view.top : onScreen(box, view)) { - shown.push({ ...self, top: box.top, member: id }) - } - } - const lines = shown.filter((unit) => unit.member !== 'run') - return lines.length > 0 ? lines : [shown[0] ?? self] -} - -/** What the reader is reading, in page order. */ -function placeOnScreen(scroller: Scroller, { placement }: Props['rowKeys']): Unit[] { - const view = scroller.getBoundingClientRect() - const rows = tagged(scroller, ROW) - // Rows in normal flow have ordered bottoms, so the first one on screen is found without - // measuring the up to 2,000 above it. - let low = 0 - let high = rows.length - while (low < high) { - const middle = low + Math.floor((high - low) / 2) - if (rows[middle]!.node.getBoundingClientRect().bottom <= view.top) { - low = middle + 1 - } else { - high = middle - } - } - let end = low - while (end < rows.length && rows[end]!.node.getBoundingClientRect().top < view.bottom) { - end += 1 - } - const self = ({ id, node }: { id: string; node: Tagged }): Unit => { - return { key: id, top: node.getBoundingClientRect().top, placed: placement(id) } - } - const units = rows.slice(low, end).flatMap((row) => unitsOf(row.node, view, self(row))) - // The nearest rows off screen stand in when the update takes away all that was on it. - return units.concat([rows[end], rows[low - 1]].flatMap((row) => (row ? [self(row)] : []))) -} - -/** Where a held part is drawn now, if the update kept it. A line in the row now drawing its old - * row's messages (an orphan result's line is its call's once paired); a run only in the row that - * kept its key; a row only while the same message places it, since a group keeps its key when the - * fold moves it. */ -function drawn( - unit: Unit, - rows: Map, - keys: Props['rowKeys'], - roots?: MobileNativeChatScrollRoots -): Tagged | undefined { - const { key, member } = unit - if (member?.startsWith('root:')) { - return roots?.frame(member.slice(5)) - } - if (member === undefined) { - return keys.placement(key) === unit.placed ? rows.get(key) : undefined - } - const row = rows.get(member === 'run' ? key : (keys.current(key) ?? '')) - const paired = member.startsWith('result:') ? `call:${member.slice('result:'.length)}` : member - return row && tagged(row, MEMBER).find((m) => m.id === member || m.id === paired)?.node -} - -/** The update moves what the reader reads to another row (a line to another group, a row placed by - * another message): the browser's anchoring loses that node, so the hold puts it back. */ -function moves( - unit: Unit | undefined, - keys: Props['rowKeys'], - placements?: ReadonlyMap, - roots?: MobileNativeChatScrollRoots -): boolean { - if (!unit) { - return false - } - if (unit.member === undefined) { - return keys.placement(unit.key) !== unit.placed - } - if (unit.member.startsWith('root:')) { - const owner = roots?.owner(unit.member.slice(5)) - const now = owner === undefined ? undefined : placements?.get(owner) - return now !== undefined && now !== unit.placed - } - // An orphan result's line goes to its call's row once the call lands, and its old row stays. - return ( - unit.member.startsWith('result:') || - (unit.member !== 'run' && keys.current(unit.key) !== unit.key) - ) -} - -/** iOS is still bouncing the list past an end. A write then lands while WebKit owns the scroll, and - * the list stops taking the reader's scrolls until they leave the chat. */ -function overscrolled({ scrollTop, scrollHeight, clientHeight }: Scroller): boolean { - return scrollTop < 0 || scrollTop > scrollHeight - clientHeight + 1 -} - -/** Keeps a parked reader's place on the page when the source window changes above them (an older - * page, a cap trim) or the rows regroup (Tools, text size): the first part of what they were reading that the update kept goes back where - * it was. Where the browser has scroll anchoring it keeps the place through ordinary changes, so the - * hold runs only for a regroup or a move to another row, with the browser's anchoring off from - * before the update until its correction. A following reader is left to the follow owner, which - * holds the end. A class because only `getSnapshotBeforeUpdate` sees the rows before the update is - * in the document. A native list holds its own place (`maintainVisibleContentPosition`). */ -export class MobileNativeChatScrollHold extends Component { - getSnapshotBeforeUpdate(previous: Props): Unit[] | null { - const regrouped = previous.layoutKey !== this.props.layoutKey - const moved = new Set( - Array.from(previous.runPlacements ?? []).flatMap(([owner, row]) => - this.props.runPlacements?.get(owner) !== row ? [owner] : [] - ) - ) - if (Platform.OS === 'web' && previous.rowKeys.scope === this.props.rowKeys.scope) { - this.props.roots?.capture(moved, regrouped) - } - // Another chat opens at its end, which the follow owner resets only after this reads it. - if ( - Platform.OS !== 'web' || - previous.rowKeys.scope !== this.props.rowKeys.scope || - (previous.rowKeys.sourceHead === this.props.rowKeys.sourceHead && - !regrouped && - moved.size === 0) || - this.props.isFollowing() - ) { - return null - } - const list = this.props.listRef.current - const scroller = list?.getScrollableNode() - const held = - scroller && !overscrolled(scroller) ? placeOnScreen(scroller, previous.rowKeys) : null - if ( - !held || - (!scrollWritesStopMomentum() && - !regrouped && - !moves(held[0], this.props.rowKeys, this.props.runPlacements, this.props.roots)) - ) { - return null - } - list?.holdPlace?.(true) - return held - } - - componentDidMount(): void { - this.props.roots?.restoreAll() - } - - componentDidUpdate(previous: Props, _state: unknown, held: Unit[] | null): void { - try { - const regrouped = previous.layoutKey !== this.props.layoutKey - this.props.roots?.restoreAll(regrouped, false) - if (held) { - this.restore(held, regrouped) - } - } finally { - this.props.roots?.releaseHold() - // Whatever the correction did, the scroll goes back to its owner. - if (held) { - this.props.listRef.current?.holdPlace?.(false) - } - } - } - - private restore(held: Unit[], regrouped: boolean): void { - const list = this.props.listRef.current - const scroller = held.length ? list?.getScrollableNode() : null - if (!scroller) { - return - } - const rows = new Map(tagged(scroller, ROW).map(({ id, node }) => [id, node])) - for (const unit of held) { - const node = drawn(unit, rows, this.props.rowKeys, this.props.roots) - if (node) { - const drift = node.getBoundingClientRect().top - unit.top - // Sub-pixel drift is layout noise; a write would stop iOS momentum for nothing. - if (Math.abs(drift) >= 0.5 && list?.keepInPlace) { - list.keepInPlace(drift, regrouped) - } else if (Math.abs(drift) >= 0.5) { - scroller.scrollTop += drift - } - return - } - } - } - - render(): React.ReactNode { - return this.props.children ?? null - } -} diff --git a/mobile/src/session/MobileNativeChatScrollRootsBoundary.tsx b/mobile/src/session/MobileNativeChatScrollRootsBoundary.tsx new file mode 100644 index 000000000000..9c0d2befda17 --- /dev/null +++ b/mobile/src/session/MobileNativeChatScrollRootsBoundary.tsx @@ -0,0 +1,46 @@ +import { Component } from 'react' +import { Platform } from 'react-native' +import type { MobileNativeChatRowKeys } from './mobile-native-chat-row-keys' +import type { MobileNativeChatScrollRoots } from './mobile-native-chat-scroll-roots' + +type Props = { + children?: React.ReactNode + roots?: MobileNativeChatScrollRoots + runPlacements?: ReadonlyMap + rowKeys: Pick + /** How rows draw (tools shown, text size); a change regroups every open box. */ + layoutKey?: string +} + +/** The commit boundary of the page's inner scroll boxes: each remembers its place before an update + * and gets it back after, deepest first. A class because only `getSnapshotBeforeUpdate` sees the + * boxes before the update is in the document. */ +export class MobileNativeChatScrollRootsBoundary extends Component { + getSnapshotBeforeUpdate(previous: Props): null { + if (Platform.OS === 'web' && previous.rowKeys.scope === this.props.rowKeys.scope) { + const moved = new Set( + Array.from(previous.runPlacements ?? []).flatMap(([owner, row]) => + this.props.runPlacements?.get(owner) !== row ? [owner] : [] + ) + ) + this.props.roots?.capture(moved, previous.layoutKey !== this.props.layoutKey) + } + return null + } + + componentDidMount(): void { + this.props.roots?.restoreAll() + } + + componentDidUpdate(previous: Props): void { + try { + this.props.roots?.restoreAll(previous.layoutKey !== this.props.layoutKey, false) + } finally { + this.props.roots?.releaseHold() + } + } + + render(): React.ReactNode { + return this.props.children ?? null + } +} diff --git a/mobile/src/session/MobileNativeChatToolLine.tsx b/mobile/src/session/MobileNativeChatToolLine.tsx index 2d601ddf3388..467cdfae8f01 100644 --- a/mobile/src/session/MobileNativeChatToolLine.tsx +++ b/mobile/src/session/MobileNativeChatToolLine.tsx @@ -159,7 +159,7 @@ export function MobileNativeChatToolLine({ fontScale, onOpenFile }: { - /** Tags the line for the page's place-hold. */ + /** Tags the line, so the page keeps the reader on it. */ memberId?: string block: NativeChatToolCallBlock | NativeChatToolResultBlock /** This call's output, when the run paired one to it. */ diff --git a/mobile/src/session/MobileNativeChatToolRun.tsx b/mobile/src/session/MobileNativeChatToolRun.tsx index 83da72d948e5..cd59281c9688 100644 --- a/mobile/src/session/MobileNativeChatToolRun.tsx +++ b/mobile/src/session/MobileNativeChatToolRun.tsx @@ -52,7 +52,7 @@ import { useMobileNativeChatDisclosure } from './mobile-native-chat-disclosure-store' import { toolStyles as styles, toolType } from './mobile-native-chat-tool-styles' -import { mobileNativeChatMemberId } from './MobileNativeChatScrollHold' +import { mobileNativeChatMemberId } from './mobile-native-chat-reading-place' import { MobileNativeChatOwnedScrollView } from './MobileNativeChatOwnedScrollView' import type { MobileNativeChatToolRunAsides, @@ -343,7 +343,7 @@ function ToolRunMemberList({ const { editCards, consumedResults } = useMemo(() => buildEditCards(blocks), [blocks]) const { resultByCall, pairedResults } = useMemo(() => pairNativeChatToolResults(blocks), [blocks]) const seen = new Map() - // Each drawn member's line identity: the page's place-hold finds it by this, and a card keyed by + // Each drawn member's line identity: the page finds the reader's line by this, and a card keyed by // it keeps its state when older calls join the run above it. const memberSeen = new Map() const memberIdentity = (block: NativeChatBlock) => diff --git a/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts b/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts index 92bd95df52c0..b9e144e0abfd 100644 --- a/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts +++ b/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts @@ -29,6 +29,7 @@ export type Overrides = { sendSurfaceId?: string keyboardInset?: number hasMore?: boolean + olderPages?: Parameters[0]['olderPages'] onLoadEarlier?: () => void status?: Parameters[0]['status'] error?: string @@ -76,7 +77,6 @@ export function installChatViewHooks(): void { scrollToEnd.mockReset() scrollToOffset.mockReset() Object.assign(pageScroller, { scrollTop: 0, scrollHeight: 0, clientHeight: 500 }) - pageScroller.style.overflowAnchor = '' vi.unstubAllGlobals() }) } diff --git a/mobile/src/session/MobileNativeChatView.history-paging.test.ts b/mobile/src/session/MobileNativeChatView.history-paging.test.ts index 7caf12ef675c..3c1d9ebf12b6 100644 --- a/mobile/src/session/MobileNativeChatView.history-paging.test.ts +++ b/mobile/src/session/MobileNativeChatView.history-paging.test.ts @@ -133,232 +133,83 @@ describe('MobileNativeChatView: history paging and anchoring', () => { expect(list().props.maintainVisibleContentPosition).toEqual({ minIndexForVisible: 0 }) }) - describe('on the page, where the list reports no drag or momentum', () => { - const observers = new Set<(entries: unknown[]) => void>() - // The browser's resize step, before paint; RN Web's size report comes a task later. - const resized = () => act(() => observers.forEach((deliver) => deliver([]))) + describe('on the page, whose list is anchored at its end', () => { const page = () => root().find((node) => hostType(node) === 'ScrollView') - const loadEarlier = () => - root().find( - (node) => - hostType(node) === 'Pressable' && - node.findAll((text) => text.props.children === 'Load earlier messages').length > 0 - ) + const jumpControl = () => + root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') beforeEach(() => { platform.OS = 'web' - scrollToOffset.mockImplementation((to: { offset: number }) => { - pageScroller.scrollTop = to.offset - }) - observers.clear() - vi.stubGlobal('ResizeObserver', function (deliver: (entries: unknown[]) => void) { - return { - observe: () => observers.add(deliver), - unobserve: () => {}, - disconnect: () => observers.delete(deliver) - } - }) + Object.assign(pageScroller, { scrollTop: 0, scrollHeight: 20_000, clientHeight: 500 }) }) afterEach(() => { platform.OS = 'ios' }) - function scrolledTo(y: number, contentHeight: number) { - Object.assign(pageScroller, { scrollTop: y, scrollHeight: contentHeight }) - return scrollEvent(y, contentHeight) - } - - // RN Web's report and the scroller's own event, for one position. - function scrollTo(y: number, contentHeight: number) { + // The reader's own scroll to `y`: 0 at the newest message, negative above it. + function readerScrollsTo(y: number) { act(() => { - page().props.onScroll(scrolledTo(y, contentHeight)) - pageScroller.scroll() + pageScroller.scrollTop = y + page().props.onScroll(scrollEvent(y, pageScroller.scrollHeight)) }) } - it('holds a following reader at the live end through growth and a smaller viewport', async () => { - const latest = [assistantTurn('a1', 'Latest')] - const landed = [assistantTurn('a0', 'Older'), ...latest] - const onLoadEarlier = vi.fn() - await render({ folded: latest, hasMore: true, onLoadEarlier }) - scrollTo(500, 1_000) - - // Older rows land; the observer pins before paint. - act(() => loadEarlier().props.onPress()) - await update({ folded: landed, hasMore: true, onLoadEarlier }) - pageScroller.scrollHeight = 5_000 - resized() - expect(onLoadEarlier).toHaveBeenCalledOnce() - expect(scrollToOffset).toHaveBeenCalledOnce() - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 4_500 }) + it('stops following once the reader scrolls off the end, and follows again back at it', async () => { + await render({ folded: [assistantTurn('a1', 'Latest')] }) - // The pin's own move, short of a reply that grew meanwhile, is not the reader's. - scrollTo(4_500, 5_400) - resized() - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 4_900 }) + readerScrollsTo(-600) + expect(jumpControl()).toHaveLength(1) + readerScrollsTo(0) + expect(jumpControl()).toHaveLength(0) - // The browser clamping the scroll to a shorter end is layout's, not the reader's: it still - // follows, and the next growth pins. - scrollTo(3_500, 4_000) - pageScroller.scrollHeight = 5_400 - resized() - expect(scrollToOffset).toHaveBeenCalledTimes(3) - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 4_900 }) - scrollTo(4_900, 5_400) - - // The reader's wheel moves it up: it leaves the end. - pageScroller.input('wheel') - scrollTo(3_000, 5_400) - pageScroller.scrollHeight = 6_000 - resized() - expect(scrollToOffset).toHaveBeenCalledTimes(3) - expect( - root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') - ).toHaveLength(1) - - // Back at the end it follows again, and a smaller viewport pins to the live end. - scrollTo(5_500, 6_000) - pageScroller.scrollHeight = 6_400 - resized() - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 5_900 }) - pageScroller.clientHeight = 300 - resized() - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 6_100 }) - expect(scrollToEnd).not.toHaveBeenCalled() - }) - - it('resumes an expired downward fling near the end, preserving small upward detachment', async () => { - const now = vi.spyOn(Date, 'now').mockReturnValue(10_000) - vi.stubGlobal('navigator', { userAgent: 'Chrome' }) - const browserHolds = () => pageScroller.style.overflowAnchor === 'auto' - await render({ folded: ['a1', 'a2', 'a3'].map((id) => assistantTurn(id, id)) }) - scrollTo(500, 1_000) - resized() - pageScroller.input('wheel') - scrollTo(497, 1_000) - // Expired input and geometry away from the end cannot detach or attach by themselves. - now.mockReturnValue(11_100) - pageScroller.scrollHeight = 1_200 - resized() - expect(browserHolds()).toBe(true) - scrollTo(632, 1_200) - expect(browserHolds()).toBe(false) - pageScroller.scrollHeight = 1_300 - resized() - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 800 }) - scrollTo(800, 1_300) // Deliver the pin's own scroll before the next reader movement. - // The first upward movement can arrive in resize before its scroll event. - pageScroller.input('wheel') - pageScroller.scrollTop = 780 - resized() - expect(browserHolds()).toBe(true) - scrollTo(799, 1_300) - expect(browserHolds()).toBe(false) - now.mockRestore() - }) - - it('detaches a following reader who drags up slowly, one pixel per scroll event', async () => { - // iOS, where it happened: WebKit has no anchoring, so every move that isn't a clamp is the reader's. - vi.stubGlobal('navigator', { - userAgent: - 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1' - }) - await render({ folded: ['a1', 'a2', 'a3'].map((id) => assistantTurn(id, id)) }) - scrollTo(500, 1_000) - resized() - const pins = scrollToOffset.mock.calls.length - // Each step's frame reconciles what it drew, as the browser's resize step does. - for (let top = 499; top >= 470; top -= 1) { - scrollTo(top, 1_000) - resized() - } - // A reply lands: the reader who dragged 30px up is not pulled back to the end. - pageScroller.scrollHeight = 1_063 - resized() - expect(scrollToOffset.mock.calls.length).toBe(pins) + // The control jumps to the end through the list, whose own move is not the reader's. + readerScrollsTo(-600) + act(() => jumpControl()[0]!.props.onPress()) + expect(pageScroller.scrollTop).toBe(0) + act(() => page().props.onScroll(scrollEvent(0, pageScroller.scrollHeight))) + expect(jumpControl()).toHaveLength(0) }) - it('resumes following after a slow one-pixel return to the end on Safari', async () => { - vi.stubGlobal('navigator', { - userAgent: - 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1' - }) - await render({ folded: ['a1', 'a2', 'a3'].map((id) => assistantTurn(id, id)) }) - scrollTo(500, 1_000) - resized() - // Deliver the initial pin's own scroll before the reader moves. - scrollTo(500, 1_000) - scrollTo(470, 1_000) - resized() - const pins = scrollToOffset.mock.calls.length - - for (let top = 471; top <= 500; top += 1) { - scrollTo(top, 1_000) - resized() - expect(scrollToOffset.mock.calls.length).toBe(pins) - } - - pageScroller.scrollHeight = 1_063 - resized() - expect(scrollToOffset.mock.calls.length).toBe(pins + 1) - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 563 }) - expect(pageScroller.scrollTop).toBe(563) - }) - - it.each([false, true])( - 'leaves a parked reader parked when a collapse below them clamps the page onto its end (finger down: %s)', - async (fingerDown) => { - await render({ folded: ['a1', 'a2', 'a3'].map((id) => assistantTurn(id, id)) }) - scrollTo(500, 1_000) - resized() - // Parked 29 px short of the end by the reader's own wheel. - pageScroller.input('wheel') - scrollTo(471, 1_000) - const pins = scrollToOffset.mock.calls.length - - // A run below collapses: the browser clamps onto the shorter end, possibly under the tap. - if (fingerDown) { - pageScroller.input('touchstart') - } - scrollTo(200, 700) - resized() - if (fingerDown) { - pageScroller.input('touchend') - } - // A reply lands. Main leaves the reader where the clamp put them; so does the page. - pageScroller.scrollHeight = 763 - resized() - expect(scrollToOffset.mock.calls.length).toBe(pins) - } - ) - - it("opens another chat at its live end, not the last chat's", async () => { + it('opens another chat at its newest message, not where the reader left the last one', async () => { await render({ folded: [assistantTurn('a1', 'Chat A')] }) - scrollTo(500, 1_000) - // The next chat is taller; its size report hasn't come yet. - pageScroller.scrollHeight = 2_500 - scrollToOffset.mockClear() + readerScrollsTo(-600) await update({ folded: [assistantTurn('b1', 'Chat B')], sendSurfaceId: 'tab-b' }) - expect(scrollToEnd).not.toHaveBeenCalled() - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 2_000 }) + expect(pageScroller.scrollTop).toBe(0) + expect(jumpControl()).toHaveLength(0) }) - it('loads older history from the Load earlier control, not from reaching the top', async () => { + it('asks for older history ahead of the top, and after a failure only the retry asks', async () => { const onLoadEarlier = vi.fn() - await render({ folded: [assistantTurn('a1', 'Latest')], hasMore: true, onLoadEarlier }) + const folded = [assistantTurn('a1', 'Latest')] + await render({ folded, hasMore: true, onLoadEarlier }) + expect(onLoadEarlier).not.toHaveBeenCalled() - // Down to the top and into iOS's bounce past it. - act(() => { - page().props.onScroll(scrolledTo(40, 540)) - page().props.onScroll(scrolledTo(-15, 540)) - }) + // Ten screens from the top of what is loaded, from the reader or not. + readerScrollsTo(-15_000) + expect(onLoadEarlier).toHaveBeenCalled() + + onLoadEarlier.mockClear() + await update({ folded, hasMore: true, olderPages: 'loading', onLoadEarlier }) + readerScrollsTo(-19_500) + await update({ folded, hasMore: true, olderPages: 'failed', onLoadEarlier }) + readerScrollsTo(-19_500) expect(onLoadEarlier).not.toHaveBeenCalled() - act(() => loadEarlier().props.onPress()) + const retry = root().find( + (node) => hostType(node) === 'Pressable' && node.props.accessibilityRole === 'button' + ) + act(() => retry.props.onPress()) expect(onLoadEarlier).toHaveBeenCalledOnce() }) + + it('fills a chat that opens shorter than its reach without a scroll', async () => { + const onLoadEarlier = vi.fn() + pageScroller.scrollHeight = 600 + await render({ folded: [assistantTurn('a1', 'Latest')], hasMore: true, onLoadEarlier }) + expect(onLoadEarlier).toHaveBeenCalled() + }) }) }) diff --git a/mobile/src/session/MobileNativeChatView.render-stubs.test-fixture.ts b/mobile/src/session/MobileNativeChatView.render-stubs.test-fixture.ts index 484e9de916ac..7ca6b35048d0 100644 --- a/mobile/src/session/MobileNativeChatView.render-stubs.test-fixture.ts +++ b/mobile/src/session/MobileNativeChatView.render-stubs.test-fixture.ts @@ -3,34 +3,30 @@ import { vi } from 'vitest' const scrollToEnd = vi.hoisted(() => vi.fn()) const scrollToOffset = vi.hoisted(() => vi.fn()) const platform = vi.hoisted(() => ({ OS: 'ios' })) -// The page list's live DOM geometry. -const pageScroller = vi.hoisted(() => { - const listeners = new Map void>>() - return { - scrollTop: 0, - scrollHeight: 0, - clientHeight: 500, - clientWidth: 320, - style: { overflowAnchor: '' }, - getInnerViewNode: () => ({}), - getBoundingClientRect: () => ({ top: 0, bottom: 500 }), - addEventListener: (type: string, listener: (event?: unknown) => void) => { - listeners.set(type, (listeners.get(type) ?? new Set()).add(listener)) - }, - removeEventListener: (type: string, listener: (event?: unknown) => void) => { - listeners.get(type)?.delete(listener) - }, - // The browser's scroll event at the scroller's current position. - scroll: () => listeners.get('scroll')?.forEach((listener) => listener()), - // The reader's hand on the scroller, e.g. a wheel turn. - input: (type: string) => listeners.get(type)?.forEach((listener) => listener()) - } -}) +// The page list's scrollable node: the geometry it reads and the touches it listens to. +const pageScroller = vi.hoisted(() => ({ + scrollTop: 0, + scrollHeight: 0, + clientHeight: 500, + getInnerViewNode: () => ({}), + getBoundingClientRect: () => ({ top: 0, bottom: 500 }), + getAttribute: () => null, + querySelectorAll: () => [], + addEventListener: () => {}, + removeEventListener: () => {} +})) export async function reactNativeStub() { const React = await import('react') return { ActivityIndicator: 'ActivityIndicator', + Animated: { + Value: class { + constructor(readonly value: number) {} + }, + timing: () => ({ start: () => {}, stop: () => {} }), + View: 'Animated.View' + }, FlatList: React.forwardRef((props, ref) => { const handle = { scrollToEnd, scrollToOffset, getScrollableNode: () => pageScroller } React.useImperativeHandle(ref, () => handle, []) @@ -44,7 +40,8 @@ export async function reactNativeStub() { scrollTo: ({ y, animated }: { y: number; animated: boolean }) => scrollToOffset({ animated, offset: y }), scrollToEnd, - getScrollableNode: () => pageScroller + getScrollableNode: () => pageScroller, + getInnerViewNode: () => pageScroller.getInnerViewNode() } React.useImperativeHandle(ref, () => handle, []) return React.createElement('ScrollView', props) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 268b5c382d31..54b8710c0107 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -50,7 +50,7 @@ import { NO_COMPOSER_TRAY, type ComposerTrayProps } from './use-mobile-native-ch import type { MobileChatPermission } from './mobile-native-chat-permission' import type { MobileChatQuestion } from './mobile-native-chat-question' import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' -import { MobileNativeChatScrollHold } from './MobileNativeChatScrollHold' +import { MobileNativeChatScrollRootsBoundary } from './MobileNativeChatScrollRootsBoundary' import { MobileNativeChatRowList } from './MobileNativeChatRowList' import { useMobileNativeChatRowRenderer } from './use-mobile-native-chat-row-renderer' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' @@ -92,8 +92,7 @@ type Props = ComposerTrayProps & { streaming: string | null hasMore?: boolean olderPages?: OlderPagePhase - /** `settled`: on the page, an older page waits for it so it never lands under a moving reader. */ - onLoadEarlier?: (settled?: () => Promise) => void + onLoadEarlier?: () => void onSend: (text: string) => Promise /** Route identity used to fence accepted sends that settle after a tab/view switch. */ sendSurfaceId: string @@ -248,7 +247,7 @@ export function MobileNativeChatView({ [messages, folded, streaming, pending, imagePreviewsByMessageId] ) const tail = useMobileNativeChatTailFollow({ hasItems: data.length > 0, scopeKey: sendSurfaceId }) - const { jumpToTail, recordScrollMetrics, scrollMayLoadEarlier, pageSettled } = tail + const { jumpToTail, recordScrollMetrics, scrollMayLoadEarlier } = tail const handleSend = useCallback( async (text: string): Promise => { @@ -266,19 +265,21 @@ export function MobileNativeChatView({ [onSend, onClearSendError, jumpToTail] ) - const loadEarlier = useCallback(() => onLoadEarlier?.(pageSettled), [onLoadEarlier, pageSettled]) + const loadEarlier = useCallback(() => onLoadEarlier?.(), [onLoadEarlier]) + // After a failure only a tap retries, so a reader still at the top can't loop the spinner. + const loadEarlierNearTop = hasMore && olderPages === 'idle' ? loadEarlier : undefined const onScroll = useCallback( (e: NativeSyntheticEvent) => { const { contentOffset } = e.nativeEvent recordScrollMetrics(e.nativeEvent) - // Near the top — page in older history. Only the reader's drag: a pin reports offsets too. - // After a failure only a tap retries, so a reader still at the top can't loop the spinner. - if (contentOffset.y < 60 && hasMore && olderPages === 'idle' && scrollMayLoadEarlier()) { - loadEarlier() + // Near the top of a native list — page in older history. Only the reader's drag: a pin reports + // offsets too. The page's list asks for itself. + if (contentOffset.y < 60 && scrollMayLoadEarlier()) { + loadEarlierNearTop?.() } }, - [hasMore, olderPages, loadEarlier, scrollMayLoadEarlier, recordScrollMetrics] + [loadEarlierNearTop, scrollMayLoadEarlier, recordScrollMetrics] ) // Per-turn status rows: one live indicator while the turn runs, then a settled @@ -327,10 +328,8 @@ export function MobileNativeChatView({ ) : null const header = mobileNativeChatListHeader(hasMore, olderPages, loadEarlier) - // How rows draw: a change re-lays every row, which the place-hold and the window both follow. + // How rows draw: a change re-lays every row, and the page keeps the reader's line through it. const layoutKey = `${fontScale}` - const pageHeader = (measuring: boolean) => - mobileNativeChatListHeader(hasMore, olderPages, loadEarlier, measuring) const footer = mobileNativeChatListFooter(liveStatus, turns.waitingRows, renderItem) const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) @@ -361,24 +360,21 @@ export function MobileNativeChatView({ ) : ( - - + new Animated.Value(0)) + useEffect(() => { + const fade = Animated.timing(opacity, { + toValue: 1, + duration: FADE_MS, + delay, + useNativeDriver: Platform.OS !== 'web' + }) + fade.start() + return () => fade.stop() + }, [opacity, delay]) + return {children} +} + +/** The top of a chat with older history. It loads on its own as the reader nears it, so this is a + * quiet spinner, a retry only after a failure, and nothing once history ends. */ export function mobileNativeChatListHeader( hasMore: boolean | undefined, olderPages: OlderPagePhase | undefined, - loadEarlier: () => void, - measuring = false + loadEarlier: () => void ): ReactElement | null { - if (olderPages === 'unavailable' && !measuring) { - // No retry gets past it, so the control gives way to a line that says so. + if (olderPages === 'unavailable') { return ( - {EARLIER_UNAVAILABLE} + + {EARLIER_UNAVAILABLE} + ) } - if (!hasMore && !measuring) { + if (!hasMore) { return null } - const loadingEarlier = measuring || olderPages === 'loading' + if (olderPages === 'failed') { + return ( + + + + + {LOAD_EARLIER_FAILED} + + [ + styles.loadEarlierRetry, + pressed && styles.loadEarlierRetryPressed + ]} + > + {LOAD_EARLIER_RETRY} + + + + + ) + } return ( - - {loadingEarlier ? ( - - ) : ( - Load earlier messages - )} - {olderPages === 'failed' ? ( - {LOAD_EARLIER_FAILED} - ) : null} - + + + + + ) } diff --git a/mobile/src/session/mobile-native-chat-page-heights.test.ts b/mobile/src/session/mobile-native-chat-page-heights.test.ts deleted file mode 100644 index 2b17925019b1..000000000000 --- a/mobile/src/session/mobile-native-chat-page-heights.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { expect, it } from 'vitest' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { MobileNativeChatPageHeights } from './mobile-native-chat-page-heights' -import type { MobileNativeChatTurnRow } from './use-mobile-native-chat-turn-disclosure' - -it('retires replaced source payloads without changing spacers or sweeping same-key content updates', () => { - const cache = new MobileNativeChatPageHeights() - const row = (id: string): NativeChatMessage => ({ - id, - role: 'user', - blocks: [], - source: 'transcript', - timestamp: 1 - }) - const keyOf = (m: NativeChatMessage) => m.id - const state: MobileNativeChatTurnRow = { - turnStatus: null, - turnExpanded: false, - activeTurnIsWorking: false, - trailingRun: false, - mayStillGrow: false, - reasoningExpanded: false, - onToggleReasoning: () => {}, - onToggleSubagentGroup: () => {}, - reasoningIsLive: false, - userMessageOpen: false, - onToggleUserMessage: () => {} - } - cache.remember('head', 80, row('head'), state, 'layout') - cache.remember('retired', 20, row('retired'), state, 'layout') - const before = ['head', 'retired', 'unknown'] - const source = cache.sourceKeys(['head', 'replacement', 'unknown'].map(row), keyOf, before) - expect(source.changed).toBe(true) - const slack = cache.px('retired')! - const geometry = cache.spacers(source.keys, [0, 0], [0, 1], slack) - cache.retain(source.keys) - expect(cache.px('retired')).toBeUndefined() - expect(cache.spacers(source.keys, [0, 0], [0, 1], slack)).toEqual(geometry) - expect(cache.sourceKeys(source.keys.map(row), keyOf, source.keys).changed).toBe(false) -}) diff --git a/mobile/src/session/mobile-native-chat-page-heights.ts b/mobile/src/session/mobile-native-chat-page-heights.ts deleted file mode 100644 index d1292c0741dc..000000000000 --- a/mobile/src/session/mobile-native-chat-page-heights.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import type { MobileNativeChatTurnRow } from './use-mobile-native-chat-turn-disclosure' - -const FIRST_ESTIMATE = 80 - -/** The drawn rows, as the measuring choice reads them. */ -type DrawnRows = { - keys: readonly string[] - rows: readonly NativeChatMessage[] - rowState: (index: number, row: NativeChatMessage) => MobileNativeChatTurnRow - start: number - /** The first row in the document: rows above it were never measured. */ - first: number - following: boolean -} - -type Entry = { px: number; row: NativeChatMessage; state: MobileNativeChatTurnRow; layout: string } - -function sameState(a: MobileNativeChatTurnRow, b: MobileNativeChatTurnRow): boolean { - const before = Object.entries(a) - const after = new Map(Object.entries(b)) - return before.length === after.size && before.every(([name, value]) => after.get(name) === value) -} - -/** Row heights as drawn. One holds while the row would draw the same: the same row object (unchanged - * rows keep theirs across a re-fold), the same row state and the same layout. A stale one keeps - * standing in for its row until it is measured again, so going stale never moves anything. */ -export class MobileNativeChatPageHeights { - private entries = new Map() - private sum = 0 - private samples = 0 - - px(key: string | undefined): number | undefined { - return key === undefined ? undefined : this.entries.get(key)?.px - } - - /** For rows never drawn, which only ever sit below the reader. */ - estimate(): number { - return this.samples > 0 ? this.sum / this.samples : FIRST_ESTIMATE - } - - valid( - key: string, - row: NativeChatMessage, - state: MobileNativeChatTurnRow, - layout: string - ): boolean { - const entry = this.entries.get(key) - return ( - entry !== undefined && - entry.row === row && - entry.layout === layout && - sameState(entry.state, state) - ) - } - - remember( - key: string, - px: number, - row: NativeChatMessage, - state: MobileNativeChatTurnRow, - layout: string - ): void { - if (!this.entries.has(key)) { - this.samples += 1 - } - this.sum += px - (this.entries.get(key)?.px ?? 0) - this.entries.set(key, { px, row, state, layout }) - } - - sourceKeys( - rows: readonly NativeChatMessage[], - keyOf: (row: NativeChatMessage) => string, - before: readonly string[] | undefined - ): { - keys: string[] - changed: boolean - indexOf: (key: string | undefined) => number | undefined - } { - let changed = before?.length !== rows.length - const keys = rows.map((row, index) => { - const key = keyOf(row) - changed ||= before?.[index] !== key - return key - }) - let index: Map | undefined - const indexOf = (key: string | undefined) => - key === undefined ? undefined : (index ??= new Map(keys.map((k, at) => [k, at]))).get(key) - return { keys, changed, indexOf } - } - - /** Retired payloads die after their heights supplied trim slack; estimation stays numeric. */ - retain(keys: readonly string[]): void { - const current = new Set(keys) - for (const key of this.entries.keys()) { - if (!current.has(key)) { - this.entries.delete(key) - } - } - } - - /** Spacer heights around the measuring rows `[m0, m1)` and the window `[start, end)`: above them - * only the measured run (from `first`, plus trim `slack`), between them, and below the window. */ - spacers( - keys: readonly string[], - [m0, m1]: [number, number], - [start, end]: [number, number], - slack: number - ): { first: number; above: number; mid: number; bottom: number } { - let [first, above, mid, bottom] = [m0, slack, 0, 0] - for (let px = this.px(keys[first - 1]); first > 0 && px !== undefined;) { - above += px - first -= 1 - px = this.px(keys[first - 1]) - } - for (let i = m1; i < start; i += 1) { - mid += this.px(keys[i]) ?? 0 - } - for (let i = end; i < keys.length; i += 1) { - bottom += this.px(keys[i]) ?? this.estimate() - } - return { first, above, mid, bottom } - } - - /** The run of rows nearest above `start` whose heights don't hold, `[from, to]`, at most `rows` - * long and within `reach` px of measured rows above the window; null when there is none. */ - nearestStale( - drawn: Pick, - layout: string, - reach: number, - rows: number - ): [number, number] | null { - const { keys, start } = drawn - const valid = (i: number): boolean => - this.valid(keys[i]!, drawn.rows[i]!, drawn.rowState(i, drawn.rows[i]!), layout) - let [to, distance] = [start - 1, 0] - for (; to >= 0 && distance <= reach && valid(to); to -= 1) { - distance += this.px(keys[to]) ?? 0 - } - if (to < 0 || distance > reach) { - return null - } - let from = to - while (from > 0 && to - from + 1 < rows && !valid(from - 1)) { - from -= 1 - } - return [from, to] - } - - /** The rows to measure next: stale ones within `near` px above the window; following, then the - * rows never drawn just above the measured run; then, with `full`, stale ones anywhere above. */ - next( - drawn: DrawnRows, - layout: string, - near: number, - rows: number, - full: boolean - ): [number, number] | null { - const close = this.nearestStale(drawn, layout, near, rows) - if (close || !drawn.following) { - return close - } - if (drawn.first > 0) { - return [Math.max(0, drawn.first - rows), drawn.first - 1] - } - return full ? this.nearestStale(drawn, layout, Infinity, rows) : null - } - - clear(): void { - this.entries.clear() - this.sum = 0 - this.samples = 0 - } -} diff --git a/mobile/src/session/mobile-native-chat-page-rows.ts b/mobile/src/session/mobile-native-chat-page-rows.ts deleted file mode 100644 index 98bbca6bb29a..000000000000 --- a/mobile/src/session/mobile-native-chat-page-rows.ts +++ /dev/null @@ -1,118 +0,0 @@ -type Scroller = { getBoundingClientRect: () => { top: number }; scrollTop: number } -import { mobileNativeChatScrollFrame } from './mobile-native-chat-scroll-roots' - -function isElement(node: unknown): node is Element { - return typeof Element !== 'undefined' && node instanceof Element -} - -/** The part of `row` at the screen's top line `view`: the first part starting on screen, or the - * innermost one crossing the line, as the browser's own scroll anchoring would pick. Growth above - * it inside the row then moves it, which the row's own top doesn't show. */ -function partAtTop(row: Element, view: number): Element { - let part = row - for (let child = row.firstElementChild; child;) { - const box = child.getBoundingClientRect() - if (box.bottom <= view || box.height === 0) { - child = child.nextElementSibling - } else if (mobileNativeChatScrollFrame(child, row) || box.top >= view) { - return child - } else { - part = child - child = child.firstElementChild - } - } - return part -} - -/** The page's drawn row nodes by key, each in the one observer while drawn, and the row the reader - * is reading: the first drawn row on screen and its offset from the scroller's top. */ -export class MobileNativeChatPageRows { - private nodes = new Map() - private keys = new WeakMap() - private refs = new Map void>() - private observer: ResizeObserver | null = null - /** The row and the part of it at the screen's top; `top` from the scroller's top, with the - * `scrollTop` it was read at. */ - private anchor: { key: string; part: Element; top: number; scrollTop: number } | null = null - - keyOf(node: Element): string | undefined { - return this.keys.get(node) - } - - node(key: string | undefined): Element | undefined { - return key === undefined ? undefined : this.nodes.get(key) - } - - readonly observeWith = (observer: ResizeObserver | null): void => { - this.observer = observer - this.nodes.forEach((node) => observer?.observe(node)) - } - - /** One ref per key for as long as the row is drawn, so a streamed commit never re-observes it. */ - readonly ref = (key: string): ((node: unknown) => void) => { - let ref = this.refs.get(key) - if (!ref) { - ref = (node: unknown) => { - const was = this.nodes.get(key) - if (was) { - this.observer?.unobserve(was) - this.nodes.delete(key) - } - if (isElement(node)) { - this.nodes.set(key, node) - this.keys.set(node, key) - this.observer?.observe(node) - } else { - this.refs.delete(key) - } - } - this.refs.set(key, ref) - } - return ref - } - - /** `deep`: anchor the part of the row at the screen's top, not the row's own top. */ - takeAnchor( - scroller: Scroller, - keys: readonly string[], - [start, end]: [number, number], - deep: boolean - ): void { - const view = scroller.getBoundingClientRect().top - let [low, high] = [start, end] - while (low < high) { - const middle = low + Math.floor((high - low) / 2) - const node = this.node(keys[middle]) - if (!node || node.getBoundingClientRect().bottom <= view) { - low = middle + 1 - } else { - high = middle - } - } - const key = keys[low] - const node = this.node(key) - const part = node && (deep ? partAtTop(node, view) : node) - const top = part && part.getBoundingClientRect().top - view - this.anchor = - key && part && top !== undefined ? { key, part, top, scrollTop: scroller.scrollTop } : null - } - - /** How far the anchored part moved in the content since it was taken (what is above it changed - * size), apart from any scrolling; null when it isn't drawn. */ - placeShift(scroller: Scroller): number | null { - const part = this.anchor?.part - if (!this.anchor || !part?.isConnected || !this.node(this.anchor.key)) { - return null - } - const now = part.getBoundingClientRect().top - scroller.getBoundingClientRect().top - return now + scroller.scrollTop - this.anchor.top - this.anchor.scrollTop - } - - placeMoved(scroller: Scroller): boolean { - return Math.abs(this.placeShift(scroller) ?? 0) >= 0.5 - } - - forgetAnchor(): void { - this.anchor = null - } -} diff --git a/mobile/src/session/mobile-native-chat-page-window-coverage.ts b/mobile/src/session/mobile-native-chat-page-window-coverage.ts deleted file mode 100644 index 9f38a69c152f..000000000000 --- a/mobile/src/session/mobile-native-chat-page-window-coverage.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** The drawn rows `[start, end)` of `n`, the first drawn row's top in content coordinates, and the - * scroller's viewport. */ -export type MobileNativeChatWindowGeometry = { - n: number - start: number - end: number - following: boolean - windowTop: number - scrollTop: number - viewport: number -} - -/** Rows within one screen of the viewport are drawn, rows past two are not; null when nothing - * changes. Rows above the window come only from the spacer, which holds measured heights, so drawing - * or dropping them never moves what the reader sees. `known` is a row's measured height. */ -export function coverMobileNativeChatWindow( - g: MobileNativeChatWindowGeometry, - known: (index: number) => number | undefined, - estimate: number -): { start: number; end: number } | null { - const { n, following, scrollTop, viewport } = g - const height = (at: number): number => known(at) ?? estimate - const [near, far] = [scrollTop - viewport, scrollTop + 2 * viewport] - let [start, end, top] = [g.start, g.end, g.windowTop] - let bottom = top - for (let at = start; at < end; at += 1) { - bottom += height(at) - } - if (!following && (top > scrollTop + viewport || bottom < scrollTop)) { - // Nothing drawn is on screen, so nothing needs to stay still: draw where the reader is. - if (top > scrollTop) { - while (start > 0 && known(start - 1) !== undefined && top > scrollTop) { - start -= 1 - top -= known(start)! - } - } else { - for (top = bottom, start = end; start < n - 1 && top + height(start) <= scrollTop;) { - top += height(start) - start += 1 - } - } - start = Math.min(start, n - 1) - for (end = start, bottom = top; end < n && bottom < far; end += 1) { - bottom += height(end) - } - } else { - while (start > 0 && top > near && known(start - 1) !== undefined) { - start -= 1 - top -= known(start)! - } - while (start < end - 1 && top + height(start) < scrollTop - 2 * viewport) { - top += height(start) - start += 1 - } - while (end < n && bottom < far) { - bottom += height(end) - end += 1 - } - while (!following && end - 1 > start && bottom - height(end - 1) > scrollTop + 3 * viewport) { - end -= 1 - bottom -= height(end) - } - } - end = following ? n : Math.max(end, start + 1) - return start === g.start && end === g.end ? null : { start, end } -} diff --git a/mobile/src/session/mobile-native-chat-page-window-state.ts b/mobile/src/session/mobile-native-chat-page-window-state.ts deleted file mode 100644 index 95786058b18d..000000000000 --- a/mobile/src/session/mobile-native-chat-page-window-state.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import type { MobileNativeChatTurnRow } from './use-mobile-native-chat-turn-disclosure' -import type { MobileNativeChatRowKeys } from './mobile-native-chat-row-keys' -import type { MobileNativeChatScrollRoots } from './mobile-native-chat-scroll-roots' - -type Box = { top: number; bottom: number } -type Measured = { getBoundingClientRect: () => Box } - -export type MobileNativeChatWindowScroller = Measured & { - scrollTop: number - scrollHeight: number - clientHeight: number - clientWidth: number - style?: { overflowAnchor: string } - addEventListener: ( - type: string, - listener: (event: unknown) => void, - options: { passive: true } - ) => void - removeEventListener: (type: string, listener: (event: unknown) => void) => void -} - -export type MobileNativeChatWindowRowState = ( - index: number, - row: NativeChatMessage -) => MobileNativeChatTurnRow -export type MobileNativeChatWindowGroup = { from: string; fromAt: number; to: string; toAt: number } - -/** Where the window stands, by key, so landings and trims never change which rows are drawn. */ -export type MobileNativeChatWindowSeat = { - scope: string - first: string - firstAt: number - last: string | null - lastAt: number - /** Measured rows trimmed from above a parked reader: their height stays until settled. */ - slack: number - /** Rows drawn in place above the window only so they get measured. */ - measure: MobileNativeChatWindowGroup | null -} - -export type MobileNativeChatWindowDrawn = { - keys: readonly string[] - rows: readonly NativeChatMessage[] - indexOf: (key: string | undefined) => number | undefined - rowState: MobileNativeChatWindowRowState - start: number - end: number - /** The first row in the document: rows above it were never measured. */ - first: number - sourceHead: string | undefined - following: boolean - layoutKey: string -} - -export type MobileNativeChatPageWindowArgs = { - roots?: MobileNativeChatScrollRoots - rows: readonly NativeChatMessage[] - rowKeys: Pick - rowState: MobileNativeChatWindowRowState - following: boolean - /** At event time, before a re-render lands. */ - isFollowing: () => boolean - layoutKey: string - scroller: () => MobileNativeChatWindowScroller | null | undefined -} diff --git a/mobile/src/session/mobile-native-chat-reading-place.test.tsx b/mobile/src/session/mobile-native-chat-reading-place.test.tsx index 5a3efd58e328..b943b61b02bc 100644 --- a/mobile/src/session/mobile-native-chat-reading-place.test.tsx +++ b/mobile/src/session/mobile-native-chat-reading-place.test.tsx @@ -1,11 +1,11 @@ -import { createElement as h, Fragment, useMemo, type ReactElement, type ReactNode } from 'react' +import { createElement as h, useMemo, type ReactElement, type ReactNode } from 'react' import { act, create, type ReactTestRenderer, type ReactTestRendererJSON as Node } from 'react-test-renderer' -import { expect, it, vi } from 'vitest' +import { afterEach, expect, it, vi } from 'vitest' import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold' import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data' @@ -13,23 +13,26 @@ import { useMobileNativeChatRowKeys, type MobileNativeChatRowKeys } from './mobile-native-chat-row-keys' -import { MobileNativeChatScrollHold, mobileNativeChatRowId } from './MobileNativeChatScrollHold' +import { mobileNativeChatRowId } from './mobile-native-chat-reading-place' +import { MobileNativeChatPageList } from './MobileNativeChatPageList' import { ToolRun } from './MobileNativeChatToolRun' const platform = vi.hoisted(() => ({ OS: 'web' })) -// The page's own hold, as on iOS: where the browser has scroll anchoring it keeps these places -// itself, and the hold runs only for a regroup or a move to another row. -vi.stubGlobal('navigator', { - userAgent: - 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1' +// The page list's scrollable node: the fake page below, once mounted. +const scrollable = vi.hoisted(() => { + const held: { node: unknown } = { node: null } + return held }) vi.mock('react-native', async () => { const React = await import('react') - // A class, so an opened run's box can scroll it the way it scrolls a real ScrollView. + // A class, so an opened run's box can scroll it the way it scrolls a real ScrollView, and the + // page list reads its scrollable node through it. class ScrollView extends React.Component<{ children?: ReactNode }> { scrollToEnd = (): void => {} scrollTo = (): void => {} + getScrollableNode = (): unknown => scrollable.node + getInnerViewNode = (): object => ({}) render(): ReactNode { return React.createElement('div', null, this.props.children) } @@ -69,6 +72,28 @@ vi.mock('../components/MobileSourceCopyButton', () => ({ MobileSourceCopyButton: vi.mock('../platform/external-link', () => ({ openExternalLink: vi.fn() })) vi.mock('../components/inline-text-selection', () => ({ INLINE_TEXT_SELECTION: false })) +// The browser's resize step: after layout, before paint. +const observers = new Set<() => void>() +const resized = () => act(() => observers.forEach((deliver) => deliver())) +vi.stubGlobal( + 'ResizeObserver', + class { + constructor(private readonly deliver: () => void) { + observers.add(deliver) + } + observe(): void {} + unobserve(): void {} + disconnect(): void { + observers.delete(this.deliver) + } + } +) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + const say = (id: string, role: 'user' | 'assistant', text: string): NativeChatMessage => ({ id, role, @@ -103,7 +128,9 @@ type Element = { const oldHostTail = (): NativeChatMessage[] => JSON.parse(JSON.stringify(chat)) const from = (id: string) => oldHostTail().slice(chat.findIndex((m) => m.id === id)) -/** The page with one 20-tall line per text, as the list's scrollable node counting its writes. */ +/** The page with one 20-tall line per text, as the list's scrollable node counting its writes. It is + * anchored at its end as the real one is: scrollTop is 0 at the end and negative above it, so rows + * added above leave the view where it was. */ function page(tree: () => Node | Node[] | null) { const boxes = (): Box[] => { const out: Box[] = [] @@ -128,8 +155,8 @@ function page(tree: () => Node | Node[] | null) { { getAttribute: () => box.id, getBoundingClientRect: () => ({ - top: box.top - node.scrollTop, - bottom: box.bottom - node.scrollTop + top: box.top - node.viewTop(), + bottom: box.bottom - node.viewTop() }), querySelectorAll: (inner: string) => select(all, at + 1, box.end, inner) } @@ -138,6 +165,7 @@ function page(tree: () => Node | Node[] | null) { ) } let scrollTop = 0 + const listeners = new Map void>() const node = { writes: 0, get scrollTop(): number { @@ -151,11 +179,25 @@ function page(tree: () => Node | Node[] | null) { get scrollHeight(): number { return boxes()[0]?.bottom ?? 0 }, + /** Where the viewport's top is in the content. */ + viewTop: (): number => Math.max(0, node.scrollHeight - node.clientHeight) + node.scrollTop, + /** Puts the viewport's top at `y` in the content. */ + showFrom: (y: number): void => { + node.scrollTop = y - Math.max(0, node.scrollHeight - node.clientHeight) + }, getAttribute: () => null, getBoundingClientRect: () => ({ top: 0, bottom: 200 }), + addEventListener: (type: string, listener: () => void): void => { + listeners.set(type, listener) + }, + removeEventListener: (type: string): void => { + listeners.delete(type) + }, + /** The reader's finger on the list. */ + touch: (type: 'touchstart' | 'touchend'): void => listeners.get(type)?.(), querySelectorAll: (selector: string): Element[] => select(boxes(), 0, Infinity, selector), top: (id: string): number => - (boxes().find((box) => box.id === id)?.top ?? Number.NaN) - node.scrollTop + (boxes().find((box) => box.id === id)?.top ?? Number.NaN) - node.viewTop() } return node } @@ -169,15 +211,12 @@ function Row({ message, rowKey }: { message: NativeChatMessage; rowKey: string } return h('div', { testID: mobileNativeChatRowId(rowKey) }, words, run) } -type Scroller = ReturnType - let shownKeys!: MobileNativeChatRowKeys function Chat(props: { messages: NativeChatMessage[] following: boolean live: boolean - listRef: { current: { getScrollableNode: () => Scroller } } }): ReactElement { const folded = useMemo( () => foldMobileNativeChatMessages(props.messages, 'claude'), @@ -185,20 +224,20 @@ function Chat(props: { ) const keys = useMobileNativeChatRowKeys(folded, props.messages, 'chat') shownKeys = keys - return h( - Fragment, - null, - h(MobileNativeChatScrollHold, { - rowKeys: keys, - isFollowing: () => props.following, - listRef: props.listRef - }), - h('div', { testID: 'header' }, h('span', null, 'Load earlier')), - folded.map((item) => - h(Row, { key: keys.keyOf(item), message: item, rowKey: keys.keyOf(item) }) - ), - props.live ? h('span', null, 'Working') : null - ) + return h(MobileNativeChatPageList, { + ref: null, + rows: folded, + rowKeys: keys, + isFollowing: () => props.following, + onEndDistance: () => {}, + renderItem: ({ item }) => h(Row, { message: item, rowKey: keys.keyOf(item) }), + layoutKey: 'layout', + contentContainerStyle: null, + header: h('div', { testID: 'header' }, h('span', null, 'Loading')), + footer: props.live ? h('span', null, 'Working') : null, + emptyStateView: null, + onScroll: () => {} + }) } function mount(messages: NativeChatMessage[], options = { following: false, live: false }) { @@ -207,12 +246,28 @@ function mount(messages: NativeChatMessage[], options = { following: false, live renderer = create(h('div')) }) const scroller = page(() => renderer.toJSON()) - const listRef = { current: { getScrollableNode: () => scroller } } + scrollable.node = scroller const show = (next: NativeChatMessage[]): number => { const before = scroller.writes - act(() => renderer.update(h(Chat, { ...options, messages: next, listRef }))) + act(() => renderer.update(h(Chat, { ...options, messages: next }))) return scroller.writes - before } + // The reader's own scroll, as RN Web reports it, on a clock the test moves. + vi.useFakeTimers({ now: 10_000, toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + const scrolled = () => + act(() => + renderer.root.find((n) => n.props.onScroll && n.props.scrollEventThrottle).props.onScroll({}) + ) + const rest = () => + act(() => { + vi.advanceTimersByTime(400) + }) + /** The reader scrolls there and stops. */ + const read = (move: () => void) => { + move() + scrolled() + rest() + } show(messages) const find = (id: string) => renderer.root.find((n) => n.props.testID === id) const press = (id: string) => act(() => find(id).findAllByType('button')[0]!.props.onPress()) @@ -222,7 +277,7 @@ function mount(messages: NativeChatMessage[], options = { following: false, live renderer.root .findAll((n) => n.type === 'div' && String(n.props.testID).startsWith('chat-row:')) .map((n) => n.props.testID) - return { scroller, show, press, drawn, lines, find, rows } + return { scroller, show, press, drawn, lines, find, rows, scrolled, rest, read } } it('keeps the line a reader was on, its run open, when older messages join the run from an old host', () => { @@ -233,14 +288,14 @@ it('keeps the line a reader was on, its run open, when older messages join the r chat0.press(mobileNativeChatRowId('a32')) chat0.press('chat-member:call:c34') const opened = chat0.lines('chat-member:call:c34') - chat0.scroller.scrollTop += chat0.scroller.top('chat-member:call:c34') + chat0.read(() => (chat0.scroller.scrollTop += chat0.scroller.top('chat-member:call:c34'))) // An old host ignores the cursor and sends the whole tail again: new objects, the same ids. for (const [older, first] of [ ['r21', 'c32'], ['u0', 'c22'] ] as const) { if (atTop) { - chat0.scroller.scrollTop = 0 + chat0.read(() => chat0.scroller.showFrom(0)) } const line = `chat-member:call:${atTop ? first : 'c34'}` const top = chat0.scroller.top(line) @@ -268,7 +323,7 @@ it('keeps the line a reader was on, its run open, when older messages join the r const tail = mount([...from('r21'), ...later]) if (open) { tail.press(mobileNativeChatRowId('a22')) - tail.scroller.scrollTop += tail.scroller.top('chat-member:call:c27') + 40 + tail.read(() => (tail.scroller.scrollTop += tail.scroller.top('chat-member:call:c27') + 40)) } const before = tail.scroller.top(mobileNativeChatRowId('t0')) tail.show([...oldHostTail(), ...later]) @@ -276,18 +331,42 @@ it('keeps the line a reader was on, its run open, when older messages join the r } }) -it('leaves the view to iOS while it bounces the list past the top, and to a native list', () => { - for (const [os, scrollTop] of [ - ['web', -15], - ['ios', 40] - ] as const) { - platform.OS = os - const chat0 = mount(from('r21')) - chat0.press(mobileNativeChatRowId('a22')) - chat0.scroller.scrollTop = scrollTop - expect(chat0.show(oldHostTail())).toBe(0) - } - platform.OS = 'web' +it('holds back a change below a scrolling reader until they rest, then keeps their line', () => { + const rows = Array.from({ length: 12 }, (_, i) => say(`m${i}`, i % 2 ? 'assistant' : 'user', 'x')) + const chat0 = mount(rows) + chat0.scroller.showFrom(40) + chat0.scrolled() + // Mid-fling the reply below would push the line, and putting it back would stop the fling. + const line = chat0.scroller.top(mobileNativeChatRowId('m4')) + expect(chat0.show([...rows, say('n1', 'assistant', 'reply')])).toBe(0) + expect(chat0.drawn(mobileNativeChatRowId('n1'))).toBe(0) + expect(chat0.scroller.top(mobileNativeChatRowId('m4'))).toBe(line) + // At rest it lands, and the line stays where the reader stopped. + chat0.rest() + expect(chat0.drawn(mobileNativeChatRowId('n1'))).toBe(1) + expect(chat0.scroller.top(mobileNativeChatRowId('m4'))).toBe(line) +}) + +it('holds a row the reader taps open in place, and a reply below waits out a flick', () => { + const later = Array.from({ length: 10 }, (_, i) => say(`t${i}`, 'user', 'later')) + const chat0 = mount([...from('r21'), ...later]) + const header = mobileNativeChatRowId('a22') + chat0.read(() => (chat0.scroller.scrollTop += chat0.scroller.top(header) - 40)) + // A tap moves nothing, so its row opens below the finger and the header stays where it was. + chat0.scroller.touch('touchstart') + chat0.scroller.touch('touchend') + const before = chat0.scroller.top(header) + chat0.press(header) + resized() + expect(chat0.scroller.top(header)).toBe(before) + + // A drag's release hands over to a fling: a reply below waits for it to end. + chat0.scroller.touch('touchstart') + chat0.scroller.scrollTop -= 30 + chat0.scrolled() + chat0.scroller.touch('touchend') + expect(chat0.show([...from('r21'), ...later, say('n1', 'assistant', 'reply')])).toBe(0) + expect(chat0.drawn(mobileNativeChatRowId('n1'))).toBe(0) }) it('at the cap, a trim and an append of equal height hold a parked reader and leave a following one to follow', () => { @@ -300,7 +379,9 @@ it('at the cap, a trim and an append of equal height hold a parked reader and le { following: false, live: false } ]) { const chat0 = mount(rows.slice(0, 12), options) - chat0.scroller.scrollTop = options.following ? chat0.scroller.scrollHeight - 200 : 50 + chat0.read(() => + options.following ? (chat0.scroller.scrollTop = 0) : chat0.scroller.showFrom(50) + ) const before = { scrollTop: chat0.scroller.scrollTop, m4: chat0.scroller.top(mobileNativeChatRowId('m4')) @@ -335,7 +416,7 @@ it('at the cap, a trim and an append of equal height hold a parked reader and le ] as const) { const top = mount([...source, ...rows]) top.press(mobileNativeChatRowId(head)) - top.scroller.scrollTop = top.scroller.top('chat-member:call:c6') + top.scroller.scrollTop - 10 + top.read(() => (top.scroller.scrollTop += top.scroller.top('chat-member:call:c6') - 10)) const trim = (trimmed: number) => { top.show([...source.slice(trimmed), ...rows, say(`done${trimmed}`, 'assistant', 'Done')]) const run = top.find(mobileNativeChatRowId(head)) @@ -358,7 +439,7 @@ it('at the cap, a trim and an append of equal height hold a parked reader and le // A trim takes the very line the reader is on: the next one keeps its place. const reader = mount([...chat.slice(3, 15), ...rows]) reader.press(mobileNativeChatRowId('a1')) - reader.scroller.scrollTop += reader.scroller.top('chat-member:call:c1') + reader.read(() => (reader.scroller.scrollTop += reader.scroller.top('chat-member:call:c1'))) const next = reader.scroller.top('chat-member:call:c2') expect(next).toBeGreaterThan(0) reader.show([...chat.slice(4, 15), ...rows, say('done', 'assistant', 'Done')]) @@ -380,13 +461,13 @@ it('a roster keeps its key when the fold moves it, and the rows the reader saw a const chat0 = mount(all.slice(25)) const tops = (ids: string[]) => ids.map((id) => chat0.scroller.top(mobileNativeChatRowId(id))) // The roster first on screen: an older sibling joining moves it up to that sibling's place. - chat0.scroller.scrollTop = 20 + chat0.read(() => chat0.scroller.showFrom(20)) const below = tops(['new0', 'new5']) expect(chat0.show(all)).toBeLessThanOrEqual(1) expect(tops(['new0', 'new5'])).toEqual(below) expect(chat0.drawn(mobileNativeChatRowId('s2'))).toBe(1) // And back: a trim of the first sibling moves the roster down to the second's place. - chat0.scroller.scrollTop += chat0.scroller.top(mobileNativeChatRowId('s2')) + chat0.read(() => (chat0.scroller.scrollTop += chat0.scroller.top(mobileNativeChatRowId('s2')))) const after = tops(['mid0', 'mid5']) expect(chat0.show(all.slice(13))).toBeLessThanOrEqual(1) expect(tops(['mid0', 'mid5'])).toEqual(after) diff --git a/mobile/src/session/mobile-native-chat-reading-place.ts b/mobile/src/session/mobile-native-chat-reading-place.ts new file mode 100644 index 000000000000..a70c4823fe7d --- /dev/null +++ b/mobile/src/session/mobile-native-chat-reading-place.ts @@ -0,0 +1,158 @@ +import type { MobileNativeChatRowKeys } from './mobile-native-chat-row-keys' +import { + mobileNativeChatReadingParts, + mobileNativeChatScrollRootKey, + type MobileNativeChatScrollRoots +} from './mobile-native-chat-scroll-roots' + +type Box = { top: number; bottom: number } + +/** The parts of a DOM element this reads; on the page the list's scrollable node is one. */ +type Tagged = { + getBoundingClientRect: () => Box + getAttribute: (name: string) => string | null + querySelectorAll: (selector: string) => ArrayLike +} +export type MobileNativeChatScrollNode = Tagged & { + scrollTop: number + scrollHeight: number + clientHeight: number +} + +type Keys = Pick + +/** Part of what the reader sees: a tool line or its run (`member`), else a row; where it was, and + * the message that placed its row. */ +export type MobileNativeChatReadingUnit = { + key: string + top: number + member?: string + placed: string +} + +const ROW = 'chat-row:' +const MEMBER = 'chat-member:' + +/** A message row's `testID`, which the page draws as `data-testid`. It sets message rows apart from + * the top slot and the live status line, which don't move with the messages. */ +export function mobileNativeChatRowId(key: string): string { + return `${ROW}${key}` +} + +/** A tool line's `testID`, by its line identity, or the run's, inside a row. */ +export function mobileNativeChatMemberId(id: string): string { + return `${MEMBER}${id}` +} + +/** Tagged elements in document order; ids are compared, never put in a selector. */ +function tagged(node: Tagged, prefix: string): { id: string; node: Tagged }[] { + return Array.from(node.querySelectorAll(`[data-testid^="${prefix}"]`), (element) => ({ + id: element.getAttribute('data-testid')?.slice(prefix.length) ?? '', + node: element + })) +} + +function onScreen(box: Box, view: Box): boolean { + return box.bottom > view.top && box.top < view.bottom +} + +/** A row's tool lines on screen, else its run when the run's top is (below that top, lines can join + * or leave), else the row itself. */ +function unitsOf( + row: Tagged, + view: Box, + self: MobileNativeChatReadingUnit +): MobileNativeChatReadingUnit[] { + const shown: MobileNativeChatReadingUnit[] = [] + const parts = + typeof Element !== 'undefined' && row instanceof Element + ? mobileNativeChatReadingParts(row).map((node) => ({ + node, + id: + mobileNativeChatScrollRootKey(node) !== undefined + ? `root:${mobileNativeChatScrollRootKey(node)}` + : (node.getAttribute('data-testid')?.slice(MEMBER.length) ?? '') + })) + : tagged(row, MEMBER) + for (const { id, node } of parts) { + const box = node.getBoundingClientRect() + if (box.top >= view.bottom) { + break + } + if (id === 'run' ? box.top >= view.top : onScreen(box, view)) { + shown.push({ ...self, top: box.top, member: id }) + } + } + const lines = shown.filter((unit) => unit.member !== 'run') + return lines.length > 0 ? lines : [shown[0] ?? self] +} + +/** What the reader is reading, in page order. */ +export function mobileNativeChatReadingPlace( + scroller: MobileNativeChatScrollNode, + { placement }: Keys +): MobileNativeChatReadingUnit[] { + const view = scroller.getBoundingClientRect() + const rows = tagged(scroller, ROW) + // Rows in normal flow have ordered bottoms, so the first one on screen is found without + // measuring the up to 2,000 above it. + let low = 0 + let high = rows.length + while (low < high) { + const middle = low + Math.floor((high - low) / 2) + if (rows[middle]!.node.getBoundingClientRect().bottom <= view.top) { + low = middle + 1 + } else { + high = middle + } + } + let end = low + while (end < rows.length && rows[end]!.node.getBoundingClientRect().top < view.bottom) { + end += 1 + } + const self = ({ id, node }: { id: string; node: Tagged }): MobileNativeChatReadingUnit => { + return { key: id, top: node.getBoundingClientRect().top, placed: placement(id) } + } + const units = rows.slice(low, end).flatMap((row) => unitsOf(row.node, view, self(row))) + // The nearest rows off screen stand in when the update takes away all that was on it. + return units.concat([rows[end], rows[low - 1]].flatMap((row) => (row ? [self(row)] : []))) +} + +/** Where a held part is drawn now, if the update kept it. A line in the row now drawing its old + * row's messages (an orphan result's line is its call's once paired); a run only in the row that + * kept its key; a row only while the same message places it, since a group keeps its key when the + * fold moves it. */ +function drawn( + unit: MobileNativeChatReadingUnit, + rows: Map, + keys: Keys, + roots?: MobileNativeChatScrollRoots +): Tagged | undefined { + const { key, member } = unit + if (member?.startsWith('root:')) { + return roots?.frame(member.slice(5)) + } + if (member === undefined) { + return keys.placement(key) === unit.placed ? rows.get(key) : undefined + } + const row = rows.get(member === 'run' ? key : (keys.current(key) ?? '')) + const paired = member.startsWith('result:') ? `call:${member.slice('result:'.length)}` : member + return row && tagged(row, MEMBER).find((m) => m.id === member || m.id === paired)?.node +} + +/** How far the first part of `place` the document still draws has moved on screen; 0 when none is. */ +export function mobileNativeChatReadingDrift( + place: readonly MobileNativeChatReadingUnit[], + scroller: MobileNativeChatScrollNode, + keys: Keys, + roots?: MobileNativeChatScrollRoots +): number { + const rows = new Map(tagged(scroller, ROW).map(({ id, node }) => [id, node])) + for (const unit of place) { + const node = drawn(unit, rows, keys, roots) + if (node) { + return node.getBoundingClientRect().top - unit.top + } + } + return 0 +} diff --git a/mobile/src/session/mobile-native-chat-page-anchoring.ts b/mobile/src/session/mobile-native-chat-scroll-anchoring.ts similarity index 78% rename from mobile/src/session/mobile-native-chat-page-anchoring.ts rename to mobile/src/session/mobile-native-chat-scroll-anchoring.ts index 5247ab7e01c2..062873581b8a 100644 --- a/mobile/src/session/mobile-native-chat-page-anchoring.ts +++ b/mobile/src/session/mobile-native-chat-scroll-anchoring.ts @@ -1,4 +1,4 @@ -import { scrollWritesStopMomentum } from './mobile-native-chat-page-motion' +import { scrollWritesStopMomentum } from './mobile-native-chat-scroll-motion' type Styled = { style?: { overflowAnchor: string } @@ -6,11 +6,10 @@ type Styled = { getBoundingClientRect: () => unknown } -/** Who keeps a parked reader's place where the browser has scroll anchoring (not WebKit): the - * browser, which sees every change inside the rows on screen, except while the page owns the - * scroll: following (its end pin) or a place-hold's own correction. Two owners would each put the - * same move back. */ -export class MobileNativeChatPageAnchoring { +/** Who keeps a parked reader's place in an inner scroll box where the browser has scroll anchoring + * (not WebKit): the browser, which sees every change inside the box, except while the box owns its + * scroll: following its end or its own restore. Two owners would each put the same move back. */ +export class MobileNativeChatScrollAnchoring { private holds = 0 private readonly none = scrollWritesStopMomentum() @@ -19,7 +18,7 @@ export class MobileNativeChatPageAnchoring { private readonly following: () => boolean ) {} - /** The page takes the scroll for one correction, or gives it back. */ + /** The box takes its scroll for one correction, or gives it back. */ hold(on: boolean): void { this.holds = Math.max(0, this.holds + (on ? 1 : -1)) this.sync() diff --git a/mobile/src/session/mobile-native-chat-page-motion.ts b/mobile/src/session/mobile-native-chat-scroll-motion.ts similarity index 76% rename from mobile/src/session/mobile-native-chat-page-motion.ts rename to mobile/src/session/mobile-native-chat-scroll-motion.ts index 3c34a05ee610..a03921acb753 100644 --- a/mobile/src/session/mobile-native-chat-page-motion.ts +++ b/mobile/src/session/mobile-native-chat-scroll-motion.ts @@ -4,7 +4,6 @@ const REST_MS = 150 const INTENT_MS = 1_000 const SCROLL_KEYS = new Set(['ArrowDown', 'ArrowUp', 'End', 'Home', 'PageDown', 'PageUp', ' ']) type Moved = { by: 'own' | 'layout' | 'reader' | 'none'; moved: number } -export type MobileNativeChatPageObservation = Moved & { fromEnd: number } /** WebKit (every iOS browser, and Safari) stops a fling on any scroll write and has no scroll * anchoring; other engines keep momentum, and their anchoring keeps a parked reader's place. */ @@ -13,9 +12,9 @@ export function scrollWritesStopMomentum(): boolean { return /AppleWebKit/.test(agent) && !/Chrome|Chromium|Android/.test(agent) } -/** Whether the page keeps a parked place itself: on WebKit, which has no scroll anchoring, and at the +/** Whether a scroll box keeps a parked place itself: on WebKit, which has no scroll anchoring, and at the * very top, where Chromium's anchoring never adjusts (rows measured above would push the reader). */ -export function pageKeepsPlace(scrollTop: number): boolean { +export function scrollKeepsPlace(scrollTop: number): boolean { return scrollWritesStopMomentum() || scrollTop < 0.5 } @@ -54,9 +53,9 @@ function touchY(event: unknown): number | undefined { return typeof y === 'number' ? y : undefined } -/** Whether the reader is moving the page, told apart from the page's own writes. Work that can move - * rows above the reader (a landing, a measured row, a correction) waits until it settles. */ -export class MobileNativeChatPageMotion { +/** Whether the reader is moving an inner scroll box, told apart from its own writes: on WebKit a + * correction waits until the box settles. */ +export class MobileNativeChatScrollMotion { private touching = false private pointing = false private lastInput = 0 @@ -75,18 +74,8 @@ export class MobileNativeChatPageMotion { private layouts = 0 /** Where the page's own writes put the scroller, until their scroll event arrives. */ private own: number[] = [] - private timer: ReturnType | undefined - private later: ReturnType | undefined - /** Owed work doesn't run again before this, so other work gets frames between its rounds. */ - private quietUntil = 0 - /** Classified before the follower sees geometry, including layout and the page's writes. */ - onMotion: ((observation: MobileNativeChatPageObservation) => void) | null = null - private waiters: (() => void)[] = [] - constructor( - private readonly onSettled: () => void, - initialTop = 0 - ) { + constructor(initialTop = 0) { this.seen = initialTop } @@ -153,10 +142,6 @@ export class MobileNativeChatPageMotion { this.lastInput = Date.now() if (down) { this.own = [] - clearTimeout(this.timer) - this.timer = undefined - } else { - this.poke() } } @@ -192,10 +177,10 @@ export class MobileNativeChatPageMotion { if (at !== -1) { this.own.splice(0, at + 1) ;[this.seen, this.seenMax] = [scrollTop, max] - return this.observed('own', moved, max - scrollTop) + return { by: 'own', moved } } if (Math.abs(moved) >= 0.5 && !this.touching && (this.unreconciled || placeMoved())) { - return this.observed('layout', moved, max - scrollTop) + return { by: 'layout', moved } } return this.account(scrollTop, max) } @@ -206,21 +191,15 @@ export class MobileNativeChatPageMotion { const [from, fromMax, moved] = [this.seen, this.seenMax, scrollTop - this.seen] ;[this.seen, this.seenMax] = [scrollTop, max] if (Math.abs(moved) < 0.5) { - return this.observed('none', moved, max - scrollTop) + return { by: 'none', moved } } // Pulled back onto a shorter end from where the old one allowed; iOS bouncing past an end is not. const clamped = Math.abs(scrollTop - max) < 0.5 && from > max + 0.5 && from <= fromMax + 0.5 if ((clamped && !this.touching) || (this.browserMoves && !this.intended(moved))) { - return this.observed('layout', moved, max - scrollTop) + return { by: 'layout', moved } } this.lastReaderScroll = Date.now() - this.poke() - return this.observed('reader', moved, max - scrollTop) - } - - private observed(by: Moved['by'], moved: number, fromEnd: number): Moved { - this.onMotion?.({ by, moved, fromEnd }) - return { by, moved } + return { by: 'reader', moved } } /** Input is recent (a fling counts from the finger's release) and the move goes its way: the @@ -243,53 +222,10 @@ export class MobileNativeChatPageMotion { * list does), else only at rest. */ readonly mayCorrect = scrollWritesStopMomentum() ? () => this.settled() : () => true - /** Resolves once settled, or when the window goes away (`release`), so a wait never strands. */ - whenSettled(): Promise { - if (this.settled()) { - return Promise.resolve() - } - return new Promise((resolve) => { - this.waiters.push(resolve) - this.poke() - }) - } - - poke(): void { - if (this.touching || this.timer !== undefined) { - return - } - const now = Date.now() - const wait = Math.max(0, REST_MS - (now - this.lastReaderScroll), this.quietUntil - now) - this.timer = setTimeout(() => { - this.timer = undefined - if (!this.settled() || Date.now() < this.quietUntil) { - this.poke() - return - } - this.waiters.splice(0).forEach((resolve) => resolve()) - this.onSettled() - }, wait) - } - - /** Owed work waits `ms` before its next round. */ - quiet(ms: number): void { - this.quietUntil = Date.now() + ms - } - - /** Checks for owed work again after `ms`, when nothing else will. */ - pokeIn(ms: number): void { - clearTimeout(this.later) - this.later = setTimeout(() => this.poke(), ms) - } - release(): void { this.touching = false this.pointing = false this.lastReaderScroll = 0 this.own = [] - clearTimeout(this.later) - clearTimeout(this.timer) - this.timer = undefined - this.waiters.splice(0).forEach((resolve) => resolve()) } } diff --git a/mobile/src/session/mobile-native-chat-scroll-ownership.test.tsx b/mobile/src/session/mobile-native-chat-scroll-ownership.test.tsx index 5dc08af6b581..fa2a135ab6f9 100644 --- a/mobile/src/session/mobile-native-chat-scroll-ownership.test.tsx +++ b/mobile/src/session/mobile-native-chat-scroll-ownership.test.tsx @@ -7,7 +7,6 @@ import { Suspense, useLayoutEffect, useMemo, - useRef, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' @@ -15,9 +14,8 @@ import { afterEach, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold' import { MobileNativeChatMessage } from './MobileNativeChatMessage' -import { MobileNativeChatScrollHold } from './MobileNativeChatScrollHold' -import { MobileNativeChatPageRows } from './mobile-native-chat-page-rows' -import { MobileNativeChatPageMotion } from './mobile-native-chat-page-motion' +import { MobileNativeChatScrollRootsBoundary } from './MobileNativeChatScrollRootsBoundary' +import { MobileNativeChatScrollMotion } from './mobile-native-chat-scroll-motion' import { mobileNativeChatScrollFrame, mobileNativeChatReadingParts, @@ -99,9 +97,6 @@ let owners: (string | undefined)[] let projected: (string | undefined)[] let store: MobileNativeChatDisclosureStore const abandoned = new Promise(() => {}) -const correctPage = vi.fn((drift: number) => { - page.scrollTop += drift -}) const msg = ( id: string, blocks: NativeChatMessage['blocks'], @@ -204,10 +199,6 @@ function Chat({ scopeKey: scope }) const keys = useMobileNativeChatRowKeys(drawn, rows, scope, turns.presentation) - const ref = useRef({ - getScrollableNode: () => page, - keepInPlace: correctPage - }) useLayoutEffect(() => { store = current roots = current.roots @@ -221,14 +212,12 @@ function Chat({ MobileNativeChatDisclosureContext.Provider, { value: disclosures }, h( - MobileNativeChatScrollHold, + MobileNativeChatScrollRootsBoundary, { rowKeys: keys, roots: current.roots, runPlacements: keys.runPlacements, - layoutKey: '1', - listRef: ref, - isFollowing: () => false + layoutKey: '1' }, h(Shift, { value: shift }), h( @@ -284,7 +273,6 @@ afterEach(() => { page?.remove() renderer = undefined frameShift = 0 - correctPage.mockClear() vi.useRealTimers() vi.restoreAllMocks() vi.unstubAllGlobals() @@ -303,16 +291,10 @@ it('keeps real structured runs and nested offsets under their own viewport acros throw new Error('missing command viewport') } const row = run.closest('[data-testid^="chat-row:"]')! - const rows = new MobileNativeChatPageRows() - rows.ref('a')(row) - rows.takeAnchor(page, ['a'], [0, 1], true) reader(run, 100) reader(detail, 35) act(() => vi.advanceTimersByTime(160)) roots.restoreAll() - expect(rows.placeShift(page)).toBe(0) - expect(page.scrollTop).toBe(0) - expect(correctPage).not.toHaveBeenCalled() const clipped = run.querySelector('[data-testid="chat-member:call:c0"]')! expect(mobileNativeChatVisible(clipped, page)).toBe(false) expect(mobileNativeChatReadingParts(row)).toContain(run) @@ -326,8 +308,6 @@ it('keeps real structured runs and nested offsets under their own viewport acros rect(restored.querySelector('[data-testid="chat-member:call:c3"]')!).top - rect(restored).top ).toBe(offset) expect(restored.querySelector('[data-chat-scroll-root]')?.scrollTop).toBe(35) - expect(page.scrollTop).toBe(80) - expect(correctPage).toHaveBeenCalledExactlyOnceWith(80, false) const top = restored.scrollTop draw([user, thought, a], { shown: false, shift: 80 }) draw([user, thought, a], { shift: 80 }) @@ -405,34 +385,18 @@ it.each([true, false])( } ) -it('waits for simultaneous inner rest, releases on close/scope change, and separates work-follow from reader chaining', async () => { +it('releases on close/scope change, and separates work-follow from reader chaining', async () => { const draw = mount() draw([user, a]) const run = container.querySelector('[data-chat-scroll-root]')! const detail = run.querySelector('[data-chat-scroll-root]')! vi.stubGlobal('navigator', { userAgent: 'AppleWebKit Chrome' }) - const pageMotion = new MobileNativeChatPageMotion(() => {}) + const pageMotion = new MobileNativeChatScrollMotion() const detach = pageMotion.attach(page) pageMotion.reconciled(100, 1000) - run.dispatchEvent(new Event('touchstart')) - run.scrollTop = 100 - run.dispatchEvent(new Event('scroll')) - let settled = false - const waiting = roots.settled(pageMotion).then(() => { - settled = true - }) - await Promise.resolve() - expect(pageMotion.settled()).toBe(true) - expect(settled).toBe(false) - detail.dispatchEvent(new Event('touchstart')) - detail.scrollTop = 35 - detail.dispatchEvent(new Event('scroll')) - run.dispatchEvent(new Event('touchend')) + reader(run, 100) + reader(detail, 35) await vi.advanceTimersByTimeAsync(160) - expect(settled).toBe(false) - detail.dispatchEvent(new Event('touchend')) - await vi.advanceTimersByTimeAsync(0) - await waiting reader(run, run.scrollHeight - run.clientHeight - 20) await vi.advanceTimersByTimeAsync(160) const parked = run.scrollTop @@ -442,21 +406,15 @@ it('waits for simultaneous inner rest, releases on close/scope change, and separ draw([user, a, b]) expect(run.scrollTop).toBe(run.scrollHeight - run.clientHeight) run.dispatchEvent(new Event('scroll')) - await roots.settled(pageMotion) + await vi.advanceTimersByTimeAsync(160) expect(pageMotion.scrolled(100, 1000, () => false).by).toBe('none') run.dispatchEvent(new WheelEvent('wheel', { deltaY: -10, bubbles: true })) expect(pageMotion.scrolled(90, 1000, () => false).by).toBe('reader') pageMotion.release() - run.dispatchEvent(new Event('touchstart')) - const closing = roots.settled(pageMotion) act(() => container.querySelector('[data-live]')!.click()) - await closing expect(run.hasAttribute('data-chat-scroll-root')).toBe(false) draw([user, a], { scope: 'intermediate' }) - container.querySelector('[data-chat-scroll-root]')!.dispatchEvent(new Event('touchstart')) - const leaving = roots.settled(pageMotion) draw([user, a], { scope: 'other' }) - await leaving vi.stubGlobal('navigator', { userAgent: 'AppleWebKit Safari' }) draw([user, a], { scope: 'live', open: false, working: true }) act(() => container.querySelector('[data-live]')!.click()) diff --git a/mobile/src/session/mobile-native-chat-scroll-root-handle.ts b/mobile/src/session/mobile-native-chat-scroll-root-handle.ts index e8f34f448886..14b3901af0e5 100644 --- a/mobile/src/session/mobile-native-chat-scroll-root-handle.ts +++ b/mobile/src/session/mobile-native-chat-scroll-root-handle.ts @@ -1,5 +1,5 @@ -import type { MobileNativeChatPageMotion } from './mobile-native-chat-page-motion' -import type { MobileNativeChatPageAnchoring } from './mobile-native-chat-page-anchoring' +import type { MobileNativeChatScrollMotion } from './mobile-native-chat-scroll-motion' +import type { MobileNativeChatScrollAnchoring } from './mobile-native-chat-scroll-anchoring' export type MobileNativeChatRootPosition = { top: number @@ -18,8 +18,8 @@ type Root = { work: number | undefined nextWork: number | undefined fresh: boolean - motion: MobileNativeChatPageMotion - anchoring: MobileNativeChatPageAnchoring + motion: MobileNativeChatScrollMotion + anchoring: MobileNativeChatScrollAnchoring detach: () => void } export type { Root as MobileNativeChatScrollRootHandle } diff --git a/mobile/src/session/mobile-native-chat-scroll-roots.ts b/mobile/src/session/mobile-native-chat-scroll-roots.ts index dd20ca245e39..b3650bbff40e 100644 --- a/mobile/src/session/mobile-native-chat-scroll-roots.ts +++ b/mobile/src/session/mobile-native-chat-scroll-roots.ts @@ -1,5 +1,5 @@ -import { MobileNativeChatPageMotion, pageKeepsPlace } from './mobile-native-chat-page-motion' -import { MobileNativeChatPageAnchoring } from './mobile-native-chat-page-anchoring' +import { MobileNativeChatScrollMotion, scrollKeepsPlace } from './mobile-native-chat-scroll-motion' +import { MobileNativeChatScrollAnchoring } from './mobile-native-chat-scroll-anchoring' import { mobileNativeChatScrollRootAttribute as ROOT, mobileNativeChatScrollRootOwners as owners, @@ -37,8 +37,6 @@ export class MobileNativeChatScrollRoots { private roots = new Map() private observer: ResizeObserver | null = null private held = new Set() - private generation = 0 - private page: MobileNativeChatPageMotion | null = null constructor( private readonly memory: Map, @@ -64,7 +62,7 @@ export class MobileNativeChatScrollRoots { if (occupant) { this.unregister(occupant, true) } - const motion = new MobileNativeChatPageMotion(() => {}, node.scrollTop) + const motion = new MobileNativeChatScrollMotion(node.scrollTop) const root: Root = { id, address, @@ -78,7 +76,7 @@ export class MobileNativeChatScrollRoots { nextWork: undefined, fresh: true, motion, - anchoring: new MobileNativeChatPageAnchoring( + anchoring: new MobileNativeChatScrollAnchoring( () => node, () => root.position.followsEnd ), @@ -240,7 +238,7 @@ export class MobileNativeChatScrollRoots { node.getBoundingClientRect().top - position.offset : (position?.top ?? node.scrollTop) - const ordinary = !position.followsEnd && pageKeepsPlace(node.scrollTop) + const ordinary = !position.followsEnd && scrollKeepsPlace(node.scrollTop) const clamped = Math.max(0, Math.min(target, max)) if ( (pin || explicit || ordinary) && @@ -291,27 +289,10 @@ export class MobileNativeChatScrollRoots { } } - async settled(page: MobileNativeChatPageMotion): Promise { - const generation = this.generation - this.page = page - do { - await Promise.all([ - page.whenSettled(), - ...Array.from(this.roots.values(), (r) => r.motion.whenSettled()) - ]) - if (generation !== this.generation) { - return - } - } while (!page.settled() || Array.from(this.roots.values()).some((r) => !r.motion.settled())) - } - dispose(): void { - this.generation += 1 for (const root of this.roots.values()) { this.unregister(root, false) } - this.page?.release() - this.page = null this.observer = null this.releaseHold() } diff --git a/mobile/src/session/mobile-native-chat-view-styles.ts b/mobile/src/session/mobile-native-chat-view-styles.ts index 1904d069bec2..9a8c993b0ff5 100644 --- a/mobile/src/session/mobile-native-chat-view-styles.ts +++ b/mobile/src/session/mobile-native-chat-view-styles.ts @@ -2,6 +2,8 @@ import { StyleSheet } from 'react-native' import { colors, spacing, typography } from '../theme/mobile-theme' const FLOATING_CONTROLS_BOTTOM = spacing.md +const LOAD_EARLIER_HEIGHT = 44 +const LOAD_EARLIER_RETRY_HEIGHT = 28 export const styles = StyleSheet.create({ root: { @@ -65,18 +67,35 @@ export const styles = StyleSheet.create({ loadEarlier: { alignItems: 'center', justifyContent: 'center', - paddingVertical: spacing.md, - minHeight: 36 + height: LOAD_EARLIER_HEIGHT }, - loadEarlierText: { - color: colors.textMuted, + loadEarlierFailed: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md + }, + loadEarlierRetry: { + justifyContent: 'center', + height: LOAD_EARLIER_RETRY_HEIGHT, + paddingHorizontal: spacing.md, + borderRadius: LOAD_EARLIER_RETRY_HEIGHT / 2, + backgroundColor: colors.bgRaised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + loadEarlierRetryPressed: { + opacity: 0.6 + }, + loadEarlierRetryLabel: { + color: colors.textSecondary, fontSize: typography.metaSize, fontWeight: '600' }, loadEarlierFailedText: { + flexShrink: 1, color: colors.textMuted, fontSize: typography.metaSize, - textAlign: 'center', - marginTop: spacing.xs + textAlign: 'center' } }) diff --git a/mobile/src/session/use-mobile-native-chat-page-render.ts b/mobile/src/session/use-mobile-native-chat-page-render.ts deleted file mode 100644 index ea46c77b649a..000000000000 --- a/mobile/src/session/use-mobile-native-chat-page-render.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useCallback, useRef, useSyncExternalStore } from 'react' - -/** Renders the caller again from any event source (a scroll, the observer, a timer) synchronously, - * so a window change is in the document before the browser paints. */ -export function useMobileNativeChatPageRender(): () => void { - const store = useRef({ version: 0, notify: () => {} }) - const subscribe = useCallback((notify: () => void) => { - store.current.notify = notify - return () => { - store.current.notify = () => {} - } - }, []) - useSyncExternalStore(subscribe, () => store.current.version) - return useCallback(() => { - store.current.version += 1 - store.current.notify() - }, []) -} diff --git a/mobile/src/session/use-mobile-native-chat-page-window.ts b/mobile/src/session/use-mobile-native-chat-page-window.ts deleted file mode 100644 index d07064e7e29a..000000000000 --- a/mobile/src/session/use-mobile-native-chat-page-window.ts +++ /dev/null @@ -1,342 +0,0 @@ -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { MobileNativeChatPageHeights } from './mobile-native-chat-page-heights' -import { MobileNativeChatPageAnchoring } from './mobile-native-chat-page-anchoring' -import { - MobileNativeChatPageMotion, - pageKeepsPlace, - scrollWritesStopMomentum -} from './mobile-native-chat-page-motion' -import { MobileNativeChatPageRows } from './mobile-native-chat-page-rows' -import { coverMobileNativeChatWindow } from './mobile-native-chat-page-window-coverage' -import type { - MobileNativeChatWindowDrawn as Drawn, - MobileNativeChatWindowGroup as Group, - MobileNativeChatPageWindowArgs, - MobileNativeChatWindowScroller, - MobileNativeChatWindowSeat as Win -} from './mobile-native-chat-page-window-state' -import { useMobileNativeChatPageRender } from './use-mobile-native-chat-page-render' - -/** Rows measured in one settled moment: few enough not to drop a frame. */ -const MEASURE_ROWS = 8 -/** Between measuring rounds, so a streamed reply still gets frames while rows are measured. */ -const MEASURE_GAP_MS = 32 -/** A parked reader's unmeasured rows are measured only within this many screens above them. */ -const MEASURE_REACH = 3 -/** While following, every measured row is checked for a change at most this often. */ -const STALE_SCAN_MS = 1_000 -/** While following, measuring waits until a streaming reply pauses this long, so its updates keep - * the frames. */ -const STREAM_PAUSE_MS = 300 - -/** The page's rows near the screen, keyed. Above the window the spacer stands only for measured rows: - * rows never measured are left out, and are measured in place only while nothing can move the - * reader (following, or settled), because iOS stops momentum on any scroll write. */ -export function useMobileNativeChatPageWindow(args: MobileNativeChatPageWindowArgs) { - const { rows, rowKeys, rowState, following, isFollowing, layoutKey, scroller, roots } = args - const render = useMobileNativeChatPageRender() - const [heights] = useState(() => new MobileNativeChatPageHeights()) - const w = useRef(null) - const drawn = useRef(null) - const settle = useRef(false) - const [drawnRows] = useState(() => new MobileNativeChatPageRows()) - const anchorStale = useRef(false) - const width = useRef(0) - const viewport = useRef(globalThis.innerHeight || 800) - const lastScan = useRef(0) - const streamedAt = useRef(0) - const onSettled = useRef(() => {}) - const [motion] = useState(() => new MobileNativeChatPageMotion(() => onSettled.current())) - const [anchoring] = useState(() => new MobileNativeChatPageAnchoring(scroller, isFollowing)) - const { keys, changed, indexOf } = useMemo( - () => heights.sourceKeys(rows, rowKeys.keyOf, drawn.current?.keys), - [rows, rowKeys.keyOf, heights] - ) - const n = keys.length - const at = (key: string, fallback: number): number => - keys[fallback] === key - ? fallback - : (indexOf(rowKeys.current(key)) ?? Math.max(0, Math.min(fallback, n - 1))) - const seatAtEnd = (): Win => { - let first = n - for (let filled = 0; first > 0 && filled < viewport.current; first -= 1) { - filled += heights.px(keys[first - 1]) ?? heights.estimate() - } - const seat = { first: keys[first] ?? '', firstAt: first, last: null, lastAt: n - 1 } - return { scope: rowKeys.scope, ...seat, slack: 0, measure: null } - } - - const kept = w.current?.scope === rowKeys.scope && w.current.first !== '' ? w.current : null - let win = kept ?? seatAtEnd() - let end = win.last === null ? n : at(win.last, win.lastAt) + 1 - if (following && win.last !== null) { - win = end >= n ? { ...win, last: null } : seatAtEnd() - end = n - } - const start = Math.min(at(win.first, win.firstAt), Math.max(0, end - 1)) - end = Math.min(n, Math.max(end, start + 1)) - - const d = drawn.current - const sourceChanged = d !== null && d.sourceHead !== rowKeys.sourceHead - let slack = following || !kept || settle.current ? 0 : win.slack - if (d && sourceChanged && !following && kept && d.keys !== keys) { - // A cap trim takes rows from the top; what was drawn of them stays as space until settled. - for (let i = d.first; i < d.end && indexOf(rowKeys.current(d.keys[i]!)) === undefined; i += 1) { - slack += heights.px(d.keys[i]) ?? 0 - } - } - // A source change keeps no measuring rows: the place-hold owns that commit's correction. - let [m0, m1] = [start, start] - const group = sourceChanged || !win.measure ? null : win.measure - if (group) { - const [from, to] = [at(group.from, group.fromAt), at(group.to, group.toAt) + 1] - if (from < to && to <= start) { - ;[m0, m1] = [from, to] - } - } - const { first, above, mid, bottom } = heights.spacers(keys, [m0, m1], [start, end], slack) - const measuring = n > 0 && Math.min(m0, start) > 0 && heights.px(keys[0]) === undefined - - const write = useCallback( - (s: MobileNativeChatWindowScroller, to: number) => { - const max = s.scrollHeight - s.clientHeight - const target = Math.max(0, Math.min(to, max)) - // Above the top only while iOS bounces there. Past the end may be a clamp the browser hasn't - // applied yet; every write here waits for rest, so it isn't a bounce. - if (s.scrollTop >= 0 && Math.abs(target - s.scrollTop) >= 0.5) { - motion.wrote(target) - s.scrollTop = target - } - }, - [motion] - ) - - const takeAnchor = useCallback( - (s: MobileNativeChatWindowScroller) => { - // Elsewhere the browser's anchoring keeps a parked place and the end pin a following one. - if (!pageKeepsPlace(s.scrollTop)) { - drawnRows.forgetAnchor() - return - } - const at = drawn.current - // A following reader is held by the end pin: the row's own top is enough there. - drawnRows.takeAnchor(s, at?.keys ?? [], [at?.start ?? 0, at?.end ?? 0], !at?.following) - }, - [drawnRows] - ) - - const cover = useCallback( - (s: MobileNativeChatWindowScroller) => { - const [at, cur] = [drawn.current, w.current] - const firstNode = at && drawnRows.node(at.keys[at.start]) - // Before the observer reports every drawn row, a guess would move the window off the reader. - const unmeasured = - at && at.keys.slice(at.start, at.end).some((key) => heights.px(key) === undefined) - if (!at || !cur || !firstNode || unmeasured) { - return - } - const next = coverMobileNativeChatWindow( - { - n: at.keys.length, - start: at.start, - end: at.end, - // Drawn to the end only while it is near: a jump away is drawn where it lands, not to the end. - following: at.following && s.scrollTop >= s.scrollHeight - 2 * s.clientHeight, - windowTop: - firstNode.getBoundingClientRect().top - s.getBoundingClientRect().top + s.scrollTop, - scrollTop: s.scrollTop, - viewport: s.clientHeight - }, - (i) => heights.px(at.keys[i]), - heights.estimate() - ) - if (next) { - const last = next.end >= at.keys.length ? null : at.keys[next.end - 1]! - const measure = next.start === at.start ? cur.measure : null - const moved = { first: at.keys[next.start]!, firstAt: next.start, last } - w.current = { ...cur, ...moved, lastAt: next.end - 1, measure } - render() - } - }, - [heights, render, drawnRows] - ) - - // Owed work that can move rows above the reader, done only while settled: measure the nearest rows - // that are unmeasured or may have changed, and give back trimmed space. - const measureOwed = useCallback(() => { - const [at, cur] = [drawn.current, w.current] - if (!at || !cur || at.keys.length === 0) { - return - } - const layout = `${at.layoutKey}:${width.current}` - // A drawn group is measured before paint; choosing again first would take it down unmeasured. - if (cur.measure && heights.px(cur.measure.to) === undefined) { - return - } - const streaming = STREAM_PAUSE_MS - (Date.now() - streamedAt.current) - if (at.following && streaming > 0) { - motion.pokeIn(streaming) - return - } - const since = Date.now() - lastScan.current - const full = at.following && at.first === 0 && since >= STALE_SCAN_MS - const run = heights.next(at, layout, MEASURE_REACH * viewport.current, MEASURE_ROWS, full) - if (full) { - lastScan.current = Date.now() - } else if (!run && at.following && at.first === 0) { - motion.pokeIn(STALE_SCAN_MS - since) - } - const measure: Group | null = run && { - from: at.keys[run[0]]!, - fromAt: run[0], - to: at.keys[run[1]]!, - toAt: run[1] - } - if (measure?.from !== cur.measure?.from || measure?.to !== cur.measure?.to || cur.slack > 0) { - w.current = { ...cur, measure } - settle.current = true - motion.quiet(MEASURE_GAP_MS) - render() - } - }, [heights, motion, render]) - - useLayoutEffect(() => { - onSettled.current = measureOwed - if (w.current && w.current.scope !== rowKeys.scope) { - heights.clear() - motion.release() - drawnRows.forgetAnchor() - } - if (changed && drawn.current?.keys !== keys) { - heights.retain(keys) - } - const last = end >= n ? null : keys[end - 1]! - const m = m0 < m1 ? { from: keys[m0]!, fromAt: m0, to: keys[m1 - 1]!, toAt: m1 - 1 } : null - const seat = { first: keys[start] ?? '', firstAt: start, last, lastAt: end - 1 } - w.current = { ...win, ...seat, slack, measure: m } - if (drawn.current && drawn.current.rows !== rows && following) { - streamedAt.current = Date.now() - } - const fresh = { keys, rows, indexOf, rowState, start, end, first, following, layoutKey } - drawn.current = { ...fresh, sourceHead: rowKeys.sourceHead } - settle.current = false - anchoring.committed() - const s = scroller() - if (s && anchorStale.current) { - anchorStale.current = false - takeAnchor(s) - } - motion.laidOut([n, m0, m1, start, end, above, mid, bottom, layoutKey].join(':')) - if (n > 0) { - motion.poke() - } - }) - - useLayoutEffect(() => { - const s = scroller() - if (!s) { - return - } - const scrolled = () => { - // A write's or layout's event can arrive after rows above changed and before the correction: - // taking the place or covering then would keep the moved position and draw what moved it. - const max = s.scrollHeight - s.clientHeight - const { by } = motion.scrolled(s.scrollTop, max, () => drawnRows.placeMoved(s)) - if (by === 'reader') { - takeAnchor(s) - } - // Where the browser keeps the place, the page writes no correction to wait for. - if (by === 'reader' || (by === 'layout' && !scrollWritesStopMomentum())) { - cover(s) - } - } - const detach = motion.attach(s) - s.addEventListener('scroll', scrolled, { passive: true }) - return () => { - detach() - s.removeEventListener('scroll', scrolled) - motion.release() - } - }, [scroller, motion, takeAnchor, cover, drawnRows, anchoring, roots]) - - /** The one observer's entries: rows measured, then, for a parked reader (on WebKit at rest), - * whatever moved the row they were reading goes back (a measured row, the header, given-back - * space). */ - const afterResize = useCallback( - (entries: readonly ResizeObserverEntry[], pin: () => void) => { - const [s, at] = [scroller(), drawn.current] - if (!s || !at) { - return pin() - } - viewport.current = s.clientHeight - width.current = s.clientWidth - const layout = `${at.layoutKey}:${width.current}` - for (const entry of entries) { - const key = drawnRows.keyOf(entry.target) - const i = at.indexOf(key) - if (key !== undefined && i !== undefined) { - const row = at.rows[i]! - heights.remember(key, entry.contentRect.height, row, at.rowState(i, row), layout) - } - } - // As of the layout, before a move no scroll event has reported yet is counted. - roots?.restoreAll() - const own = !at.following && motion.mayCorrect() && pageKeepsPlace(s.scrollTop) - const shift = own ? drawnRows.placeShift(s) : null - const max = s.scrollHeight - s.clientHeight - const { by, moved } = motion.reconciled(s.scrollTop, max) - if (shift !== null) { - // Rows above moved by `shift`: put that back, keep the reader's own move, undo a clamp. - write(s, s.scrollTop - (by === 'layout' ? moved : 0) + shift) - } - // After the reader's unreported move is counted (it may end following), so the pin can't undo it. - pin() - // The place is where the reader is now: a correction that couldn't land, or a move while - // they scrolled, is not owed later. - takeAnchor(s) - cover(s) - motion.poke() - }, - [scroller, heights, motion, write, cover, drawnRows, takeAnchor, roots] - ) - - /** The place-hold's correction, when one may be written: on WebKit only at rest (mid-motion the - * reader already saw the move, and putting it back at rest would be a second jump), except a - * regroup, which resizes drawn rows at once and nothing redoes later. */ - const keepInPlace = useCallback( - (drift: number, regrouped: boolean) => { - const s = scroller() - if (s && !drawn.current?.following && (regrouped || motion.mayCorrect())) { - write(s, s.scrollTop + drift) - anchorStale.current = true - } - }, - [scroller, motion, write] - ) - - // Stable, so the list's handle (and with it the one observer) is made once. - const list = useMemo( - () => ({ - afterResize, - keepInPlace, - observeWith: (observer: ResizeObserver | null) => { - drawnRows.observeWith(observer) - roots?.observeWith(observer) - }, - settled: () => roots?.settled(motion) ?? motion.whenSettled(), - syncAnchoring: () => anchoring.sync(), - holdPlace: (on: boolean) => anchoring.hold(on), - watchMotion: (fn: typeof motion.onMotion) => { - motion.onMotion = fn - }, - wrote: (target: number) => motion.wrote(target) - }), - [afterResize, keepInPlace, drawnRows, motion, anchoring, roots] - ) - - return { - view: { m0, m1, start, end, above, mid, bottom, measuring }, - rowRef: drawnRows.ref, - list - } -} diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index da787a91d4aa..8fa7c0248f15 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -3,7 +3,6 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import type { RpcClient } from '../transport/rpc-client' -import { createFakeRpcClient, rpcSuccess } from '../mobile-web-shell/bridge-host-test-fakes' import { useMobileNativeChatSession, type MobileNativeChatSession @@ -50,36 +49,6 @@ describe('useMobileNativeChatSession', () => { }) } - it.each([true, false])( - 'preserves only post-response live changes while history waits (overlap: %s)', - async (overlap) => { - const cached = message('cached') - const partial = message('reply') - const history = { ...cached, blocks: [{ type: 'text' as const, text: 'fresh history' }] } - const final = { ...partial, blocks: [{ type: 'text' as const, text: 'complete' }] } - const response = [message('older'), history, partial, ...(overlap ? [] : [message('absent')])] - const subscribe: RpcClient['subscribe'] = (_method, _params, onData) => { - emit = onData - onData({ type: 'snapshot', messages: [cached, partial], hasMore: true }) - return () => {} - } - const sendRequest = async () => rpcSuccess('older', { messages: response }) - let land = () => {} - const settled = new Promise((resolve) => { - land = resolve - }) - await mount({ ...createFakeRpcClient(), subscribe, sendRequest }) - await act(async () => { - state?.loadEarlier(() => settled) - }) - await act(async () => emit({ type: 'appended', messages: [final, message('suffix')] })) - await act(async () => land()) - expect(state?.messages.find((m) => m.id === 'reply')).toEqual(final) - expect(state?.messages.find((m) => m.id === 'cached')).toEqual(history) - expect(state?.messages.map((m) => m.id)).toEqual(response.map((m) => m.id).concat('suffix')) - } - ) - it('drops an older-page response captured before transcript replacement', async () => { let resolveEarlier: (response: unknown) => void = () => {} const sendRequest = vi.fn( diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index e1936514b4d3..dd7d2f78da32 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -3,11 +3,7 @@ import { createNativeChatTranscriptRetention, encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' -import { - createNativeChatMerger, - mergeNativeChatMessages, - replaceList -} from '../../../src/shared/native-chat-merge' +import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe' import type { RpcClient } from '../transport/rpc-client' @@ -50,8 +46,7 @@ export type MobileNativeChatSession = { /** The Load earlier control's state: reading now, or how the last read ended. */ olderPages?: OlderPagePhase /** Grow the window to page in older history. */ - /** `settled`: the page waits for it before it lands (the phone page, until the reader stops). */ - loadEarlier: (settled?: () => Promise) => void + loadEarlier: () => void } // Small first page for a fast first paint; grows by a page as the user scrolls. @@ -243,87 +238,74 @@ export function useMobileNativeChatSession(args: { } }, [client, agent, sessionId, transcriptPath, identity, setList, supersede]) - const loadEarlier = useCallback( - (settled?: () => Promise) => { - if (!client || !agent || !sessionId || !hasMore) { - return + const loadEarlier = useCallback(() => { + if (!client || !agent || !sessionId || !hasMore) { + return + } + // Capture the session this page belongs to; a swap underneath us must not + // apply this read's result onto the new session (mirrors desktop's guard). + const requestSessionId = sessionId + loadOlderPages(async ({ timeoutMs, isCurrent }) => { + const page = nextPageWindow(limitRef.current, mergerRef.current.list.length) + if (!page) { + setHasMore(false) + return 'end' } - // Capture the session this page belongs to; a swap underneath us must not - // apply this read's result onto the new session (mirrors desktop's guard). - const requestSessionId = sessionId - loadOlderPages(async ({ timeoutMs, isCurrent }) => { - const page = nextPageWindow(limitRef.current, mergerRef.current.list.length) - if (!page) { - setHasMore(false) - return 'end' - } - const { nextLimit, pageLimit } = page - const beforeOffset = beforeOffsetRef.current - const response = await nativeChatSessionPageRead.request( - client, - { - agent, - sessionId, - limit: beforeOffset === null ? nextLimit : pageLimit, - ...(beforeOffset === null ? {} : { beforeOffset }), - ...(transcriptPath ? { transcriptPath } : {}) - }, - { timeoutMs, budgetSpansConnect: true } - ) - const accepted = nativeChatSessionPageRead.interpret(response) - if (!accepted.accepted) { - return 'failed' - } - // The read is `z.unknown()` because the reply is a union, so an accepted success can still - // carry no result at all, or null; `'error' in` throws on either. - const payload = accepted.value - if (payload === null || typeof payload !== 'object') { - return 'failed' - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: main cast this payload unread; the reader hands back the same result. - const result = payload as ReadSessionResult - if ('error' in result) { - return 'failed' - } - const receivedLive = new Map(mergerRef.current.list.map((message) => [message.id, message])) - // The page lands once the reader stops scrolling: rows growing above them mid-fling would move - // what they read, and iOS stops the fling on the write that would hold it. - if (settled) { - await settled() - } - // Lands only on the window and cursor it was asked from; otherwise the window it - // read against is gone, and the reader pages again from the new one. - if ( - !isCurrent() || - sessionIdRef.current !== requestSessionId || - beforeOffsetRef.current !== beforeOffset - ) { - return 'end' - } - limitRef.current = nextLimit - let more: boolean - if (beforeOffset !== null && result.beforeOffset != null) { - beforeOffsetRef.current = result.beforeOffset - setList([...result.messages, ...mergerRef.current.list]) - more = nextLimit < MAX_MESSAGES && (result.hasMore ?? result.messages.length >= pageLimit) - } else { - // Older runtimes ignore the cursor and return the growing tail; rows that streamed in while - // it waited stay. - const live = mergerRef.current.list - const after = live.findIndex((message) => message.id === result.messages.at(-1)?.id) - const landed = - after === -1 ? result.messages : [...result.messages, ...live.slice(after + 1)] - // Only updates accepted after receipt outrank history; unchanged cached rows may be stale. - const changed = live.filter((message) => receivedLive.get(message.id) !== message) - setList(mergeNativeChatMessages(landed, changed)) - more = result.messages.length >= nextLimit - } - setHasMore(more) - return more ? 'drew' : 'end' - }) - }, - [client, agent, sessionId, transcriptPath, hasMore, setList, loadOlderPages] - ) + const { nextLimit, pageLimit } = page + const beforeOffset = beforeOffsetRef.current + const response = await nativeChatSessionPageRead.request( + client, + { + agent, + sessionId, + limit: beforeOffset === null ? nextLimit : pageLimit, + ...(beforeOffset === null ? {} : { beforeOffset }), + ...(transcriptPath ? { transcriptPath } : {}) + }, + { timeoutMs, budgetSpansConnect: true } + ) + const accepted = nativeChatSessionPageRead.interpret(response) + if (!accepted.accepted) { + return 'failed' + } + // The read is `z.unknown()` because the reply is a union, so an accepted success can still + // carry no result at all, or null; `'error' in` throws on either. + const payload = accepted.value + if (payload === null || typeof payload !== 'object') { + return 'failed' + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: main cast this payload unread; the reader hands back the same result. + const result = payload as ReadSessionResult + if ('error' in result) { + return 'failed' + } + // Lands only on the window and cursor it was asked from; otherwise the window it + // read against is gone, and the reader pages again from the new one. + if ( + !isCurrent() || + sessionIdRef.current !== requestSessionId || + beforeOffsetRef.current !== beforeOffset + ) { + return 'end' + } + limitRef.current = nextLimit + let more: boolean + if (beforeOffset !== null && result.beforeOffset != null) { + beforeOffsetRef.current = result.beforeOffset + setList([...result.messages, ...mergerRef.current.list]) + more = nextLimit < MAX_MESSAGES && (result.hasMore ?? result.messages.length >= pageLimit) + } else { + // Older runtimes ignore the cursor and return the growing tail; rows that streamed in while + // it waited stay. + const live = mergerRef.current.list + const after = live.findIndex((message) => message.id === result.messages.at(-1)?.id) + setList(after === -1 ? result.messages : [...result.messages, ...live.slice(after + 1)]) + more = result.messages.length >= nextLimit + } + setHasMore(more) + return more ? 'drew' : 'end' + }) + }, [client, agent, sessionId, transcriptPath, hasMore, setList, loadOlderPages]) // Held for any unsettled read, not just an in-flight one: a stream error or a // dropped client would otherwise trade the conversation for an error card. diff --git a/mobile/src/session/use-mobile-native-chat-tail-follow.ts b/mobile/src/session/use-mobile-native-chat-tail-follow.ts index 05e7fa561a39..a8a469d5b955 100644 --- a/mobile/src/session/use-mobile-native-chat-tail-follow.ts +++ b/mobile/src/session/use-mobile-native-chat-tail-follow.ts @@ -1,20 +1,14 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { Platform, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native' -import type { MobileNativeChatScrollNode } from './MobileNativeChatScrollHold' -import type { MobileNativeChatPageObservation } from './mobile-native-chat-page-motion' /** Distance from the bottom, in points, still treated as "at the tail". */ const AT_TAIL_SLOP = 80 -/** RN Web's ScrollView reports no drag or momentum, so on the page the offsets themselves have to - * say what the reader did. It ignores `maintainVisibleContentPosition` too. */ +/** The page's list is anchored at its end, so it reports the reader's distance from it itself. */ function listReportsGestures(): boolean { return Platform.OS !== 'web' } -/** Offset change, in points, below which the page treats the view as not having moved. */ -const PAGE_MOVE_SLOP = 1 - /** Holds the first visible row in place when rows above it change. The header is not a row. */ const ANCHOR_BELOW_HEADER = { minIndexForVisible: 1 } as const const ANCHOR_AT_FIRST_ROW = { minIndexForVisible: 0 } as const @@ -23,24 +17,6 @@ export function mobileNativeChatRowAnchor(hasHeader: boolean): { minIndexForVisi return hasHeader ? ANCHOR_BELOW_HEADER : ANCHOR_AT_FIRST_ROW } -/** The list's scrollable node on the page, as RN Web's ScrollView decorates it. */ -type PageScroller = Element & MobileNativeChatScrollNode & { getInnerViewNode: () => Element } - -/** RN Web reports content size a task after layout, so a frame can paint between growth and a pin - * driven by that report. An observer on the scroller and its content runs before that paint. */ -function observePageResize( - scroller: PageScroller | null | undefined, - onResize: (entries: readonly ResizeObserverEntry[]) => void -): ResizeObserver | null { - if (!scroller || typeof ResizeObserver === 'undefined') { - return null - } - const observer = new ResizeObserver(onResize) - observer.observe(scroller) - observer.observe(scroller.getInnerViewNode()) - return observer -} - function distanceFromTail(event: NativeScrollEvent): number { const { contentOffset, contentSize, layoutMeasurement } = event return contentSize.height - (contentOffset.y + layoutMeasurement.height) @@ -50,43 +26,27 @@ function isAtTail(event: NativeScrollEvent): boolean { return distanceFromTail(event) <= AT_TAIL_SLOP } -/** What the owner and the place-hold use of a transcript list: native's FlatList, or the page's. */ +/** What the owner uses of a transcript list: native's FlatList, or the page's. */ export type MobileNativeChatListHandle = { - getScrollableNode: () => PageScroller | null | undefined scrollToOffset: (params: { animated: boolean; offset: number }) => void scrollToEnd: (params: { animated: boolean }) => void - /** Page only: each resize, with the observer's entries; runs the end `pin` once it has counted a - * move of the reader's no scroll event reported yet. */ - afterResize?: (entries: readonly ResizeObserverEntry[], pin: () => void) => void - /** Page only: the list's rows join the one observer, so each reports its own height. */ - observeWith?: (observer: ResizeObserver | null) => void - /** Page only: the place-hold's correction, written while the reader moves only for a regroup. */ - keepInPlace?: (drift: number, regrouped: boolean) => void - /** Page only: resolves once the reader stops moving the list, or the list goes away. */ - settled?: () => Promise - /** Page only: each classified move, before any resize correction or end pin. */ - watchMotion?: (fn: ((observation: MobileNativeChatPageObservation) => void) | null) => void - /** Page only: following changed, which decides whether the browser's anchoring may move it. */ - syncAnchoring?: () => void - /** Page only: the place-hold takes the scroll from the browser's anchoring for its correction. */ - holdPlace?: (on: boolean) => void } export type MobileNativeChatTailFollow = { /** Spread on the native list. `ref`: the hook scrolls through it alone. */ - listProps: { ref: (list: MobileNativeChatListHandle | null) => () => void } - /** Spread on the page list: the same `ref`, which also watches the list resize. */ + listProps: { ref: (list: MobileNativeChatListHandle | null) => void } + /** Spread on the page list, which reports where each scroll leaves the reader. */ pageListProps: { - ref: (list: MobileNativeChatListHandle | null) => () => void - endAnchored: boolean + ref: (list: MobileNativeChatListHandle | null) => void isFollowing: () => boolean - pin: () => void + /** `byReader`: the reader moved it, not the list's own correction. */ + onEndDistance: (fromEnd: number, byReader: boolean) => void } /** Render flag for the jump-to-latest control. */ showJumpToTail: boolean /** Passive maintenance: re-pin after the viewport resizes. */ pinToTail: () => void - /** Content-size maintenance: native uses the measured height, the page its live end. */ + /** Content-size maintenance: native uses the measured height. */ pinToTailAfterContentResize: (_width: number, height: number) => void /** Explicit jump — send, or the jump-to-latest control. Resumes following. */ jumpToTail: () => void @@ -94,18 +54,12 @@ export type MobileNativeChatTailFollow = { endUserDrag: (event: NativeSyntheticEvent) => void beginMomentum: () => void endMomentum: (event: NativeSyntheticEvent) => void - /** Whether a scroll near the top may page in older history: only a native list's own drag. The - * page loads it only from the "Load earlier" control, since a page landing in iOS's top bounce - * gets the place-hold's write while WebKit owns the scroll, and the list stops scrolling. */ + /** Whether a scroll near the top may page in older history: a native list's own drag. */ scrollMayLoadEarlier: () => boolean recordScrollMetrics: (event: NativeScrollEvent) => void - /** Page only: an older page waits for it, so it never lands under a reader who is scrolling. */ - pageSettled: (() => Promise) | undefined - /** Read at commit by the page's place-hold: the list, and whether the reader follows the end. */ - placeHold: { listRef: RefObject; isFollowing: () => boolean } } -/** Sole owner of transcript scroll position. +/** Sole owner of transcript follow intent. * * Streaming used to have several tail-followers at once: a delayed *animated* * `scrollToEnd` alongside an immediate non-animated one on content growth. The @@ -116,8 +70,8 @@ export type MobileNativeChatTailFollow = { * Intent (`following`) is kept separate from geometry (at-tail): a programmatic * scroll reports metrics like any other, so letting metrics decide intent let * the view argue with itself. Only the user's own gestures and explicit jumps - * move native intent; metrics decide where a *released* gesture leaves us. On - * the page, a move toward the end resumes following; only reader input detaches it. + * move native intent; metrics decide where a *released* gesture leaves us. The + * page's list holds its end by itself; there the reader's own scroll decides. */ export function useMobileNativeChatTailFollow(args: { /** Guards `scrollToEnd` against an empty list. */ @@ -142,8 +96,6 @@ export function useMobileNativeChatTailFollow(args: { return } followingRef.current = next - // Before any write or commit: the browser's anchoring and the end pin never both hold the place. - listRef.current?.syncAnchoring?.() setFollowingFlag(next) }, []) @@ -155,76 +107,23 @@ export function useMobileNativeChatTailFollow(args: { setAtTailFlag(next) }, []) - // Live geometry: a size report arrives late and can name an end the view has already left. - // Not `scrollToEnd`: its estimate can land short, and the page reads a move up as the reader's. - const pinPageToEnd = useCallback(() => { - const scroller = listRef.current?.getScrollableNode() - if (!followingRef.current || !scroller) { - return - } - const end = scroller.scrollHeight - scroller.clientHeight - // Already there, or iOS bouncing past an end: a write then lands while WebKit owns the scroll. - if (scroller.scrollTop < 0 || scroller.scrollTop >= end - PAGE_MOVE_SLOP) { - return - } - listRef.current?.scrollToOffset({ animated: false, offset: end }) + const attachList = useCallback((list: MobileNativeChatListHandle | null) => { + listRef.current = list }, []) - const attachList = useCallback( - (list: MobileNativeChatListHandle | null) => { - listRef.current = list - const page = !listReportsGestures() - // Following comes only from classified motion: a resize can land a parked reader on a shorter - // end (a collapse below them), and that clamp is layout's, not the reader asking to follow. - const observer = page - ? observePageResize(list?.getScrollableNode(), (entries) => - list?.afterResize ? list.afterResize(entries, pinPageToEnd) : pinPageToEnd() - ) - : null - list?.observeWith?.(observer) - // Only a move toward the end attaches: a clamp onto a shorter end moves up, even one that - // lands under the reader's finger (a tap on a collapse below them). - list?.watchMotion?.(({ by, moved, fromEnd }) => { - if (followingRef.current) { - // Any upward move of the reader's that leaves the end detaches: a slow drag reports many - // 1px moves, and none of them alone is past a per-event threshold. - if (by === 'reader' && moved < 0 && fromEnd > PAGE_MOVE_SLOP) { - setFollowing(false) - } - } else if ( - (by === 'reader' && moved > 0 && fromEnd <= PAGE_MOVE_SLOP) || - (by !== 'own' && moved > PAGE_MOVE_SLOP && fromEnd <= AT_TAIL_SLOP) - ) { - setFollowing(true) - } - }) - return () => { - list?.watchMotion?.(null) - list?.observeWith?.(null) - observer?.disconnect() - listRef.current = null - } - }, - [pinPageToEnd, setFollowing] - ) - const pinToTail = useCallback(() => { - if (!listReportsGestures()) { - pinPageToEnd() - } else if (followingRef.current && hasItems) { + if (followingRef.current && hasItems) { listRef.current?.scrollToEnd({ animated: false }) } - }, [hasItems, pinPageToEnd]) + }, [hasItems]) const pinToTailAfterContentResize = useCallback( (_width: number, height: number) => { - if (!listReportsGestures()) { - pinPageToEnd() - } else if (followingRef.current && hasItems) { + if (followingRef.current && hasItems) { listRef.current?.scrollToOffset({ animated: false, offset: height }) } }, - [hasItems, pinPageToEnd] + [hasItems] ) const clearUserScrollSettle = useCallback(() => { @@ -236,11 +135,24 @@ export function useMobileNativeChatTailFollow(args: { const recordScrollMetrics = useCallback( (event: NativeScrollEvent) => { - setAtTail(isAtTail(event)) + if (listReportsGestures()) { + setAtTail(isAtTail(event)) + } }, [setAtTail] ) + // A one-point step still leaves the end, so a slow drag detaches; only the reader's move counts. + const onEndDistance = useCallback( + (fromEnd: number, byReader: boolean) => { + setAtTail(fromEnd <= AT_TAIL_SLOP) + if (byReader) { + setFollowing(fromEnd <= 1) + } + }, + [setAtTail, setFollowing] + ) + const jumpToTail = useCallback(() => { clearUserScrollSettle() userScrollActiveRef.current = false @@ -304,7 +216,6 @@ export function useMobileNativeChatTailFollow(args: { ) const isFollowing = useCallback(() => followingRef.current, []) - const pageSettled = useCallback(() => listRef.current?.settled?.() ?? Promise.resolve(), []) // The view stays mounted across chats: before paint, so the next one never shows where the reader // left the last. @@ -320,7 +231,7 @@ export function useMobileNativeChatTailFollow(args: { return { listProps: { ref: attachList }, - pageListProps: { ref: attachList, endAnchored: following, isFollowing, pin: pinPageToEnd }, + pageListProps: { ref: attachList, isFollowing, onEndDistance }, showJumpToTail: !following && !atTail, pinToTail, pinToTailAfterContentResize, @@ -330,8 +241,6 @@ export function useMobileNativeChatTailFollow(args: { beginMomentum, endMomentum, scrollMayLoadEarlier, - recordScrollMetrics, - pageSettled: listReportsGestures() ? undefined : pageSettled, - placeHold: { listRef, isFollowing } + recordScrollMetrics } } diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts index 72785c4de810..be687db2358a 100644 --- a/mobile/src/session/use-mobile-structured-agent-state.ts +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -55,8 +55,7 @@ export function useMobileStructuredAgentState(args: { /** The whole queue's pause, published with the drafts. */ queuePause: MobileQueuePause olderPages: OlderPagePhase - /** `settled`: the page waits for it before it lands (the phone page, until the reader stops). */ - loadEarlier: (settled?: () => Promise) => void + loadEarlier: () => void } { const { client, connected, enabled, sessionId, sessionKey } = args // Keep a bounded cache so offline tab switches select the right transcript @@ -197,72 +196,65 @@ export function useMobileStructuredAgentState(args: { } }, [apply, applyQueued, client, connected, enabled, sessionId, sessionKey, supersede]) - const loadEarlier = useCallback( - (settled?: () => Promise) => { - const start = oldestStructuredAgentSessionCursor(stateRef.current) - if (!client || !sessionId || !sessionKey || !stateRef.current.hasOlder || !start) { - return - } - let cursor = start - // Every head this tap produced. Any other head means a live batch trimmed the window mid-tap, - // and the reducer drops a page that no longer abuts it, so the read restarts from that head. - // Holding them all keeps the check right while `stateRef` lags a commit behind a landed page. - let heads = new Set([start.sequence]) - const intact = (): boolean => { + const loadEarlier = useCallback(() => { + const start = oldestStructuredAgentSessionCursor(stateRef.current) + if (!client || !sessionId || !sessionKey || !stateRef.current.hasOlder || !start) { + return + } + let cursor = start + // Every head this tap produced. Any other head means a live batch trimmed the window mid-tap, + // and the reducer drops a page that no longer abuts it, so the read restarts from that head. + // Holding them all keeps the check right while `stateRef` lags a commit behind a landed page. + let heads = new Set([start.sequence]) + const intact = (): boolean => { + const head = oldestStructuredAgentSessionCursor(stateRef.current) + return head?.epoch === cursor.epoch && heads.has(head.sequence) + } + loadOlderPages(async ({ timeoutMs, isCurrent }) => { + if (!intact()) { const head = oldestStructuredAgentSessionCursor(stateRef.current) - return head?.epoch === cursor.epoch && heads.has(head.sequence) - } - loadOlderPages(async ({ timeoutMs, isCurrent }) => { - if (!intact()) { - const head = oldestStructuredAgentSessionCursor(stateRef.current) - if (!head) { - return 'end' - } - cursor = head - heads = new Set([head.sequence]) - } - let result: AgentSessionHistoryResult - try { - result = await callAgentSession( - client, - 'agentSession.history', - { sessionId, direction: 'before', cursor, limit: AGENT_SESSION_HISTORY_MAX_LIMIT }, - timeoutMs - ) - } catch (error) { - return isFinalAgentSessionReadRefusal(agentSessionReadFailureRefusal(error)) - ? 'unavailable' - : 'failed' - } - // Lands once the reader stops scrolling, so nothing grows above them mid-fling. - if (settled) { - await settled() - } - if (!isCurrent()) { + if (!head) { return 'end' } - if (!result.ok) { - // Every reset reason makes the subscription reload a fresh window, which replaces this one. - return 'end' - } - if (!intact()) { - return 'drew-nothing' - } - const { page } = result - apply({ type: 'older-page', requestedCursor: cursor, page }) - const oldest = page.items[0] - if (!page.hasOlder || !oldest) { - return 'end' - } - heads.add(oldest.sequence) - cursor = page.window.nextCursor - // Mobile draws only the session's own rows, so a page of a subagent's rows alone lands as - // nothing; the loader reads on until a page adds a row the reader can see. - return page.items.some(isRootAgentJournalItem) ? 'drew' : 'drew-nothing' - }) - }, - [apply, client, loadOlderPages, sessionId, sessionKey] - ) + cursor = head + heads = new Set([head.sequence]) + } + let result: AgentSessionHistoryResult + try { + result = await callAgentSession( + client, + 'agentSession.history', + { sessionId, direction: 'before', cursor, limit: AGENT_SESSION_HISTORY_MAX_LIMIT }, + timeoutMs + ) + } catch (error) { + return isFinalAgentSessionReadRefusal(agentSessionReadFailureRefusal(error)) + ? 'unavailable' + : 'failed' + } + if (!isCurrent()) { + return 'end' + } + if (!result.ok) { + // Every reset reason makes the subscription reload a fresh window, which replaces this one. + return 'end' + } + if (!intact()) { + return 'drew-nothing' + } + const { page } = result + apply({ type: 'older-page', requestedCursor: cursor, page }) + const oldest = page.items[0] + if (!page.hasOlder || !oldest) { + return 'end' + } + heads.add(oldest.sequence) + cursor = page.window.nextCursor + // Mobile draws only the session's own rows, so a page of a subagent's rows alone lands as + // nothing; the loader reads on until a page adds a row the reader can see. + return page.items.some(isRootAgentJournalItem) ? 'drew' : 'drew-nothing' + }) + }, [apply, client, loadOlderPages, sessionId, sessionKey]) // A window of only a subagent's rows draws nothing, and an empty list cannot be scrolled // to ask for more, so it reads back once from each such head. A first page can be one: an From eb758debbbd39ae96026b9cd0a1705e255cac335 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:27:27 +0000 Subject: [PATCH 2/5] fix(mobile): pause a streaming reply's reveal while a scrolled-up reader moves The reveal grows its row on its own frames, outside the list's hold, so each new line pushed the reader mid-fling and through the long iOS coast after it. --- ...bileNativeChatMessage.paced-reveal.test.ts | 29 ++++++++++++++- .../src/session/MobileNativeChatPageList.tsx | 35 ++++++++++++------- .../session/mobile-native-chat-growth-hold.ts | 34 ++++++++++++++++++ .../use-mobile-native-chat-paced-text.ts | 18 ++++++++-- 4 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-growth-hold.ts diff --git a/mobile/src/session/MobileNativeChatMessage.paced-reveal.test.ts b/mobile/src/session/MobileNativeChatMessage.paced-reveal.test.ts index 6567bbe0c54d..65241d06e946 100644 --- a/mobile/src/session/MobileNativeChatMessage.paced-reveal.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.paced-reveal.test.ts @@ -11,6 +11,10 @@ import { MobileNativeChatReplyRevealsContext, type MobileNativeChatReplyReveals } from './mobile-native-chat-reply-reveals' +import { + createMobileNativeChatGrowthHold, + MobileNativeChatGrowthHoldContext +} from './mobile-native-chat-growth-hold' vi.mock('react-native', () => ({ Image: 'Image', @@ -39,6 +43,8 @@ const second = first + 'more '.repeat(40) describe('a growing reply on the phone', () => { let renderer: ReactTestRenderer | undefined let chat: MobileNativeChatReplyReveals + // The page's hold on growth, as a reader who scrolled up moves. + let growth = createMobileNativeChatGrowthHold() function render(text: string | string[], working = true, runningTool = false): void { const message: NativeChatMessage = { @@ -59,7 +65,16 @@ describe('a growing reply on the phone', () => { mayStillGrow: working, activeTurnIsWorking: true }) - const tree = createElement(MobileNativeChatReplyRevealsContext.Provider, { value: chat }, row) + const revealing = createElement( + MobileNativeChatReplyRevealsContext.Provider, + { value: chat }, + row + ) + const tree = createElement( + MobileNativeChatGrowthHoldContext.Provider, + { value: growth }, + revealing + ) act(() => { if (renderer) { renderer.update(tree) @@ -101,6 +116,7 @@ describe('a growing reply on the phone', () => { afterEach(() => { act(() => renderer?.unmount()) renderer = undefined + growth = createMobileNativeChatGrowthHold() vi.unstubAllGlobals() vi.restoreAllMocks() vi.useRealTimers() @@ -128,6 +144,17 @@ describe('a growing reply on the phone', () => { expect(drawn()).toBe(second.length) }) + it('waits while the page holds growth, then catches up in one step', () => { + growth.set(true) + render(first) + const shown = drawn() + advance(DELAY * 2) + expect(drawn()).toBe(shown) + act(() => growth.set(false)) + advance(16) + expect(drawn()).toBe(first.length) + }) + it.each([false, true])('paces a second prose block after the first caught up: %s', (caughtUp) => { render(first) advance(caughtUp ? DELAY : DELAY / 2) diff --git a/mobile/src/session/MobileNativeChatPageList.tsx b/mobile/src/session/MobileNativeChatPageList.tsx index 0747ff793313..3bdc1cd2ed63 100644 --- a/mobile/src/session/MobileNativeChatPageList.tsx +++ b/mobile/src/session/MobileNativeChatPageList.tsx @@ -28,6 +28,10 @@ import { } from './mobile-native-chat-reading-place' import type { MobileNativeChatListHandle } from './use-mobile-native-chat-tail-follow' import type { MobileNativeChatScrollRoots } from './mobile-native-chat-scroll-roots' +import { + createMobileNativeChatGrowthHold, + MobileNativeChatGrowthHoldContext +} from './mobile-native-chat-growth-hold' /** Rows a chat opens with; more are drawn as the reader nears the top of what is drawn. */ const FIRST_ROWS = 24 @@ -191,15 +195,18 @@ export function MobileNativeChatPageList({ // The reader moves while parked: hold what is below them, and let it in once they rest. const settle = useRef(() => {}) + const [growth] = useState(createMobileNativeChatGrowthHold) const unhold = useCallback(() => { if (heldRef.current) { heldRef.current = null + growth.set(false) release((n) => n + 1) } }, []) const hold = useCallback(() => { if (!isFollowing()) { heldRef.current ??= live.current + growth.set(true) clearTimeout(settleTimer.current) settleTimer.current = setTimeout(() => settle.current(), REST_MS) } @@ -345,19 +352,21 @@ export function MobileNativeChatPageList({ onScroll={scrolled} scrollEventThrottle={16} > - {header} - {rows.length === 0 ? ( - emptyStateView - ) : ( - // Keyed by the chat, so no row carries into another. Rows above the drawn ones aren't in the - // document; what the reader opened in them is kept in the chat's disclosure store. - - {rows.slice(first).map((item, at) => ( - {renderItem({ item, index: first + at })} - ))} - - )} - {footer} + + {header} + {rows.length === 0 ? ( + emptyStateView + ) : ( + // Keyed by the chat, so no row carries into another. Rows above the drawn ones aren't in the + // document; what the reader opened in them is kept in the chat's disclosure store. + + {rows.slice(first).map((item, at) => ( + {renderItem({ item, index: first + at })} + ))} + + )} + {footer} + ) } diff --git a/mobile/src/session/mobile-native-chat-growth-hold.ts b/mobile/src/session/mobile-native-chat-growth-hold.ts new file mode 100644 index 000000000000..d00d54d5e80f --- /dev/null +++ b/mobile/src/session/mobile-native-chat-growth-hold.ts @@ -0,0 +1,34 @@ +import { createContext } from 'react' + +/** Whether rows may grow now. The page's list holds growth while a reader who scrolled up is + * moving: growth below them would push the line they read, and correcting it mid-fling stops the + * fling. A store, so holding re-renders only the rows that would grow. */ +export type MobileNativeChatGrowthHold = { + held: () => boolean + subscribe: (onChange: () => void) => () => void +} + +export function createMobileNativeChatGrowthHold(): MobileNativeChatGrowthHold & { + set: (held: boolean) => void +} { + let held = false + const listeners = new Set<() => void>() + return { + held: () => held, + subscribe: (onChange) => { + listeners.add(onChange) + return () => listeners.delete(onChange) + }, + set: (next) => { + if (next !== held) { + held = next + listeners.forEach((onChange) => onChange()) + } + } + } +} + +const NEVER_HELD: MobileNativeChatGrowthHold = { held: () => false, subscribe: () => () => {} } + +export const MobileNativeChatGrowthHoldContext = + createContext(NEVER_HELD) diff --git a/mobile/src/session/use-mobile-native-chat-paced-text.ts b/mobile/src/session/use-mobile-native-chat-paced-text.ts index 261b4c0f8d2a..bad74c64b677 100644 --- a/mobile/src/session/use-mobile-native-chat-paced-text.ts +++ b/mobile/src/session/use-mobile-native-chat-paced-text.ts @@ -1,4 +1,11 @@ -import { useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { + useContext, + useEffect, + useLayoutEffect, + useRef, + useState, + useSyncExternalStore +} from 'react' import { playNativeChatTextReveal, receiveNativeChatText, @@ -6,6 +13,7 @@ import { type NativeChatTextRevealFrames } from '../../../src/shared/native-chat-text-reveal' import { MobileNativeChatReplyRevealsContext } from './mobile-native-chat-reply-reveals' +import { MobileNativeChatGrowthHoldContext } from './mobile-native-chat-growth-hold' const FRAMES: NativeChatTextRevealFrames = { now: () => performance.now(), @@ -23,6 +31,8 @@ export function useMobileNativeChatPacedText( paced: boolean ): MobileNativeChatPacedText { const chat = useContext(MobileNativeChatReplyRevealsContext) + const growth = useContext(MobileNativeChatGrowthHoldContext) + const held = useSyncExternalStore(growth.subscribe, growth.held, growth.held) const list = paced ? (chat ?? undefined) : undefined const immediate = list === undefined || list.reducedMotion const [initial] = useState(() => @@ -45,7 +55,11 @@ export function useMobileNativeChatPacedText( }, [text, streaming, immediate, list, rowKey]) const behind = !immediate && shown < text.length + // Held, the reveal waits; its deadlines keep running, so it catches up in one step after. useEffect(() => { + if (held) { + return + } if (!behind) { if (!streaming) { list?.drawn.delete(rowKey) @@ -61,7 +75,7 @@ export function useMobileNativeChatPacedText( }, FRAMES ) - }, [behind, text, list, rowKey, streaming]) + }, [behind, text, list, rowKey, streaming, held]) return immediate ? { text, revealing: false } From 5d2c32dfe6b646ffa1c40ba4e212a6131f929c38 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:13:32 +0000 Subject: [PATCH 3/5] fix(mobile): draw held chat rows as they were, and stop trimming drawn rows A held row drawn through the live renderer still took a run's new members, and trimming back to the newest rows could undo a draw on every commit. --- .../src/session/MobileNativeChatPageList.tsx | 64 ++++++++++--------- .../mobile-native-chat-reading-place.test.tsx | 57 +++++++++++++++-- 2 files changed, 84 insertions(+), 37 deletions(-) diff --git a/mobile/src/session/MobileNativeChatPageList.tsx b/mobile/src/session/MobileNativeChatPageList.tsx index 3bdc1cd2ed63..d18961c50d6e 100644 --- a/mobile/src/session/MobileNativeChatPageList.tsx +++ b/mobile/src/session/MobileNativeChatPageList.tsx @@ -53,7 +53,12 @@ type Scroller = MobileNativeChatScrollNode & { removeEventListener: Listen } /** What a scrolling reader sees below them, held until they rest. */ -type Held = { rows: readonly NativeChatMessage[]; footer: ReactElement | null } +type Held = { + rows: readonly NativeChatMessage[] + footer: ReactElement | null + /** Drawn as they were: a row's look comes from the chat's state too (a run's members, its turn). */ + renderItem: (row: Row) => ReactElement +} type Props = { roots?: MobileNativeChatScrollRoots @@ -110,14 +115,12 @@ export function MobileNativeChatPageList({ const heldRef = useRef(null) const [, release] = useState(0) const held = heldRef.current - const rows = useMemo(() => { + const { rows, heldFrom } = useMemo(() => { const head = held?.rows[0] - if (!held || !head) { - return liveRows - } - const key = rowKeys.keyOf(head) - const at = liveRows.findIndex((row) => rowKeys.keyOf(row) === key) - return at === -1 ? liveRows : [...liveRows.slice(0, at), ...held.rows] + const at = head ? liveRows.findIndex((row) => rowKeys.keyOf(row) === rowKeys.keyOf(head)) : -1 + return !held || at === -1 + ? { rows: liveRows, heldFrom: Infinity } + : { rows: [...liveRows.slice(0, at), ...held.rows], heldFrom: at } }, [held, liveRows, rowKeys]) const footer = held ? held.footer : liveFooter const keys = useMemo(() => rows.map(rowKeys.keyOf), [rows, rowKeys.keyOf]) @@ -140,14 +143,14 @@ export function MobileNativeChatPageList({ const place = useRef(null) const placedLayout = useRef(layoutKey) const settleTimer = useRef | undefined>(undefined) - const live = useRef({ rows: liveRows, footer: liveFooter }) + const live = useRef({ rows: liveRows, footer: liveFooter, renderItem }) const ask = useRef(onNearTop) // Read at event time, so a scroll or resize between commits acts on the latest render. const latest = useRef({ first, n, keys, layoutKey, rowKeys }) useLayoutEffect(() => { latest.current = { first, n, keys, layoutKey, rowKeys } ask.current = onNearTop - live.current = { rows: liveRows, footer: liveFooter } + live.current = { rows: liveRows, footer: liveFooter, renderItem } }) const write = useCallback((s: Scroller, to: number) => { @@ -222,23 +225,17 @@ export function MobileNativeChatPageList({ }) useEffect(() => () => clearTimeout(settleTimer.current), []) - const drawAndFetch = useCallback( - (s: Scroller) => { - const { first: at, keys: drawnKeys, n: total, rowKeys: current } = latest.current - const screens = (s.scrollHeight - s.clientHeight + s.scrollTop) / s.clientHeight - if (at > 0 && screens < DRAW_AHEAD_SCREENS) { - const next = Math.max(0, at - DRAW_BATCH) - setDrawn({ scope: current.scope, firstKey: drawnKeys[next]!, count: total - next }) - } else if (isFollowing() && at < total - FIRST_ROWS && screens > 3 * DRAW_AHEAD_SCREENS) { - // Back at the end, rows far above go: taking them away moves nothing. - setDrawn({ scope: current.scope, firstKey: null, count: 0 }) - } - if (at < FETCH_AHEAD_ROWS && screens < FETCH_REACH_SCREENS) { - ask.current?.() - } - }, - [isFollowing] - ) + const drawAndFetch = useCallback((s: Scroller) => { + const { first: at, keys: drawnKeys, n: total, rowKeys: current } = latest.current + const screens = (s.scrollHeight - s.clientHeight + s.scrollTop) / s.clientHeight + if (at > 0 && screens < DRAW_AHEAD_SCREENS) { + const next = Math.max(0, at - DRAW_BATCH) + setDrawn({ scope: current.scope, firstKey: drawnKeys[next]!, count: total - next }) + } + if (at < FETCH_AHEAD_ROWS && screens < FETCH_REACH_SCREENS) { + ask.current?.() + } + }, []) useImperativeHandle( ref, @@ -360,9 +357,16 @@ export function MobileNativeChatPageList({ // Keyed by the chat, so no row carries into another. Rows above the drawn ones aren't in the // document; what the reader opened in them is kept in the chat's disclosure store. - {rows.slice(first).map((item, at) => ( - {renderItem({ item, index: first + at })} - ))} + {rows.slice(first).map((item, at) => { + const index = first + at + return ( + + {held && index >= heldFrom + ? held.renderItem({ item, index: index - heldFrom }) + : renderItem({ item, index })} + + ) + })} )} {footer} diff --git a/mobile/src/session/mobile-native-chat-reading-place.test.tsx b/mobile/src/session/mobile-native-chat-reading-place.test.tsx index b943b61b02bc..4ad8753b9e48 100644 --- a/mobile/src/session/mobile-native-chat-reading-place.test.tsx +++ b/mobile/src/session/mobile-native-chat-reading-place.test.tsx @@ -202,11 +202,20 @@ function page(tree: () => Node | Node[] | null) { return node } -function Row({ message, rowKey }: { message: NativeChatMessage; rowKey: string }): ReactElement { +function Row({ + message, + rowKey, + joined = 0 +}: { + message: NativeChatMessage + rowKey: string + /** Lines the chat's state adds to the row, as a run's new members do. */ + joined?: number +}): ReactElement { const { prose, tools } = splitNativeChatBlocks(message.blocks) - const words = prose.map((block, i) => - h('span', { key: i }, block.type === 'text' ? block.text : '') - ) + const words = prose + .map((block, i) => h('span', { key: i }, block.type === 'text' ? block.text : '')) + .concat(Array.from({ length: joined }, (_, i) => h('span', { key: `joined:${i}` }, 'joined'))) const run = tools.length > 0 && h(ToolRun, { blocks: tools }) return h('div', { testID: mobileNativeChatRowId(rowKey) }, words, run) } @@ -217,6 +226,8 @@ function Chat(props: { messages: NativeChatMessage[] following: boolean live: boolean + /** Lines the last row's state adds, outside its messages. */ + joined?: number }): ReactElement { const folded = useMemo( () => foldMobileNativeChatMessages(props.messages, 'claude'), @@ -230,7 +241,12 @@ function Chat(props: { rowKeys: keys, isFollowing: () => props.following, onEndDistance: () => {}, - renderItem: ({ item }) => h(Row, { message: item, rowKey: keys.keyOf(item) }), + renderItem: ({ item }) => + h(Row, { + message: item, + rowKey: keys.keyOf(item), + joined: item === folded.at(-1) ? props.joined : 0 + }), layoutKey: 'layout', contentContainerStyle: null, header: h('div', { testID: 'header' }, h('span', null, 'Loading')), @@ -247,9 +263,9 @@ function mount(messages: NativeChatMessage[], options = { following: false, live }) const scroller = page(() => renderer.toJSON()) scrollable.node = scroller - const show = (next: NativeChatMessage[]): number => { + const show = (next: NativeChatMessage[], joined = 0): number => { const before = scroller.writes - act(() => renderer.update(h(Chat, { ...options, messages: next }))) + act(() => renderer.update(h(Chat, { ...options, messages: next, joined }))) return scroller.writes - before } // The reader's own scroll, as RN Web reports it, on a clock the test moves. @@ -347,6 +363,33 @@ it('holds back a change below a scrolling reader until they rest, then keeps the expect(chat0.scroller.top(mobileNativeChatRowId('m4'))).toBe(line) }) +it("holds a row's look with its messages: what the chat's state adds lands once the reader rests", () => { + const rows = Array.from({ length: 12 }, (_, i) => say(`m${i}`, i % 2 ? 'assistant' : 'user', 'x')) + const chat0 = mount(rows) + chat0.scroller.showFrom(40) + chat0.scrolled() + const line = chat0.scroller.top(mobileNativeChatRowId('m4')) + // Moving: a new member of the last row's run is drawn by the renderer, not by a new message. + expect(chat0.show(rows, 3)).toBe(0) + expect(chat0.lines(mobileNativeChatRowId('m11'))).toBe(1) + expect(chat0.scroller.top(mobileNativeChatRowId('m4'))).toBe(line) + chat0.rest() + expect(chat0.lines(mobileNativeChatRowId('m11'))).toBe(4) + expect(chat0.scroller.top(mobileNativeChatRowId('m4'))).toBe(line) +}) + +it('opens a chat whose newest rows are short and older ones tall without drawing in a loop', () => { + const tall = Array.from({ length: 12 }, (_, i) => ({ + ...say(`t${i}`, 'assistant', ''), + blocks: Array.from({ length: 12 }, () => ({ type: 'text' as const, text: 'line' })) + })) + const short = Array.from({ length: 24 }, (_, i) => + say(`s${i}`, i % 2 ? 'assistant' : 'user', 'x') + ) + const chat0 = mount([...tall, ...short], { following: true, live: false }) + expect(chat0.drawn(mobileNativeChatRowId('t11'))).toBe(1) +}) + it('holds a row the reader taps open in place, and a reply below waits out a flick', () => { const later = Array.from({ length: 10 }, (_, i) => say(`t${i}`, 'user', 'later')) const chat0 = mount([...from('r21'), ...later]) From cbadc033ebdecfb8318a1cd375cfe28854441e5b Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:06:37 +0000 Subject: [PATCH 4/5] fix(mobile): draw a tool run once when an older page brings in its start mid-scroll The held rows began inside a run whose head the older page drew live, so the held head drew the run's members a second time until the reader rested. --- .../src/session/MobileNativeChatPageList.tsx | 36 ++++++++-- .../src/session/MobileNativeChatRowList.tsx | 5 +- mobile/src/session/MobileNativeChatView.tsx | 1 + .../mobile-native-chat-reading-place.test.tsx | 67 ++++++++++++++++--- 4 files changed, 91 insertions(+), 18 deletions(-) diff --git a/mobile/src/session/MobileNativeChatPageList.tsx b/mobile/src/session/MobileNativeChatPageList.tsx index d18961c50d6e..b44eba3f0f5d 100644 --- a/mobile/src/session/MobileNativeChatPageList.tsx +++ b/mobile/src/session/MobileNativeChatPageList.tsx @@ -68,6 +68,8 @@ type Props = { rows: readonly NativeChatMessage[] rowKeys: Pick renderItem: (row: Row) => ReactElement + /** Each run's rows by its head's index: the head draws them all. */ + runs?: ReadonlyMap /** How rows draw (Tools, text size): a change re-lays every row, so the place is kept mid-scroll. */ layoutKey: string contentContainerStyle: StyleProp @@ -94,6 +96,7 @@ export function MobileNativeChatPageList({ rows: liveRows, rowKeys, renderItem, + runs, layoutKey, contentContainerStyle, header, @@ -115,13 +118,34 @@ export function MobileNativeChatPageList({ const heldRef = useRef(null) const [, release] = useState(0) const held = heldRef.current - const { rows, heldFrom } = useMemo(() => { + const { rows, heldFrom, heldAt } = useMemo(() => { + const unheld = { rows: liveRows, heldFrom: Infinity, heldAt: 0 } const head = held?.rows[0] const at = head ? liveRows.findIndex((row) => rowKeys.keyOf(row) === rowKeys.keyOf(head)) : -1 - return !held || at === -1 - ? { rows: liveRows, heldFrom: Infinity } - : { rows: [...liveRows.slice(0, at), ...held.rows], heldFrom: at } - }, [held, liveRows, rowKeys]) + if (!held || at === -1) { + return unheld + } + // An older page can begin a run the held rows are in; its head draws them, so they draw live. + let last: NativeChatMessage | undefined + for (const [index, members] of runs ?? []) { + if (index >= at) { + break + } + last = members.at(-1) + } + const from = Math.max(at, last ? liveRows.indexOf(last, at) + 1 : 0) + const kept = liveRows.slice(at, from).every((row, i) => { + const was = held.rows[i] + return was !== undefined && rowKeys.keyOf(was) === rowKeys.keyOf(row) + }) + return kept + ? { + rows: [...liveRows.slice(0, from), ...held.rows.slice(from - at)], + heldFrom: from, + heldAt: at + } + : unheld + }, [held, liveRows, rowKeys, runs]) const footer = held ? held.footer : liveFooter const keys = useMemo(() => rows.map(rowKeys.keyOf), [rows, rowKeys.keyOf]) const indexOf = useMemo(() => new Map(keys.map((key, at) => [key, at])), [keys]) @@ -362,7 +386,7 @@ export function MobileNativeChatPageList({ return ( {held && index >= heldFrom - ? held.renderItem({ item, index: index - heldFrom }) + ? held.renderItem({ item, index: index - heldAt }) : renderItem({ item, index })} ) diff --git a/mobile/src/session/MobileNativeChatRowList.tsx b/mobile/src/session/MobileNativeChatRowList.tsx index fafd1dff84c2..f9f50b5a0b12 100644 --- a/mobile/src/session/MobileNativeChatRowList.tsx +++ b/mobile/src/session/MobileNativeChatRowList.tsx @@ -14,6 +14,7 @@ type Props = Pick< | 'rows' | 'roots' | 'renderItem' + | 'runs' | 'layoutKey' | 'contentContainerStyle' | 'header' @@ -33,11 +34,11 @@ export function MobileNativeChatRowList(props: Props): ReactElement { const { tail, rowKeys, rows, renderItem, contentContainerStyle, onScroll, footer } = props const { header, emptyStateView } = props if (Platform.OS === 'web') { - const { roots, layoutKey, onNearTop } = props + const { roots, runs, layoutKey, onNearTop } = props return ( ) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 54b8710c0107..0c522669b907 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -370,6 +370,7 @@ export function MobileNativeChatView({ {...{ tail, rowKeys, renderItem, footer, emptyStateView, onScroll, layoutKey }} {...{ header, hasHeader: hasMore === true, onNearTop: loadEarlierNearTop }} rows={turns.listMessages} + runs={turns.presentation.workRuns.heads} roots={disclosures.roots} contentContainerStyle={styles.listContent} /> diff --git a/mobile/src/session/mobile-native-chat-reading-place.test.tsx b/mobile/src/session/mobile-native-chat-reading-place.test.tsx index 4ad8753b9e48..9c31b3ad31fd 100644 --- a/mobile/src/session/mobile-native-chat-reading-place.test.tsx +++ b/mobile/src/session/mobile-native-chat-reading-place.test.tsx @@ -16,6 +16,7 @@ import { import { mobileNativeChatRowId } from './mobile-native-chat-reading-place' import { MobileNativeChatPageList } from './MobileNativeChatPageList' import { ToolRun } from './MobileNativeChatToolRun' +import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' const platform = vi.hoisted(() => ({ OS: 'web' })) // The page list's scrollable node: the fake page below, once mounted. @@ -205,14 +206,22 @@ function page(tree: () => Node | Node[] | null) { function Row({ message, rowKey, - joined = 0 + joined = 0, + workRun, + joinsWorkRun }: { message: NativeChatMessage rowKey: string /** Lines the chat's state adds to the row, as a run's new members do. */ joined?: number -}): ReactElement { - const { prose, tools } = splitNativeChatBlocks(message.blocks) + /** As the chat draws a run: its head draws every member, and the members draw nothing. */ + workRun?: readonly NativeChatMessage[] + joinsWorkRun?: boolean +}): ReactElement | null { + if (joinsWorkRun) { + return null + } + const { prose, tools } = splitNativeChatBlocks((workRun ?? [message]).flatMap((m) => m.blocks)) const words = prose .map((block, i) => h('span', { key: i }, block.type === 'text' ? block.text : '')) .concat(Array.from({ length: joined }, (_, i) => h('span', { key: `joined:${i}` }, 'joined'))) @@ -228,25 +237,38 @@ function Chat(props: { live: boolean /** Lines the last row's state adds, outside its messages. */ joined?: number + /** Tool rows group into runs, as in a structured chat. */ + runs?: boolean }): ReactElement { const folded = useMemo( () => foldMobileNativeChatMessages(props.messages, 'claude'), [props.messages] ) - const keys = useMobileNativeChatRowKeys(folded, props.messages, 'chat') + const turns = useMobileNativeChatTurnDisclosure({ + messages: folded, + enabled: props.runs === true, + isWorking: false, + scopeKey: 'chat' + }) + const keys = useMobileNativeChatRowKeys(folded, props.messages, 'chat', turns.presentation) shownKeys = keys return h(MobileNativeChatPageList, { ref: null, - rows: folded, + rows: turns.listMessages, rowKeys: keys, + runs: turns.presentation.workRuns.heads, isFollowing: () => props.following, onEndDistance: () => {}, - renderItem: ({ item }) => - h(Row, { + renderItem: ({ item, index }) => { + const { workRun, joinsWorkRun } = turns.resolveRow(index, item) + return h(Row, { message: item, rowKey: keys.keyOf(item), - joined: item === folded.at(-1) ? props.joined : 0 - }), + joined: item === folded.at(-1) ? props.joined : 0, + workRun, + joinsWorkRun + }) + }, layoutKey: 'layout', contentContainerStyle: null, header: h('div', { testID: 'header' }, h('span', null, 'Loading')), @@ -256,7 +278,10 @@ function Chat(props: { }) } -function mount(messages: NativeChatMessage[], options = { following: false, live: false }) { +function mount( + messages: NativeChatMessage[], + options: { following: boolean; live: boolean; runs?: boolean } = { following: false, live: false } +) { let renderer!: ReactTestRenderer act(() => { renderer = create(h('div')) @@ -378,6 +403,28 @@ it("holds a row's look with its messages: what the chat's state adds lands once expect(chat0.scroller.top(mobileNativeChatRowId('m4'))).toBe(line) }) +it('draws a run once when an older page brings in its start while the reader moves', () => { + const call = (id: string): NativeChatMessage => ({ + ...say(id, 'assistant', ''), + blocks: [{ type: 'tool-call', name: 'Bash', callId: id, input: { command: id } }] + }) + const thought: NativeChatMessage = { ...say('t', 'assistant', 'weighing it'), role: 'reasoning' } + const later = Array.from({ length: 12 }, (_, i) => + say(`m${i}`, i % 2 ? 'assistant' : 'user', 'x') + ) + const chat0 = mount([thought, call('b'), ...later], { following: false, live: false, runs: true }) + chat0.scroller.showFrom(40) + chat0.scrolled() + // The page ends inside the run: its first call joins the thought and call the reader has. + chat0.show([say('u', 'user', 'go'), call('a'), thought, call('b'), ...later]) + const once = ['u', 'a', 'm0'].map(mobileNativeChatRowId) + expect(chat0.rows().slice(0, 3)).toEqual(once) + expect(chat0.drawn('chat-member:run')).toBe(1) + chat0.rest() + expect(chat0.rows().slice(0, 3)).toEqual(once) + expect(chat0.drawn('chat-member:run')).toBe(1) +}) + it('opens a chat whose newest rows are short and older ones tall without drawing in a loop', () => { const tall = Array.from({ length: 12 }, (_, i) => ({ ...say(`t${i}`, 'assistant', ''), From dd10762802a5491798b1d6954cf47ff6bb565f9c Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:54:11 +0000 Subject: [PATCH 5/5] perf(mobile): lay out chat page rows in block flow In RN's column flexbox every streamed token re-laid each drawn row; with 500 rows drawn deep in history that was a 20 ms layout per frame on a throttled phone-class CPU. Block flow draws the same geometry for a fraction of it. --- mobile/src/session/MobileNativeChatPageList.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mobile/src/session/MobileNativeChatPageList.tsx b/mobile/src/session/MobileNativeChatPageList.tsx index b44eba3f0f5d..221f1198d9e7 100644 --- a/mobile/src/session/MobileNativeChatPageList.tsx +++ b/mobile/src/session/MobileNativeChatPageList.tsx @@ -379,8 +379,9 @@ export function MobileNativeChatPageList({ emptyStateView ) : ( // Keyed by the chat, so no row carries into another. Rows above the drawn ones aren't in the - // document; what the reader opened in them is kept in the chat's disclosure store. - + // document; what the reader opened in them is kept in the chat's disclosure store. Block flow: + // in a column flexbox each token of a reply re-lays every drawn row. +
{rows.slice(first).map((item, at) => { const index = first + at return ( @@ -391,7 +392,7 @@ export function MobileNativeChatPageList({ ) })} - +
)} {footer}