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))