mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user