mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 16:02:55 +00:00
fix(mobile): preserve authoritative chat state
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user