fix(mobile-native-chat): retire an image echo glued with the send beside it

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.
This commit is contained in:
Merge Sim
2026-08-31 19:06:50 -07:00
parent eff317939a
commit 36d798e5bb
4 changed files with 84 additions and 4 deletions
@@ -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]'),
@@ -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
@@ -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<string> = 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([])
@@ -23,7 +23,10 @@ type GlueSegment = { text: string; tail: number } | null
export function selectGluedPendingIds(
messages: readonly NativeChatMessage[],
pending: readonly MobileNativeChatPendingMessage[],
excludedPendingIds: ReadonlySet<string> = NO_PENDING_IDS
excludedPendingIds: ReadonlySet<string> = 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<string> = NO_PENDING_IDS
): ReadonlySet<string> {
const retired = new Set<string>()
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<string>()
// 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<string>()
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))