mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(mobile): surface load-earlier failures and stop scroll-retry storms
A failed older-history page silently did nothing: the near-top scroll trigger kept re-firing the same doomed request, and a rejected transport request escaped as an unhandled rejection. Failures (RPC not-ok, error results, rejections) now set an inline "Couldn't load earlier messages / Tap to retry" header row, and the scroll trigger stands down until the user retries explicitly or an authoritative window supersedes the failed page. Refs STA-3333.
This commit is contained in:
@@ -73,6 +73,7 @@ export function MobileNativeChatOverlay({
|
||||
onOpenFile={controller.handleNativeChatOpenFile}
|
||||
hasMore={session.hasMore}
|
||||
loadingEarlier={session.loadingEarlier}
|
||||
loadEarlierError={session.loadEarlierError}
|
||||
onLoadEarlier={session.loadEarlier}
|
||||
onSend={images.sendNativeChat}
|
||||
pending={controller.chatPending}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createElement } from 'react'
|
||||
import { createElement, isValidElement, type ReactElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
@@ -65,6 +65,10 @@ type Overrides = {
|
||||
sendErrorMessage?: string | null
|
||||
onClearSendError?: () => void
|
||||
inputLockReason?: 'disconnected' | 'waiting' | null
|
||||
hasMore?: boolean
|
||||
loadingEarlier?: boolean
|
||||
loadEarlierError?: string | null
|
||||
onLoadEarlier?: () => void
|
||||
onSend?: (text: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -145,6 +149,39 @@ describe('MobileNativeChatView', () => {
|
||||
.join('')
|
||||
}
|
||||
|
||||
// The list is a string stand-in, so its header lives as an unrendered element
|
||||
// on the prop; read it directly rather than through the rendered tree.
|
||||
function loadEarlierHeader(): ReactElement<Record<string, unknown>> {
|
||||
const list = renderer!.root.find((node) => node.type === 'FlatList')
|
||||
return list.props.ListHeaderComponent as ReactElement<Record<string, unknown>>
|
||||
}
|
||||
|
||||
function headerText(element: unknown): string {
|
||||
if (typeof element === 'string') {
|
||||
return element
|
||||
}
|
||||
if (Array.isArray(element)) {
|
||||
return element.map((entry) => headerText(entry)).join(' ')
|
||||
}
|
||||
if (isValidElement<{ children?: unknown }>(element)) {
|
||||
return headerText(element.props.children)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function scrollToTop(): void {
|
||||
const list = renderer!.root.find((node) => node.type === 'FlatList')
|
||||
act(() => {
|
||||
list.props.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { y: 0 },
|
||||
contentSize: { height: 1000 },
|
||||
layoutMeasurement: { height: 400 }
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function pressSend(): Promise<void> {
|
||||
const composer = renderer!.root.find((node) => node.type === 'Composer') as {
|
||||
props: { onPress: () => Promise<boolean> }
|
||||
@@ -197,4 +234,46 @@ describe('MobileNativeChatView', () => {
|
||||
|
||||
expect(listIds()).toEqual(['a1', 'streaming'])
|
||||
})
|
||||
|
||||
it('pages in older history automatically near the top', async () => {
|
||||
const onLoadEarlier = vi.fn()
|
||||
await render({ hasMore: true, onLoadEarlier })
|
||||
|
||||
scrollToTop()
|
||||
|
||||
expect(onLoadEarlier).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('blocks automatic paging but keeps explicit retry after loading earlier fails', async () => {
|
||||
const onLoadEarlier = vi.fn()
|
||||
await render({
|
||||
hasMore: true,
|
||||
loadEarlierError: 'Couldn’t load earlier messages',
|
||||
onLoadEarlier
|
||||
})
|
||||
|
||||
scrollToTop()
|
||||
scrollToTop()
|
||||
expect(onLoadEarlier).not.toHaveBeenCalled()
|
||||
|
||||
act(() => (loadEarlierHeader().props.onPress as () => void)())
|
||||
expect(onLoadEarlier).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('surfaces the failure and a retry affordance in the header row', async () => {
|
||||
await render({ hasMore: true, loadEarlierError: 'Couldn’t load earlier messages' })
|
||||
|
||||
const header = loadEarlierHeader()
|
||||
expect(headerText(header)).toContain('Couldn’t load earlier messages')
|
||||
expect(headerText(header)).toContain('Tap to retry')
|
||||
expect(header.props.accessibilityLabel).toBe('Couldn’t load earlier messages. Tap to retry')
|
||||
})
|
||||
|
||||
it('keeps the header tappable-but-idle while a page is in flight', async () => {
|
||||
await render({ hasMore: true, loadingEarlier: true, onLoadEarlier: vi.fn() })
|
||||
|
||||
const header = loadEarlierHeader()
|
||||
expect(header.props.disabled).toBe(true)
|
||||
expect(headerText(header)).not.toContain('Tap to retry')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,6 +54,9 @@ type Props = {
|
||||
streaming: string | null
|
||||
hasMore?: boolean
|
||||
loadingEarlier?: boolean
|
||||
/** Last older-history page failed; shows an inline retry in the header row and
|
||||
* stops the near-top scroll trigger from re-firing against the same failure. */
|
||||
loadEarlierError?: string | null
|
||||
onLoadEarlier?: () => void
|
||||
onSend: (text: string) => Promise<boolean>
|
||||
/** Optimistic queued sends (owned by the route so they survive view switches). */
|
||||
@@ -113,6 +116,7 @@ export function MobileNativeChatView({
|
||||
streaming,
|
||||
hasMore,
|
||||
loadingEarlier,
|
||||
loadEarlierError,
|
||||
onLoadEarlier,
|
||||
onSend,
|
||||
pending,
|
||||
@@ -213,12 +217,14 @@ export function MobileNativeChatView({
|
||||
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent
|
||||
const distanceFromBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height)
|
||||
setAtBottom(distanceFromBottom < 80)
|
||||
// Near the top — page in older history.
|
||||
if (contentOffset.y < 60 && hasMore && !loadingEarlier) {
|
||||
// Near the top — page in older history. A failed page stops this: the user
|
||||
// stays near the top, so every later scroll frame would re-fire the same
|
||||
// doomed request. Only the header's explicit retry clears it.
|
||||
if (contentOffset.y < 60 && hasMore && !loadingEarlier && !loadEarlierError) {
|
||||
onLoadEarlier?.()
|
||||
}
|
||||
},
|
||||
[hasMore, loadingEarlier, onLoadEarlier]
|
||||
[hasMore, loadingEarlier, loadEarlierError, onLoadEarlier]
|
||||
)
|
||||
|
||||
// Align a single message's top to the top of the viewport.
|
||||
@@ -302,9 +308,20 @@ export function MobileNativeChatView({
|
||||
style={styles.loadEarlier}
|
||||
onPress={onLoadEarlier}
|
||||
disabled={loadingEarlier}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
loadEarlierError
|
||||
? `${loadEarlierError}. Tap to retry`
|
||||
: 'Load earlier messages'
|
||||
}
|
||||
>
|
||||
{loadingEarlier ? (
|
||||
<ActivityIndicator size="small" color={colors.textMuted} />
|
||||
) : loadEarlierError ? (
|
||||
<>
|
||||
<Text style={styles.loadEarlierErrorText}>{loadEarlierError}</Text>
|
||||
<Text style={styles.loadEarlierRetryText}>Tap to retry</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.loadEarlierText}>Load earlier messages</Text>
|
||||
)}
|
||||
|
||||
@@ -105,5 +105,16 @@ export const styles = StyleSheet.create({
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
loadEarlierErrorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
loadEarlierRetryText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '700',
|
||||
marginTop: spacing.xs
|
||||
}
|
||||
})
|
||||
|
||||
@@ -174,6 +174,144 @@ describe('useMobileNativeChatSession', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('surfaces a failed older-history page and clears it on a retry', async () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: false, error: 'offline' })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { messages: [message('older')], hasMore: false, beforeOffset: 0 }
|
||||
})
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
onData({
|
||||
type: 'snapshot',
|
||||
messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)),
|
||||
hasMore: true,
|
||||
beforeOffset: 100
|
||||
})
|
||||
return () => {}
|
||||
})
|
||||
await mount({ sendRequest, subscribe } as unknown as RpcClient)
|
||||
|
||||
await act(async () => {
|
||||
state?.loadEarlier()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(state?.loadEarlierError).toBe('Couldn’t load earlier messages')
|
||||
expect(state?.loadingEarlier).toBe(false)
|
||||
// The failure must not consume the page budget — the retry asks for the same
|
||||
// cursor, not the one after it.
|
||||
expect(state?.hasMore).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
state?.loadEarlier()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(state?.loadEarlierError).toBeNull()
|
||||
expect(state?.messages.map((entry) => entry.id)[0]).toBe('older')
|
||||
expect(sendRequest.mock.calls[1]?.[1]).toEqual(sendRequest.mock.calls[0]?.[1])
|
||||
})
|
||||
|
||||
it('surfaces an error result from an older-history page', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: { error: 'read failed' } })
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
onData({
|
||||
type: 'snapshot',
|
||||
messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)),
|
||||
hasMore: true,
|
||||
beforeOffset: 100
|
||||
})
|
||||
return () => {}
|
||||
})
|
||||
await mount({ sendRequest, subscribe } as unknown as RpcClient)
|
||||
|
||||
await act(async () => {
|
||||
state?.loadEarlier()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(state?.loadEarlierError).toBe('Couldn’t load earlier messages')
|
||||
})
|
||||
|
||||
it('surfaces a rejected older-history request instead of leaking it', async () => {
|
||||
const sendRequest = vi.fn().mockRejectedValue(new Error('disconnected'))
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
onData({
|
||||
type: 'snapshot',
|
||||
messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)),
|
||||
hasMore: true,
|
||||
beforeOffset: 100
|
||||
})
|
||||
return () => {}
|
||||
})
|
||||
await mount({ sendRequest, subscribe } as unknown as RpcClient)
|
||||
|
||||
await act(async () => {
|
||||
state?.loadEarlier()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(state?.loadEarlierError).toBe('Couldn’t load earlier messages')
|
||||
expect(state?.loadingEarlier).toBe(false)
|
||||
})
|
||||
|
||||
it('drops a failure from a page the session swapped out from under', async () => {
|
||||
let rejectEarlier: (reason: unknown) => void = () => {}
|
||||
const sendRequest = vi.fn(
|
||||
() => new Promise((_resolve, reject) => (rejectEarlier = reject))
|
||||
) as unknown as RpcClient['sendRequest']
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
emit = onData
|
||||
onData({
|
||||
type: 'snapshot',
|
||||
messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)),
|
||||
hasMore: true,
|
||||
beforeOffset: 100
|
||||
})
|
||||
return () => {}
|
||||
})
|
||||
await mount({ sendRequest, subscribe } as unknown as RpcClient)
|
||||
act(() => state?.loadEarlier())
|
||||
|
||||
await act(async () =>
|
||||
emit({ type: 'replacement', messages: [message('fresh')], hasMore: true, beforeOffset: 0 })
|
||||
)
|
||||
await act(async () => {
|
||||
rejectEarlier(new Error('disconnected'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
// The fresh window already superseded that page; a stale failure must not
|
||||
// block paging on it.
|
||||
expect(state?.loadEarlierError).toBeNull()
|
||||
})
|
||||
|
||||
it('clears a surfaced failure when an authoritative window replaces the transcript', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue({ ok: false, error: 'offline' })
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
emit = onData
|
||||
onData({
|
||||
type: 'snapshot',
|
||||
messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)),
|
||||
hasMore: true,
|
||||
beforeOffset: 100
|
||||
})
|
||||
return () => {}
|
||||
})
|
||||
await mount({ sendRequest, subscribe } as unknown as RpcClient)
|
||||
await act(async () => {
|
||||
state?.loadEarlier()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(state?.loadEarlierError).toBe('Couldn’t load earlier messages')
|
||||
|
||||
await act(async () =>
|
||||
emit({ type: 'replacement', messages: [message('fresh')], hasMore: true, beforeOffset: 0 })
|
||||
)
|
||||
|
||||
expect(state?.loadEarlierError).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a cursor page invalidated by live trim and retries with a growing tail', async () => {
|
||||
let resolveCursorPage: (response: unknown) => void = () => {}
|
||||
const sendRequest = vi
|
||||
|
||||
@@ -24,6 +24,8 @@ export type MobileNativeChatSession = {
|
||||
hasMore: boolean
|
||||
/** Whether an older-history page is currently loading. */
|
||||
loadingEarlier: boolean
|
||||
/** Set when the last older-history page failed; retry via `loadEarlier`. */
|
||||
loadEarlierError: string | null
|
||||
/** Grow the window to page in older history. */
|
||||
loadEarlier: () => void
|
||||
}
|
||||
@@ -36,6 +38,8 @@ const INITIAL_LIMIT = 40
|
||||
const PAGE = 60
|
||||
const MAX_MESSAGES = 2000
|
||||
|
||||
const LOAD_EARLIER_ERROR = 'Couldn’t load earlier messages'
|
||||
|
||||
type ReadSessionResult =
|
||||
| { messages: NativeChatMessage[]; hasMore?: boolean; beforeOffset?: number }
|
||||
| { error: string }
|
||||
@@ -81,6 +85,7 @@ export function useMobileNativeChatSession(args: {
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loadingEarlier, setLoadingEarlier] = useState(false)
|
||||
const [loadEarlierError, setLoadEarlierError] = useState<string | null>(null)
|
||||
const loadingEarlierRef = useRef(false)
|
||||
const beforeOffsetRef = useRef<number | null>(null)
|
||||
// Stateful id-dedup merger: caches the id→index map so each live append frame
|
||||
@@ -108,6 +113,7 @@ export function useMobileNativeChatSession(args: {
|
||||
limitRef.current = INITIAL_LIMIT
|
||||
loadingEarlierRef.current = false
|
||||
setLoadingEarlier(false)
|
||||
setLoadEarlierError(null)
|
||||
setList([])
|
||||
setError(undefined)
|
||||
setHasMore(false)
|
||||
@@ -140,6 +146,8 @@ export function useMobileNativeChatSession(args: {
|
||||
limitRef.current = INITIAL_LIMIT
|
||||
loadingEarlierRef.current = false
|
||||
setLoadingEarlier(false)
|
||||
// The fresh window supersedes the failed page, so stop blocking paging.
|
||||
setLoadEarlierError(null)
|
||||
}
|
||||
const replaceSnapshot = frame.type === 'snapshot'
|
||||
const applied = applyMobileNativeChatStreamFrame({
|
||||
@@ -198,6 +206,9 @@ export function useMobileNativeChatSession(args: {
|
||||
const beforeOffset = beforeOffsetRef.current
|
||||
loadingEarlierRef.current = true
|
||||
setLoadingEarlier(true)
|
||||
setLoadEarlierError(null)
|
||||
const requestIsCurrent = (): boolean =>
|
||||
sessionIdRef.current === requestSessionId && streamGenerationRef.current === requestGeneration
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await client.sendRequest('nativeChat.readSession', {
|
||||
@@ -207,18 +218,17 @@ export function useMobileNativeChatSession(args: {
|
||||
...(beforeOffset === null ? {} : { beforeOffset }),
|
||||
...(transcriptPath ? { transcriptPath } : {})
|
||||
})
|
||||
if (!response.ok) {
|
||||
return
|
||||
}
|
||||
const result = response.result as ReadSessionResult
|
||||
if ('error' in result) {
|
||||
const result = response.ok ? (response.result as ReadSessionResult) : null
|
||||
if (!result || 'error' in result) {
|
||||
// Surface the failed page: doing nothing silently reads as a dead
|
||||
// scroll, and the header row is the only retry affordance.
|
||||
if (requestIsCurrent()) {
|
||||
setLoadEarlierError(LOAD_EARLIER_ERROR)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Drop a stale resolve from a session that swapped underneath us.
|
||||
if (
|
||||
sessionIdRef.current !== requestSessionId ||
|
||||
streamGenerationRef.current !== requestGeneration
|
||||
) {
|
||||
if (!requestIsCurrent()) {
|
||||
return
|
||||
}
|
||||
limitRef.current = nextLimit
|
||||
@@ -233,12 +243,15 @@ export function useMobileNativeChatSession(args: {
|
||||
setList(result.messages)
|
||||
setHasMore(result.messages.length >= nextLimit)
|
||||
}
|
||||
} catch {
|
||||
// A rejected transport request (disconnect mid-page) is a failed page too;
|
||||
// without this it also escaped as an unhandled rejection.
|
||||
if (requestIsCurrent()) {
|
||||
setLoadEarlierError(LOAD_EARLIER_ERROR)
|
||||
}
|
||||
} finally {
|
||||
// A late page from a prior tab must not unlock the current tab's request.
|
||||
if (
|
||||
sessionIdRef.current === requestSessionId &&
|
||||
streamGenerationRef.current === requestGeneration
|
||||
) {
|
||||
if (requestIsCurrent()) {
|
||||
loadingEarlierRef.current = false
|
||||
setLoadingEarlier(false)
|
||||
}
|
||||
@@ -255,6 +268,7 @@ export function useMobileNativeChatSession(args: {
|
||||
error,
|
||||
hasMore,
|
||||
loadingEarlier,
|
||||
loadEarlierError,
|
||||
loadEarlier
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user