diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index d224c2e34a0e..47e8a312ec11 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -383,117 +383,6 @@ describe('MobileNativeChatView', () => { } }) - it('uses momentum-end metrics instead of a stale throttled scroll sample', async () => { - const folded = [assistantTurn('a1', 'Latest')] - await render({ folded }) - scrollToEnd.mockClear() - - act(() => { - list().props.onScrollBeginDrag?.({}) - list().props.onScroll({ - nativeEvent: { - contentOffset: { y: 700 }, - contentSize: { height: 1_200 }, - layoutMeasurement: { height: 500 } - } - }) - list().props.onScrollEndDrag?.({ - nativeEvent: { - contentOffset: { y: 700 }, - contentSize: { height: 1_200 }, - layoutMeasurement: { height: 500 } - } - }) - list().props.onMomentumScrollBegin?.({}) - list().props.onMomentumScrollEnd?.({ - nativeEvent: { - contentOffset: { y: 200 }, - contentSize: { height: 1_300 }, - layoutMeasurement: { height: 500 } - } - }) - list().props.onContentSizeChange(320, 1_350) - }) - - expect(scrollToEnd).not.toHaveBeenCalled() - expect(scrollToOffset).not.toHaveBeenCalled() - expect( - renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') - ).toHaveLength(1) - }) - - it('uses finger-release metrics when no momentum event follows', async () => { - vi.useFakeTimers() - try { - const folded = [assistantTurn('a1', 'Latest')] - await render({ folded }) - scrollToEnd.mockClear() - - act(() => { - list().props.onScrollBeginDrag?.({}) - list().props.onScroll({ - nativeEvent: { - contentOffset: { y: 200 }, - contentSize: { height: 1_200 }, - layoutMeasurement: { height: 500 } - } - }) - list().props.onScrollEndDrag?.({ - nativeEvent: { - contentOffset: { y: 620 }, - contentSize: { height: 1_200 }, - layoutMeasurement: { height: 500 } - } - }) - }) - await act(async () => { - vi.advanceTimersByTime(200) - }) - act(() => list().props.onContentSizeChange(320, 1_250)) - - expect(scrollToEnd).toHaveBeenCalledOnce() - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_250 }) - } finally { - vi.useRealTimers() - } - }) - - it('repins when content grows between tail release and drag settle', async () => { - vi.useFakeTimers() - try { - const folded = [assistantTurn('a1', 'Latest')] - await render({ folded }) - scrollToEnd.mockClear() - - act(() => { - list().props.onScrollBeginDrag?.({}) - list().props.onScrollEndDrag?.({ - nativeEvent: { - contentOffset: { y: 700 }, - contentSize: { height: 1_200 }, - layoutMeasurement: { height: 500 } - } - }) - list().props.onContentSizeChange(320, 1_250) - }) - - expect(scrollToEnd).not.toHaveBeenCalled() - expect(scrollToOffset).not.toHaveBeenCalled() - - await act(async () => { - vi.runOnlyPendingTimers() - }) - - expect(scrollToEnd).toHaveBeenCalledOnce() - expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false }) - expect( - renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') - ).toHaveLength(0) - } finally { - vi.useRealTimers() - } - }) - it('uses measured content height when the first message fills an empty transcript', async () => { await render() diff --git a/mobile/src/session/use-mobile-native-chat-tail-follow.test.ts b/mobile/src/session/use-mobile-native-chat-tail-follow.test.ts new file mode 100644 index 000000000000..95158b947bf9 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-tail-follow.test.ts @@ -0,0 +1,159 @@ +import { createElement } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + useMobileNativeChatTailFollow, + type MobileNativeChatTailFollow +} from './use-mobile-native-chat-tail-follow' + +const scrollToEnd = vi.fn() +const scrollToOffset = vi.fn() + +let renderer: ReactTestRenderer | null = null +let tail: MobileNativeChatTailFollow | null = null + +// Wired the way MobileNativeChatView wires its transcript list. +function Transcript(): ReturnType { + const follow = useMobileNativeChatTailFollow({ hasItems: true }) + tail = follow + return createElement('FlatList', { + ref: follow.listRef, + onScroll: (event: { nativeEvent: Parameters[0] }) => + follow.recordScrollMetrics(event.nativeEvent), + onScrollBeginDrag: follow.beginUserScroll, + onScrollEndDrag: follow.endUserDrag, + onMomentumScrollBegin: follow.beginMomentum, + onMomentumScrollEnd: follow.endMomentum, + onContentSizeChange: follow.pinToTailAfterContentResize + }) +} + +// Host element names are plain strings at runtime; React Native's types don't list them. +function hostNamed(name: string): ReactTestInstance { + return renderer!.root.find((node) => node.type === name) +} + +function list(): ReactTestInstance { + return hostNamed('FlatList') +} + +/** A scroll event in a 500pt viewport; 80pt from the bottom still counts as the tail. */ +function at(offsetY: number, contentHeight = 1_200) { + return { + nativeEvent: { + contentOffset: { y: offsetY }, + contentSize: { height: contentHeight }, + layoutMeasurement: { height: 500 } + } + } +} + +describe('useMobileNativeChatTailFollow gesture settling', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => callback(0), 0) + ) + vi.stubGlobal('cancelAnimationFrame', (handle: ReturnType) => + clearTimeout(handle) + ) + act(() => { + renderer = create(createElement(Transcript), { + createNodeMock: () => ({ scrollToEnd, scrollToOffset }) + }) + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + tail = null + scrollToEnd.mockReset() + scrollToOffset.mockReset() + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + // iOS emits a bare momentum-end after a non-animated pin; Android a begin/end pair after an animated one. + it.each([ + ['momentum-end', false], + ['momentum-begin/end pair', true] + ])('ignores a programmatic %s while the finger is still down', (_label, withBegin) => { + act(() => { + list().props.onScrollBeginDrag({}) + if (withBegin) { + list().props.onMomentumScrollBegin({}) + } + list().props.onMomentumScrollEnd(at(700)) + list().props.onContentSizeChange(320, 1_250) + }) + + expect(scrollToEnd).not.toHaveBeenCalled() + expect(scrollToOffset).not.toHaveBeenCalled() + }) + + it('settles a released drag from release metrics, not a programmatic momentum-end', () => { + act(() => { + list().props.onScrollBeginDrag({}) + list().props.onScrollEndDrag(at(200)) + list().props.onMomentumScrollEnd(at(700)) + }) + act(() => { + vi.runOnlyPendingTimers() + }) + act(() => list().props.onContentSizeChange(320, 1_250)) + + expect(scrollToEnd).not.toHaveBeenCalled() + expect(scrollToOffset).not.toHaveBeenCalled() + expect(tail!.showJumpToTail).toBe(true) + }) + + it('uses momentum-end metrics instead of a stale throttled scroll sample', () => { + act(() => { + list().props.onScrollBeginDrag({}) + list().props.onScroll(at(700)) + list().props.onScrollEndDrag(at(700)) + list().props.onMomentumScrollBegin({}) + list().props.onMomentumScrollEnd(at(200, 1_300)) + list().props.onContentSizeChange(320, 1_350) + }) + + expect(scrollToEnd).not.toHaveBeenCalled() + expect(scrollToOffset).not.toHaveBeenCalled() + expect(tail!.showJumpToTail).toBe(true) + }) + + it('uses finger-release metrics when no momentum event follows', () => { + act(() => { + list().props.onScrollBeginDrag({}) + list().props.onScroll(at(200)) + list().props.onScrollEndDrag(at(620)) + }) + act(() => { + vi.advanceTimersByTime(200) + }) + act(() => list().props.onContentSizeChange(320, 1_250)) + + expect(scrollToEnd).toHaveBeenCalledOnce() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_250 }) + }) + + it('repins when content grows between tail release and drag settle', () => { + act(() => { + list().props.onScrollBeginDrag({}) + list().props.onScrollEndDrag(at(700)) + list().props.onContentSizeChange(320, 1_250) + }) + + expect(scrollToEnd).not.toHaveBeenCalled() + expect(scrollToOffset).not.toHaveBeenCalled() + + act(() => { + vi.runOnlyPendingTimers() + }) + + expect(scrollToEnd).toHaveBeenCalledOnce() + expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false }) + expect(tail!.showJumpToTail).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-tail-follow.ts b/mobile/src/session/use-mobile-native-chat-tail-follow.ts index 7a3072ad7006..5505d6fa4425 100644 --- a/mobile/src/session/use-mobile-native-chat-tail-follow.ts +++ b/mobile/src/session/use-mobile-native-chat-tail-follow.ts @@ -4,6 +4,9 @@ import type { FlatList, NativeScrollEvent, NativeSyntheticEvent } from 'react-na /** Distance from the bottom, in points, still treated as "at the tail". */ const AT_TAIL_SLOP = 80 +/** A user gesture's progress; each native event is honored only in the phase that expects it. */ +type UserScrollPhase = 'idle' | 'dragging' | 'released' | 'momentum' + function isAtTail(event: NativeScrollEvent): boolean { const { contentOffset, contentSize, layoutMeasurement } = event return contentSize.height - (contentOffset.y + layoutMeasurement.height) <= AT_TAIL_SLOP @@ -53,7 +56,7 @@ export function useMobileNativeChatTailFollow(args: { // Event handlers read intent at event time, before a re-render lands. const followingRef = useRef(true) const atTailRef = useRef(true) - const userScrollActiveRef = useRef(false) + const userScrollPhaseRef = useRef('idle') const userScrollSettleFrameRef = useRef(null) // Single writer, so the event-time ref and the render flag cannot disagree. @@ -104,7 +107,7 @@ export function useMobileNativeChatTailFollow(args: { const jumpToTail = useCallback(() => { clearUserScrollSettle() - userScrollActiveRef.current = false + userScrollPhaseRef.current = 'idle' setAtTail(true) setFollowing(true) pinToTail() @@ -112,17 +115,14 @@ export function useMobileNativeChatTailFollow(args: { const beginUserScroll = useCallback(() => { clearUserScrollSettle() - userScrollActiveRef.current = true + userScrollPhaseRef.current = 'dragging' setFollowing(false) }, [clearUserScrollSettle, setFollowing]) const finishUserScroll = useCallback( (finishedAtTail: boolean) => { - if (!userScrollActiveRef.current) { - return - } clearUserScrollSettle() - userScrollActiveRef.current = false + userScrollPhaseRef.current = 'idle' setAtTail(finishedAtTail) setFollowing(finishedAtTail) if (finishedAtTail) { @@ -134,10 +134,11 @@ export function useMobileNativeChatTailFollow(args: { const endUserDrag = useCallback( (event: NativeSyntheticEvent) => { - if (!userScrollActiveRef.current) { + if (userScrollPhaseRef.current !== 'dragging') { return } clearUserScrollSettle() + userScrollPhaseRef.current = 'released' const releasedAtTail = isAtTail(event.nativeEvent) userScrollSettleFrameRef.current = requestAnimationFrame(() => { userScrollSettleFrameRef.current = null @@ -147,21 +148,29 @@ export function useMobileNativeChatTailFollow(args: { [clearUserScrollSettle, finishUserScroll] ) + // Android also emits this for animated programmatic scrolls, so only a release hands off to it. const beginMomentum = useCallback(() => { - if (userScrollActiveRef.current) { - clearUserScrollSettle() + if (userScrollPhaseRef.current !== 'released') { + return } + clearUserScrollSettle() + userScrollPhaseRef.current = 'momentum' }, [clearUserScrollSettle]) + // iOS also emits this, with no begin, after every non-animated pin — even mid-drag. const endMomentum = useCallback( - (event: NativeSyntheticEvent) => - finishUserScroll(isAtTail(event.nativeEvent)), + (event: NativeSyntheticEvent) => { + if (userScrollPhaseRef.current !== 'momentum') { + return + } + finishUserScroll(isAtTail(event.nativeEvent)) + }, [finishUserScroll] ) const detachFromTail = useCallback(() => { clearUserScrollSettle() - userScrollActiveRef.current = false + userScrollPhaseRef.current = 'idle' setAtTail(false) setFollowing(false) }, [clearUserScrollSettle, setAtTail, setFollowing])