From 4877dccc12e3234e6b04d0062942efe878395bad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:07:29 -0700 Subject: [PATCH] fix(mobile-native-chat): stop an unbounded send claiming an image turn already in the read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...e-native-chat-drafts-glued-pending.test.ts | 27 +++++++++++++++++++ .../session/use-mobile-native-chat-drafts.ts | 8 +++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts index 2b89164ac06..bcea1f1054f 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -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') diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index e11df733987..2597a615cc5 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -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) =>