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.
This commit is contained in:
Merge Sim
2026-09-13 10:54:05 -07:00
parent 1b6d29ef6f
commit 9bcd016e87
2 changed files with 106 additions and 39 deletions
+16 -39
View File
@@ -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<FlatList<NativeChatMessage>>(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<NativeChatMessage>({ hasItems: data.length > 0 })
const handleSend = useCallback(
async (text: string): Promise<boolean> => {
@@ -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<NativeScrollEvent>) => {
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
@@ -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<TItem> = {
/** Attach to the transcript list; the hook scrolls through this ref alone. */
listRef: RefObject<FlatList<TItem> | 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<TItem>(args: {
/** Guards `scrollToEnd` against an empty list. */
hasItems: boolean
}): MobileNativeChatTailFollow<TItem> {
const { hasItems } = args
const listRef = useRef<FlatList<TItem> | 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
}
}