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:
Brennan Benson
2026-08-16 21:21:42 -07:00
parent 67529be450
commit 4db02de8a0
6 changed files with 43 additions and 19 deletions
@@ -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