fix(mobile): stabilize native chat tail following

This commit is contained in:
Merge Sim
2026-09-12 00:27:04 -07:00
parent 20ab995065
commit 1b6d29ef6f
2 changed files with 236 additions and 53 deletions
+187 -13
View File
@@ -4,14 +4,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileNativeChatView } from './MobileNativeChatView'
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
FlatList: 'FlatList',
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
const scrollToEnd = vi.hoisted(() => vi.fn())
vi.mock('react-native', async () => {
const React = await import('react')
return {
ActivityIndicator: 'ActivityIndicator',
FlatList: React.forwardRef((props, ref) => {
React.useImperativeHandle(ref, () => ({ scrollToEnd }), [])
return React.createElement('FlatList', props)
}),
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}
})
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
@@ -77,6 +85,9 @@ type Overrides = {
agentWorking?: boolean
canStop?: boolean
sendSurfaceId?: string
keyboardInset?: number
hasMore?: boolean
onLoadEarlier?: () => void
}
function assistantTurn(id: string, text: string): NativeChatMessage {
@@ -105,6 +116,7 @@ describe('MobileNativeChatView', () => {
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
scrollToEnd.mockReset()
})
async function render(overrides: Overrides = {}): Promise<void> {
@@ -133,15 +145,18 @@ describe('MobileNativeChatView', () => {
})
function listIds(): string[] {
const list = renderer!.root.find((node) => node.type === 'FlatList')
return (list.props.data as { id: string }[]).map((row) => row.id)
return (list().props.data as { id: string }[]).map((row) => row.id)
}
function list(): ReactTestInstance {
return renderer!.root.find((node) => node.type === 'FlatList')
}
function renderedRow(id: string): ReturnType<typeof createElement> {
const list = renderer!.root.find((node) => node.type === 'FlatList')
const data = list.props.data as NativeChatMessage[]
const listNode = list()
const data = listNode.props.data as NativeChatMessage[]
const index = data.findIndex((row) => row.id === id)
return list.props.renderItem({ item: data[index], index })
return listNode.props.renderItem({ item: data[index], index })
}
function banners(): ReactTestInstance[] {
@@ -170,6 +185,19 @@ describe('MobileNativeChatView', () => {
})
}
async function scrollAwayFromTail(): Promise<void> {
await act(async () => {
list().props.onScrollBeginDrag?.({})
list().props.onScroll({
nativeEvent: {
contentOffset: { y: 200 },
contentSize: { height: 1_200 },
layoutMeasurement: { height: 500 }
}
})
})
}
it('renders the route-reported failure verbatim', async () => {
await render({ sendErrorMessage: 'Permission reply failed' })
@@ -214,6 +242,152 @@ describe('MobileNativeChatView', () => {
expect(listIds()).toEqual(['a1', 'streaming'])
})
it('lets content growth own streaming tail-follow without a delayed animated command', async () => {
vi.useFakeTimers()
try {
const folded = [assistantTurn('a1', 'Starting')]
await render({ folded })
await act(async () => vi.runOnlyPendingTimers())
scrollToEnd.mockClear()
await update({ folded, streaming: 'Streaming output' })
act(() => list().props.onContentSizeChange(320, 900))
expect(scrollToEnd).toHaveBeenCalledOnce()
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
await act(async () => vi.advanceTimersByTime(60))
expect(scrollToEnd).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('stops tail-follow before loading earlier history can resize the list', async () => {
const folded = [assistantTurn('a1', 'History')]
const onLoadEarlier = vi.fn()
await render({ folded, hasMore: true, onLoadEarlier })
scrollToEnd.mockClear()
act(() => {
list().props.onScrollBeginDrag?.({})
list().props.onScroll({
nativeEvent: {
contentOffset: { y: 40 },
contentSize: { height: 1_200 },
layoutMeasurement: { height: 500 }
}
})
list().props.onContentSizeChange(320, 950)
})
expect(onLoadEarlier).toHaveBeenCalledOnce()
expect(scrollToEnd).not.toHaveBeenCalled()
})
it('does not treat programmatic scroll metrics as user intent', async () => {
const folded = [assistantTurn('a1', 'Latest')]
await render({ folded })
scrollToEnd.mockClear()
act(() => {
list().props.onScroll({
nativeEvent: {
contentOffset: { y: 200 },
contentSize: { height: 1_200 },
layoutMeasurement: { height: 500 }
}
})
list().props.onContentSizeChange(320, 1_300)
})
expect(scrollToEnd).toHaveBeenCalledOnce()
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
expect(
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
).toHaveLength(0)
})
it('detaches before the Load earlier messages button prepends history', async () => {
const folded = [assistantTurn('a1', 'Short history')]
const onLoadEarlier = vi.fn()
await render({ folded, hasMore: true, onLoadEarlier })
scrollToEnd.mockClear()
const header = list().props.ListHeaderComponent as { props: { onPress: () => void } }
act(() => {
header.props.onPress()
list().props.onContentSizeChange(320, 950)
})
expect(onLoadEarlier).toHaveBeenCalledOnce()
expect(scrollToEnd).not.toHaveBeenCalled()
expect(
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
).toHaveLength(1)
})
it('routes an accepted send through the immediate nonanimated tail owner', async () => {
vi.useFakeTimers()
try {
const folded = [assistantTurn('a1', 'History')]
await render({ folded })
await act(async () => vi.runOnlyPendingTimers())
await scrollAwayFromTail()
scrollToEnd.mockClear()
await pressSend()
expect(scrollToEnd).toHaveBeenCalledOnce()
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
await act(async () => vi.advanceTimersByTime(60))
expect(scrollToEnd).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('routes the latest-message chevron through the tail owner and resumes following', async () => {
const folded = [assistantTurn('a1', 'History')]
await render({ folded })
await scrollAwayFromTail()
scrollToEnd.mockClear()
const chevron = renderer!.root.find(
(node) => node.props.accessibilityLabel === 'Scroll to latest'
)
act(() => chevron.props.onPress())
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
act(() => list().props.onContentSizeChange(320, 1_300))
expect(scrollToEnd).toHaveBeenCalledTimes(2)
})
it('repins after a keyboard-driven viewport layout only while following', async () => {
vi.useFakeTimers()
try {
const folded = [assistantTurn('a1', 'Latest')]
await render({ folded })
await act(async () => vi.runOnlyPendingTimers())
scrollToEnd.mockClear()
await update({ folded, keyboardInset: 320 })
act(() => list().props.onLayout?.({ nativeEvent: { layout: { height: 400 } } }))
expect(scrollToEnd).toHaveBeenCalledOnce()
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
await act(async () => vi.advanceTimersByTime(60))
expect(scrollToEnd).toHaveBeenCalledOnce()
await scrollAwayFromTail()
scrollToEnd.mockClear()
await update({ folded, keyboardInset: 0 })
act(() => list().props.onLayout?.({ nativeEvent: { layout: { height: 700 } } }))
expect(scrollToEnd).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('renders an accepted optimistic image send without a queued state', async () => {
await render({
pending: [{ id: 'pending-1', text: 'look', images: ['file:///phone-photo.jpg'] }]
+49 -40
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
FlatList,
@@ -190,17 +190,10 @@ export function MobileNativeChatView({
// Lift the composer clear of the keyboard, plus the bottom safe-area so it
// never sits under the home indicator / nav bar (mirrors the terminal dock).
const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom
const [atBottom, setAtBottom] = useState(true)
const sendScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [followingTail, setFollowingTail] = useState(true)
const atBottomRef = useRef(true)
const followingTailRef = useRef(true)
const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture()
useEffect(
() => () => {
if (sendScrollTimerRef.current) {
clearTimeout(sendScrollTimerRef.current)
}
},
[]
)
// `data` is the list source: folded transcript + synthetic streaming bubble +
// route-owned accepted echoes. Memoize on the same deps so the
@@ -216,18 +209,22 @@ export function MobileNativeChatView({
}),
[messages, folded, streaming, pending, imagePreviewsByMessageId]
)
const hasDataRef = useRef(false)
hasDataRef.current = data.length > 0
// Follow the tail as the conversation grows and keep the newest message above
// the keyboard when it opens — but only when already pinned to the bottom, so
// we don't yank the user away while they read history. (Also fires on keyboard
// close, which is harmless while atBottom.)
useEffect(() => {
if (data.length === 0 || !atBottom) {
const pinToTail = useCallback(() => {
if (!followingTailRef.current || !hasDataRef.current) {
return
}
const t = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 60)
return () => clearTimeout(t)
}, [data.length, atBottom, keyboardInset])
listRef.current?.scrollToEnd({ animated: false })
}, [])
const jumpToTail = useCallback(() => {
followingTailRef.current = true
atBottomRef.current = true
setFollowingTail(true)
pinToTail()
}, [pinToTail])
const handleSend = useCallback(
async (text: string): Promise<boolean> => {
@@ -239,30 +236,41 @@ export function MobileNativeChatView({
// or a stale "Message not sent" sits above the delivered message.
onClearSendError?.()
// Always jump to the newest message when the user sends.
setAtBottom(true)
if (sendScrollTimerRef.current) {
clearTimeout(sendScrollTimerRef.current)
}
sendScrollTimerRef.current = setTimeout(() => {
sendScrollTimerRef.current = null
listRef.current?.scrollToEnd({ animated: true })
}, 60)
jumpToTail()
return true
},
[onSend, onClearSendError]
[onSend, onClearSendError, jumpToTail]
)
const beginUserScroll = useCallback(() => {
followingTailRef.current = false
setFollowingTail(false)
}, [])
const finishUserScroll = useCallback(() => {
followingTailRef.current = atBottomRef.current
setFollowingTail(atBottomRef.current)
}, [])
const loadEarlier = useCallback(() => {
followingTailRef.current = false
atBottomRef.current = false
setFollowingTail(false)
onLoadEarlier?.()
}, [onLoadEarlier])
const onScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent
const distanceFromBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height)
setAtBottom(distanceFromBottom < 80)
const isAtBottom = distanceFromBottom < 80
atBottomRef.current = isAtBottom
// Near the top — page in older history.
if (contentOffset.y < 60 && hasMore && !loadingEarlier) {
onLoadEarlier?.()
loadEarlier()
}
},
[hasMore, loadingEarlier, onLoadEarlier]
[hasMore, loadingEarlier, loadEarlier]
)
// Per-turn status rows: one live indicator while the turn runs, then a settled
@@ -318,17 +326,18 @@ export function MobileNativeChatView({
// instead of being swallowed by the dismiss gesture.
keyboardShouldPersistTaps="handled"
onScroll={onScroll}
onScrollBeginDrag={beginUserScroll}
onScrollEndDrag={finishUserScroll}
onMomentumScrollBegin={beginUserScroll}
onMomentumScrollEnd={finishUserScroll}
scrollEventThrottle={32}
onContentSizeChange={() => {
if (data.length > 0 && atBottom) {
listRef.current?.scrollToEnd({ animated: false })
}
}}
onContentSizeChange={pinToTail}
onLayout={pinToTail}
ListHeaderComponent={
hasMore ? (
<Pressable
style={styles.loadEarlier}
onPress={onLoadEarlier}
onPress={loadEarlier}
disabled={loadingEarlier}
>
{loadingEarlier ? (
@@ -360,11 +369,11 @@ export function MobileNativeChatView({
/>
</GestureDetector>
{/* Jump-to-latest control. */}
{!atBottom ? (
{!followingTail ? (
<Pressable
accessibilityLabel="Scroll to latest"
style={[styles.fab, styles.fabBottom]}
onPress={() => listRef.current?.scrollToEnd({ animated: true })}
onPress={jumpToTail}
>
<ArrowDown size={18} color={colors.textPrimary} strokeWidth={2.2} />
</Pressable>