mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix(mobile-native-chat): pin an image echo that captured no tail, and require the settled flag
A caption-less image echo keeps its captured tail because it counts image turns after it — but a send issued before any history was known captured null, which counts from the top of the transcript. An old image turn then claimed the send and bound the user's fresh photo to it, leaving the just-sent turn with no preview. A null tail is not a boundary worth preserving, so pin those too. `transcriptSettled` was optional and defaulted to the gate it replaced, so any caller that omitted it silently got the pre-fix behaviour. Required now, and threaded through every harness.
This commit is contained in:
@@ -100,16 +100,29 @@ describe('rebaseMobileNativeChatPendingBaselines', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// An image echo counts image turns AFTER its tail, so moving the tail onto this
|
||||
// read would discard an echo the read already carries.
|
||||
it('keeps a caption-less image echo on its captured tail', () => {
|
||||
const images = { ...unresolved('p1', ''), images: ['file:///a.png'] }
|
||||
// An image echo counts image turns AFTER its tail, so moving a real tail onto
|
||||
// this read would discard an echo the read already carries.
|
||||
it('keeps a caption-less image echo on the tail it actually captured', () => {
|
||||
const images = {
|
||||
...unresolved('p1', ''),
|
||||
baselineTailMessageId: 'm1',
|
||||
images: ['file:///a.png']
|
||||
}
|
||||
const rebased = rebaseMobileNativeChatPendingBaselines(history, [images])
|
||||
expect(rebased[0]?.baselineTailMessageId).toBe('m1')
|
||||
expect(rebased[0]?.expectedOccurrence).toBe(1)
|
||||
expect(rebased[0]?.baselineResolved).toBe(true)
|
||||
})
|
||||
|
||||
// A null tail counts from the top of the transcript, so an image turn already
|
||||
// in this read would claim the send and take the user's fresh photo with it.
|
||||
it('pins a caption-less image echo that captured no tail at all', () => {
|
||||
const rebased = rebaseMobileNativeChatPendingBaselines(history, [
|
||||
images,
|
||||
{ ...unresolved('p1', ''), images: ['file:///a.png'] },
|
||||
{ ...unresolved('p2', '', 2), images: ['file:///b.png'] }
|
||||
])
|
||||
expect(rebased.map((item) => item.baselineTailMessageId)).toEqual(['m2', 'm2'])
|
||||
expect(rebased.map((item) => item.expectedOccurrence)).toEqual([1, 2])
|
||||
expect(rebased.map((item) => item.baselineTailMessageId)).toEqual([null, null])
|
||||
expect(rebased.every((item) => item.baselineResolved)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,9 +21,11 @@ import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pendin
|
||||
* anything the transcript can supply, which strands the bubble for the rest of
|
||||
* the session and stops its whole run from ever gluing.
|
||||
*
|
||||
* A caption-less image echo keeps its captured tail: it reconciles by counting
|
||||
* image turns AFTER that tail, so pinning the tail past this read would throw
|
||||
* away an echo the read already carries.
|
||||
* A caption-less image echo keeps a captured tail it actually has: it reconciles
|
||||
* by counting image turns AFTER that tail, so moving the tail onto this read
|
||||
* would throw away an echo the read already carries. A null tail is not such a
|
||||
* boundary — it counts from the top of the transcript, so an old image turn can
|
||||
* claim the send and bind the user's fresh photo to it. Those get pinned too.
|
||||
*/
|
||||
export function rebaseMobileNativeChatPendingBaselines(
|
||||
messages: readonly NativeChatMessage[],
|
||||
@@ -37,7 +39,8 @@ export function rebaseMobileNativeChatPendingBaselines(
|
||||
if (item.baselineResolved) {
|
||||
return item
|
||||
}
|
||||
return item.images?.length || normalizeReconcileText(item.text) === ''
|
||||
const countsAfterItsOwnTail = item.images?.length || normalizeReconcileText(item.text) === ''
|
||||
return countsAfterItsOwnTail && item.baselineTailMessageId !== null
|
||||
? { ...item, baselineResolved: true }
|
||||
: { ...item, baselineResolved: true, baselineTailMessageId }
|
||||
})
|
||||
|
||||
@@ -229,9 +229,11 @@ describe('useMobileNativeChatDrafts glued pending sends', () => {
|
||||
// The read that resolves a hydration-time send can already contain that
|
||||
// send's own echo — a re-subscribe returns whatever exists now — and nothing
|
||||
// local tells that echo from an identical older prompt. Claiming the row is
|
||||
// the safe direction: being wrong costs one round trip of invisibility,
|
||||
// while holding costs a queued bubble that never clears and a run that can
|
||||
// never glue again.
|
||||
// the lesser evil, not a free one: normally the real echo lands a round trip
|
||||
// later, but a send that never landed and was claimed by an older identical
|
||||
// row stops being tracked at all — worst for short repeats ("yes", "1").
|
||||
// Holding instead costs a queued bubble that never clears AND a run that can
|
||||
// never glue again, for the rest of the session.
|
||||
it('retires a hydration-time send whose echo arrives with the read', async () => {
|
||||
await mount([], true)
|
||||
act(() => send('run the tests'))
|
||||
|
||||
@@ -36,7 +36,8 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
launchDraft = null,
|
||||
launchDraftCreatedAt = null,
|
||||
chatActive = true,
|
||||
transcriptLoading = false
|
||||
transcriptLoading = false,
|
||||
transcriptSettled = !transcriptLoading
|
||||
}: {
|
||||
tabId: string
|
||||
sessionId?: string | null
|
||||
@@ -45,6 +46,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
launchDraftCreatedAt?: number | null
|
||||
chatActive?: boolean
|
||||
transcriptLoading?: boolean
|
||||
transcriptSettled?: boolean
|
||||
}): null {
|
||||
state = useMobileNativeChatDrafts({
|
||||
hostId: 'host',
|
||||
@@ -55,7 +57,8 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
launchDraft,
|
||||
launchDraftCreatedAt,
|
||||
chatActive,
|
||||
transcriptLoading
|
||||
transcriptLoading,
|
||||
transcriptSettled
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
messages = [],
|
||||
launchDraft = null,
|
||||
chatActive = true,
|
||||
transcriptLoading = false
|
||||
transcriptLoading = false,
|
||||
transcriptSettled = !transcriptLoading
|
||||
}: {
|
||||
tabId: string
|
||||
sessionId?: string | null
|
||||
@@ -50,6 +51,7 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
launchDraft?: string | null
|
||||
chatActive?: boolean
|
||||
transcriptLoading?: boolean
|
||||
transcriptSettled?: boolean
|
||||
}): null {
|
||||
state = useMobileNativeChatDrafts({
|
||||
hostId: 'host',
|
||||
@@ -59,7 +61,8 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
messages,
|
||||
launchDraft,
|
||||
chatActive,
|
||||
transcriptLoading
|
||||
transcriptLoading,
|
||||
transcriptSettled
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
/** `messages` is this session's own settled history — so an empty one really
|
||||
* is an empty conversation, not a read that failed or never ran. Only then
|
||||
* does a send's captured tail describe a real boundary. */
|
||||
transcriptSettled?: boolean
|
||||
transcriptSettled: boolean
|
||||
}): {
|
||||
composerText: string
|
||||
setComposerText: Dispatch<SetStateAction<string>>
|
||||
@@ -85,7 +85,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
launchDraftCreatedAt,
|
||||
chatActive = true,
|
||||
transcriptLoading,
|
||||
transcriptSettled = !transcriptLoading
|
||||
transcriptSettled
|
||||
} = args
|
||||
const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId)
|
||||
const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null
|
||||
|
||||
Reference in New Issue
Block a user