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.
This commit is contained in:
Brennan Benson
2026-08-16 21:36:46 -07:00
parent 4402da00b4
commit 947ccc3ef9
3 changed files with 41 additions and 28 deletions
@@ -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)
})
})
@@ -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 }
})
@@ -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')