fix(mobile): preserve authoritative chat state

This commit is contained in:
Brennan Benson
2026-08-03 18:42:07 -07:00
parent b8c4a89a3a
commit 4603ede7ff
5 changed files with 78 additions and 8 deletions
@@ -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')])
@@ -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 {
@@ -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)
})
})
@@ -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<Map<string | null, string>>(
() => 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))
}
}
@@ -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()