feat(mobile): load earlier chat messages on scroll-up, keeping the reader's place (#31339)

This commit is contained in:
Kelvin Amoaba
2026-10-21 23:49:32 +00:00
committed by GitHub
parent 106716c700
commit 54ef452d71
36 changed files with 1256 additions and 2000 deletions
@@ -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)
@@ -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'
+338 -79
View File
@@ -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<MobileNativeChatListHandle>
endAnchored: boolean
isFollowing: () => boolean
pin: () => void
onEndDistance: (fromEnd: number, byReader: boolean) => void
rows: readonly NativeChatMessage[]
rowKeys: Pick<MobileNativeChatRowKeys, 'scope' | 'sourceHead' | 'keyOf' | 'current'>
rowKeys: Pick<MobileNativeChatRowKeys, 'scope' | 'keyOf' | 'current' | 'placement'>
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<number, readonly NativeChatMessage[]>
/** How rows draw (Tools, text size): a change re-lays every row, so the place is kept mid-scroll. */
layoutKey: string
contentContainerStyle: StyleProp<ViewStyle>
/** `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<NativeScrollEvent>) => 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<ScrollView>(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<Held | null>(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<Drawn>({ 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<number | null>(null)
const touching = useRef(false)
const readerScrolledAt = useRef(0)
const place = useRef<MobileNativeChatReadingUnit[] | null>(null)
const placedLayout = useRef(layoutKey)
const settleTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const live = useRef<Held>({ 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<NativeScrollEvent>) => {
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 (
<View key={key} ref={rowRef(key)}>
{renderItem({ item, index })}
</View>
)
}
return (
<MobileNativeChatPageScroll
ref={scroll}
@@ -118,25 +370,32 @@ export function MobileNativeChatPageList({
// Let link/file taps land while the composer keyboard is up
// instead of being swallowed by the dismiss gesture.
keyboardShouldPersistTaps="handled"
onScroll={onScroll}
scrollEventThrottle={32}
onScroll={scrolled}
scrollEventThrottle={16}
>
{/* Never what the browser's anchoring holds: rows land below it, and spacers stand in for rows. */}
<View style={outOfAnchoring}>{header(view.measuring)}</View>
{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.
<Fragment key={rowKeys.scope}>
<View style={[outOfAnchoring, { height: view.above }]} />
{rows.slice(view.m0, view.m1).map((item, at) => drawRow(item, view.m0 + at))}
<View style={[outOfAnchoring, { height: view.mid }]} />
{rows.slice(view.start, view.end).map((item, at) => drawRow(item, view.start + at))}
<View style={[outOfAnchoring, { height: view.bottom }]} />
</Fragment>
)}
{footer}
<MobileNativeChatGrowthHoldContext.Provider value={growth}>
{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.
<div key={rowKeys.scope}>
{rows.slice(first).map((item, at) => {
const index = first + at
return (
<Fragment key={keys[index]}>
{held && index >= heldFrom
? held.renderItem({ item, index: index - heldAt })
: renderItem({ item, index })}
</Fragment>
)
})}
</div>
)}
{footer}
</MobileNativeChatGrowthHoldContext.Provider>
</MobileNativeChatPageScroll>
)
}
@@ -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<ScrollView> }) {
return (
<ScrollView ref={ref} {...props} contentContainerStyle={pageScroll.fill}>
<ScrollView
ref={ref}
{...props}
style={pageScroll.endAnchored}
contentContainerStyle={pageScroll.content}
>
<View style={contentContainerStyle}>{children}</View>
</ScrollView>
)
}
// 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
@@ -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'
+10 -9
View File
@@ -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 (
<MobileNativeChatPageList
{...tail.pageListProps}
{...{ rows, rowKeys, roots, renderItem, rowState, contentContainerStyle }}
{...{ footer, emptyStateView, onScroll, header, layoutKey }}
{...{ rows, rowKeys, roots, renderItem, runs, contentContainerStyle, layoutKey }}
{...{ header, footer, emptyStateView, onScroll, onNearTop }}
/>
)
}
@@ -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}
/>
)
}
@@ -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<Tagged>
}
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<string, string>
rowKeys: Pick<MobileNativeChatRowKeys, 'scope' | 'sourceHead' | 'current' | 'placement'>
/** 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<string, Tagged>,
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<string, string>,
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<Props> {
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
}
}
@@ -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<string, string>
rowKeys: Pick<MobileNativeChatRowKeys, 'scope'>
/** 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<Props> {
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
}
}
@@ -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. */
@@ -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<string, number>()
// 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<string, number>()
const memberIdentity = (block: NativeChatBlock) =>
@@ -30,6 +30,7 @@ export type Overrides = {
sendSurfaceId?: string
keyboardInset?: number
hasMore?: boolean
olderPages?: Parameters<typeof MobileNativeChatView>[0]['olderPages']
onLoadEarlier?: () => void
status?: Parameters<typeof MobileNativeChatView>[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()
})
}
@@ -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()
})
})
})
@@ -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<string, Set<(event?: unknown) => 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)
+17 -20
View File
@@ -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>) => void
onLoadEarlier?: () => void
onSend: (text: string) => Promise<boolean>
/** 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<boolean> => {
@@ -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<NativeScrollEvent>) => {
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({
) : (
<GestureHandlerRootView style={styles.listWrap}>
<MobileNativeChatDisclosureContext.Provider value={disclosures}>
<MobileNativeChatScrollHold
{...{ rowKeys, layoutKey, ...tail.placeHold }}
<MobileNativeChatScrollRootsBoundary
{...{ rowKeys, layoutKey }}
roots={disclosures.roots}
runPlacements={rowKeys.runPlacements}
>
<GestureDetector gesture={pinchGesture}>
<MobileNativeChatRowList
{...{ tail, rowKeys, renderItem, footer, emptyStateView, onScroll, layoutKey }}
{...{ header, hasHeader: hasMore === true, onNearTop: loadEarlierNearTop }}
rows={turns.listMessages}
runs={turns.presentation.workRuns.heads}
roots={disclosures.roots}
rowState={turns.resolveRow}
contentContainerStyle={styles.listContent}
header={pageHeader}
nativeHeader={header}
hasHeader={hasMore === true}
/>
</GestureDetector>
</MobileNativeChatScrollHold>
</MobileNativeChatScrollRootsBoundary>
</MobileNativeChatDisclosureContext.Provider>
<MobileNativeChatFloatingControls
showJumpToTail={tail.showJumpToTail}
@@ -0,0 +1,34 @@
import { createContext } from 'react'
/** Whether rows may grow now. The page's list holds growth while a reader who scrolled up is
* moving: growth below them would push the line they read, and correcting it mid-fling stops the
* fling. A store, so holding re-renders only the rows that would grow. */
export type MobileNativeChatGrowthHold = {
held: () => boolean
subscribe: (onChange: () => void) => () => void
}
export function createMobileNativeChatGrowthHold(): MobileNativeChatGrowthHold & {
set: (held: boolean) => void
} {
let held = false
const listeners = new Set<() => void>()
return {
held: () => held,
subscribe: (onChange) => {
listeners.add(onChange)
return () => listeners.delete(onChange)
},
set: (next) => {
if (next !== held) {
held = next
listeners.forEach((onChange) => onChange())
}
}
}
}
const NEVER_HELD: MobileNativeChatGrowthHold = { held: () => false, subscribe: () => () => {} }
export const MobileNativeChatGrowthHoldContext =
createContext<MobileNativeChatGrowthHold>(NEVER_HELD)
@@ -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 <Animated.View style={{ opacity }}>{children}</Animated.View>
}
/** 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 (
<View style={styles.loadEarlier}>
<Text style={styles.loadEarlierFailedText}>{EARLIER_UNAVAILABLE}</Text>
<FadeIn delay={0}>
<Text style={styles.loadEarlierFailedText}>{EARLIER_UNAVAILABLE}</Text>
</FadeIn>
</View>
)
}
if (!hasMore && !measuring) {
if (!hasMore) {
return null
}
const loadingEarlier = measuring || olderPages === 'loading'
if (olderPages === 'failed') {
return (
<View style={styles.loadEarlier}>
<FadeIn delay={0}>
<View style={styles.loadEarlierFailed}>
<Text style={styles.loadEarlierFailedText} numberOfLines={1}>
{LOAD_EARLIER_FAILED}
</Text>
<Pressable
accessibilityRole="button"
onPress={loadEarlier}
style={({ pressed }) => [
styles.loadEarlierRetry,
pressed && styles.loadEarlierRetryPressed
]}
>
<Text style={styles.loadEarlierRetryLabel}>{LOAD_EARLIER_RETRY}</Text>
</Pressable>
</View>
</FadeIn>
</View>
)
}
return (
<Pressable style={styles.loadEarlier} onPress={loadEarlier} disabled={loadingEarlier}>
{loadingEarlier ? (
<ActivityIndicator size="small" color={colors.textMuted} />
) : (
<Text style={styles.loadEarlierText}>Load earlier messages</Text>
)}
{olderPages === 'failed' ? (
<Text style={styles.loadEarlierFailedText}>{LOAD_EARLIER_FAILED}</Text>
) : null}
</Pressable>
<View style={styles.loadEarlier}>
<FadeIn key="loading" delay={SPINNER_DELAY_MS}>
<ActivityIndicator
size="small"
color={colors.textMuted}
accessibilityLabel={LOADING_EARLIER}
/>
</FadeIn>
</View>
)
}
@@ -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)
})
@@ -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<string, Entry>()
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<string, number> | 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<DrawnRows, 'keys' | 'rows' | 'rowState' | 'start'>,
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
}
}
@@ -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<string, Element>()
private keys = new WeakMap<Element, string>()
private refs = new Map<string, (node: unknown) => 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
}
}
@@ -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 }
}
@@ -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<MobileNativeChatRowKeys, 'scope' | 'sourceHead' | 'keyOf' | 'current'>
rowState: MobileNativeChatWindowRowState
following: boolean
/** At event time, before a re-render lands. */
isFollowing: () => boolean
layoutKey: string
scroller: () => MobileNativeChatWindowScroller | null | undefined
}
@@ -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<string, () => 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<typeof page>
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)
@@ -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<Tagged>
}
export type MobileNativeChatScrollNode = Tagged & {
scrollTop: number
scrollHeight: number
clientHeight: number
}
type Keys = Pick<MobileNativeChatRowKeys, 'current' | 'placement'>
/** 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<string, Tagged>,
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
}
@@ -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()
@@ -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<typeof setTimeout> | undefined
private later: ReturnType<typeof setTimeout> | 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<void> {
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())
}
}
@@ -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<void>(() => {})
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<HTMLElement>('[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<HTMLElement>('[data-live]')!.click())
@@ -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 }
@@ -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<string, Root>()
private observer: ResizeObserver | null = null
private held = new Set<Root>()
private generation = 0
private page: MobileNativeChatPageMotion | null = null
constructor(
private readonly memory: Map<string, MobileNativeChatRootPosition>,
@@ -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<void> {
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()
}
@@ -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'
}
})
@@ -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 }
@@ -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()
}, [])
}
@@ -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<Win | null>(null)
const drawn = useRef<Drawn | null>(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
}
}
@@ -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<void>((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(
@@ -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>) => 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<void>) => {
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.
@@ -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<void>
/** 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<NativeScrollEvent>) => void
beginMomentum: () => void
endMomentum: (event: NativeSyntheticEvent<NativeScrollEvent>) => 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<void>) | undefined
/** Read at commit by the page's place-hold: the list, and whether the reader follows the end. */
placeHold: { listRef: RefObject<MobileNativeChatListHandle | null>; 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
}
}
@@ -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>) => 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<void>) => {
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<AgentSessionHistoryResult>(
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<AgentSessionHistoryResult>(
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