diff --git a/mobile/src/session/MobileNativeChatMessage.paced-reveal.test.ts b/mobile/src/session/MobileNativeChatMessage.paced-reveal.test.ts index 6567bbe0c54..65241d06e94 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/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 21247a007b6..d240eeccb4a 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 8a0edc28e82..221f1198d9e 100644 --- a/mobile/src/session/MobileNativeChatPageList.tsx +++ b/mobile/src/session/MobileNativeChatPageList.tsx @@ -1,116 +1,368 @@ 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' +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 +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 + /** 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 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 + /** 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 - /** `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, + runs, 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, 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 + 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]) + 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, 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, renderItem } + }) + + 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 [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) + } + }, [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 }) + } + if (at < FETCH_AHEAD_ROWS && screens < FETCH_REACH_SCREENS) { + ask.current?.() + } + }, []) + 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] + ) + + // Every commit: put the line back, then draw or ask for what the reader nears. + useLayoutEffect(() => { + 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 + } + 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] ) - // 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}` - useLayoutEffect(() => { - if (opened.current !== shown) { - opened.current = shown - pin() - } - }, [shown, pin]) - const drawRow = (item: NativeChatMessage, index: number) => { - const key = rowKeys.keyOf(item) - return ( - - {renderItem({ item, index })} - - ) - } return ( - {/* Never what the browser's anchoring holds: rows land below it, and spacers stand in for rows. */} - {header(view.measuring)} - {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. - - - {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))} - - - )} - {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. 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 ( + + {held && index >= heldFrom + ? held.renderItem({ item, index: index - heldAt }) + : renderItem({ item, index })} + + ) + })} +
+ )} + {footer} +
) } diff --git a/mobile/src/session/MobileNativeChatPageScroll.tsx b/mobile/src/session/MobileNativeChatPageScroll.tsx index edc833f5786..39cd4415c58 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 522b6aae334..27d1365482e 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 4952bca5303..f9f50b5a0b1 100644 --- a/mobile/src/session/MobileNativeChatRowList.tsx +++ b/mobile/src/session/MobileNativeChatRowList.tsx @@ -14,31 +14,32 @@ type Props = Pick< | 'rows' | 'roots' | 'renderItem' - | 'rowState' + | 'runs' | '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, runs, layoutKey, onNearTop } = props return ( ) } @@ -61,9 +62,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 a4f57370863..00000000000 --- 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 00000000000..9c0d2befda1 --- /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 2d601ddf338..467cdfae8f0 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 83da72d948e..cd59281c968 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 0489b358d1a..f3b10ae2de7 100644 --- a/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts +++ b/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts @@ -30,6 +30,7 @@ export type Overrides = { sendSurfaceId?: string keyboardInset?: number hasMore?: boolean + olderPages?: Parameters[0]['olderPages'] onLoadEarlier?: () => void status?: Parameters[0]['status'] error?: string @@ -77,7 +78,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 7caf12ef675..3c1d9ebf12b 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) + it('stops following once the reader scrolls off the end, and follows again back at it', async () => { + await render({ folded: [assistantTurn('a1', 'Latest')] }) - // 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 }) + readerScrollsTo(-600) + expect(jumpControl()).toHaveLength(1) + readerScrollsTo(0) + expect(jumpControl()).toHaveLength(0) - // 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 }) - - // 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() + // 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 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) - }) - - 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 }) - - // Down to the top and into iOS's bounce past it. - act(() => { - page().props.onScroll(scrolledTo(40, 540)) - page().props.onScroll(scrolledTo(-15, 540)) - }) + const folded = [assistantTurn('a1', 'Latest')] + await render({ folded, hasMore: true, onLoadEarlier }) expect(onLoadEarlier).not.toHaveBeenCalled() - act(() => loadEarlier().props.onPress()) + // 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() + + 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 484e9de916a..7ca6b35048d 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 9415db4e42a..f57461ec646 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 @@ -252,7 +251,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 => { @@ -270,19 +269,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 @@ -331,10 +332,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) @@ -369,24 +368,22 @@ export function MobileNativeChatView({ ) : ( - - + 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/mobile-native-chat-list-header.tsx b/mobile/src/session/mobile-native-chat-list-header.tsx index 30c9a0387a3..a5f50b19415 100644 --- a/mobile/src/session/mobile-native-chat-list-header.tsx +++ b/mobile/src/session/mobile-native-chat-list-header.tsx @@ -1,42 +1,83 @@ -import type { ReactElement } from 'react' -import { ActivityIndicator, Pressable, Text, View } from 'react-native' +import { useEffect, useState, type ReactElement, type ReactNode } from 'react' +import { ActivityIndicator, Animated, Platform, Pressable, Text, View } from 'react-native' import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-view-styles' import type { OlderPagePhase } from './use-mobile-older-page-loader' -const LOAD_EARLIER_FAILED = "Couldn't load earlier messages. Tap to try again." +const LOAD_EARLIER_FAILED = "Couldn't load earlier messages" +const LOAD_EARLIER_RETRY = 'Try Again' const EARLIER_UNAVAILABLE = "Earlier messages can't be loaded." +const LOADING_EARLIER = 'Loading earlier messages' +/** A page that lands this fast never shows its spinner. */ +const SPINNER_DELAY_MS = 200 +const FADE_MS = 180 -/** The "Load earlier messages" control, while older history remains. `measuring`: the page has loaded - * rows above it still to draw, so it shows loading until they are in. */ +function FadeIn({ delay, children }: { delay: number; children: ReactNode }): ReactElement { + const [opacity] = useState(() => 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 2b17925019b..00000000000 --- 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 d1292c0741d..00000000000 --- 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 98bbca6bb29..00000000000 --- 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 9f38a69c152..00000000000 --- 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 95786058b18..00000000000 --- 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 5a3efd58e32..9c31b3ad31f 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,27 @@ 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' +import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' 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 +73,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 +129,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 +156,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 +166,7 @@ function page(tree: () => Node | Node[] | null) { ) } let scrollTop = 0 + const listeners = new Map void>() const node = { writes: 0, get scrollTop(): number { @@ -151,68 +180,135 @@ 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 } -function Row({ message, rowKey }: { message: NativeChatMessage; rowKey: string }): ReactElement { - const { prose, tools } = splitNativeChatBlocks(message.blocks) - const words = prose.map((block, i) => - h('span', { key: i }, block.type === 'text' ? block.text : '') - ) +function Row({ + message, + rowKey, + 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 + /** 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'))) const run = tools.length > 0 && h(ToolRun, { blocks: tools }) 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 } } + /** 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( - 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: turns.listMessages, + rowKeys: keys, + runs: turns.presentation.workRuns.heads, + isFollowing: () => props.following, + onEndDistance: () => {}, + 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, + workRun, + joinsWorkRun + }) + }, + 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 }) { +function mount( + messages: NativeChatMessage[], + options: { following: boolean; live: boolean; runs?: boolean } = { following: false, live: false } +) { let renderer!: ReactTestRenderer act(() => { renderer = create(h('div')) }) const scroller = page(() => renderer.toJSON()) - const listRef = { current: { getScrollableNode: () => scroller } } - const show = (next: NativeChatMessage[]): number => { + scrollable.node = scroller + const show = (next: NativeChatMessage[], joined = 0): number => { const before = scroller.writes - act(() => renderer.update(h(Chat, { ...options, messages: next, listRef }))) + 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. + 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 +318,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 +329,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 +364,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 +372,91 @@ 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'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('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', ''), + 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]) + 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 +469,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 +506,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 +529,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 +551,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 00000000000..a70c4823fe7 --- /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 5247ab7e01c..062873581b8 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 3c34a05ee61..a03921acb75 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 5dc08af6b58..fa2a135ab6f 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 e8f34f44888..14b3901af0e 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 dd20ca245e3..b3650bbff40 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 ae120d66f59..f3601b15b07 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: { @@ -71,18 +73,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-paced-text.ts b/mobile/src/session/use-mobile-native-chat-paced-text.ts index 261b4c0f8d2..bad74c64b67 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 } 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 ea46c77b649..00000000000 --- 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 d07064e7e29..00000000000 --- 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 da787a91d4a..8fa7c0248f1 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 e1936514b4d..dd7d2f78da3 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 05e7fa561a3..a8a469d5b95 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 72785c4de81..be687db2358 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) + if (!head) { + return 'end' + } + cursor = head + heads = new Set([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()) { - 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] - ) + 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