From 36d798e5bb2fdae847240a49ad1cdc34e8ed62c7 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Mon, 31 Aug 2026 19:06:50 -0700 Subject: [PATCH] fix(mobile-native-chat): retire an image echo glued with the send beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A message sent with images could render two or three times over, with the copy carrying the photos sorting below the reply that answered it — and it never cleared. A send issued while the agent is mid-turn is glued onto the agent's input line with any send adjacent to it, so the pair lands as one transcript row whose text is the concatenation. Every retirement path then declined the pair: - The image matcher wanted the whole row to equal the echo's text, so a glued row never bound. That also stranded the local preview: the phone's photo never reached the authoritative row. - The exact-count path skips image echoes by design. - The glue path excluded image echoes too, which made one a *barrier* — splitting the run so the text-only send beside it was left alone, and a lone match is rejected as an ordinary landing. So neither echo could ever retire, and the unmatched image echo fell through to the trailing bucket, which is what put it below the reply. Match a glued row in the image matcher, and let an image echo take part in the glue pass once its preview has been rebound. It stays a barrier while unbound, so the existing guarantee is kept: an image echo never retires before its local preview reaches the transcript row, or the photo would disappear. --- ...mobile-native-chat-draft-reconcile.test.ts | 26 +++++++++++++++ .../mobile-native-chat-draft-reconcile.ts | 11 ++++++- ...ile-native-chat-pending-retirement.test.ts | 32 +++++++++++++++++++ .../mobile-native-chat-pending-retirement.ts | 19 +++++++++-- 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts index b7453e7a67d..5084448ea4a 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts @@ -47,6 +47,32 @@ describe('mobile native chat image preview reconciliation', () => { ]) }) + it('binds an image echo to the row it was glued into with a following send', () => { + // Regression: a send issued while the agent was mid-turn glues onto the input line + // with the send beside it, so the landed row's text is the concatenation. Demanding + // the whole row equal this echo left it unbound — the phone-local photo never + // reached the authoritative row and the echo could never be retired. + const messages = [ + userText('source', '[Image: source: /tmp/a.png]'), + userText('prompt', 'look at this[Image #1] is it still working?') + ] + const preview = { ...pending('pending', ['file:///a.jpg']), text: 'look at this' } + + expect(findLandedImagePreviewEchoes(messages, [preview])).toEqual([ + { pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] } + ]) + }) + + it('does not bind an image echo to a row that merely shares a word', () => { + const messages = [ + userText('source', '[Image: source: /tmp/a.png]'), + userText('prompt', 'totally different[Image #1]') + ] + const preview = { ...pending('pending', ['file:///a.jpg']), text: 'look at this' } + + expect(findLandedImagePreviewEchoes(messages, [preview])).toEqual([]) + }) + it('reconciles a middle-marker echo without changing its rendered whitespace', () => { const messages = [ userText('source', '[Image: source: /tmp/a.png]'), diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index a68fe5bcf32..17fcb343198 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -166,7 +166,16 @@ export function findLandedImagePreviewEchoes( return false } if (targetText) { - return normalizedUserText(message) === targetText + const text = normalizedUserText(message) + if (text === null) { + return false + } + // Why not equality alone: a send is glued onto the agent's input line with any + // send adjacent to it, so an image send that shares a turn with a following + // text-only send lands in a row whose text is the concatenation. Requiring the + // whole row to equal this echo left it unmatched, and since both other + // retirement paths skip image echoes, nothing could ever retire it. + return text === targetText || text.startsWith(targetText) } const imageCount = message.blocks.filter(isImageRefBlock).length return message.blocks.length === 0 || imageCount >= entry.images!.length diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts index 1dedec13d44..00231c7f114 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts @@ -232,6 +232,38 @@ describe('selectGluedPendingIds', () => { ).toEqual(['p1', 'p2']) }) + it('retires an image send glued with the text send beside it once its preview rebinds', () => { + // Regression: one message typed, images attached, then a second send glued onto the + // same input line. The image echo could not match the glued row (its matcher wanted + // the whole row to equal the echo), and because an image echo was excluded from the + // glue pass it also acted as a barrier — stranding the text send in a run of one, + // which `matchGluedRun` rejects. Neither echo could ever retire, so the message + // rendered twice over, with the image copy sorting below the reply it preceded. + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)] + const pending = [ + { ...pendingSend('p1', 'one', 'm1'), images: ['file:///a.png'] }, + pendingSend('p2', 'two', 'm1') + ] + const rebound: ReadonlySet = new Set(['p1']) + expect([...selectGluedPendingIds(messages, pending, new Set(), rebound)].sort()).toEqual([ + 'p1', + 'p2' + ]) + expect( + retireLandedMobileNativeChatPending(messages, pending, rebound).map((item) => item.id) + ).toEqual([]) + }) + + it('still strands nothing when the glued row belongs to an older baseline', () => { + // The per-send boundary still applies to a rebound image echo. + const messages = [userTurn('m1', 'one two', 1000), assistantTurn('m2', 'ready', 5000)] + const pending = [ + { ...pendingSend('p1', 'one', 'm2'), images: ['file:///a.png'] }, + pendingSend('p2', 'two', 'm2') + ] + expect([...selectGluedPendingIds(messages, pending, new Set(), new Set(['p1']))]).toEqual([]) + }) + it('does nothing for a single pending send', () => { const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)] expect(retiredIds(messages, [pendingSend('p1', 'one', 'm1')])).toEqual([]) diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.ts b/mobile/src/session/mobile-native-chat-pending-retirement.ts index ffdde70ddd4..7f720302ecc 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.ts @@ -23,7 +23,10 @@ type GlueSegment = { text: string; tail: number } | null export function selectGluedPendingIds( messages: readonly NativeChatMessage[], pending: readonly MobileNativeChatPendingMessage[], - excludedPendingIds: ReadonlySet = NO_PENDING_IDS + excludedPendingIds: ReadonlySet = NO_PENDING_IDS, + /** Image echoes whose local preview has been rebound onto its transcript row. + * Until that happens an image echo must stay put, so it is not a glue segment. */ + reboundImagePendingIds: ReadonlySet = NO_PENDING_IDS ): ReadonlySet { const retired = new Set() if (pending.length < 2) { @@ -44,9 +47,14 @@ export function selectGluedPendingIds( item.baselineTailMessageId === null ? -1 : (messageIndexById.get(item.baselineTailMessageId) ?? null) + // An image send glues onto the input line like any other, so once its preview is + // rebound its text is a real segment of the resulting row. While it is still + // unbound it stays a barrier: retiring it early would drop the phone-local photo, + // which the transcript's host path cannot render. + const unboundImage = Boolean(item.images?.length) && !reboundImagePendingIds.has(item.id) return excludedPendingIds.has(item.id) || !item.baselineResolved || - item.images?.length || + unboundImage || text === '' || tail === null ? null @@ -146,6 +154,10 @@ export function retireLandedMobileNativeChatPending( } } const landedPendingIds = new Set() + // Why a separate set: a barrier preserves adjacency after a landing consumed a whole + // row. An image landing can share its row with the send glued after it, so treating it + // as a barrier would strand that send in a run of one and keep its echo forever. + const exactLandedIds = new Set() for (const item of current) { if (landedImagePendingIds.has(item.id)) { landedPendingIds.add(item.id) @@ -164,9 +176,10 @@ export function retireLandedMobileNativeChatPending( : (landedCounts.get(normalizeReconcileText(item.text)) ?? 0) >= item.expectedOccurrence if (landed) { landedPendingIds.add(item.id) + exactLandedIds.add(item.id) } } - const glued = selectGluedPendingIds(messages, current, landedPendingIds) + const glued = selectGluedPendingIds(messages, current, exactLandedIds, landedImagePendingIds) return landedPendingIds.size === 0 && glued.size === 0 ? current : current.filter((item) => !landedPendingIds.has(item.id) && !glued.has(item.id))