From a034cc0efb440c59d0198f56438e06c930326894 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:43:13 -0700 Subject: [PATCH 1/2] fix(mobile): a streaming reply can't pull a drag back to the bottom Track the user's scroll gesture as phases (dragging, released, momentum, idle) and honor each native scroll event only in the phase that expects it. iOS emits a momentum-end after every non-animated tail pin, even while a finger is down; it used to end the drag and re-arm tail-follow, so the next pin yanked the user back to the bottom. Gesture-settling tests move beside the hook they exercise. --- .../src/session/MobileNativeChatView.test.ts | 111 ------------- ...use-mobile-native-chat-tail-follow.test.ts | 148 ++++++++++++++++++ .../use-mobile-native-chat-tail-follow.ts | 35 +++-- 3 files changed, 170 insertions(+), 124 deletions(-) create mode 100644 mobile/src/session/use-mobile-native-chat-tail-follow.test.ts diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index fa29e13c8c87..b73a13657856 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -382,117 +382,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..e1d61c2d13c4 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-tail-follow.test.ts @@ -0,0 +1,148 @@ +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 + }) +} + +function list(): ReactTestInstance { + return renderer!.root.findByType('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]) From 228dcb625d650bfc73aef7939f0e66b3ceb1cb97 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:07:03 -0700 Subject: [PATCH 2/2] test(mobile): make the tail-follow gesture tests typecheck --- ...use-mobile-native-chat-tail-follow.test.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) 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 index e1d61c2d13c4..95158b947bf9 100644 --- a/mobile/src/session/use-mobile-native-chat-tail-follow.test.ts +++ b/mobile/src/session/use-mobile-native-chat-tail-follow.test.ts @@ -28,8 +28,13 @@ function Transcript(): ReturnType { }) } +// 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 renderer!.root.findByType('FlatList') + return hostNamed('FlatList') } /** A scroll event in a 500pt viewport; 80pt from the bottom still counts as the tail. */ @@ -93,7 +98,9 @@ describe('useMobileNativeChatTailFollow gesture settling', () => { list().props.onScrollEndDrag(at(200)) list().props.onMomentumScrollEnd(at(700)) }) - act(() => vi.runOnlyPendingTimers()) + act(() => { + vi.runOnlyPendingTimers() + }) act(() => list().props.onContentSizeChange(320, 1_250)) expect(scrollToEnd).not.toHaveBeenCalled() @@ -122,7 +129,9 @@ describe('useMobileNativeChatTailFollow gesture settling', () => { list().props.onScroll(at(200)) list().props.onScrollEndDrag(at(620)) }) - act(() => vi.advanceTimersByTime(200)) + act(() => { + vi.advanceTimersByTime(200) + }) act(() => list().props.onContentSizeChange(320, 1_250)) expect(scrollToEnd).toHaveBeenCalledOnce() @@ -139,7 +148,9 @@ describe('useMobileNativeChatTailFollow gesture settling', () => { expect(scrollToEnd).not.toHaveBeenCalled() expect(scrollToOffset).not.toHaveBeenCalled() - act(() => vi.runOnlyPendingTimers()) + act(() => { + vi.runOnlyPendingTimers() + }) expect(scrollToEnd).toHaveBeenCalledOnce() expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })