diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 77c5ab72fed..0f55a9b1ba7 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -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} diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 05763d59707..e8325dbb125 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -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 } @@ -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> { + const list = renderer!.root.find((node) => node.type === 'FlatList') + return list.props.ListHeaderComponent as ReactElement> + } + + 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 { const composer = renderer!.root.find((node) => node.type === 'Composer') as { props: { onPress: () => Promise } @@ -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') + }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 1a2ae71ca66..d36f3c8f597 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -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 /** 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 ? ( + ) : loadEarlierError ? ( + <> + {loadEarlierError} + Tap to retry + ) : ( Load earlier messages )} diff --git a/mobile/src/session/mobile-native-chat-view-styles.ts b/mobile/src/session/mobile-native-chat-view-styles.ts index 160477cfad8..26ca338db81 100644 --- a/mobile/src/session/mobile-native-chat-view-styles.ts +++ b/mobile/src/session/mobile-native-chat-view-styles.ts @@ -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 } }) diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index 3c12630690b..0461b074164 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -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 diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 9c739ce49ef..c9f2e3fe102 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -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(undefined) const [hasMore, setHasMore] = useState(false) const [loadingEarlier, setLoadingEarlier] = useState(false) + const [loadEarlierError, setLoadEarlierError] = useState(null) const loadingEarlierRef = useRef(false) const beforeOffsetRef = useRef(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 } }