mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(mobile): keep the viewport steady when chat history pages in
Tapping "Load earlier messages" grew the list from the top while the viewport stayed at a fixed offset, so the reader was thrown into the middle of the newly-prepended page. Anchor the FlatList with maintainVisibleContentPosition so a prepend grows upward, and hold the two tail-follow paths (the post-commit autoscroll effect and onContentSizeChange) for the duration of the page-in. The hold matters on short transcripts, where the reader is simultaneously at the top and `atBottom` — there the autoscroll would otherwise immediately undo the anchoring. Refs STA-3333.
This commit is contained in:
@@ -3,14 +3,22 @@ import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'rea
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
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(() => ({ scrollToEnd: vi.fn() }))
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
FlatList: React.forwardRef((props: object, 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 })
|
||||
@@ -58,9 +66,12 @@ vi.mock('./MobileNativeChatComposer', async () => {
|
||||
})
|
||||
|
||||
type Overrides = {
|
||||
messages?: Parameters<typeof MobileNativeChatView>[0]['messages']
|
||||
sendErrorMessage?: string | null
|
||||
onClearSendError?: () => void
|
||||
inputLockReason?: 'disconnected' | 'waiting' | null
|
||||
hasMore?: boolean
|
||||
loadingEarlier?: boolean
|
||||
onSend?: (text: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -75,11 +86,12 @@ function suppressRendererWarning(): () => void {
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
describe('MobileNativeChatView send-error banner', () => {
|
||||
describe('MobileNativeChatView', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
scrollToEnd.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -108,6 +120,22 @@ describe('MobileNativeChatView send-error banner', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function update(overrides: Overrides = {}): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(MobileNativeChatView, {
|
||||
messages: [],
|
||||
status: 'ready',
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
pending: [],
|
||||
composerText: '',
|
||||
onComposerTextChange: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function banners(): ReactTestInstance[] {
|
||||
return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert')
|
||||
}
|
||||
@@ -161,4 +189,52 @@ describe('MobileNativeChatView send-error banner', () => {
|
||||
|
||||
expect(onClearSendError).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
const message = {
|
||||
id: 'current',
|
||||
role: 'assistant' as const,
|
||||
blocks: [{ type: 'text' as const, text: 'current' }],
|
||||
timestamp: 0,
|
||||
source: 'transcript' as const
|
||||
}
|
||||
const older = { ...message, id: 'older', blocks: [{ type: 'text' as const, text: 'older' }] }
|
||||
|
||||
it('does not defeat prepend anchoring when a short list was also at bottom', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await render({ messages: [message], hasMore: true })
|
||||
await act(async () => vi.runAllTimersAsync())
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
await update({ messages: [message], hasMore: true, loadingEarlier: true })
|
||||
await update({ messages: [older, message], hasMore: false, loadingEarlier: false })
|
||||
const list = renderer!.root.find((node) => node.type === 'FlatList')
|
||||
expect(list.props.maintainVisibleContentPosition).toEqual({ minIndexForVisible: 0 })
|
||||
act(() => list.props.onContentSizeChange())
|
||||
await act(async () => vi.runAllTimersAsync())
|
||||
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('still follows the tail once the history page has settled', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await render({ messages: [older, message], hasMore: false })
|
||||
await act(async () => vi.runAllTimersAsync())
|
||||
scrollToEnd.mockClear()
|
||||
|
||||
const appended = { ...message, id: 'appended' }
|
||||
await update({ messages: [older, message, appended], hasMore: false })
|
||||
const list = renderer!.root.find((node) => node.type === 'FlatList')
|
||||
act(() => list.props.onContentSizeChange())
|
||||
await act(async () => vi.runAllTimersAsync())
|
||||
|
||||
expect(scrollToEnd).toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -151,6 +151,10 @@ export function MobileNativeChatView({
|
||||
const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom
|
||||
const [atBottom, setAtBottom] = useState(true)
|
||||
const sendScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// A short transcript leaves the reader both at the top and `atBottom`, so the
|
||||
// tail-follow paths would scroll away the history that just paged in.
|
||||
const keepHistoryPositionRef = useRef(Boolean(loadingEarlier))
|
||||
const keepHistoryPosition = keepHistoryPositionRef.current
|
||||
const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture()
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -176,12 +180,17 @@ export function MobileNativeChatView({
|
||||
// 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) {
|
||||
if (data.length === 0 || !atBottom || keepHistoryPositionRef.current) {
|
||||
return
|
||||
}
|
||||
const t = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 60)
|
||||
return () => clearTimeout(t)
|
||||
}, [data.length, atBottom, keyboardInset])
|
||||
// Must stay below the tail-follow effect: the prepend lands in the same commit
|
||||
// that clears `loadingEarlier`, and that effect has to still see the hold.
|
||||
useEffect(() => {
|
||||
keepHistoryPositionRef.current = Boolean(loadingEarlier)
|
||||
}, [loadingEarlier])
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text: string): Promise<boolean> => {
|
||||
@@ -274,8 +283,12 @@ export function MobileNativeChatView({
|
||||
contentContainerStyle={styles.listContent}
|
||||
onScroll={onScroll}
|
||||
scrollEventThrottle={32}
|
||||
// Grow the list upward on a loadEarlier prepend instead of jumping
|
||||
// the reader. Index 0 is the first message — FlatList accounts for
|
||||
// the load-earlier header offset internally.
|
||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||
onContentSizeChange={() => {
|
||||
if (data.length > 0 && atBottom) {
|
||||
if (data.length > 0 && atBottom && !keepHistoryPosition) {
|
||||
listRef.current?.scrollToEnd({ animated: false })
|
||||
}
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user