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