fix(mobile-native-chat): stop an unbounded send claiming an image turn already in the read

The image-preview pass runs before the rebase, so a send captured with no
boundary matched any image turn the settled read carried — binding the user's
freshly attached photo to an old one and retiring the bubble through
landedImagePendingIds, which short-circuits the retirement path entirely.
Pinning the tail in the rebase could not help: the claim was already made.
Such an entry now waits one tick and claims against a real tail.
This commit is contained in:
Brennan Benson
2026-08-16 21:21:43 -07:00
parent 4db02de8a0
commit 4877dccc12
2 changed files with 34 additions and 1 deletions
@@ -25,6 +25,16 @@ function assistantTurn(id: string, text: string, timestamp: number): NativeChatM
}
}
function imageTurn(id: string, path: string, timestamp: number): NativeChatMessage {
return {
id,
role: 'user',
blocks: [{ type: 'text', text: `[Image: source: ${path}]` }],
timestamp,
source: 'transcript'
}
}
/** Texts of the optimistic bubbles the chat list actually renders. */
function renderedPendingTexts(
messages: NativeChatMessage[],
@@ -299,6 +309,23 @@ 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')
@@ -291,7 +291,13 @@ export function useMobileNativeChatDrafts(args: {
if (pending.length === 0) {
return
}
const landedImagePreviews = findLandedImagePreviewEchoes(messages, pending)
// This pass runs before the rebase below, so a send captured with no
// boundary would claim any image turn in the read — binding the user's fresh
// photo to an old one. It waits a tick and claims against a real tail.
const landedImagePreviews = findLandedImagePreviewEchoes(
messages,
pending.filter((item) => item.baselineResolved)
)
const landedImagePendingIds = new Set(landedImagePreviews.map((preview) => preview.pendingId))
if (landedImagePreviews.length > 0) {
setImagePreviewsBySession((previous) =>