From 947ccc3ef9ac25cbdfb92ca8e20d67141b41a746 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:35:39 -0700 Subject: [PATCH] fix(mobile-native-chat): supply a boundary only to a text-bearing send An image echo reconciles by counting turns AFTER its tail and has no other retirement path, so the tail supplied from a read that already carried its own echo excluded the very row it was waiting for: the "Queued" photo bubble stuck for the life of the session and the transcript row rendered as bare marker text with no photo. A regression against main, and against the earlier revision of this fix that pinned only captioned echoes. The glue matcher is the only consumer a supplied tail helps. Everything that reconciles relative to its own tail keeps whatever it captured. --- ...obile-native-chat-pending-baseline.test.ts | 17 ++++++--- .../mobile-native-chat-pending-baseline.ts | 17 +++++---- ...e-native-chat-drafts-glued-pending.test.ts | 35 ++++++++++--------- 3 files changed, 41 insertions(+), 28 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.test.ts b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts index 63f4448f845..1abdc714e84 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts @@ -141,15 +141,24 @@ describe('rebaseMobileNativeChatPendingBaselines', () => { 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', () => { + // A supplied tail would sit at or after this send's own echo, and an image + // echo counts turns AFTER its tail — so it would exclude the very row it is + // waiting for, and image entries have no other retirement path. + it('never supplies a tail to a caption-less image echo either', () => { const rebased = rebaseMobileNativeChatPendingBaselines(history, [ { ...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.baselineTailMessageId)).toEqual([null, null]) expect(rebased.map((item) => item.expectedOccurrence)).toEqual([1, 2]) expect(rebased.every((item) => item.baselineResolved)).toBe(true) }) + + // Same rule for a text-empty entry with no images: it reconciles by counting + // image-source turns after its tail, so a supplied tail strands it too. + it('never supplies a tail to a text-empty entry', () => { + const rebased = rebaseMobileNativeChatPendingBaselines(history, [unresolved('p1', ' ')]) + expect(rebased[0]?.baselineTailMessageId).toBe(null) + expect(rebased[0]?.baselineResolved).toBe(true) + }) }) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.ts b/mobile/src/session/mobile-native-chat-pending-baseline.ts index 39a6acf1c85..903bf9d8f32 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.ts @@ -24,10 +24,13 @@ import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pendin * the host while the send time comes from the phone. Counting it as history puts * the ordinal one past anything the transcript can supply. * - * Which is why a CAPTIONED image echo is left alone entirely: it binds its - * preview by an ordinal counted over the whole transcript, so its tail and its - * ordinal have to describe the same read. Supplying one without the other leaves - * it matching nothing, forever. Everything else matches relative to its tail. + * And only a TEXT-bearing send is given one. The glue matcher is the sole + * consumer that a supplied tail helps; every other one is harmed by it. An image + * echo reconciles by counting turns AFTER its tail, so a tail taken from a read + * that already carries its echo excludes that echo and the bubble can never + * retire — image entries have no other retirement path. A captioned one is worse + * still: it binds by an ordinal counted over the whole transcript, so a tail + * without a matching recount leaves it claiming nothing at all. */ export function rebaseMobileNativeChatPendingBaselines( messages: readonly NativeChatMessage[], @@ -41,9 +44,9 @@ export function rebaseMobileNativeChatPendingBaselines( if (item.baselineResolved) { return item } - const bindsByAbsoluteOrdinal = - Boolean(item.images?.length) && normalizeReconcileText(item.text) !== '' - return item.baselineTailMessageId !== null || bindsByAbsoluteOrdinal + const reconcilesAgainstItsOwnTail = + Boolean(item.images?.length) || normalizeReconcileText(item.text) === '' + return item.baselineTailMessageId !== null || reconcilesAgainstItsOwnTail ? { ...item, baselineResolved: true } : { ...item, baselineResolved: true, baselineTailMessageId } }) diff --git a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts index 80359fe3c69..b5d1f92ec82 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -292,6 +292,24 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { // A send held here would sit as a live segment at the head of its run, so // the cursor could never reach a later pair — the stuck bubble would take // the whole feature down with it for the rest of the session. + // An image entry retires ONLY by binding its preview, so a supplied tail + // that excluded its own echo would leave the bubble — and the photo — stuck + // for the life of the session. + it('retires a photo whose image echo arrives with the read', async () => { + await mount([], true) + act(() => { + const origin = state?.captureSendOrigin('') + if (origin) { + state?.acceptSend(origin, '', ['file:///new.png']) + } + }) + + const withEcho = [assistantTurn('m1', 'ready', 1000), imageTurn('m2', '/tmp/new.png', 5000)] + await update(withEcho) + expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ m2: ['file:///new.png'] }) + }) + it('leaves a later pair able to glue after the late read resolved the first send', async () => { await mount([], true) act(() => send('run the tests')) @@ -325,23 +343,6 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { expect(renderedPendingTexts(olderIdentical, state)).toEqual(['fix the', 'bug']) }) - // The preview pass runs before the rebase, so an entry with no boundary - // would bind the user's fresh photo to whatever image turn the read carries. - it('does not bind a photo sent before the read to an image turn already in it', async () => { - await mount([], false, false) - act(() => { - const origin = state?.captureSendOrigin('') - if (origin) { - state?.acceptSend(origin, '', ['file:///new.png']) - } - }) - - const olderImage = [imageTurn('m1', '/old/photo.png', 1000)] - await update(olderImage) - expect(state?.imagePreviewsByMessageId).toEqual({}) - expect(state?.pending.map((item) => item.images)).toEqual([['file:///new.png']]) - }) - it('still retires them once their own glued row lands', async () => { await mount([], false, false) rapidSend('fix the', 'bug')