mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
feat(mobile): load earlier chat messages on scroll-up, keeping the reader's place (#31339)
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+6
-7
@@ -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()
|
||||
+11
-75
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user