From 4db02de8a0e6867e8930aec777925f7bea7c103e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:56:43 -0700 Subject: [PATCH] fix(mobile-native-chat): pin an image echo that captured no tail, and require the settled flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A caption-less image echo keeps its captured tail because it counts image turns after it — but a send issued before any history was known captured null, which counts from the top of the transcript. An old image turn then claimed the send and bound the user's fresh photo to it, leaving the just-sent turn with no preview. A null tail is not a boundary worth preserving, so pin those too. `transcriptSettled` was optional and defaulted to the gate it replaced, so any caller that omitted it silently got the pre-fix behaviour. Required now, and threaded through every harness. --- ...obile-native-chat-pending-baseline.test.ts | 25 ++++++++++++++----- .../mobile-native-chat-pending-baseline.ts | 11 +++++--- ...e-native-chat-drafts-glued-pending.test.ts | 8 +++--- ...le-native-chat-drafts-launch-draft.test.ts | 7 ++++-- .../use-mobile-native-chat-drafts.test.ts | 7 ++++-- .../session/use-mobile-native-chat-drafts.ts | 4 +-- 6 files changed, 43 insertions(+), 19 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.test.ts b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts index f421cbd388c..8ec1671b0a1 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts @@ -100,16 +100,29 @@ describe('rebaseMobileNativeChatPendingBaselines', () => { }) }) - // An image echo counts image turns AFTER its tail, so moving the tail onto this - // read would discard an echo the read already carries. - it('keeps a caption-less image echo on its captured tail', () => { - const images = { ...unresolved('p1', ''), images: ['file:///a.png'] } + // An image echo counts image turns AFTER its tail, so moving a real tail onto + // this read would discard an echo the read already carries. + it('keeps a caption-less image echo on the tail it actually captured', () => { + const images = { + ...unresolved('p1', ''), + baselineTailMessageId: 'm1', + images: ['file:///a.png'] + } + const rebased = rebaseMobileNativeChatPendingBaselines(history, [images]) + expect(rebased[0]?.baselineTailMessageId).toBe('m1') + expect(rebased[0]?.expectedOccurrence).toBe(1) + 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', () => { const rebased = rebaseMobileNativeChatPendingBaselines(history, [ - images, + { ...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.expectedOccurrence)).toEqual([1, 2]) - expect(rebased.map((item) => item.baselineTailMessageId)).toEqual([null, null]) expect(rebased.every((item) => item.baselineResolved)).toBe(true) }) }) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.ts b/mobile/src/session/mobile-native-chat-pending-baseline.ts index 995e3a6a650..99d7cc2134d 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.ts @@ -21,9 +21,11 @@ import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pendin * anything the transcript can supply, which strands the bubble for the rest of * the session and stops its whole run from ever gluing. * - * A caption-less image echo keeps its captured tail: it reconciles by counting - * image turns AFTER that tail, so pinning the tail past this read would throw - * away an echo the read already carries. + * A caption-less image echo keeps a captured tail it actually has: it reconciles + * by counting image turns AFTER that tail, so moving the tail onto this read + * would throw away an echo the read already carries. A null tail is not such a + * boundary — it counts from the top of the transcript, so an old image turn can + * claim the send and bind the user's fresh photo to it. Those get pinned too. */ export function rebaseMobileNativeChatPendingBaselines( messages: readonly NativeChatMessage[], @@ -37,7 +39,8 @@ export function rebaseMobileNativeChatPendingBaselines( if (item.baselineResolved) { return item } - return item.images?.length || normalizeReconcileText(item.text) === '' + const countsAfterItsOwnTail = item.images?.length || normalizeReconcileText(item.text) === '' + return countsAfterItsOwnTail && item.baselineTailMessageId !== null ? { ...item, baselineResolved: true } : { ...item, baselineResolved: true, baselineTailMessageId } }) 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 3079691adb2..2b89164ac06 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 @@ -229,9 +229,11 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { // The read that resolves a hydration-time send can already contain that // send's own echo — a re-subscribe returns whatever exists now — and nothing // local tells that echo from an identical older prompt. Claiming the row is - // the safe direction: being wrong costs one round trip of invisibility, - // while holding costs a queued bubble that never clears and a run that can - // never glue again. + // the lesser evil, not a free one: normally the real echo lands a round trip + // later, but a send that never landed and was claimed by an older identical + // row stops being tracked at all — worst for short repeats ("yes", "1"). + // Holding instead costs a queued bubble that never clears AND a run that can + // never glue again, for the rest of the session. it('retires a hydration-time send whose echo arrives with the read', async () => { await mount([], true) act(() => send('run the tests')) diff --git a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts index 665d3ac1f2f..0c8d3bf674a 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts @@ -36,7 +36,8 @@ describe('useMobileNativeChatDrafts launch draft', () => { launchDraft = null, launchDraftCreatedAt = null, chatActive = true, - transcriptLoading = false + transcriptLoading = false, + transcriptSettled = !transcriptLoading }: { tabId: string sessionId?: string | null @@ -45,6 +46,7 @@ describe('useMobileNativeChatDrafts launch draft', () => { launchDraftCreatedAt?: number | null chatActive?: boolean transcriptLoading?: boolean + transcriptSettled?: boolean }): null { state = useMobileNativeChatDrafts({ hostId: 'host', @@ -55,7 +57,8 @@ describe('useMobileNativeChatDrafts launch draft', () => { launchDraft, launchDraftCreatedAt, chatActive, - transcriptLoading + transcriptLoading, + transcriptSettled }) return null } diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index daf451e53f9..2a5ff2d54c6 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -42,7 +42,8 @@ describe('useMobileNativeChatDrafts', () => { messages = [], launchDraft = null, chatActive = true, - transcriptLoading = false + transcriptLoading = false, + transcriptSettled = !transcriptLoading }: { tabId: string sessionId?: string | null @@ -50,6 +51,7 @@ describe('useMobileNativeChatDrafts', () => { launchDraft?: string | null chatActive?: boolean transcriptLoading?: boolean + transcriptSettled?: boolean }): null { state = useMobileNativeChatDrafts({ hostId: 'host', @@ -59,7 +61,8 @@ describe('useMobileNativeChatDrafts', () => { messages, launchDraft, chatActive, - transcriptLoading + transcriptLoading, + transcriptSettled }) return null } diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 64cdf7cdc99..e11df733987 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -50,7 +50,7 @@ export function useMobileNativeChatDrafts(args: { /** `messages` is this session's own settled history — so an empty one really * is an empty conversation, not a read that failed or never ran. Only then * does a send's captured tail describe a real boundary. */ - transcriptSettled?: boolean + transcriptSettled: boolean }): { composerText: string setComposerText: Dispatch> @@ -85,7 +85,7 @@ export function useMobileNativeChatDrafts(args: { launchDraftCreatedAt, chatActive = true, transcriptLoading, - transcriptSettled = !transcriptLoading + transcriptSettled } = args const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId) const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null