mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 00:02:37 +00:00
fix(mobile): stabilize native chat tail following
This commit is contained in:
@@ -4,14 +4,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { MobileNativeChatView } from './MobileNativeChatView'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
FlatList: 'FlatList',
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
const scrollToEnd = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
FlatList: React.forwardRef((props, ref) => {
|
||||
React.useImperativeHandle(ref, () => ({ scrollToEnd }), [])
|
||||
return React.createElement('FlatList', props)
|
||||
}),
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
|
||||
@@ -77,6 +85,9 @@ type Overrides = {
|
||||
agentWorking?: boolean
|
||||
canStop?: boolean
|
||||
sendSurfaceId?: string
|
||||
keyboardInset?: number
|
||||
hasMore?: boolean
|
||||
onLoadEarlier?: () => void
|
||||
}
|
||||
|
||||
function assistantTurn(id: string, text: string): NativeChatMessage {
|
||||
@@ -105,6 +116,7 @@ describe('MobileNativeChatView', () => {
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
scrollToEnd.mockReset()
|
||||
})
|
||||
|
||||
async function render(overrides: Overrides = {}): Promise<void> {
|
||||
@@ -133,15 +145,18 @@ describe('MobileNativeChatView', () => {
|
||||
})
|
||||
|
||||
function listIds(): string[] {
|
||||
const list = renderer!.root.find((node) => node.type === 'FlatList')
|
||||
return (list.props.data as { id: string }[]).map((row) => row.id)
|
||||
return (list().props.data as { id: string }[]).map((row) => row.id)
|
||||
}
|
||||
|
||||
function list(): ReactTestInstance {
|
||||
return renderer!.root.find((node) => node.type === 'FlatList')
|
||||
}
|
||||
|
||||
function renderedRow(id: string): ReturnType<typeof createElement> {
|
||||
const list = renderer!.root.find((node) => node.type === 'FlatList')
|
||||
const data = list.props.data as NativeChatMessage[]
|
||||
const listNode = list()
|
||||
const data = listNode.props.data as NativeChatMessage[]
|
||||
const index = data.findIndex((row) => row.id === id)
|
||||
return list.props.renderItem({ item: data[index], index })
|
||||
return listNode.props.renderItem({ item: data[index], index })
|
||||
}
|
||||
|
||||
function banners(): ReactTestInstance[] {
|
||||
@@ -170,6 +185,19 @@ describe('MobileNativeChatView', () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function scrollAwayFromTail(): Promise<void> {
|
||||
await act(async () => {
|
||||
list().props.onScrollBeginDrag?.({})
|
||||
list().props.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 200 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
it('renders the route-reported failure verbatim', async () => {
|
||||
await render({ sendErrorMessage: 'Permission reply failed' })
|
||||
|
||||
@@ -214,6 +242,152 @@ describe('MobileNativeChatView', () => {
|
||||
expect(listIds()).toEqual(['a1', 'streaming'])
|
||||
})
|
||||
|
||||
it('lets content growth own streaming tail-follow without a delayed animated command', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const folded = [assistantTurn('a1', 'Starting')]
|
||||
await render({ folded })
|
||||
await act(async () => vi.runOnlyPendingTimers())
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
await update({ folded, streaming: 'Streaming output' })
|
||||
act(() => list().props.onContentSizeChange(320, 900))
|
||||
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
|
||||
await act(async () => vi.advanceTimersByTime(60))
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('stops tail-follow before loading earlier history can resize the list', async () => {
|
||||
const folded = [assistantTurn('a1', 'History')]
|
||||
const onLoadEarlier = vi.fn()
|
||||
await render({ folded, hasMore: true, onLoadEarlier })
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
act(() => {
|
||||
list().props.onScrollBeginDrag?.({})
|
||||
list().props.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 40 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onContentSizeChange(320, 950)
|
||||
})
|
||||
|
||||
expect(onLoadEarlier).toHaveBeenCalledOnce()
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not treat programmatic scroll metrics as user intent', async () => {
|
||||
const folded = [assistantTurn('a1', 'Latest')]
|
||||
await render({ folded })
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
act(() => {
|
||||
list().props.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 200 },
|
||||
contentSize: { height: 1_200 },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
})
|
||||
list().props.onContentSizeChange(320, 1_300)
|
||||
})
|
||||
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
|
||||
expect(
|
||||
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('detaches before the Load earlier messages button prepends history', async () => {
|
||||
const folded = [assistantTurn('a1', 'Short history')]
|
||||
const onLoadEarlier = vi.fn()
|
||||
await render({ folded, hasMore: true, onLoadEarlier })
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
const header = list().props.ListHeaderComponent as { props: { onPress: () => void } }
|
||||
act(() => {
|
||||
header.props.onPress()
|
||||
list().props.onContentSizeChange(320, 950)
|
||||
})
|
||||
|
||||
expect(onLoadEarlier).toHaveBeenCalledOnce()
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
expect(
|
||||
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('routes an accepted send through the immediate nonanimated tail owner', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const folded = [assistantTurn('a1', 'History')]
|
||||
await render({ folded })
|
||||
await act(async () => vi.runOnlyPendingTimers())
|
||||
await scrollAwayFromTail()
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
await pressSend()
|
||||
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
|
||||
await act(async () => vi.advanceTimersByTime(60))
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('routes the latest-message chevron through the tail owner and resumes following', async () => {
|
||||
const folded = [assistantTurn('a1', 'History')]
|
||||
await render({ folded })
|
||||
await scrollAwayFromTail()
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
const chevron = renderer!.root.find(
|
||||
(node) => node.props.accessibilityLabel === 'Scroll to latest'
|
||||
)
|
||||
act(() => chevron.props.onPress())
|
||||
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
|
||||
|
||||
act(() => list().props.onContentSizeChange(320, 1_300))
|
||||
expect(scrollToEnd).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('repins after a keyboard-driven viewport layout only while following', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const folded = [assistantTurn('a1', 'Latest')]
|
||||
await render({ folded })
|
||||
await act(async () => vi.runOnlyPendingTimers())
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
await update({ folded, keyboardInset: 320 })
|
||||
act(() => list().props.onLayout?.({ nativeEvent: { layout: { height: 400 } } }))
|
||||
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false })
|
||||
await act(async () => vi.advanceTimersByTime(60))
|
||||
expect(scrollToEnd).toHaveBeenCalledOnce()
|
||||
|
||||
await scrollAwayFromTail()
|
||||
scrollToEnd.mockClear()
|
||||
await update({ folded, keyboardInset: 0 })
|
||||
act(() => list().props.onLayout?.({ nativeEvent: { layout: { height: 700 } } }))
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders an accepted optimistic image send without a queued state', async () => {
|
||||
await render({
|
||||
pending: [{ id: 'pending-1', text: 'look', images: ['file:///phone-photo.jpg'] }]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
@@ -190,17 +190,10 @@ export function MobileNativeChatView({
|
||||
// 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 [atBottom, setAtBottom] = useState(true)
|
||||
const sendScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [followingTail, setFollowingTail] = useState(true)
|
||||
const atBottomRef = useRef(true)
|
||||
const followingTailRef = useRef(true)
|
||||
const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture()
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (sendScrollTimerRef.current) {
|
||||
clearTimeout(sendScrollTimerRef.current)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// `data` is the list source: folded transcript + synthetic streaming bubble +
|
||||
// route-owned accepted echoes. Memoize on the same deps so the
|
||||
@@ -216,18 +209,22 @@ export function MobileNativeChatView({
|
||||
}),
|
||||
[messages, folded, streaming, pending, imagePreviewsByMessageId]
|
||||
)
|
||||
const hasDataRef = useRef(false)
|
||||
hasDataRef.current = data.length > 0
|
||||
|
||||
// Follow the tail as the conversation grows and keep the newest message above
|
||||
// the keyboard when it opens — but only when already pinned to the bottom, so
|
||||
// we don't yank the user away while they read history. (Also fires on keyboard
|
||||
// close, which is harmless while atBottom.)
|
||||
useEffect(() => {
|
||||
if (data.length === 0 || !atBottom) {
|
||||
const pinToTail = useCallback(() => {
|
||||
if (!followingTailRef.current || !hasDataRef.current) {
|
||||
return
|
||||
}
|
||||
const t = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 60)
|
||||
return () => clearTimeout(t)
|
||||
}, [data.length, atBottom, keyboardInset])
|
||||
listRef.current?.scrollToEnd({ animated: false })
|
||||
}, [])
|
||||
|
||||
const jumpToTail = useCallback(() => {
|
||||
followingTailRef.current = true
|
||||
atBottomRef.current = true
|
||||
setFollowingTail(true)
|
||||
pinToTail()
|
||||
}, [pinToTail])
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text: string): Promise<boolean> => {
|
||||
@@ -239,30 +236,41 @@ export function MobileNativeChatView({
|
||||
// or a stale "Message not sent" sits above the delivered message.
|
||||
onClearSendError?.()
|
||||
// Always jump to the newest message when the user sends.
|
||||
setAtBottom(true)
|
||||
if (sendScrollTimerRef.current) {
|
||||
clearTimeout(sendScrollTimerRef.current)
|
||||
}
|
||||
sendScrollTimerRef.current = setTimeout(() => {
|
||||
sendScrollTimerRef.current = null
|
||||
listRef.current?.scrollToEnd({ animated: true })
|
||||
}, 60)
|
||||
jumpToTail()
|
||||
return true
|
||||
},
|
||||
[onSend, onClearSendError]
|
||||
[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)
|
||||
onLoadEarlier?.()
|
||||
}, [onLoadEarlier])
|
||||
|
||||
const onScroll = useCallback(
|
||||
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent
|
||||
const distanceFromBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height)
|
||||
setAtBottom(distanceFromBottom < 80)
|
||||
const isAtBottom = distanceFromBottom < 80
|
||||
atBottomRef.current = isAtBottom
|
||||
// Near the top — page in older history.
|
||||
if (contentOffset.y < 60 && hasMore && !loadingEarlier) {
|
||||
onLoadEarlier?.()
|
||||
loadEarlier()
|
||||
}
|
||||
},
|
||||
[hasMore, loadingEarlier, onLoadEarlier]
|
||||
[hasMore, loadingEarlier, loadEarlier]
|
||||
)
|
||||
|
||||
// Per-turn status rows: one live indicator while the turn runs, then a settled
|
||||
@@ -318,17 +326,18 @@ export function MobileNativeChatView({
|
||||
// instead of being swallowed by the dismiss gesture.
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onScroll={onScroll}
|
||||
onScrollBeginDrag={beginUserScroll}
|
||||
onScrollEndDrag={finishUserScroll}
|
||||
onMomentumScrollBegin={beginUserScroll}
|
||||
onMomentumScrollEnd={finishUserScroll}
|
||||
scrollEventThrottle={32}
|
||||
onContentSizeChange={() => {
|
||||
if (data.length > 0 && atBottom) {
|
||||
listRef.current?.scrollToEnd({ animated: false })
|
||||
}
|
||||
}}
|
||||
onContentSizeChange={pinToTail}
|
||||
onLayout={pinToTail}
|
||||
ListHeaderComponent={
|
||||
hasMore ? (
|
||||
<Pressable
|
||||
style={styles.loadEarlier}
|
||||
onPress={onLoadEarlier}
|
||||
onPress={loadEarlier}
|
||||
disabled={loadingEarlier}
|
||||
>
|
||||
{loadingEarlier ? (
|
||||
@@ -360,11 +369,11 @@ export function MobileNativeChatView({
|
||||
/>
|
||||
</GestureDetector>
|
||||
{/* Jump-to-latest control. */}
|
||||
{!atBottom ? (
|
||||
{!followingTail ? (
|
||||
<Pressable
|
||||
accessibilityLabel="Scroll to latest"
|
||||
style={[styles.fab, styles.fabBottom]}
|
||||
onPress={() => listRef.current?.scrollToEnd({ animated: true })}
|
||||
onPress={jumpToTail}
|
||||
>
|
||||
<ArrowDown size={18} color={colors.textPrimary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
|
||||
Reference in New Issue
Block a user