diff --git a/mobile/src/session/mobile-native-chat-stream-frame.test.ts b/mobile/src/session/mobile-native-chat-stream-frame.test.ts index 09e7e225e2b..ac545a8f311 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.test.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.test.ts @@ -120,6 +120,27 @@ describe('applyMobileNativeChatStreamFrame', () => { }) }) + it('replaces retained history when replay metadata says no earlier rows remain', () => { + const merger = createNativeChatMerger() + replaceList(merger, ['removed-1', 'removed-2', 'a', 'b'].map(message)) + + const replay = [message('a'), message('b')] + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: replay, hasMore: false, beforeOffset: 0 }, + limit: 100, + replaceSnapshot: false + }) + ).toEqual({ + kind: 'messages', + messages: replay, + hasMore: false, + beforeOffset: 0, + windowReplaced: true + }) + }) + it('keeps the pagination cursor when an append does not trim history', () => { const merger = createNativeChatMerger() replaceList(merger, [message('a')]) diff --git a/mobile/src/session/mobile-native-chat-stream-frame.ts b/mobile/src/session/mobile-native-chat-stream-frame.ts index 3d9b310ccb3..c19c5976ff7 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.ts @@ -27,12 +27,18 @@ export type AppliedMobileNativeChatFrame = function replayExtendsRetainedTail( merger: NativeChatMerger, - messages: readonly NativeChatMessage[] + messages: readonly NativeChatMessage[], + hasMore: boolean | undefined ): boolean { const firstIndex = messages[0] ? merger.indexById.get(messages[0].id) : undefined if (firstIndex === undefined) { return false } + // `hasMore: false` is authoritative: retained rows before the replay window + // were removed while disconnected, even when the newest IDs still match. + if (hasMore === false && firstIndex > 0) { + return false + } let expectedIndex = firstIndex let sawNewMessage = false for (const message of messages) { @@ -77,7 +83,7 @@ export function applyMobileNativeChatStreamFrame(args: { frame.type === 'snapshot' && !replaceSnapshot && merger.list.length > 0 && - replayExtendsRetainedTail(merger, frame.messages) + replayExtendsRetainedTail(merger, frame.messages, frame.hasMore) if (frame.type === 'replacement' || (frame.type === 'snapshot' && !replayExtendsHistory)) { replaceList(merger, frame.messages) return { diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts index 77dc99db661..f178c8025a1 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts @@ -104,9 +104,14 @@ describe('useMobileNativeChatAskDismiss', () => { // The same question on another tab is a different pending prompt. await update({ prompt: first, scopeKey: 'tab-2' }) expect(state?.showAsk).toBe(true) + act(() => state?.dismissAsk()) + expect(state?.showAsk).toBe(false) - // Returning to the dismissing tab keeps it hidden. + // Dismissing tab 2 must not overwrite tab 1's dismissal. await update({ prompt: first, scopeKey: 'tab-1' }) expect(state?.showAsk).toBe(false) + + await update({ prompt: first, scopeKey: 'tab-2' }) + expect(state?.showAsk).toBe(false) }) }) diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts index 3f6efe366f7..59116b063f8 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts @@ -23,20 +23,28 @@ export function useMobileNativeChatAskDismiss(args: { } { const { ask, scopeKey, observing } = args const askKey = ask ? JSON.stringify(ask.questions) : null - const [dismissed, setDismissed] = useState<{ scope: string | null; key: string } | null>(null) + const [dismissedByScope, setDismissedByScope] = useState>( + () => new Map() + ) // Once the prompt clears while observable (agent moved on), forget the // dismissal so a later question — even an identical one — shows again. const askPresent = ask != null useEffect(() => { if (observing && !askPresent) { - setDismissed((prev) => (prev !== null && prev.scope === scopeKey ? null : prev)) + setDismissedByScope((previous) => { + if (!previous.has(scopeKey)) { + return previous + } + const next = new Map(previous) + next.delete(scopeKey) + return next + }) } }, [observing, askPresent, scopeKey]) - const showAsk = - askPresent && !(dismissed !== null && dismissed.scope === scopeKey && dismissed.key === askKey) + const showAsk = askPresent && dismissedByScope.get(scopeKey) !== askKey const dismissAsk = (): void => { if (askKey !== null) { - setDismissed({ scope: scopeKey, key: askKey }) + setDismissedByScope((previous) => new Map(previous).set(scopeKey, askKey)) } } 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 80afe0e2547..f16aa00a8fc 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -222,6 +222,36 @@ describe('useMobileNativeChatSession', () => { expect(state?.messages.at(-1)?.id).toBe('live-1') }) + it('drops paged rows that an authoritative replay says were removed', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { + messages: Array.from({ length: 60 }, (_unused, index) => message(`paged-${index}`)), + hasMore: false, + beforeOffset: 40 + } + }) + const window = Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`)) + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ type: 'snapshot', messages: window, hasMore: true, beforeOffset: 100 }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + expect(state?.messages).toHaveLength(100) + + await act(async () => + emit({ type: 'snapshot', messages: window, hasMore: false, beforeOffset: 0 }) + ) + + expect(state?.messages).toEqual(window) + expect(state?.hasMore).toBe(false) + }) + it('surfaces a failed older-history page and clears it on a retry', async () => { const sendRequest = vi .fn()