diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index c573d89cc89..4c96fe28863 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -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 { @@ -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 { - 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 { + 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'] }] diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 67fc93506a9..63527495c3e 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -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 | 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 => { @@ -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) => { 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 ? ( {loadingEarlier ? ( @@ -360,11 +369,11 @@ export function MobileNativeChatView({ /> {/* Jump-to-latest control. */} - {!atBottom ? ( + {!followingTail ? ( listRef.current?.scrollToEnd({ animated: true })} + onPress={jumpToTail} >