diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 86ead424b90..4214df7f521 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -3,14 +3,22 @@ import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'rea import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 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(() => ({ scrollToEnd: vi.fn() })) + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + FlatList: React.forwardRef((props: object, 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 }) @@ -58,9 +66,12 @@ vi.mock('./MobileNativeChatComposer', async () => { }) type Overrides = { + messages?: Parameters[0]['messages'] sendErrorMessage?: string | null onClearSendError?: () => void inputLockReason?: 'disconnected' | 'waiting' | null + hasMore?: boolean + loadingEarlier?: boolean onSend?: (text: string) => Promise } @@ -75,11 +86,12 @@ function suppressRendererWarning(): () => void { return () => spy.mockRestore() } -describe('MobileNativeChatView send-error banner', () => { +describe('MobileNativeChatView', () => { let renderer: ReactTestRenderer | null = null beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true + scrollToEnd.mockClear() }) afterEach(() => { @@ -108,6 +120,22 @@ describe('MobileNativeChatView send-error banner', () => { } } + async function update(overrides: Overrides = {}): Promise { + await act(async () => { + renderer?.update( + createElement(MobileNativeChatView, { + messages: [], + status: 'ready', + onSend: vi.fn().mockResolvedValue(true), + pending: [], + composerText: '', + onComposerTextChange: vi.fn(), + ...overrides + }) + ) + }) + } + function banners(): ReactTestInstance[] { return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert') } @@ -161,4 +189,52 @@ describe('MobileNativeChatView send-error banner', () => { expect(onClearSendError).toHaveBeenCalledOnce() }) + + const message = { + id: 'current', + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: 'current' }], + timestamp: 0, + source: 'transcript' as const + } + const older = { ...message, id: 'older', blocks: [{ type: 'text' as const, text: 'older' }] } + + it('does not defeat prepend anchoring when a short list was also at bottom', async () => { + vi.useFakeTimers() + try { + await render({ messages: [message], hasMore: true }) + await act(async () => vi.runAllTimersAsync()) + scrollToEnd.mockClear() + + await update({ messages: [message], hasMore: true, loadingEarlier: true }) + await update({ messages: [older, message], hasMore: false, loadingEarlier: false }) + const list = renderer!.root.find((node) => node.type === 'FlatList') + expect(list.props.maintainVisibleContentPosition).toEqual({ minIndexForVisible: 0 }) + act(() => list.props.onContentSizeChange()) + await act(async () => vi.runAllTimersAsync()) + + expect(scrollToEnd).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('still follows the tail once the history page has settled', async () => { + vi.useFakeTimers() + try { + await render({ messages: [older, message], hasMore: false }) + await act(async () => vi.runAllTimersAsync()) + scrollToEnd.mockClear() + + const appended = { ...message, id: 'appended' } + await update({ messages: [older, message, appended], hasMore: false }) + const list = renderer!.root.find((node) => node.type === 'FlatList') + act(() => list.props.onContentSizeChange()) + await act(async () => vi.runAllTimersAsync()) + + expect(scrollToEnd).toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index f14efc1f25f..857cf3b73b0 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -151,6 +151,10 @@ export function MobileNativeChatView({ const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom const [atBottom, setAtBottom] = useState(true) const sendScrollTimerRef = useRef | null>(null) + // A short transcript leaves the reader both at the top and `atBottom`, so the + // tail-follow paths would scroll away the history that just paged in. + const keepHistoryPositionRef = useRef(Boolean(loadingEarlier)) + const keepHistoryPosition = keepHistoryPositionRef.current const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture() useEffect( () => () => { @@ -176,12 +180,17 @@ export function MobileNativeChatView({ // 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) { + if (data.length === 0 || !atBottom || keepHistoryPositionRef.current) { return } const t = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 60) return () => clearTimeout(t) }, [data.length, atBottom, keyboardInset]) + // Must stay below the tail-follow effect: the prepend lands in the same commit + // that clears `loadingEarlier`, and that effect has to still see the hold. + useEffect(() => { + keepHistoryPositionRef.current = Boolean(loadingEarlier) + }, [loadingEarlier]) const handleSend = useCallback( async (text: string): Promise => { @@ -274,8 +283,12 @@ export function MobileNativeChatView({ contentContainerStyle={styles.listContent} onScroll={onScroll} scrollEventThrottle={32} + // Grow the list upward on a loadEarlier prepend instead of jumping + // the reader. Index 0 is the first message — FlatList accounts for + // the load-earlier header offset internally. + maintainVisibleContentPosition={{ minIndexForVisible: 0 }} onContentSizeChange={() => { - if (data.length > 0 && atBottom) { + if (data.length > 0 && atBottom && !keepHistoryPosition) { listRef.current?.scrollToEnd({ animated: false }) } }}