From 9bcd016e8729ec74b33fac3418c72885f20c467f Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sun, 13 Sep 2026 10:54:05 -0700 Subject: [PATCH] refactor(mobile): give native chat one tail-follow owner Extract the streaming scroll contract into use-mobile-native-chat-tail-follow, so intent and geometry have a single writer instead of a state/ref pair hand-synced at five call sites. No behaviour change: the existing guards pass untouched. --- mobile/src/session/MobileNativeChatView.tsx | 55 ++++-------- .../use-mobile-native-chat-tail-follow.ts | 90 +++++++++++++++++++ 2 files changed, 106 insertions(+), 39 deletions(-) create mode 100644 mobile/src/session/use-mobile-native-chat-tail-follow.ts diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 63527495c3e..190020d1014 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { ActivityIndicator, FlatList, @@ -25,6 +25,7 @@ import { type MobileNativeChatPendingItem } from './mobile-native-chat-render-data' import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture' +import { useMobileNativeChatTailFollow } from './use-mobile-native-chat-tail-follow' import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' import { useSettledMobileNativeChatInputLock } from './use-mobile-native-chat-input-lease' import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' @@ -185,14 +186,10 @@ export function MobileNativeChatView({ keyboardInset = 0 }: Props): React.JSX.Element { const insets = useSafeAreaInsets() - const listRef = useRef>(null) const [toolsExpanded, setToolsExpanded] = useState(false) // 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 [followingTail, setFollowingTail] = useState(true) - const atBottomRef = useRef(true) - const followingTailRef = useRef(true) const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture() // `data` is the list source: folded transcript + synthetic streaming bubble + @@ -209,22 +206,16 @@ export function MobileNativeChatView({ }), [messages, folded, streaming, pending, imagePreviewsByMessageId] ) - const hasDataRef = useRef(false) - hasDataRef.current = data.length > 0 - - const pinToTail = useCallback(() => { - if (!followingTailRef.current || !hasDataRef.current) { - return - } - listRef.current?.scrollToEnd({ animated: false }) - }, []) - - const jumpToTail = useCallback(() => { - followingTailRef.current = true - atBottomRef.current = true - setFollowingTail(true) - pinToTail() - }, [pinToTail]) + const { + listRef, + following: followingTail, + pinToTail, + jumpToTail, + beginUserScroll, + finishUserScroll, + detachFromTail, + recordScrollMetrics + } = useMobileNativeChatTailFollow({ hasItems: data.length > 0 }) const handleSend = useCallback( async (text: string): Promise => { @@ -242,35 +233,21 @@ export function MobileNativeChatView({ [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) + detachFromTail() onLoadEarlier?.() - }, [onLoadEarlier]) + }, [detachFromTail, onLoadEarlier]) const onScroll = useCallback( (e: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent - const distanceFromBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height) - const isAtBottom = distanceFromBottom < 80 - atBottomRef.current = isAtBottom + recordScrollMetrics(contentSize.height - (contentOffset.y + layoutMeasurement.height)) // Near the top — page in older history. if (contentOffset.y < 60 && hasMore && !loadingEarlier) { loadEarlier() } }, - [hasMore, loadingEarlier, loadEarlier] + [hasMore, loadingEarlier, loadEarlier, recordScrollMetrics] ) // Per-turn status rows: one live indicator while the turn runs, then a settled diff --git a/mobile/src/session/use-mobile-native-chat-tail-follow.ts b/mobile/src/session/use-mobile-native-chat-tail-follow.ts new file mode 100644 index 00000000000..d1d81b0ee7a --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-tail-follow.ts @@ -0,0 +1,90 @@ +import { useCallback, useRef, useState, type RefObject } from 'react' +import type { FlatList } from 'react-native' + +/** Distance from the bottom, in points, still treated as "at the tail". */ +const AT_TAIL_SLOP = 80 + +export type MobileNativeChatTailFollow = { + /** Attach to the transcript list; the hook scrolls through this ref alone. */ + listRef: RefObject | null> + /** Render flag for the jump-to-latest control. */ + following: boolean + /** Passive maintenance: re-pin after the content or viewport resizes. */ + pinToTail: () => void + /** Explicit jump — send, or the jump-to-latest control. Resumes following. */ + jumpToTail: () => void + beginUserScroll: () => void + finishUserScroll: () => void + /** Leave the tail deliberately, e.g. before prepending older history. */ + detachFromTail: () => void + recordScrollMetrics: (distanceFromBottom: number) => void +} + +/** Sole owner of transcript scroll position. + * + * Streaming used to have several tail-followers at once: a delayed *animated* + * `scrollToEnd` alongside an immediate non-animated one on content growth. The + * animated command eases toward the endpoint measured when it started, so while + * tokens kept arriving it ran backwards until the content-size pin yanked it + * forward — the visible drift-then-snap. One owner, never animated, removes it. + * + * Intent (`following`) is kept separate from geometry (at-tail): a programmatic + * scroll reports metrics like any other, so letting metrics decide intent let + * the view argue with itself. Only the user's own gestures and explicit jumps + * move intent; metrics only decide where a *released* gesture leaves us. + */ +export function useMobileNativeChatTailFollow(args: { + /** Guards `scrollToEnd` against an empty list. */ + hasItems: boolean +}): MobileNativeChatTailFollow { + const { hasItems } = args + const listRef = useRef | null>(null) + const [following, setFollowingFlag] = useState(true) + // Event handlers read intent at event time, before a re-render lands. + const followingRef = useRef(true) + const atTailRef = useRef(true) + + // Single writer, so the event-time ref and the render flag cannot disagree. + const setFollowing = useCallback((next: boolean) => { + followingRef.current = next + setFollowingFlag(next) + }, []) + + const pinToTail = useCallback(() => { + if (!followingRef.current || !hasItems) { + return + } + listRef.current?.scrollToEnd({ animated: false }) + }, [hasItems]) + + const jumpToTail = useCallback(() => { + atTailRef.current = true + setFollowing(true) + pinToTail() + }, [pinToTail, setFollowing]) + + const beginUserScroll = useCallback(() => setFollowing(false), [setFollowing]) + + // Where the gesture left us decides whether following resumes. + const finishUserScroll = useCallback(() => setFollowing(atTailRef.current), [setFollowing]) + + const detachFromTail = useCallback(() => { + atTailRef.current = false + setFollowing(false) + }, [setFollowing]) + + const recordScrollMetrics = useCallback((distanceFromBottom: number) => { + atTailRef.current = distanceFromBottom < AT_TAIL_SLOP + }, []) + + return { + listRef, + following, + pinToTail, + jumpToTail, + beginUserScroll, + finishUserScroll, + detachFromTail, + recordScrollMetrics + } +}