From 6521c7417f5eb8bd0b62b76791f67e4255b0b9fd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:07:17 -0700 Subject: [PATCH 01/44] fix(native-chat): preserve literal image markers (cherry picked from commit fab320e4242c3366aa3ef1629242bc11930298b3) --- .../mobile-native-chat-draft-reconcile.test.ts | 9 +++++++++ .../mobile-native-chat-draft-reconcile.ts | 6 +++++- .../mobile-native-chat-render-data.test.ts | 15 +++++++++++++-- .../native-chat-session-assembler.test.ts | 18 ++++++++++++++++++ ...ative-chat-image-transcript-markers.test.ts | 2 +- .../native-chat-image-transcript-markers.ts | 11 ++++++++++- 6 files changed, 56 insertions(+), 5 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts index b7453e7a67d5..5c6776008b14 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts @@ -84,6 +84,15 @@ describe('mobile native chat image preview reconciliation', () => { ]) }) + it('hands a local preview to a marker-only transcript echo', () => { + expect( + findLandedImagePreviewEchoes( + [userText('prompt', '[Image #1]')], + [pending('pending', ['file:///a.jpg'])] + ) + ).toEqual([{ pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] }]) + }) + it('keeps separate adjacent image-only sends independently reconcilable', () => { const landed = findLandedImagePreviewEchoes( [ diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index a68fe5bcf324..59fcd98683a1 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -169,7 +169,11 @@ export function findLandedImagePreviewEchoes( return normalizedUserText(message) === targetText } const imageCount = message.blocks.filter(isImageRefBlock).length - return message.blocks.length === 0 || imageCount >= entry.images!.length + return ( + message.blocks.length === 0 || + imageCount >= entry.images!.length || + (hasImagePromptMarker(message) && normalizedUserText(message) === null) + ) }) const tailIndex = entry.baselineTailMessageId ? messageIndexById.get(entry.baselineTailMessageId) diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index 1a1811f808d4..2bb0ef89305b 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -125,6 +125,14 @@ describe('buildMobileNativeChatTransientData', () => { ]) }) + it('renders literal image-marker text in a standalone user turn', () => { + const data = build([user('u1', 'Please preserve [Image #1] literally')], null, []) + + expect(data[0]?.blocks).toEqual([ + { type: 'text', text: 'Please preserve [Image #1] literally' } + ]) + }) + it('renders a lone image marker turn (no caption) as an image-ref block', () => { const data = build([user('u1', '[Image: source: /tmp/a.png]')], null, []) expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }]) @@ -149,7 +157,7 @@ describe('buildMobileNativeChatTransientData', () => { ]) }) - it('restores the local preview onto a marker-only transcript turn', () => { + it('restores a local preview without treating it as proof to strip marker text', () => { const result = buildMobileNativeChatTransientData({ folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]), streaming: null, @@ -157,7 +165,10 @@ describe('buildMobileNativeChatTransientData', () => { imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } }) - expect(result.data[0]?.blocks).toEqual([{ type: 'image-ref', url: 'file:///phone-photo.jpg' }]) + expect(result.data[0]?.blocks).toEqual([ + { type: 'text', text: '[Image #1]' }, + { type: 'image-ref', url: 'file:///phone-photo.jpg' } + ]) }) it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts b/src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts index 78321d615486..34d2d6fae8cc 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts @@ -140,6 +140,24 @@ describe('assembleNativeChatSession', () => { ]) }) + it('renders literal image-marker text in a standalone user turn', () => { + const literal = msg({ + id: 'u-literal', + role: 'user', + blocks: [{ type: 'text', text: 'Please preserve [Image #1] literally' }] + }) + + const session = assembleNativeChatSession({ + sources: { transcript: [literal] }, + sessionId: 's1', + agent: 'claude' + }) + + expect(session.messages[0]?.blocks).toEqual([ + { type: 'text', text: 'Please preserve [Image #1] literally' } + ]) + }) + it('drops a scrape duplicate even when scrape is processed first by id', () => { const scrape = msg({ id: 'shared-id', diff --git a/src/shared/native-chat-image-transcript-markers.test.ts b/src/shared/native-chat-image-transcript-markers.test.ts index aff2c6f71fe5..9dbfd849b971 100644 --- a/src/shared/native-chat-image-transcript-markers.test.ts +++ b/src/shared/native-chat-image-transcript-markers.test.ts @@ -173,7 +173,7 @@ describe('normalizeImageTranscriptMessages', () => { }) it('leaves ordinary user text untouched', () => { - const message = userText('a', 'how about this') + const message = userText('a', 'keep [Image #1] as literal text') const messages = [message] const out = normalizeImageTranscriptMessages(messages) expect(out).toBe(messages) diff --git a/src/shared/native-chat-image-transcript-markers.ts b/src/shared/native-chat-image-transcript-markers.ts index df52ec8c12ce..32af0f47b885 100644 --- a/src/shared/native-chat-image-transcript-markers.ts +++ b/src/shared/native-chat-image-transcript-markers.ts @@ -81,6 +81,15 @@ function stripImagePromptMarkersFromTextBlocks( return next ?? (blocks as NativeChatBlock[]) } +function removeEmptyFirstTextBlock(blocks: readonly NativeChatBlock[]): NativeChatBlock[] { + const index = blocks.findIndex(isTextBlock) + const block = blocks[index] + if (index === -1 || !block || !isTextBlock(block) || block.text.trim()) { + return blocks as NativeChatBlock[] + } + return [...blocks.slice(0, index), ...blocks.slice(index + 1)] +} + export function hasImagePromptMarker(message: NativeChatMessage): boolean { return message.blocks.some((block) => isTextBlock(block) && IMAGE_PROMPT_MARKER.test(block.text)) } @@ -133,7 +142,7 @@ export function normalizeImageTranscriptMessages( }) continue } - const blocks = stripImagePromptMarkersFromTextBlocks(message.blocks) + const blocks = removeEmptyFirstTextBlock(message.blocks) if (blocks === message.blocks) { normalized?.push(message) } else { From f04cfaf54b91170790f7cce27d17c0ab02f87535 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:47:07 -0700 Subject: [PATCH 02/44] fix(native-chat): key optimistic echoes on literal image markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The render path became provenance-aware for `[Image #n]` markers, but the pending-key path kept stripping them unconditionally, so the two disagreed: a standalone marker-only turn rendered its literal text while its content key computed to empty, leaving the optimistic echo unmatchable and the row duplicated on desktop and mobile. Give both sides the same rule via `nativeChatUserMessageMatchText` — a marker run that stripping would erase, on a turn carrying no image, is the user's own text and keys verbatim. Mobile's preview reconciliation also accepted a single marker as proof for any number of pending images, so an incomplete multi-image echo could clear or rebind every preview; it now requires one marker per image. (cherry picked from commit 7803de9c1b256923ea220cabf951ed262e43cd12) --- .../mobile-native-chat-draft-reconcile.ts | 41 ++++- ...le-native-chat-image-transcript-markers.ts | 3 + ...le-native-chat-literal-marker-echo.test.ts | 161 ++++++++++++++++++ .../mobile-native-chat-pending-echo.ts | 21 ++- .../session/use-mobile-native-chat-drafts.ts | 24 +-- .../use-mobile-native-chat-message-send.ts | 4 +- .../native-chat-literal-marker-echo.test.ts | 127 ++++++++++++++ .../native-chat-pending-occurrence.ts | 24 ++- .../native-chat-image-transcript-markers.ts | 54 +++++- 9 files changed, 422 insertions(+), 37 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 59fcd98683a1..f07eaffd6a72 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -1,12 +1,17 @@ import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { + countImagePromptMarkers, hasImagePromptMarker, isImageSourceUserTurn, + nativeChatUserMessageMatchText, normalizeImageTranscriptMessages, normalizeNativeChatUserText, normalizedNativeChatUserMessageText } from './mobile-native-chat-image-transcript-markers' -export { normalizeNativeChatUserText as normalizeReconcileText } from './mobile-native-chat-image-transcript-markers' +export { + normalizeNativeChatUserText as normalizeReconcileText, + normalizeNativeChatUserTextWithLiteralFallback as normalizeReconcileTextWithLiteralFallback +} from './mobile-native-chat-image-transcript-markers' /** An ack-lost ('unknown' outcome) send held until its transcript echo lands or * the deadline surfaces the uncertainty. */ @@ -29,13 +34,26 @@ export function countUserTextOccurrences( ): number { let count = 0 for (const message of messages) { - if (normalizedUserText(message) === text) { + if (nativeChatUserMessageMatchText(message) === text) { count++ } } return count } +export function userTextOccurrenceCounts( + messages: readonly NativeChatMessage[] +): Map { + const counts = new Map() + for (const message of messages) { + const text = nativeChatUserMessageMatchText(message) + if (text) { + counts.set(text, (counts.get(text) ?? 0) + 1) + } + } + return counts +} + /** Number of `[Image: source: …]` echo turns strictly after `tailId` (or the * whole transcript when the tail was paginated out). An image-only send has no * caption to match, so it reconciles by ordinal against this count — counting @@ -169,10 +187,13 @@ export function findLandedImagePreviewEchoes( return normalizedUserText(message) === targetText } const imageCount = message.blocks.filter(isImageRefBlock).length + // Why: a marker-only echo proves delivery per marker, so a partially + // rendered turn must not claim (and rebind) previews it cannot account for. return ( message.blocks.length === 0 || imageCount >= entry.images!.length || - (hasImagePromptMarker(message) && normalizedUserText(message) === null) + (countImagePromptMarkers(message) >= entry.images!.length && + normalizedUserText(message) === null) ) }) const tailIndex = entry.baselineTailMessageId @@ -213,10 +234,16 @@ export function findLandedUnconfirmedSends( if (message.role !== 'user') { continue } - const key = isImageSourceUserTurn(message) ? '' : (normalizedUserText(message) ?? '') - const current = userMessagesByText.get(key) ?? [] - current.push({ id: message.id, index }) - userMessagesByText.set(key, current) + const isImageSource = isImageSourceUserTurn(message) + const key = isImageSource ? '' : (normalizedUserText(message) ?? '') + // A marker-only turn keys both ways: '' claims it for a caption-less image + // send, and its literal text claims it for a send of that same text. + const literalKey = isImageSource ? key : (nativeChatUserMessageMatchText(message) ?? key) + for (const candidateKey of literalKey === key ? [key] : [key, literalKey]) { + const current = userMessagesByText.get(candidateKey) ?? [] + current.push({ id: message.id, index }) + userMessagesByText.set(candidateKey, current) + } } const claimedMessageIds = new Set() diff --git a/mobile/src/session/mobile-native-chat-image-transcript-markers.ts b/mobile/src/session/mobile-native-chat-image-transcript-markers.ts index 3c3998349ee8..658252d3ff89 100644 --- a/mobile/src/session/mobile-native-chat-image-transcript-markers.ts +++ b/mobile/src/session/mobile-native-chat-image-transcript-markers.ts @@ -3,11 +3,14 @@ // on the caption turn), and both render and echo reconciliation must // agree with desktop on how those marker turns are interpreted. export { + countImagePromptMarkers, imageSourcePathFromText, hasImagePromptMarker, isImageSourceUserTurn, + nativeChatUserMessageMatchText, normalizeImageTranscriptMessages, normalizeNativeChatUserText, + normalizeNativeChatUserTextWithLiteralFallback, normalizedNativeChatUserMessageText, stripImagePromptMarker } from '../../../src/shared/native-chat-image-transcript-markers' diff --git a/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts b/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts new file mode 100644 index 000000000000..c8aef152b3a2 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts @@ -0,0 +1,161 @@ +// Outcome-level guard for STA-4363 on mobile: what the chat list ends up +// showing after a send whose text is nothing but `[Image #n]` markers, and what +// an incomplete multi-image echo is allowed to do to the local previews. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it } from 'vitest' +import { + isImageRefBlock, + isTextBlock, + type NativeChatMessage +} from '../../../src/shared/native-chat-types' +import { + buildMobileNativeChatTransientData, + foldMobileNativeChatMessages +} from './mobile-native-chat-render-data' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +type DraftState = ReturnType + +function userTextMessage(id: string, text: string): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +describe('mobile literal image-marker turns', () => { + let renderer: ReactTestRenderer | null = null + let state: DraftState | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + }) + + function Harness({ messages = [] }: { messages?: NativeChatMessage[] }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: 'a', + sessionId: 'session-a', + messages + }) + return null + } + + async function mount(): Promise { + await act(async () => { + renderer = create(createElement(Harness, {})) + }) + } + + async function transcript(messages: NativeChatMessage[]): Promise { + await act(async () => renderer?.update(createElement(Harness, { messages }))) + } + + function send(text: string, images?: string[]): void { + const origin = state?.captureSendOrigin(text, images) + act(() => { + if (origin) { + state?.acceptSend(origin, text, images) + } + }) + } + + /** The rows the chat list renders: folded transcript (with any rebound local + * previews) followed by the still-unreconciled optimistic echoes. */ + function rows(messages: NativeChatMessage[]): NativeChatMessage[] { + return buildMobileNativeChatTransientData({ + folded: foldMobileNativeChatMessages(messages), + streaming: null, + pending: state?.pending ?? [], + imagePreviewsByMessageId: state?.imagePreviewsByMessageId + }).data + } + + function rowText(message: NativeChatMessage): string { + return message.blocks + .filter(isTextBlock) + .map((block) => block.text) + .join('') + } + + function rowImages(message: NativeChatMessage): string[] { + return message.blocks + .filter(isImageRefBlock) + .map((block) => block.url ?? block.path ?? '') + .filter(Boolean) + } + + it('shows a standalone marker-only send exactly once', async () => { + await mount() + send('[Image #1]') + + const messages = [userTextMessage('u1', '[Image #1]')] + await transcript(messages) + + expect(rows(messages).map(rowText)).toEqual(['[Image #1]']) + }) + + it('shows a marker-only send with surrounding prose exactly once', async () => { + await mount() + send('keep [Image #1] literal') + + const messages = [userTextMessage('u1', 'keep [Image #1] literal')] + await transcript(messages) + + expect(rows(messages).map(rowText)).toEqual(['keep [Image #1] literal']) + }) + + it('holds every preview on the optimistic echo until the marker echo covers them all', async () => { + await mount() + send('', ['file:///a.jpg', 'file:///b.jpg']) + + const partial = [userTextMessage('prompt', '[Image #1]')] + await transcript(partial) + + const partialRows = rows(partial) + expect(partialRows.map(rowImages)).toEqual([[], ['file:///a.jpg', 'file:///b.jpg']]) + expect(state?.imagePreviewsByMessageId).toEqual({}) + + const complete = [userTextMessage('prompt', '[Image #1] [Image #2]')] + await transcript(complete) + + expect(state?.imagePreviewsByMessageId).toEqual({ + prompt: ['file:///a.jpg', 'file:///b.jpg'] + }) + expect(rows(complete).map(rowImages)).toEqual([['file:///a.jpg', 'file:///b.jpg']]) + }) + + it('still binds a single-image preview to its marker-only echo', async () => { + await mount() + send('', ['file:///a.jpg']) + + const messages = [userTextMessage('prompt', '[Image #1]')] + await transcript(messages) + + expect(state?.imagePreviewsByMessageId).toEqual({ prompt: ['file:///a.jpg'] }) + expect(rows(messages).map(rowImages)).toEqual([['file:///a.jpg']]) + }) + + it('still strips a real marker that follows an attached image', async () => { + await mount() + send('look', ['file:///a.jpg']) + + const messages = [ + userTextMessage('src', '[Image: source: /tmp/a.png]'), + userTextMessage('prompt', 'look [Image #1]') + ] + await transcript(messages) + + const rendered = rows(messages) + expect(rendered.map(rowText)).toEqual(['look']) + expect(rendered.map(rowImages)).toEqual([['file:///a.jpg']]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index b18136a9d473..bcf66250872f 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -1,3 +1,8 @@ +import { + normalizeNativeChatUserText, + normalizeNativeChatUserTextWithLiteralFallback +} from './mobile-native-chat-image-transcript-markers' + export type MobileNativeChatPendingMessage = { id: string text: string @@ -17,6 +22,14 @@ export type MobileNativeChatSendOrigin = { type PendingByKey = Record +/** Markers are the agent's only on a send that carried images; otherwise they + * are literal text, so `captureSendOrigin` keeps them and so must this. */ +function pendingMatchText(pending: MobileNativeChatPendingMessage): string { + return pending.images?.length + ? normalizeNativeChatUserText(pending.text) + : normalizeNativeChatUserTextWithLiteralFallback(pending.text) +} + export function combineMobileNativeChatPending( session: MobileNativeChatPendingMessage[], waiting: readonly MobileNativeChatPendingMessage[] @@ -39,11 +52,15 @@ export function appendMobileNativeChatPending( const current = previous[key] ?? [] const earlierOutstanding = current.filter( (pending) => - pending.text.trim() === origin.normalizedText && + pendingMatchText(pending) === origin.normalizedText && pending.expectedOccurrence > origin.baselineOccurrences ).length + // An image send whose caption is only markers still reconciles by image + // ordinal, matching how `captureSendOrigin` normalized it. const expectedImageEchoOrdinal = - current.filter((pending) => pending.text.trim() === '' && pending.images?.length).length + 1 + current.filter( + (pending) => pending.images?.length && normalizeNativeChatUserText(pending.text) === '' + ).length + 1 return { ...previous, [key]: [ diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 55fff5b42e39..456ca583ca58 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -8,7 +8,8 @@ import { mergeLandedImagePreviewEchoes, migrateImagePreviewMessageIds, normalizeReconcileText, - normalizedUserText, + normalizeReconcileTextWithLiteralFallback, + userTextOccurrenceCounts, type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' import { @@ -54,7 +55,7 @@ export function useMobileNativeChatDrafts(args: { /** Phone-local previews rebound to the transcript message that replaced the * optimistic echo, keyed by authoritative message id. */ imagePreviewsByMessageId: Record - captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null + captureSendOrigin: (text: string, images?: readonly string[]) => MobileNativeChatSendOrigin | null /** Launch-context text still believed to be parked on the agent's TUI input * line, or null once it has been declined or retired. Send paths size their * pre-clear from it, since one Ctrl+U clears only one logical line. */ @@ -128,11 +129,15 @@ export function useMobileNativeChatDrafts(args: { ) const captureSendOrigin = useCallback( - (text: string) => { + (text: string, images?: readonly string[]) => { if (!draftKey) { return null } - const normalizedText = normalizeReconcileText(text) + // Without an attached image an `[Image #n]` run is the user's own text, so + // it has to stay in the key the transcript echo will be matched against. + const normalizedText = images?.length + ? normalizeReconcileText(text) + : normalizeReconcileTextWithLiteralFallback(text) return { draftKey, pendingKey, @@ -291,13 +296,7 @@ export function useMobileNativeChatDrafts(args: { } setPendingBySession((previous) => { const current = previous[pendingKey] ?? [] - const landedCounts = new Map() - for (const message of messages) { - const text = normalizedUserText(message) - if (text) { - landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1) - } - } + const landedCounts = userTextOccurrenceCounts(messages) // Image-only source-turn counts stay stable across reruns and ignore paginated history. const next = current.filter((item) => { if (landedImagePendingIds.has(item.id)) { @@ -310,7 +309,8 @@ export function useMobileNativeChatDrafts(args: { return item.text.trim() === '' ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) < item.expectedOccurrence - : (landedCounts.get(normalizeReconcileText(item.text)) ?? 0) < item.expectedOccurrence + : (landedCounts.get(normalizeReconcileTextWithLiteralFallback(item.text)) ?? 0) < + item.expectedOccurrence }) if (next.length === current.length) { return previous diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 17f34b86d27a..742009cd2033 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -53,7 +53,7 @@ export function useMobileNativeChatMessageSend(args: { /** Captured when a control send starts so a later tab switch cannot record its * session-option effects against the newly active tab. */ commandSendRef: MutableRefObject<(command: string) => void> - captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null + captureSendOrigin: (text: string, images?: readonly string[]) => MobileNativeChatSendOrigin | null /** Launch-context text Orca parked on the agent's TUI input line, or null. Read * at send time so the pre-clear can be sized to every line it occupies. */ readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null @@ -92,7 +92,7 @@ export function useMobileNativeChatMessageSend(args: { sharedDeadline?: number ): Promise => { const handle = handleRef.current - const origin = captureSendOrigin(text) + const origin = captureSendOrigin(text, images) const agent = agentRef.current const recordCommand = commandSendRef.current // Why: the lease collapses one render after `connState`, so a question-card diff --git a/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts b/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts new file mode 100644 index 000000000000..65c137f9f0c8 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts @@ -0,0 +1,127 @@ +// Outcome-level guard for STA-4363: the render path and the pending-echo key +// path must agree on which `[Image #n]` runs are literal text, or the optimistic +// echo never reconciles and the turn renders twice. + +import { describe, expect, it } from 'vitest' +import { + isImageRefBlock, + isTextBlock, + type NativeChatMessage +} from '../../../../shared/native-chat-types' +import { pendingSendsAsMessages, prunePendingSends } from './native-chat-pending' +import type { NativeChatPendingSend } from './native-chat-pending' +import { assembleNativeChatSession } from './native-chat-session-assembler' + +function userMessage(id: string, text: string): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 1, source: 'transcript' } +} + +function assistantMessage(id: string, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: 2, + source: 'transcript' + } +} + +function assembled(transcript: NativeChatMessage[]): NativeChatMessage[] { + return assembleNativeChatSession({ sources: { transcript }, sessionId: 's1', agent: 'claude' }) + .messages +} + +/** The user rows NativeChatView actually renders: assembled transcript first, + * then whichever optimistic echoes are still unrepresented. */ +function renderedUserRows( + transcript: NativeChatMessage[], + pending: NativeChatPendingSend[] +): NativeChatMessage[] { + const messages = assembled(transcript) + return [...messages, ...pendingSendsAsMessages(pending, messages)].filter( + (message) => message.role === 'user' + ) +} + +function rowText(message: NativeChatMessage): string { + return message.blocks + .filter(isTextBlock) + .map((block) => block.text) + .join('') +} + +function rowImagePaths(message: NativeChatMessage): string[] { + return message.blocks + .filter(isImageRefBlock) + .map((block) => block.path ?? block.url ?? '') + .filter(Boolean) +} + +describe('literal image-marker turns in native chat', () => { + it('shows a standalone marker-only send exactly once', () => { + const pending: NativeChatPendingSend[] = [{ id: 'p1', text: '[Image #1]', sentAt: 100 }] + const transcript = [userMessage('m1', '[Image #1]')] + + const rows = renderedUserRows(transcript, pending) + + expect(rows.map(rowText)).toEqual(['[Image #1]']) + }) + + it('shows a marker-only send with surrounding prose exactly once', () => { + const pending: NativeChatPendingSend[] = [ + { id: 'p1', text: 'keep [Image #1] literal', sentAt: 100 } + ] + const transcript = [userMessage('m1', 'keep [Image #1] literal')] + + expect(renderedUserRows(transcript, pending).map(rowText)).toEqual(['keep [Image #1] literal']) + }) + + it('prunes the marker-only echo once the assistant answers it', () => { + const pending: NativeChatPendingSend[] = [{ id: 'p1', text: '[Image #1]', sentAt: 100 }] + const transcript = [userMessage('m1', '[Image #1]'), assistantMessage('m2', 'preserved')] + + expect(prunePendingSends(pending, assembled(transcript))).toEqual([]) + }) + + it('shows two rapid marker-only sends exactly twice', () => { + const pending: NativeChatPendingSend[] = [ + { id: 'p1', text: '[Image #1]', sentAt: 100, matchingOccurrence: 1 }, + { id: 'p2', text: '[Image #1]', sentAt: 101, matchingOccurrence: 2 } + ] + const transcript = [userMessage('m1', '[Image #1]'), userMessage('m2', '[Image #1]')] + + expect(renderedUserRows(transcript, pending).map(rowText)).toEqual(['[Image #1]', '[Image #1]']) + }) + + it('still strips a real marker that follows an attached image, and echoes once', () => { + const pending: NativeChatPendingSend[] = [ + { id: 'p1', text: 'look', imagePaths: ['/tmp/a.png'], sentAt: 100 } + ] + const transcript = [ + userMessage('src', '[Image: source: /tmp/a.png]'), + userMessage('prompt', 'look [Image #1]') + ] + + const rows = renderedUserRows(transcript, pending) + + expect(rows).toHaveLength(1) + expect(rowText(rows[0]!)).toBe('look') + expect(rowImagePaths(rows[0]!)).toEqual(['/tmp/a.png']) + }) + + it('still reconciles a caption-less image send whose echo is marker-only', () => { + const pending: NativeChatPendingSend[] = [ + { id: 'p1', text: '', imagePaths: ['/tmp/a.png'], sentAt: 100 } + ] + const transcript = [ + userMessage('src', '[Image: source: /tmp/a.png]'), + userMessage('prompt', '[Image #1]') + ] + + const rows = renderedUserRows(transcript, pending) + + expect(rows).toHaveLength(1) + expect(rowText(rows[0]!)).toBe('') + expect(rowImagePaths(rows[0]!)).toEqual(['/tmp/a.png']) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index 1184affb3690..b455be57dc32 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -1,6 +1,7 @@ import { + nativeChatUserMessageMatchText, normalizeNativeChatUserText, - normalizedNativeChatUserMessageText + normalizeNativeChatUserTextWithLiteralFallback } from '../../../../shared/native-chat-image-transcript-markers' import { isImageRefBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' @@ -18,10 +19,21 @@ export function normalizeNativeChatPendingText(text: string): string { return normalizeNativeChatUserText(text) } +/** Message-side rule (`nativeChatUserMessageMatchText`) applied to a send: only + * a send that actually carried images can own an `[Image #n]` marker, so on + * every other send the marker is literal text and has to key verbatim. */ +function nativeChatPendingMatchText( + pending: Pick +): string { + return pending.imagePaths?.some(Boolean) + ? normalizeNativeChatPendingText(pending.text) + : normalizeNativeChatUserTextWithLiteralFallback(pending.text) +} + export function nativeChatPendingContentKey( pending: Pick ): string { - const text = normalizeNativeChatPendingText(pending.text) + const text = nativeChatPendingMatchText(pending) if (text) { return `text:${text}` } @@ -33,7 +45,7 @@ function nativeChatUserMessageContentKey(message: NativeChatMessage): string | n if (message.role !== 'user') { return null } - const text = normalizedNativeChatUserMessageText(message) ?? '' + const text = nativeChatUserMessageMatchText(message) ?? '' if (text) { return `text:${text}` } @@ -87,7 +99,7 @@ export function advancedNativeChatUserTexts( const waiting: string[] = [] for (const message of messages) { if (message.role === 'user') { - const text = normalizedNativeChatUserMessageText(message) + const text = nativeChatUserMessageMatchText(message) if (text) { waiting.push(text) } @@ -105,7 +117,7 @@ export function matchingNativeChatUserTexts( ): readonly string[] { const texts: string[] = [] for (const message of messages) { - const text = normalizedNativeChatUserMessageText(message) + const text = nativeChatUserMessageMatchText(message) if (text) { texts.push(text) } @@ -168,7 +180,7 @@ export function selectPendingIndicesRepresentedByUserTexts( } const remaining = pending.map((entry, index) => ({ index, - text: normalizeNativeChatPendingText(entry.text) + text: nativeChatPendingMatchText(entry) })) for (const userText of userTexts) { const open = remaining.filter((entry) => !represented.has(entry.index) && entry.text.length > 0) diff --git a/src/shared/native-chat-image-transcript-markers.ts b/src/shared/native-chat-image-transcript-markers.ts index 32af0f47b885..9c28e52cc1b1 100644 --- a/src/shared/native-chat-image-transcript-markers.ts +++ b/src/shared/native-chat-image-transcript-markers.ts @@ -1,4 +1,9 @@ -import { isTextBlock, type NativeChatBlock, type NativeChatMessage } from './native-chat-types' +import { + isImageRefBlock, + isTextBlock, + type NativeChatBlock, + type NativeChatMessage +} from './native-chat-types' const IMAGE_SOURCE_MARKER = /^\[Image:\s*source:\s*(.+?)\]\s*$/ const IMAGE_PROMPT_MARKER = /\[Image #\d+\]/ @@ -40,17 +45,40 @@ export function normalizeNativeChatUserText(text: string): string { return stripImagePromptMarker(text).trim().replace(/\s+/g, ' ') } +/** Marker text that stripping would erase entirely is text the user typed + * literally — a real marker always rides on an image turn, which normalization + * has already folded away by the time echo matching runs. */ +export function normalizeNativeChatUserTextWithLiteralFallback(text: string): string { + return normalizeNativeChatUserText(text) || text.trim().replace(/\s+/g, ' ') +} + +function joinedUserText(message: NativeChatMessage): string { + return message.blocks + .filter(isTextBlock) + .map((block) => block.text) + .join(' ') +} + export function normalizedNativeChatUserMessageText(message: NativeChatMessage): string | null { if (message.role !== 'user') { return null } - const normalized = normalizeNativeChatUserText( - message.blocks - .filter(isTextBlock) - .map((block) => block.text) - .join(' ') - ) - return normalized || null + return normalizeNativeChatUserText(joinedUserText(message)) || null +} + +/** The text an optimistic echo matches a landed turn on. Keeps literal marker + * text keyable so the pending path and the render path agree about which + * `[Image #n]` runs are the user's own words. */ +export function nativeChatUserMessageMatchText(message: NativeChatMessage): string | null { + if (message.role !== 'user') { + return null + } + const joined = joinedUserText(message) + const normalized = normalizeNativeChatUserText(joined) + if (normalized || message.blocks.some(isImageRefBlock)) { + return normalized || null + } + return normalizeNativeChatUserTextWithLiteralFallback(joined) || null } function stripImagePromptMarkersFromTextBlocks( @@ -94,6 +122,16 @@ export function hasImagePromptMarker(message: NativeChatMessage): boolean { return message.blocks.some((block) => isTextBlock(block) && IMAGE_PROMPT_MARKER.test(block.text)) } +/** Markers carried by a turn. One marker vouches for one image, so a send of N + * images is only echoed by a turn bearing N of them. */ +export function countImagePromptMarkers(message: NativeChatMessage): number { + return message.blocks.reduce( + (count, block) => + count + (isTextBlock(block) ? (block.text.match(IMAGE_PROMPT_MARKERS)?.length ?? 0) : 0), + 0 + ) +} + /** Claude records image paths as source turns followed by a prompt carrying * image markers. Merge the whole run back into one native user turn. */ export function normalizeImageTranscriptMessages( From e2450ab9dbc8dba665c41d9aba6d2042cf092b68 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:42:10 -0700 Subject: [PATCH 03/44] fix(native-chat): require complete image echo evidence (cherry picked from commit 94f1cc66eefad1750ff18a131acc50fe3a8b7800) --- ...mobile-native-chat-draft-reconcile.test.ts | 2 + .../mobile-native-chat-draft-reconcile.ts | 43 ++++++++----- ...le-native-chat-image-transcript-markers.ts | 3 +- ...le-native-chat-literal-marker-echo.test.ts | 52 ++++++++++++++-- .../mobile-native-chat-pending-echo.ts | 9 ++- .../session/use-mobile-native-chat-drafts.ts | 23 +++---- .../native-chat-literal-marker-echo.test.ts | 34 +++++++++++ .../native-chat-pending-occurrence.ts | 60 ++++++++++++------- .../native-chat-image-transcript-markers.ts | 22 +++++-- 9 files changed, 182 insertions(+), 66 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts index 5c6776008b14..2b300bf83872 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts @@ -37,6 +37,7 @@ describe('mobile native chat image preview reconciliation', () => { pendingKey: 'pending-key', text: 'look at this', normalizedText: 'look at this', + imageCount: 1, baselineTailMessageId: null, deadline: null } @@ -58,6 +59,7 @@ describe('mobile native chat image preview reconciliation', () => { pendingKey: 'pending-key', text: 'look here', normalizedText: 'look here', + imageCount: 1, baselineTailMessageId: null, deadline: null } diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index f07eaffd6a72..3f27a42f72af 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -3,15 +3,12 @@ import { countImagePromptMarkers, hasImagePromptMarker, isImageSourceUserTurn, + nativeChatUserMessageImageEvidenceCount, nativeChatUserMessageMatchText, + nativeChatUserTextMatchText, normalizeImageTranscriptMessages, - normalizeNativeChatUserText, normalizedNativeChatUserMessageText } from './mobile-native-chat-image-transcript-markers' -export { - normalizeNativeChatUserText as normalizeReconcileText, - normalizeNativeChatUserTextWithLiteralFallback as normalizeReconcileTextWithLiteralFallback -} from './mobile-native-chat-image-transcript-markers' /** An ack-lost ('unknown' outcome) send held until its transcript echo lands or * the deadline surfaces the uncertainty. */ @@ -20,6 +17,7 @@ export type UnconfirmedSend = { pendingKey: string | null text: string normalizedText: string + imageCount: number baselineTailMessageId: string | null deadline: ReturnType | null } @@ -30,11 +28,15 @@ export function normalizedUserText(message: NativeChatMessage): string | null { export function countUserTextOccurrences( messages: readonly NativeChatMessage[], - text: string + text: string, + imageCount = 0 ): number { let count = 0 for (const message of messages) { - if (nativeChatUserMessageMatchText(message) === text) { + if ( + nativeChatUserMessageMatchText(message) === text && + nativeChatUserMessageImageEvidenceCount(message) >= imageCount + ) { count++ } } @@ -178,20 +180,24 @@ export function findLandedImagePreviewEchoes( if (!entry.images?.length) { continue } - const targetText = normalizeNativeChatUserText(entry.text) + const targetText = nativeChatUserTextMatchText(entry.text, true) const candidates = normalized.filter((message) => { if (message.role !== 'user') { return false } + const imageCount = nativeChatUserMessageImageEvidenceCount(message) if (targetText) { - return normalizedUserText(message) === targetText + return ( + nativeChatUserMessageMatchText(message) === targetText && + imageCount >= entry.images!.length + ) } - const imageCount = message.blocks.filter(isImageRefBlock).length + const imageRefCount = message.blocks.filter(isImageRefBlock).length // Why: a marker-only echo proves delivery per marker, so a partially // rendered turn must not claim (and rebind) previews it cannot account for. return ( message.blocks.length === 0 || - imageCount >= entry.images!.length || + imageRefCount >= entry.images!.length || (countImagePromptMarkers(message) >= entry.images!.length && normalizedUserText(message) === null) ) @@ -228,7 +234,10 @@ export function findLandedUnconfirmedSends( // (`[Image: source: …]` or no text) keys under '' so an empty-text send can // claim it. const messageIndexById = new Map() - const userMessagesByText = new Map>() + const userMessagesByText = new Map< + string, + Array<{ id: string; index: number; imageCount: number }> + >() for (const [index, message] of messages.entries()) { messageIndexById.set(message.id, index) if (message.role !== 'user') { @@ -239,9 +248,10 @@ export function findLandedUnconfirmedSends( // A marker-only turn keys both ways: '' claims it for a caption-less image // send, and its literal text claims it for a send of that same text. const literalKey = isImageSource ? key : (nativeChatUserMessageMatchText(message) ?? key) + const imageCount = nativeChatUserMessageImageEvidenceCount(message) for (const candidateKey of literalKey === key ? [key] : [key, literalKey]) { const current = userMessagesByText.get(candidateKey) ?? [] - current.push({ id: message.id, index }) + current.push({ id: message.id, index, imageCount }) userMessagesByText.set(candidateKey, current) } } @@ -257,7 +267,12 @@ export function findLandedUnconfirmedSends( } const echo = userMessagesByText .get(entry.normalizedText) - ?.find((message) => message.index > tailIndex && !claimedMessageIds.has(message.id)) + ?.find( + (message) => + message.index > tailIndex && + message.imageCount >= entry.imageCount && + !claimedMessageIds.has(message.id) + ) if (echo) { claimedMessageIds.add(echo.id) landed.push(entry) diff --git a/mobile/src/session/mobile-native-chat-image-transcript-markers.ts b/mobile/src/session/mobile-native-chat-image-transcript-markers.ts index 658252d3ff89..6196b6c725c3 100644 --- a/mobile/src/session/mobile-native-chat-image-transcript-markers.ts +++ b/mobile/src/session/mobile-native-chat-image-transcript-markers.ts @@ -7,10 +7,11 @@ export { imageSourcePathFromText, hasImagePromptMarker, isImageSourceUserTurn, + nativeChatUserMessageImageEvidenceCount, nativeChatUserMessageMatchText, + nativeChatUserTextMatchText, normalizeImageTranscriptMessages, normalizeNativeChatUserText, - normalizeNativeChatUserTextWithLiteralFallback, normalizedNativeChatUserMessageText, stripImagePromptMarker } from '../../../src/shared/native-chat-image-transcript-markers' diff --git a/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts b/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts index c8aef152b3a2..77ad99005ba1 100644 --- a/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts +++ b/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts @@ -3,7 +3,7 @@ // an incomplete multi-image echo is allowed to do to the local previews. import { createElement } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { act, create } from 'react-test-renderer' import { afterEach, describe, expect, it } from 'vitest' import { isImageRefBlock, @@ -29,12 +29,14 @@ function userTextMessage(id: string, text: string): NativeChatMessage { } describe('mobile literal image-marker turns', () => { - let renderer: ReactTestRenderer | null = null + let updateRenderer: (messages: NativeChatMessage[]) => void = () => {} + let unmountRenderer = (): void => {} let state: DraftState | null = null afterEach(() => { - act(() => renderer?.unmount()) - renderer = null + act(unmountRenderer) + updateRenderer = () => {} + unmountRenderer = () => {} state = null }) @@ -51,12 +53,14 @@ describe('mobile literal image-marker turns', () => { async function mount(): Promise { await act(async () => { - renderer = create(createElement(Harness, {})) + const renderer = create(createElement(Harness, {})) + updateRenderer = (messages) => renderer.update(createElement(Harness, { messages })) + unmountRenderer = () => renderer.unmount() }) } async function transcript(messages: NativeChatMessage[]): Promise { - await act(async () => renderer?.update(createElement(Harness, { messages }))) + await act(async () => updateRenderer(messages)) } function send(text: string, images?: string[]): void { @@ -158,4 +162,40 @@ describe('mobile literal image-marker turns', () => { expect(rendered.map(rowText)).toEqual(['look']) expect(rendered.map(rowImages)).toEqual([['file:///a.jpg']]) }) + + it('keeps a captioned multi-image echo until the transcript accounts for every image', async () => { + await mount() + send('compare these', ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg']) + + const partial = [ + userTextMessage('source-a', '[Image: source: /tmp/a.png]'), + userTextMessage('prompt', 'compare these [Image #1]') + ] + await transcript(partial) + + expect(rows(partial).map(rowImages)).toEqual([ + ['/tmp/a.png'], + ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg'] + ]) + expect(rows(partial).map(rowText)).toEqual(['compare these', 'compare these']) + expect(state?.imagePreviewsByMessageId).toEqual({}) + + const complete = [ + userTextMessage('source-a', '[Image: source: /tmp/a.png]'), + userTextMessage('source-b', '[Image: source: /tmp/b.png]'), + userTextMessage('source-c', '[Image: source: /tmp/c.png]'), + userTextMessage('complete-prompt', 'compare these [Image #1] [Image #2] [Image #3]') + ] + await transcript(complete) + + expect(state?.imagePreviewsByMessageId).toEqual({ + 'complete-prompt': ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg'] + }) + const completeRows = rows(complete) + expect(completeRows).toHaveLength(1) + expect(completeRows.map(rowText)).toEqual(['compare these']) + expect(completeRows.map(rowImages)).toEqual([ + ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg'] + ]) + }) }) diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index bcf66250872f..b5fb6ab4f904 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -1,6 +1,6 @@ import { - normalizeNativeChatUserText, - normalizeNativeChatUserTextWithLiteralFallback + nativeChatUserTextMatchText, + normalizeNativeChatUserText } from './mobile-native-chat-image-transcript-markers' export type MobileNativeChatPendingMessage = { @@ -16,6 +16,7 @@ export type MobileNativeChatSendOrigin = { draftKey: string pendingKey: string | null normalizedText: string + imageCount: number baselineOccurrences: number baselineTailMessageId: string | null } @@ -25,9 +26,7 @@ type PendingByKey = Record /** Markers are the agent's only on a send that carried images; otherwise they * are literal text, so `captureSendOrigin` keeps them and so must this. */ function pendingMatchText(pending: MobileNativeChatPendingMessage): string { - return pending.images?.length - ? normalizeNativeChatUserText(pending.text) - : normalizeNativeChatUserTextWithLiteralFallback(pending.text) + return nativeChatUserTextMatchText(pending.text, Boolean(pending.images?.length)) } export function combineMobileNativeChatPending( diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 456ca583ca58..21d70a1a33e8 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -7,8 +7,6 @@ import { findLandedUnconfirmedSends, mergeLandedImagePreviewEchoes, migrateImagePreviewMessageIds, - normalizeReconcileText, - normalizeReconcileTextWithLiteralFallback, userTextOccurrenceCounts, type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' @@ -21,6 +19,7 @@ import { type MobileNativeChatSendOrigin } from './mobile-native-chat-pending-echo' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' +import { nativeChatUserTextMatchText } from './mobile-native-chat-image-transcript-markers' import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' @@ -135,14 +134,18 @@ export function useMobileNativeChatDrafts(args: { } // Without an attached image an `[Image #n]` run is the user's own text, so // it has to stay in the key the transcript echo will be matched against. - const normalizedText = images?.length - ? normalizeReconcileText(text) - : normalizeReconcileTextWithLiteralFallback(text) + const imageCount = images?.length ?? 0 + const normalizedText = nativeChatUserTextMatchText(text, imageCount > 0) return { draftKey, pendingKey, normalizedText, - baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText), + imageCount, + baselineOccurrences: countUserTextOccurrences( + messagesRef.current, + normalizedText, + imageCount + ), baselineTailMessageId: messagesRef.current.at(-1)?.id ?? null } }, @@ -207,6 +210,7 @@ export function useMobileNativeChatDrafts(args: { pendingKey: origin.pendingKey, text, normalizedText: origin.normalizedText, + imageCount: origin.imageCount, baselineTailMessageId: origin.baselineTailMessageId, deadline: null } @@ -299,17 +303,14 @@ export function useMobileNativeChatDrafts(args: { const landedCounts = userTextOccurrenceCounts(messages) // Image-only source-turn counts stay stable across reruns and ignore paginated history. const next = current.filter((item) => { - if (landedImagePendingIds.has(item.id)) { - return false - } // Keep image echoes until their local preview reaches the authoritative message. if (item.images?.length) { - return true + return !landedImagePendingIds.has(item.id) } return item.text.trim() === '' ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) < item.expectedOccurrence - : (landedCounts.get(normalizeReconcileTextWithLiteralFallback(item.text)) ?? 0) < + : (landedCounts.get(nativeChatUserTextMatchText(item.text, false)) ?? 0) < item.expectedOccurrence }) if (next.length === current.length) { diff --git a/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts b/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts index 65c137f9f0c8..d530a3a00595 100644 --- a/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts @@ -124,4 +124,38 @@ describe('literal image-marker turns in native chat', () => { expect(rowText(rows[0]!)).toBe('') expect(rowImagePaths(rows[0]!)).toEqual(['/tmp/a.png']) }) + + it('keeps a captioned multi-image echo until the transcript accounts for every image', () => { + const pending: NativeChatPendingSend[] = [ + { + id: 'p1', + text: 'compare these', + imagePaths: ['/tmp/a.png', '/tmp/b.png', '/tmp/c.png'], + sentAt: 100 + } + ] + const transcript = [ + userMessage('source-a', '[Image: source: /tmp/a.png]'), + userMessage('prompt', 'compare these [Image #1]') + ] + + const rows = renderedUserRows(transcript, pending) + + expect(rows.map(rowText)).toEqual(['compare these', 'compare these']) + expect(rows.map(rowImagePaths)).toEqual([ + ['/tmp/a.png'], + ['/tmp/a.png', '/tmp/b.png', '/tmp/c.png'] + ]) + + const complete = [ + userMessage('source-a', '[Image: source: /tmp/a.png]'), + userMessage('source-b', '[Image: source: /tmp/b.png]'), + userMessage('source-c', '[Image: source: /tmp/c.png]'), + userMessage('complete-prompt', 'compare these [Image #1] [Image #2] [Image #3]') + ] + const completeRows = renderedUserRows(complete, pending) + expect(completeRows).toHaveLength(1) + expect(rowText(completeRows[0]!)).toBe('compare these') + expect(rowImagePaths(completeRows[0]!)).toEqual(['/tmp/a.png', '/tmp/b.png', '/tmp/c.png']) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index b455be57dc32..a3dd2706406e 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -1,7 +1,9 @@ import { + nativeChatUserMessageImageEvidenceCount, nativeChatUserMessageMatchText, + nativeChatUserTextMatchText, normalizeNativeChatUserText, - normalizeNativeChatUserTextWithLiteralFallback + normalizedNativeChatUserMessageText } from '../../../../shared/native-chat-image-transcript-markers' import { isImageRefBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' @@ -25,36 +27,47 @@ export function normalizeNativeChatPendingText(text: string): string { function nativeChatPendingMatchText( pending: Pick ): string { - return pending.imagePaths?.some(Boolean) - ? normalizeNativeChatPendingText(pending.text) - : normalizeNativeChatUserTextWithLiteralFallback(pending.text) + return nativeChatUserTextMatchText(pending.text, Boolean(pending.imagePaths?.some(Boolean))) +} + +function nativeChatTextContentKey(text: string, imageCount: number): string { + return imageCount > 0 ? `text:${text}\0images:${imageCount}` : `text:${text}` } export function nativeChatPendingContentKey( pending: Pick ): string { const text = nativeChatPendingMatchText(pending) + const imagePaths = pending.imagePaths?.filter(Boolean) ?? [] if (text) { - return `text:${text}` + return nativeChatTextContentKey(text, imagePaths.length) } - const imagePaths = pending.imagePaths?.filter(Boolean) ?? [] return imagePaths.length > 0 ? `images:${JSON.stringify(imagePaths)}` : 'empty' } -function nativeChatUserMessageContentKey(message: NativeChatMessage): string | null { +function nativeChatUserMessageContentKeys(message: NativeChatMessage): readonly string[] { if (message.role !== 'user') { - return null + return [] } - const text = nativeChatUserMessageMatchText(message) ?? '' - if (text) { - return `text:${text}` + const keys = new Set() + const matchText = nativeChatUserMessageMatchText(message) ?? '' + if (matchText) { + keys.add(nativeChatTextContentKey(matchText, 0)) + } + const imageCount = nativeChatUserMessageImageEvidenceCount(message) + const imageText = normalizedNativeChatUserMessageText(message) + if (imageText && imageCount > 0) { + keys.add(nativeChatTextContentKey(imageText, imageCount)) + } else if (imageCount > 0) { + const imagePaths = message.blocks + .filter(isImageRefBlock) + .map((block) => block.path) + .filter((path): path is string => Boolean(path)) + if (imagePaths.length > 0) { + keys.add(`images:${JSON.stringify(imagePaths)}`) + } } - const imagePaths = message.blocks - .filter(isImageRefBlock) - .map((block) => block.path) - .filter((path): path is string => Boolean(path)) - const key = nativeChatPendingContentKey({ text: '', imagePaths }) - return key === 'empty' ? null : key + return [...keys] } export function matchingNativeChatUserContentCounts( @@ -62,8 +75,7 @@ export function matchingNativeChatUserContentCounts( ): Map { const counts = new Map() for (const message of messages) { - const key = nativeChatUserMessageContentKey(message) - if (key) { + for (const key of nativeChatUserMessageContentKeys(message)) { counts.set(key, (counts.get(key) ?? 0) + 1) } } @@ -77,8 +89,7 @@ export function advancedNativeChatUserContentCounts( const waiting = new Map() for (const message of messages) { if (message.role === 'user') { - const key = nativeChatUserMessageContentKey(message) - if (key) { + for (const key of nativeChatUserMessageContentKeys(message)) { waiting.set(key, (waiting.get(key) ?? 0) + 1) } continue @@ -180,10 +191,13 @@ export function selectPendingIndicesRepresentedByUserTexts( } const remaining = pending.map((entry, index) => ({ index, - text: nativeChatPendingMatchText(entry) + text: nativeChatPendingMatchText(entry), + hasImages: Boolean(entry.imagePaths?.some(Boolean)) })) for (const userText of userTexts) { - const open = remaining.filter((entry) => !represented.has(entry.index) && entry.text.length > 0) + const open = remaining.filter( + (entry) => !entry.hasImages && !represented.has(entry.index) && entry.text.length > 0 + ) const gluedCount = countLeadingPendingTextsGluedToUserText( open.map((entry) => entry.text), userText diff --git a/src/shared/native-chat-image-transcript-markers.ts b/src/shared/native-chat-image-transcript-markers.ts index 9c28e52cc1b1..9a0621a41f6b 100644 --- a/src/shared/native-chat-image-transcript-markers.ts +++ b/src/shared/native-chat-image-transcript-markers.ts @@ -48,10 +48,16 @@ export function normalizeNativeChatUserText(text: string): string { /** Marker text that stripping would erase entirely is text the user typed * literally — a real marker always rides on an image turn, which normalization * has already folded away by the time echo matching runs. */ -export function normalizeNativeChatUserTextWithLiteralFallback(text: string): string { +function normalizeNativeChatUserTextWithLiteralFallback(text: string): string { return normalizeNativeChatUserText(text) || text.trim().replace(/\s+/g, ' ') } +export function nativeChatUserTextMatchText(text: string, hasImages: boolean): string { + return hasImages + ? normalizeNativeChatUserText(text) + : normalizeNativeChatUserTextWithLiteralFallback(text) +} + function joinedUserText(message: NativeChatMessage): string { return message.blocks .filter(isTextBlock) @@ -74,11 +80,7 @@ export function nativeChatUserMessageMatchText(message: NativeChatMessage): stri return null } const joined = joinedUserText(message) - const normalized = normalizeNativeChatUserText(joined) - if (normalized || message.blocks.some(isImageRefBlock)) { - return normalized || null - } - return normalizeNativeChatUserTextWithLiteralFallback(joined) || null + return nativeChatUserTextMatchText(joined, message.blocks.some(isImageRefBlock)) || null } function stripImagePromptMarkersFromTextBlocks( @@ -132,6 +134,14 @@ export function countImagePromptMarkers(message: NativeChatMessage): number { ) } +export function nativeChatUserMessageImageEvidenceCount(message: NativeChatMessage): number { + if (message.role !== 'user') { + return 0 + } + const imageRefCount = message.blocks.filter(isImageRefBlock).length + return Math.max(imageRefCount, countImagePromptMarkers(message)) +} + /** Claude records image paths as source turns followed by a prompt carrying * image markers. Merge the whole run back into one native user turn. */ export function normalizeImageTranscriptMessages( From 40f42403890f3b89429e00a71e32703edcee23aa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:12:17 -0700 Subject: [PATCH 04/44] fix(mobile): isolate image echo occurrences by count (cherry picked from commit c51e8e6bbbb9099a3be82c422f52b3037b36a194) --- ...le-native-chat-literal-marker-echo.test.ts | 42 +++++++++++++++++++ .../mobile-native-chat-pending-echo.ts | 5 ++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts b/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts index 77ad99005ba1..660a6c202827 100644 --- a/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts +++ b/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts @@ -198,4 +198,46 @@ describe('mobile literal image-marker turns', () => { ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg'] ]) }) + + it('reconciles rapid equal captions in separate image-count namespaces', async () => { + await mount() + send('compare', ['file:///a.jpg']) + send('compare', ['file:///b.jpg', 'file:///c.jpg', 'file:///d.jpg']) + + const messages = [ + userTextMessage('source-a', '[Image: source: /tmp/a.png]'), + userTextMessage('prompt-a', 'compare [Image #1]'), + userTextMessage('source-b', '[Image: source: /tmp/b.png]'), + userTextMessage('source-c', '[Image: source: /tmp/c.png]'), + userTextMessage('source-d', '[Image: source: /tmp/d.png]'), + userTextMessage('prompt-b', 'compare [Image #1] [Image #2] [Image #3]') + ] + await transcript(messages) + + const rendered = rows(messages) + expect(rendered).toHaveLength(2) + expect(rendered.map(rowImages)).toEqual([ + ['file:///a.jpg'], + ['file:///b.jpg', 'file:///c.jpg', 'file:///d.jpg'] + ]) + }) + + it('reconciles rapid captionless sends in separate image-count namespaces', async () => { + await mount() + send('', ['file:///a.jpg']) + send('', ['file:///b.jpg', 'file:///c.jpg', 'file:///d.jpg']) + + const messages = [ + userTextMessage('prompt-a', '[Image #1]'), + userTextMessage('prompt-b', '[Image #1] [Image #2] [Image #3]') + ] + await transcript(messages) + + const rendered = rows(messages) + expect(rendered).toHaveLength(2) + expect(rendered.map(rowImages)).toEqual([ + ['file:///a.jpg'], + ['file:///b.jpg', 'file:///c.jpg', 'file:///d.jpg'] + ]) + }) }) diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index b5fb6ab4f904..a40c5c4bc75c 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -52,13 +52,16 @@ export function appendMobileNativeChatPending( const earlierOutstanding = current.filter( (pending) => pendingMatchText(pending) === origin.normalizedText && + (pending.images?.length ?? 0) >= origin.imageCount && pending.expectedOccurrence > origin.baselineOccurrences ).length // An image send whose caption is only markers still reconciles by image // ordinal, matching how `captureSendOrigin` normalized it. const expectedImageEchoOrdinal = current.filter( - (pending) => pending.images?.length && normalizeNativeChatUserText(pending.text) === '' + (pending) => + (pending.images?.length ?? 0) >= origin.imageCount && + normalizeNativeChatUserText(pending.text) === '' ).length + 1 return { ...previous, From ce409f52da5e2c7a28903c35cda497ea0eb4fd7b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:21:48 -0700 Subject: [PATCH 05/44] fix(mobile): retire launch drafts on literal marker turns (cherry picked from commit 794e07c8dc33fe32f14194670137f1e1346a62d4) --- ...le-native-chat-drafts-launch-draft.test.ts | 34 +++++++++++++++++++ ...se-mobile-native-chat-launch-draft-seed.ts | 6 ++-- 2 files changed, 37 insertions(+), 3 deletions(-) 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 665d3ac1f2fe..93f03b9ea0cb 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 @@ -123,6 +123,40 @@ describe('useMobileNativeChatDrafts launch draft', () => { expect(state?.composerText).toBe('') }) + it('declines a launch draft when the only user turn is a literal image marker', async () => { + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('m1', '[Image #1]')], + launchDraft: 'issue link' + }) + ) + ) + + expect(state?.composerText).toBe('') + }) + + it('retires an adopted launch draft when a literal marker turn lands', async () => { + await mount('a') + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('m1', '[Image #1]')], + launchDraft: 'issue link' + }) + ) + ) + expect(state?.composerText).toBe('') + }) + it('clears an untouched prefill once a user turn lands, keeping user edits', async () => { await mount('a') await act(async () => diff --git a/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts b/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts index b75dbf127678..8dbe3280c7a0 100644 --- a/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts +++ b/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { normalizedUserText } from './mobile-native-chat-draft-reconcile' +import { nativeChatUserMessageMatchText } from './mobile-native-chat-image-transcript-markers' export type MobileNativeChatLaunchDraftSeed = { text: string @@ -63,7 +63,7 @@ export function useMobileNativeChatLaunchDraftSeed(args: { } // A user turn already in the transcript means the TUI prefill was submitted // or deliberately cleared; decline instead of resurrecting it. - if (messages.some((message) => normalizedUserText(message) !== null)) { + if (messages.some((message) => nativeChatUserMessageMatchText(message) !== null)) { seededLaunchDraftByKeyRef.current.set(draftKey, null) return } @@ -98,7 +98,7 @@ export function useMobileNativeChatLaunchDraftSeed(args: { if (!seeded) { return } - const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null) + const hasUserTurn = messages.some((message) => nativeChatUserMessageMatchText(message) !== null) if (!hasUserTurn && launchDraft?.trim()) { return } From 803ea59e7c6f36fd7dab9dd1a8f0dbbcbab296a0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:30:52 -0700 Subject: [PATCH 06/44] fix(mobile): use array shorthand in draft reconciliation (cherry picked from commit 9b9aa6f757947d1b511d54c6e20113f76833bb17) --- mobile/src/session/mobile-native-chat-draft-reconcile.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 3f27a42f72af..558937b30139 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -234,10 +234,7 @@ export function findLandedUnconfirmedSends( // (`[Image: source: …]` or no text) keys under '' so an empty-text send can // claim it. const messageIndexById = new Map() - const userMessagesByText = new Map< - string, - Array<{ id: string; index: number; imageCount: number }> - >() + const userMessagesByText = new Map() for (const [index, message] of messages.entries()) { messageIndexById.set(message.id, index) if (message.role !== 'user') { From 8e1a978f035a2dd56a21c5c895d51de48a272ad6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:41:28 -0700 Subject: [PATCH 07/44] fix(native-chat): isolate literal marker match keys (cherry picked from commit d49d6ad02689972749afb2823320332416b22176) --- ...mobile-native-chat-draft-reconcile.test.ts | 19 +++++++++++++++ .../mobile-native-chat-draft-reconcile.ts | 24 ++++++------------- ...le-native-chat-literal-marker-echo.test.ts | 13 ++++++++++ .../native-chat-literal-marker-echo.test.ts | 12 ++++++++++ ...tive-chat-image-transcript-markers.test.ts | 8 +++++++ .../native-chat-image-transcript-markers.ts | 11 +++------ 6 files changed, 62 insertions(+), 25 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts index 2b300bf83872..8dfce5ebbfac 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts @@ -70,6 +70,25 @@ describe('mobile native chat image preview reconciliation', () => { ]) }) + it('does not treat markerless text as an unknown-ack literal marker echo', () => { + const unconfirmed: UnconfirmedSend = { + draftKey: 'draft', + pendingKey: 'pending-key', + text: 'keep [Image #1] literal', + normalizedText: 'keep [Image #1] literal', + imageCount: 0, + baselineTailMessageId: null, + deadline: null + } + + expect(findLandedUnconfirmedSends([userText('wrong', 'keep literal')], [unconfirmed])).toEqual( + [] + ) + expect( + findLandedUnconfirmedSends([userText('right', 'keep [Image #1] literal')], [unconfirmed]) + ).toEqual([unconfirmed]) + }) + it('reconciles multiple transcript text blocks with desktop separators', () => { const prompt: NativeChatMessage = { ...userText('prompt', 'unused'), diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 558937b30139..7ba9bbc3927f 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -32,7 +32,7 @@ export function countUserTextOccurrences( imageCount = 0 ): number { let count = 0 - for (const message of messages) { + for (const message of normalizeImageTranscriptMessages(messages)) { if ( nativeChatUserMessageMatchText(message) === text && nativeChatUserMessageImageEvidenceCount(message) >= imageCount @@ -43,19 +43,6 @@ export function countUserTextOccurrences( return count } -export function userTextOccurrenceCounts( - messages: readonly NativeChatMessage[] -): Map { - const counts = new Map() - for (const message of messages) { - const text = nativeChatUserMessageMatchText(message) - if (text) { - counts.set(text, (counts.get(text) ?? 0) + 1) - } - } - return counts -} - /** Number of `[Image: source: …]` echo turns strictly after `tailId` (or the * whole transcript when the tail was paginated out). An image-only send has no * caption to match, so it reconciles by ordinal against this count — counting @@ -233,10 +220,13 @@ export function findLandedUnconfirmedSends( // captured tail prove new echoes. User turns are keyed by text; an image echo // (`[Image: source: …]` or no text) keys under '' so an empty-text send can // claim it. - const messageIndexById = new Map() + const messageIndexById = new Map(messages.map((message, index) => [message.id, index])) const userMessagesByText = new Map() - for (const [index, message] of messages.entries()) { - messageIndexById.set(message.id, index) + for (const message of normalizeImageTranscriptMessages(messages)) { + const index = messageIndexById.get(message.id) + if (index === undefined) { + continue + } if (message.role !== 'user') { continue } diff --git a/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts b/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts index 660a6c202827..f7d491aef2f2 100644 --- a/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts +++ b/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts @@ -117,6 +117,19 @@ describe('mobile literal image-marker turns', () => { expect(rows(messages).map(rowText)).toEqual(['keep [Image #1] literal']) }) + it('does not let a markerless row retire marker-bearing literal text', async () => { + await mount() + send('keep [Image #1] literal') + + const markerless = [userTextMessage('wrong', 'keep literal')] + await transcript(markerless) + expect(rows(markerless).map(rowText)).toEqual(['keep literal', 'keep [Image #1] literal']) + + const exact = [userTextMessage('right', 'keep [Image #1] literal')] + await transcript(exact) + expect(rows(exact).map(rowText)).toEqual(['keep [Image #1] literal']) + }) + it('holds every preview on the optimistic echo until the marker echo covers them all', async () => { await mount() send('', ['file:///a.jpg', 'file:///b.jpg']) diff --git a/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts b/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts index d530a3a00595..d2f4f5eef2d1 100644 --- a/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts @@ -76,6 +76,18 @@ describe('literal image-marker turns in native chat', () => { expect(renderedUserRows(transcript, pending).map(rowText)).toEqual(['keep [Image #1] literal']) }) + it('does not let a markerless row hide marker-bearing literal text', () => { + const pending: NativeChatPendingSend[] = [ + { id: 'p1', text: 'keep [Image #1] literal', sentAt: 100 } + ] + const transcript = [userMessage('m1', 'keep literal')] + + expect(renderedUserRows(transcript, pending).map(rowText)).toEqual([ + 'keep literal', + 'keep [Image #1] literal' + ]) + }) + it('prunes the marker-only echo once the assistant answers it', () => { const pending: NativeChatPendingSend[] = [{ id: 'p1', text: '[Image #1]', sentAt: 100 }] const transcript = [userMessage('m1', '[Image #1]'), assistantMessage('m2', 'preserved')] diff --git a/src/shared/native-chat-image-transcript-markers.test.ts b/src/shared/native-chat-image-transcript-markers.test.ts index 9dbfd849b971..6a1ebaa6e6b4 100644 --- a/src/shared/native-chat-image-transcript-markers.test.ts +++ b/src/shared/native-chat-image-transcript-markers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { NativeChatMessage } from './native-chat-types' import { isImageSourceUserTurn, + nativeChatUserTextMatchText, normalizeImageTranscriptMessages, normalizeNativeChatUserText, normalizedNativeChatUserMessageText, @@ -104,6 +105,13 @@ describe('normalizeImageTranscriptMessages', () => { expect(normalizedNativeChatUserMessageText(message)).toBe('look here') }) + it('keeps markers in no-image match keys without changing real image keys', () => { + expect(nativeChatUserTextMatchText(' keep [Image #1] literal ', false)).toBe( + 'keep [Image #1] literal' + ) + expect(nativeChatUserTextMatchText(' keep [Image #1] literal ', true)).toBe('keep literal') + }) + it('recognizes only sole-text image-source user turns', () => { const source = userText('source', '[Image: source: /tmp/a.png]') expect(isImageSourceUserTurn(source)).toBe(true) diff --git a/src/shared/native-chat-image-transcript-markers.ts b/src/shared/native-chat-image-transcript-markers.ts index 9a0621a41f6b..4864187cc6b5 100644 --- a/src/shared/native-chat-image-transcript-markers.ts +++ b/src/shared/native-chat-image-transcript-markers.ts @@ -45,17 +45,12 @@ export function normalizeNativeChatUserText(text: string): string { return stripImagePromptMarker(text).trim().replace(/\s+/g, ' ') } -/** Marker text that stripping would erase entirely is text the user typed - * literally — a real marker always rides on an image turn, which normalization - * has already folded away by the time echo matching runs. */ -function normalizeNativeChatUserTextWithLiteralFallback(text: string): string { - return normalizeNativeChatUserText(text) || text.trim().replace(/\s+/g, ' ') +function normalizeLiteralNativeChatUserText(text: string): string { + return text.trim().replace(/\s+/g, ' ') } export function nativeChatUserTextMatchText(text: string, hasImages: boolean): string { - return hasImages - ? normalizeNativeChatUserText(text) - : normalizeNativeChatUserTextWithLiteralFallback(text) + return hasImages ? normalizeNativeChatUserText(text) : normalizeLiteralNativeChatUserText(text) } function joinedUserText(message: NativeChatMessage): string { From a776a86d5cb5864b812315ef3a74a0424c297feb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:08:59 -0700 Subject: [PATCH 08/44] fix(native-chat): separate literal and image echo identity (cherry picked from commit bf74570db2226808bf0f6f7a25aa657d591e0566) --- ...mobile-native-chat-draft-reconcile.test.ts | 39 +++++++++++++++++++ .../mobile-native-chat-draft-reconcile.ts | 39 +++++++++++++------ ...le-native-chat-literal-marker-echo.test.ts | 12 ++++++ .../mobile-native-chat-pending-echo.ts | 5 ++- ...le-native-chat-drafts-launch-draft.test.ts | 4 +- .../use-mobile-native-chat-drafts.test.ts | 20 +++++----- .../native-chat-literal-marker-echo.test.ts | 21 ++++++++++ .../native-chat-pending-occurrence.ts | 11 ++++-- .../native-chat/native-chat-pending.test.ts | 26 ++++++------- ...tive-chat-image-transcript-markers.test.ts | 9 +++++ 10 files changed, 145 insertions(+), 41 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts index 8dfce5ebbfac..25255d5743c1 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { + countUserTextOccurrences, findLandedImagePreviewEchoes, findLandedUnconfirmedSends, migrateImagePreviewMessageIds, @@ -89,6 +90,35 @@ describe('mobile native chat image preview reconciliation', () => { ).toEqual([unconfirmed]) }) + it('does not treat literal marker text as a markerless unknown-ack echo', () => { + const unconfirmed: UnconfirmedSend = { + draftKey: 'draft', + pendingKey: 'pending-key', + text: 'keep literal', + normalizedText: 'keep literal', + imageCount: 0, + baselineTailMessageId: null, + deadline: null + } + + expect( + findLandedUnconfirmedSends([userText('wrong', 'keep [Image #1] literal')], [unconfirmed]) + ).toEqual([]) + expect(findLandedUnconfirmedSends([userText('right', 'keep literal')], [unconfirmed])).toEqual([ + unconfirmed + ]) + }) + + it('keeps no-image occurrence baselines separate from attached-image turns', () => { + const messages = [ + userText('source', '[Image: source: /tmp/a.png]'), + userText('prompt', 'keep [Image #1] literal') + ] + + expect(countUserTextOccurrences(messages, 'keep literal')).toBe(0) + expect(countUserTextOccurrences(messages, 'keep literal', 1)).toBe(1) + }) + it('reconciles multiple transcript text blocks with desktop separators', () => { const prompt: NativeChatMessage = { ...userText('prompt', 'unused'), @@ -114,6 +144,15 @@ describe('mobile native chat image preview reconciliation', () => { ).toEqual([{ pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] }]) }) + it('hands a captioned preview to a marker-only compatibility echo', () => { + expect( + findLandedImagePreviewEchoes( + [userText('prompt', 'look [Image #1] here')], + [{ ...pending('pending', ['file:///a.jpg']), text: 'look here' }] + ) + ).toEqual([{ pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] }]) + }) + it('keeps separate adjacent image-only sends independently reconcilable', () => { const landed = findLandedImagePreviewEchoes( [ diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 7ba9bbc3927f..a49a98eb1ec8 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -33,8 +33,14 @@ export function countUserTextOccurrences( ): number { let count = 0 for (const message of normalizeImageTranscriptMessages(messages)) { + const hasImageRefs = message.blocks.some(isImageRefBlock) + const matchText = + imageCount > 0 + ? normalizedNativeChatUserMessageText(message) + : nativeChatUserMessageMatchText(message) if ( - nativeChatUserMessageMatchText(message) === text && + matchText === text && + (imageCount > 0 || !hasImageRefs) && nativeChatUserMessageImageEvidenceCount(message) >= imageCount ) { count++ @@ -175,7 +181,7 @@ export function findLandedImagePreviewEchoes( const imageCount = nativeChatUserMessageImageEvidenceCount(message) if (targetText) { return ( - nativeChatUserMessageMatchText(message) === targetText && + normalizedNativeChatUserMessageText(message) === targetText && imageCount >= entry.images!.length ) } @@ -221,7 +227,11 @@ export function findLandedUnconfirmedSends( // (`[Image: source: …]` or no text) keys under '' so an empty-text send can // claim it. const messageIndexById = new Map(messages.map((message, index) => [message.id, index])) - const userMessagesByText = new Map() + const literalMessagesByText = new Map< + string, + { id: string; index: number; imageCount: number }[] + >() + const imageMessagesByText = new Map() for (const message of normalizeImageTranscriptMessages(messages)) { const index = messageIndexById.get(message.id) if (index === undefined) { @@ -231,15 +241,19 @@ export function findLandedUnconfirmedSends( continue } const isImageSource = isImageSourceUserTurn(message) - const key = isImageSource ? '' : (normalizedUserText(message) ?? '') - // A marker-only turn keys both ways: '' claims it for a caption-less image - // send, and its literal text claims it for a send of that same text. - const literalKey = isImageSource ? key : (nativeChatUserMessageMatchText(message) ?? key) const imageCount = nativeChatUserMessageImageEvidenceCount(message) - for (const candidateKey of literalKey === key ? [key] : [key, literalKey]) { - const current = userMessagesByText.get(candidateKey) ?? [] - current.push({ id: message.id, index, imageCount }) - userMessagesByText.set(candidateKey, current) + const candidate = { id: message.id, index, imageCount } + if (!message.blocks.some(isImageRefBlock)) { + const literalKey = isImageSource ? '' : (nativeChatUserMessageMatchText(message) ?? '') + const current = literalMessagesByText.get(literalKey) ?? [] + current.push(candidate) + literalMessagesByText.set(literalKey, current) + } + if (imageCount > 0) { + const imageKey = isImageSource ? '' : (normalizedUserText(message) ?? '') + const current = imageMessagesByText.get(imageKey) ?? [] + current.push(candidate) + imageMessagesByText.set(imageKey, current) } } @@ -252,7 +266,8 @@ export function findLandedUnconfirmedSends( if (tailIndex === undefined) { continue } - const echo = userMessagesByText + const messagesByText = entry.imageCount > 0 ? imageMessagesByText : literalMessagesByText + const echo = messagesByText .get(entry.normalizedText) ?.find( (message) => diff --git a/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts b/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts index f7d491aef2f2..18cb6d2a1a9c 100644 --- a/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts +++ b/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts @@ -130,6 +130,18 @@ describe('mobile literal image-marker turns', () => { expect(rows(exact).map(rowText)).toEqual(['keep [Image #1] literal']) }) + it('does not let an attached-image send shift a markerless send ordinal', async () => { + await mount() + send('keep [Image #1] literal', ['file:///a.jpg']) + send('keep literal') + + const messages = [userTextMessage('plain', 'keep literal')] + await transcript(messages) + + expect(rows(messages).map(rowText)).toEqual(['keep literal', 'keep [Image #1] literal']) + expect(rows(messages).map(rowImages)).toEqual([[], ['file:///a.jpg']]) + }) + it('holds every preview on the optimistic echo until the marker echo covers them all', async () => { await mount() send('', ['file:///a.jpg', 'file:///b.jpg']) diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index a40c5c4bc75c..e7af20aa3227 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -52,7 +52,9 @@ export function appendMobileNativeChatPending( const earlierOutstanding = current.filter( (pending) => pendingMatchText(pending) === origin.normalizedText && - (pending.images?.length ?? 0) >= origin.imageCount && + (origin.imageCount > 0 + ? (pending.images?.length ?? 0) >= origin.imageCount + : !pending.images?.length) && pending.expectedOccurrence > origin.baselineOccurrences ).length // An image send whose caption is only markers still reconciles by image @@ -60,6 +62,7 @@ export function appendMobileNativeChatPending( const expectedImageEchoOrdinal = current.filter( (pending) => + origin.imageCount > 0 && (pending.images?.length ?? 0) >= origin.imageCount && normalizeNativeChatUserText(pending.text) === '' ).length + 1 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 93f03b9ea0cb..f709bcba451c 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 @@ -129,7 +129,7 @@ describe('useMobileNativeChatDrafts launch draft', () => { renderer?.update( createElement(Harness, { tabId: 'a', - messages: [userTextMessage('m1', '[Image #1]')], + messages: [userTextMessage('m1', 'keep [Image #1] literal')], launchDraft: 'issue link' }) ) @@ -149,7 +149,7 @@ describe('useMobileNativeChatDrafts launch draft', () => { renderer?.update( createElement(Harness, { tabId: 'a', - messages: [userTextMessage('m1', '[Image #1]')], + messages: [userTextMessage('m1', 'keep [Image #1] literal')], launchDraft: 'issue link' }) ) 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 daf451e53f92..3a5ae07351af 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -219,7 +219,7 @@ describe('useMobileNativeChatDrafts', () => { createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] }) ) ) - const origin = state?.captureSendOrigin('') + const origin = state?.captureSendOrigin('', ['file:///a.jpg']) act(() => { if (origin) { state?.acceptSend(origin, '', ['file:///a.jpg']) @@ -265,7 +265,7 @@ describe('useMobileNativeChatDrafts', () => { ) ) const textOrigin = state?.captureSendOrigin('ping') - const imageOrigin = state?.captureSendOrigin('') + const imageOrigin = state?.captureSendOrigin('', ['file:///a.jpg']) act(() => { if (textOrigin && imageOrigin) { state?.acceptSend(textOrigin, 'ping') @@ -308,7 +308,7 @@ describe('useMobileNativeChatDrafts', () => { createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] }) ) ) - const origin = state?.captureSendOrigin('look at this') + const origin = state?.captureSendOrigin('look at this', ['file:///a.jpg']) act(() => { if (origin) { state?.acceptSend(origin, 'look at this', ['file:///a.jpg']) @@ -341,7 +341,7 @@ describe('useMobileNativeChatDrafts', () => { createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] }) ) ) - const origin = state?.captureSendOrigin('look at this') + const origin = state?.captureSendOrigin('look at this', ['file:///a.jpg']) act(() => { if (origin) { state?.acceptSend(origin, 'look at this', ['file:///a.jpg']) @@ -367,7 +367,7 @@ describe('useMobileNativeChatDrafts', () => { it('hands a marker-only image preview to the authoritative user bubble', async () => { await mount('a') - const origin = state?.captureSendOrigin('') + const origin = state?.captureSendOrigin('', ['file:///a.jpg']) act(() => { if (origin) { state?.acceptSend(origin, '', ['file:///a.jpg']) @@ -475,7 +475,7 @@ describe('useMobileNativeChatDrafts', () => { ) ) // Image-only send: empty text, so it can only reconcile against a new user turn. - const origin = state?.captureSendOrigin('') + const origin = state?.captureSendOrigin('', ['file:///a.jpg']) const onUnconfirmed = vi.fn() act(() => { if (origin) { @@ -492,7 +492,7 @@ describe('useMobileNativeChatDrafts', () => { }) ) ) - // ...but the user's own turn landing does, so the deadline never warns. + // ...but the user's own image turn landing does, so the deadline never warns. await act(async () => renderer?.update( createElement(Harness, { @@ -500,7 +500,7 @@ describe('useMobileNativeChatDrafts', () => { messages: [ assistantTextMessage('a1', 'hi'), assistantTextMessage('a2', 'ok'), - userTextMessage('u1', '') + userTextMessage('u1', '[Image #1]') ] }) ) @@ -519,7 +519,7 @@ describe('useMobileNativeChatDrafts', () => { createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] }) ) ) - const origin = state?.captureSendOrigin('') + const origin = state?.captureSendOrigin('', ['file:///a.jpg']) act(() => { if (origin) { state?.acceptSend(origin, '', ['file:///a.jpg']) @@ -768,7 +768,7 @@ describe('useMobileNativeChatDrafts', () => { const images = ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg'] act(() => state?.setComposerText('look')) - const origin = state?.captureSendOrigin('look') + const origin = state?.captureSendOrigin('look', images) expect(origin).toMatchObject({ pendingKey: null }) act(() => { if (origin) { diff --git a/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts b/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts index d2f4f5eef2d1..c36aabd2dfd9 100644 --- a/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts @@ -88,6 +88,27 @@ describe('literal image-marker turns in native chat', () => { ]) }) + it('does not let literal or attached-image marker rows hide markerless text', () => { + const pending: NativeChatPendingSend[] = [{ id: 'p1', text: 'keep literal', sentAt: 100 }] + const literalTranscript = [userMessage('literal', 'keep [Image #1] literal')] + const imageTranscript = [ + userMessage('source', '[Image: source: /tmp/a.png]'), + userMessage('prompt', 'keep[Image #1] literal') + ] + + expect(renderedUserRows(literalTranscript, pending).map(rowText)).toEqual([ + 'keep [Image #1] literal', + 'keep literal' + ]) + expect(renderedUserRows(imageTranscript, pending).map(rowText)).toEqual([ + 'keep literal', + 'keep literal' + ]) + expect( + prunePendingSends(pending, assembled([...imageTranscript, assistantMessage('a1', 'done')])) + ).toEqual(pending) + }) + it('prunes the marker-only echo once the assistant answers it', () => { const pending: NativeChatPendingSend[] = [{ id: 'p1', text: '[Image #1]', sentAt: 100 }] const transcript = [userMessage('m1', '[Image #1]'), assistantMessage('m2', 'preserved')] diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index a3dd2706406e..a770df39cf5a 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -51,7 +51,8 @@ function nativeChatUserMessageContentKeys(message: NativeChatMessage): readonly } const keys = new Set() const matchText = nativeChatUserMessageMatchText(message) ?? '' - if (matchText) { + const hasImageRefs = message.blocks.some(isImageRefBlock) + if (matchText && !hasImageRefs) { keys.add(nativeChatTextContentKey(matchText, 0)) } const imageCount = nativeChatUserMessageImageEvidenceCount(message) @@ -110,7 +111,9 @@ export function advancedNativeChatUserTexts( const waiting: string[] = [] for (const message of messages) { if (message.role === 'user') { - const text = nativeChatUserMessageMatchText(message) + const text = message.blocks.some(isImageRefBlock) + ? null + : nativeChatUserMessageMatchText(message) if (text) { waiting.push(text) } @@ -128,7 +131,9 @@ export function matchingNativeChatUserTexts( ): readonly string[] { const texts: string[] = [] for (const message of messages) { - const text = nativeChatUserMessageMatchText(message) + const text = message.blocks.some(isImageRefBlock) + ? null + : nativeChatUserMessageMatchText(message) if (text) { texts.push(text) } diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 29771fd9507b..38559f6e05f1 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -116,7 +116,7 @@ describe('prunePendingSends', () => { ] } const next = prunePendingSends( - [pendingOf('p1', 'what do you see')], + [{ ...pendingOf('p1', 'what do you see'), imagePaths: ['/tmp/a.png'] }], [prompt, assistantMessage('m2', 'an image')] ) @@ -468,7 +468,7 @@ describe('launchPromptAsMessage', () => { ).toBeNull() }) - it('uses pending-send normalization for large multiline generated prompts', () => { + it('does not strip literal markers from launch-prompt identity', () => { const prompt = [ '[Image #1] Resolve the failing checks:', '', @@ -488,17 +488,17 @@ describe('launchPromptAsMessage', () => { { ...assistantMessage('a1', 'I will fix it'), timestamp: 44 } ] - expect( - shouldPruneLaunchPrompt( - { - tabId: 'tab-1', - agent: 'codex', - text: prompt, - createdAt: 42 - }, - transcript - ) - ).toBe(true) + const entry = { tabId: 'tab-1', agent: 'codex' as const, text: prompt, createdAt: 42 } + + expect(launchPromptAsMessage(entry, transcript)).not.toBeNull() + expect(shouldPruneLaunchPrompt(entry, transcript)).toBe(false) + + const exact = [ + { ...userMessage('u1', prompt), timestamp: 43 }, + { ...assistantMessage('a1', 'I will fix it'), timestamp: 44 } + ] + expect(launchPromptAsMessage(entry, exact)).toBeNull() + expect(shouldPruneLaunchPrompt(entry, exact)).toBe(true) }) it('keeps the launch prompt until the transcript advances past the user turn', () => { diff --git a/src/shared/native-chat-image-transcript-markers.test.ts b/src/shared/native-chat-image-transcript-markers.test.ts index 6a1ebaa6e6b4..859aaf6ab436 100644 --- a/src/shared/native-chat-image-transcript-markers.test.ts +++ b/src/shared/native-chat-image-transcript-markers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { NativeChatMessage } from './native-chat-types' import { isImageSourceUserTurn, + nativeChatUserMessageMatchText, nativeChatUserTextMatchText, normalizeImageTranscriptMessages, normalizeNativeChatUserText, @@ -110,6 +111,14 @@ describe('normalizeImageTranscriptMessages', () => { 'keep [Image #1] literal' ) expect(nativeChatUserTextMatchText(' keep [Image #1] literal ', true)).toBe('keep literal') + + const literal = userText('literal', 'keep [Image #1] literal') + const attached = { + ...literal, + blocks: [{ type: 'image-ref' as const, path: '/tmp/a.png' }, ...literal.blocks] + } + expect(nativeChatUserMessageMatchText(literal)).toBe('keep [Image #1] literal') + expect(nativeChatUserMessageMatchText(attached)).toBe('keep literal') }) it('recognizes only sole-text image-source user turns', () => { From 0f74ea00cc78596df2b65c287a165832c1f2b902 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:13:52 -0700 Subject: [PATCH 09/44] fix(native-chat): anchor marker stripping to a contiguous image-source run Re-home the landed literal-text counts onto the draft reconciler after the #14665 revert removed the module they lived in, and cover the anchoring boundary: a marker the user typed must not absorb a later send's image. --- ...mobile-native-chat-draft-reconcile.test.ts | 20 +++++++++ .../mobile-native-chat-draft-reconcile.ts | 18 ++++++++ ...tive-chat-image-transcript-markers.test.ts | 42 +++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts index 25255d5743c1..b18b6b5393dc 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts @@ -5,6 +5,7 @@ import { findLandedImagePreviewEchoes, findLandedUnconfirmedSends, migrateImagePreviewMessageIds, + userTextOccurrenceCounts, type PendingImagePreviewEcho, type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' @@ -234,3 +235,22 @@ describe('mobile native chat image preview reconciliation', () => { ).toEqual({ [sessionKey]: { prompt: ['file:///a.jpg'] } }) }) }) + +describe('userTextOccurrenceCounts', () => { + it('counts a literal marker turn under its verbatim text', () => { + expect(userTextOccurrenceCounts([userText('u1', 'keep [Image #1] literal')])).toEqual( + new Map([['keep [Image #1] literal', 1]]) + ) + }) + + // An attached-image turn must not retire a send that carried no images. + it('excludes a folded image turn from the literal landed counts', () => { + const counts = userTextOccurrenceCounts([ + userText('s1', '[Image: source: /tmp/a.png]'), + userText('p1', '[Image #1] look') + ]) + + expect(counts.get('look')).toBeUndefined() + expect(counts.size).toBe(0) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index a49a98eb1ec8..6512d9c88617 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -49,6 +49,24 @@ export function countUserTextOccurrences( return count } +/** Landed literal-text turns counted under the same key a markerless send uses. + * Image turns are excluded so they cannot retire a send that carried none. */ +export function userTextOccurrenceCounts( + messages: readonly NativeChatMessage[] +): Map { + const counts = new Map() + for (const message of normalizeImageTranscriptMessages(messages)) { + if (message.blocks.some(isImageRefBlock)) { + continue + } + const text = nativeChatUserMessageMatchText(message) + if (text) { + counts.set(text, (counts.get(text) ?? 0) + 1) + } + } + return counts +} + /** Number of `[Image: source: …]` echo turns strictly after `tailId` (or the * whole transcript when the tail was paginated out). An image-only send has no * caption to match, so it reconciles by ordinal against this count — counting diff --git a/src/shared/native-chat-image-transcript-markers.test.ts b/src/shared/native-chat-image-transcript-markers.test.ts index 859aaf6ab436..477d17d54ae6 100644 --- a/src/shared/native-chat-image-transcript-markers.test.ts +++ b/src/shared/native-chat-image-transcript-markers.test.ts @@ -217,6 +217,48 @@ describe('normalizeImageTranscriptMessages', () => { expect(out[2]).toBe(after) }) + it('keeps a later literal marker when earlier turns separate it from a real image send', () => { + const out = normalizeImageTranscriptMessages([ + userText('s1', '[Image: source: /tmp/a.jpg]'), + userText('p1', '[Image #1] what is this?'), + userText('u1', 'thanks'), + userText('u2', 'in my notes I wrote [Image #2] as plain text') + ]) + + expect(out.at(-1)?.blocks).toEqual([ + { type: 'text', text: 'in my notes I wrote [Image #2] as plain text' } + ]) + }) + + it('breaks the anchor when a non-image user turn separates the source run from the prompt', () => { + const out = normalizeImageTranscriptMessages([ + userText('s1', '[Image: source: /tmp/a.jpg]'), + userText('u1', 'here is the picture'), + userText('u2', 'please preserve [Image #1] literally') + ]) + + expect(out.map((message) => message.blocks)).toEqual([ + [{ type: 'image-ref', path: '/tmp/a.jpg' }], + [{ type: 'text', text: 'here is the picture' }], + [{ type: 'text', text: 'please preserve [Image #1] literally' }] + ]) + }) + + // A marker the user typed must not absorb the image of the send that follows it. + it('does not let a literal marker turn claim a later send’s image source', () => { + const out = normalizeImageTranscriptMessages([ + userText('u1', 'remember I typed [Image #1] by hand'), + userText('s1', '[Image: source: /tmp/a.jpg]'), + userText('p1', '[Image #1] what is this?') + ]) + + expect(out[0]?.blocks).toEqual([{ type: 'text', text: 'remember I typed [Image #1] by hand' }]) + expect(out.at(-1)?.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.jpg' }, + { type: 'text', text: 'what is this?' } + ]) + }) + it('leaves assistant messages untouched', () => { const assistant: NativeChatMessage = { id: 'a', From 1689e2f241865c0f9d3e8c7e91c705fe5f7b5216 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:40:08 -0700 Subject: [PATCH 10/44] fix(native-chat): bound marker stripping by the run's image count Anchoring decided which turn gets stripped but not how many markers may go, so a turn attaching one image and mentioning a second marker in prose still lost the user's literal text. Strip at most one marker per image in the anchored source run, in document order across the turn's text blocks; markers past the count stay verbatim. Position, not ordinal, decides which markers are placeholders, so the rule holds whether the producing CLI numbers markers per-turn or per-session. --- .../mobile-native-chat-render-data.test.ts | 16 ++++ ...tive-chat-image-transcript-markers.test.ts | 73 +++++++++++++++++++ .../native-chat-image-transcript-markers.ts | 38 ++++++++-- 3 files changed, 121 insertions(+), 6 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index 2bb0ef89305b..bbeb953b7937 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -133,6 +133,22 @@ describe('buildMobileNativeChatTransientData', () => { ]) }) + it('keeps a marker beyond the folded run’s image count as literal text', () => { + const data = build( + [ + user('u1', '[Image: source: /tmp/a.png]'), + user('u2', '[Image #1] compare with the [Image #2] I mentioned earlier') + ], + null, + [] + ) + + expect(data[0]?.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'text', text: 'compare with the [Image #2] I mentioned earlier' } + ]) + }) + it('renders a lone image marker turn (no caption) as an image-ref block', () => { const data = build([user('u1', '[Image: source: /tmp/a.png]')], null, []) expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }]) diff --git a/src/shared/native-chat-image-transcript-markers.test.ts b/src/shared/native-chat-image-transcript-markers.test.ts index 477d17d54ae6..16ffff4267bc 100644 --- a/src/shared/native-chat-image-transcript-markers.test.ts +++ b/src/shared/native-chat-image-transcript-markers.test.ts @@ -259,6 +259,79 @@ describe('normalizeImageTranscriptMessages', () => { ]) }) + it('keeps markers beyond the anchored run’s image count as the user’s own words', () => { + const out = normalizeImageTranscriptMessages([ + userText('s1', '[Image: source: /tmp/a.png]'), + userText('p1', '[Image #1] compare with the [Image #2] I mentioned earlier') + ]) + + expect(out).toHaveLength(1) + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'text', text: 'compare with the [Image #2] I mentioned earlier' } + ]) + }) + + it('strips by document position, not by marker ordinal', () => { + const out = normalizeImageTranscriptMessages([ + userText('s1', '[Image: source: /tmp/a.png]'), + userText('p1', '[Image #2] came first but [Image #1] came second') + ]) + + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'text', text: 'came first but [Image #1] came second' } + ]) + }) + + it('strips a marker whose ordinal exceeds the run size when it is within the count', () => { + const out = normalizeImageTranscriptMessages([ + userText('s1', '[Image: source: /tmp/a.png]'), + userText('p1', '[Image #7] what is this?') + ]) + + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'text', text: 'what is this?' } + ]) + }) + + it('spends the image budget across text blocks in document order', () => { + const prompt: NativeChatMessage = { + ...userText('prompt', 'unused'), + blocks: [ + { type: 'text', text: '[Image #1] look at' }, + { type: 'image-ref', path: '/tmp/existing.png' }, + { type: 'text', text: 'not [Image #2] though' } + ] + } + const out = normalizeImageTranscriptMessages([ + userText('s1', '[Image: source: /tmp/a.png]'), + prompt + ]) + + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'text', text: 'look at' }, + { type: 'image-ref', path: '/tmp/existing.png' }, + { type: 'text', text: 'not [Image #2] though' } + ]) + }) + + it('keeps the surplus marker on a two-image run that mentions a third', () => { + const out = normalizeImageTranscriptMessages([ + userText('s1', '[Image: source: /tmp/a.png]'), + userText('s2', '[Image: source: /tmp/b.png]'), + userText('p1', '[Image #1] [Image #2] versus the [Image #3] from before') + ]) + + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'image-ref', path: '/tmp/b.png' }, + { type: 'text', text: 'versus the [Image #3] from before' } + ]) + }) + it('leaves assistant messages untouched', () => { const assistant: NativeChatMessage = { id: 'a', diff --git a/src/shared/native-chat-image-transcript-markers.ts b/src/shared/native-chat-image-transcript-markers.ts index 4864187cc6b5..e27006f0a3ac 100644 --- a/src/shared/native-chat-image-transcript-markers.ts +++ b/src/shared/native-chat-image-transcript-markers.ts @@ -27,20 +27,41 @@ export function isImageSourceUserTurn(message: NativeChatMessage): boolean { return message.role === 'user' && imageSourcePathFromText(soleText(message) ?? '') !== null } -export function stripImagePromptMarker(text: string): string { - const stripped = text.replace(IMAGE_PROMPT_MARKERS, '') +function countMarkers(text: string): number { + return text.match(IMAGE_PROMPT_MARKERS)?.length ?? 0 +} + +/** Strips the first `limit` markers in document order; the rest are the user's + * own words, so they stay verbatim. */ +function stripImagePromptMarkersUpTo(text: string, limit: number): string { + if (limit <= 0) { + return text + } + let used = 0 + const stripped = text.replace(IMAGE_PROMPT_MARKERS, (match) => { + if (used >= limit) { + return match + } + used += 1 + return '' + }) if (stripped === text) { return text } let result = IMAGE_PROMPT_MARKER_AT_START.test(text) ? stripped.replace(HORIZONTAL_WHITESPACE_START, '') : stripped - if (IMAGE_PROMPT_MARKER_AT_END.test(text)) { + // Why: a trailing marker left in place still owns the whitespace before it. + if (used === countMarkers(text) && IMAGE_PROMPT_MARKER_AT_END.test(text)) { result = result.replace(HORIZONTAL_WHITESPACE_END, '') } return result } +export function stripImagePromptMarker(text: string): string { + return stripImagePromptMarkersUpTo(text, Number.POSITIVE_INFINITY) +} + export function normalizeNativeChatUserText(text: string): string { return stripImagePromptMarker(text).trim().replace(/\s+/g, ' ') } @@ -78,10 +99,14 @@ export function nativeChatUserMessageMatchText(message: NativeChatMessage): stri return nativeChatUserTextMatchText(joined, message.blocks.some(isImageRefBlock)) || null } +/** `limit` is the number of images the run actually carried. Only that many + * markers can be placeholders; the rest are text the user typed. */ function stripImagePromptMarkersFromTextBlocks( - blocks: readonly NativeChatBlock[] + blocks: readonly NativeChatBlock[], + limit: number ): NativeChatBlock[] { let sawText = false + let remaining = limit let next: NativeChatBlock[] | null = null for (let index = 0; index < blocks.length; index += 1) { const block = blocks[index]! @@ -91,7 +116,8 @@ function stripImagePromptMarkersFromTextBlocks( } const isFirstText = !sawText sawText = true - const text = stripImagePromptMarker(block.text) + const text = stripImagePromptMarkersUpTo(block.text, remaining) + remaining -= Math.min(remaining, countMarkers(block.text)) if (!text.trim() && (text !== block.text || isFirstText)) { next ??= blocks.slice(0, index) continue @@ -173,7 +199,7 @@ export function normalizeImageTranscriptMessages( ...prompt, blocks: [ ...imagePaths.map((path) => ({ type: 'image-ref' as const, path })), - ...stripImagePromptMarkersFromTextBlocks(prompt.blocks) + ...stripImagePromptMarkersFromTextBlocks(prompt.blocks, imagePaths.length) ] }) index = nextIndex From ca9a22f1b41fa83990b186c7f3bb1316c8d497c1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:40:39 -0700 Subject: [PATCH 11/44] fix(native-chat): keep definition-shaped user lines out of Markdown's mouth A preserved literal marker can still vanish one layer later: CommonMark reads `[Image #1]: /tmp/a.png` as a link reference definition, so a user turn that is only that line renders as an empty bubble. Escape the line-leading bracket on user turns before they reach the Markdown renderer. Assistant prose is untouched, and the escape is output-neutral everywhere else, including inside fenced code. --- .../native-chat/NativeChatMessageList.tsx | 3 +- .../native-chat-user-markdown.test.tsx | 54 +++++++++++++++++++ .../native-chat/native-chat-user-markdown.ts | 25 +++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-user-markdown.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-user-markdown.ts diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 354732ba0d81..b7b2c81194e5 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -17,6 +17,7 @@ import { stripNoiseMessages } from './native-chat-noise' import { foldToolMessages, splitNativeChatBlocks } from './native-chat-tool-fold' import { isNearBottom, shouldShowJumpToLatest, type ScrollGeometry } from './native-chat-autoscroll' import { isNativeChatPastedImagePath } from './native-chat-image-paste' +import { escapeNativeChatUserMarkdown } from './native-chat-user-markdown' import { NativeChatToolRun } from './NativeChatToolRun' import { NativeChatCopyButton } from './NativeChatCopyButton' import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' @@ -174,7 +175,7 @@ function MessageRow({ <> ) + .replace(/<[^>]*>/g, '') + .trim() +} + +describe('escapeNativeChatUserMarkdown', () => { + // The swallow this guards against: without the escape the marker line is a + // CommonMark link reference definition and the whole turn renders empty. + it('keeps a marker line the raw Markdown pipeline drops entirely', () => { + const typed = '[Image #1]: /tmp/a.png' + + expect(renderedText(typed)).toBe('') + expect(renderedText(escapeNativeChatUserMarkdown(typed))).toBe('[Image #1]: /tmp/a.png') + }) + + it('keeps a definition-shaped line that precedes other prose', () => { + const typed = '[Image #1]: /tmp/a.png\n\nwhat do you make of it?' + + expect(renderedText(typed)).toBe('what do you make of it?') + expect(renderedText(escapeNativeChatUserMarkdown(typed))).toBe( + '[Image #1]: /tmp/a.png\nwhat do you make of it?' + ) + }) + + it.each([ + 'keep [Image #1] literal', + '[Image #1] describe this', + 'here is my note\n[Image #1]: /tmp/a.png\nend', + '[Image #1]: /tmp/a.png and more text after', + '```\n[Image #1]: /tmp/a.png\n```', + '- [Image #1]: /tmp/a.png', + 'plain prose with a colon: like this' + ])('renders text the pipeline already showed identically', (typed) => { + expect(renderedText(escapeNativeChatUserMarkdown(typed))).toBe(renderedText(typed)) + }) + + it.each(['keep [Image #1] literal', '```\n[Image #1]: /tmp/a.png\n```'])( + 'leaves lines that cannot open a definition byte-identical', + (typed) => { + expect(escapeNativeChatUserMarkdown(typed)).toBe(typed) + } + ) + + it('escapes only the line-leading bracket, once per line', () => { + expect(escapeNativeChatUserMarkdown('[a]: x\n[b]: y')).toBe('\\[a]: x\n\\[b]: y') + expect(escapeNativeChatUserMarkdown(' [a]: x')).toBe(' \\[a]: x') + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-user-markdown.ts b/src/renderer/src/components/native-chat/native-chat-user-markdown.ts new file mode 100644 index 000000000000..bfc0cc5e718b --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-user-markdown.ts @@ -0,0 +1,25 @@ +const LINK_REFERENCE_DEFINITION_LINE = /^( {0,3})\[(?=[^\]\n]*\]:)/ +const FENCE_LINE = /^ {0,3}(?:`{3,}|~{3,})/ + +/** + * A user turn is prose, not authored Markdown. CommonMark reads a line like + * `[Image #1]: /tmp/a.png` as a link reference definition and renders nothing at + * all, so a marker the transcript rules preserve would still vanish on screen. + * Escaping the opening bracket is output-neutral for every other line. + */ +export function escapeNativeChatUserMarkdown(markdown: string): string { + if (!markdown.includes(']:')) { + return markdown + } + let inFence = false + return markdown + .split('\n') + .map((line) => { + if (FENCE_LINE.test(line)) { + inFence = !inFence + return line + } + return inFence ? line : line.replace(LINK_REFERENCE_DEFINITION_LINE, '$1\\[') + }) + .join('\n') +} From 4fa5a6202f100d1777e9186aadf97005c82cbecb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:14:01 -0700 Subject: [PATCH 12/44] fix(native-chat): stop parsing user prose as link reference definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Escaping a line-leading bracket only reached definitions at the top level. Measured against the live renderer, a definition-shaped marker line still rendered as an empty bubble inside a bullet, an ordered item or a blockquote, and whenever the label wrapped across lines — and the escape injected a visible backslash into a fenced block whose fence characters differed from an inner fence line. Turn the construct off for user rows instead, so every container is covered and the source text is never rewritten. Inline links, reference links, GFM and fenced code all render byte-identically. --- .../native-chat/NativeChatMessageList.tsx | 6 +- .../native-chat-user-markdown.test.tsx | 54 ---------------- .../native-chat/native-chat-user-markdown.ts | 25 -------- .../native-chat-user-turn-render.test.tsx | 57 +++++++++++++++++ .../components/sidebar/CommentMarkdown.tsx | 15 +++-- .../remark-disable-definitions.test.tsx | 63 +++++++++++++++++++ .../sidebar/remark-disable-definitions.ts | 19 ++++++ 7 files changed, 154 insertions(+), 85 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/native-chat-user-markdown.test.tsx delete mode 100644 src/renderer/src/components/native-chat/native-chat-user-markdown.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-user-turn-render.test.tsx create mode 100644 src/renderer/src/components/sidebar/remark-disable-definitions.test.tsx create mode 100644 src/renderer/src/components/sidebar/remark-disable-definitions.ts diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index b7b2c81194e5..ebb84acf9a7d 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -17,7 +17,6 @@ import { stripNoiseMessages } from './native-chat-noise' import { foldToolMessages, splitNativeChatBlocks } from './native-chat-tool-fold' import { isNearBottom, shouldShowJumpToLatest, type ScrollGeometry } from './native-chat-autoscroll' import { isNativeChatPastedImagePath } from './native-chat-image-paste' -import { escapeNativeChatUserMarkdown } from './native-chat-user-markdown' import { NativeChatToolRun } from './NativeChatToolRun' import { NativeChatCopyButton } from './NativeChatCopyButton' import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' @@ -175,11 +174,14 @@ function MessageRow({ <> ) : ( diff --git a/src/renderer/src/components/native-chat/native-chat-user-markdown.test.tsx b/src/renderer/src/components/native-chat/native-chat-user-markdown.test.tsx deleted file mode 100644 index 4162fe62d483..000000000000 --- a/src/renderer/src/components/native-chat/native-chat-user-markdown.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it } from 'vitest' -import CommentMarkdown from '@/components/sidebar/CommentMarkdown' -import { escapeNativeChatUserMarkdown } from './native-chat-user-markdown' - -function renderedText(content: string): string { - return renderToStaticMarkup() - .replace(/<[^>]*>/g, '') - .trim() -} - -describe('escapeNativeChatUserMarkdown', () => { - // The swallow this guards against: without the escape the marker line is a - // CommonMark link reference definition and the whole turn renders empty. - it('keeps a marker line the raw Markdown pipeline drops entirely', () => { - const typed = '[Image #1]: /tmp/a.png' - - expect(renderedText(typed)).toBe('') - expect(renderedText(escapeNativeChatUserMarkdown(typed))).toBe('[Image #1]: /tmp/a.png') - }) - - it('keeps a definition-shaped line that precedes other prose', () => { - const typed = '[Image #1]: /tmp/a.png\n\nwhat do you make of it?' - - expect(renderedText(typed)).toBe('what do you make of it?') - expect(renderedText(escapeNativeChatUserMarkdown(typed))).toBe( - '[Image #1]: /tmp/a.png\nwhat do you make of it?' - ) - }) - - it.each([ - 'keep [Image #1] literal', - '[Image #1] describe this', - 'here is my note\n[Image #1]: /tmp/a.png\nend', - '[Image #1]: /tmp/a.png and more text after', - '```\n[Image #1]: /tmp/a.png\n```', - '- [Image #1]: /tmp/a.png', - 'plain prose with a colon: like this' - ])('renders text the pipeline already showed identically', (typed) => { - expect(renderedText(escapeNativeChatUserMarkdown(typed))).toBe(renderedText(typed)) - }) - - it.each(['keep [Image #1] literal', '```\n[Image #1]: /tmp/a.png\n```'])( - 'leaves lines that cannot open a definition byte-identical', - (typed) => { - expect(escapeNativeChatUserMarkdown(typed)).toBe(typed) - } - ) - - it('escapes only the line-leading bracket, once per line', () => { - expect(escapeNativeChatUserMarkdown('[a]: x\n[b]: y')).toBe('\\[a]: x\n\\[b]: y') - expect(escapeNativeChatUserMarkdown(' [a]: x')).toBe(' \\[a]: x') - }) -}) diff --git a/src/renderer/src/components/native-chat/native-chat-user-markdown.ts b/src/renderer/src/components/native-chat/native-chat-user-markdown.ts deleted file mode 100644 index bfc0cc5e718b..000000000000 --- a/src/renderer/src/components/native-chat/native-chat-user-markdown.ts +++ /dev/null @@ -1,25 +0,0 @@ -const LINK_REFERENCE_DEFINITION_LINE = /^( {0,3})\[(?=[^\]\n]*\]:)/ -const FENCE_LINE = /^ {0,3}(?:`{3,}|~{3,})/ - -/** - * A user turn is prose, not authored Markdown. CommonMark reads a line like - * `[Image #1]: /tmp/a.png` as a link reference definition and renders nothing at - * all, so a marker the transcript rules preserve would still vanish on screen. - * Escaping the opening bracket is output-neutral for every other line. - */ -export function escapeNativeChatUserMarkdown(markdown: string): string { - if (!markdown.includes(']:')) { - return markdown - } - let inFence = false - return markdown - .split('\n') - .map((line) => { - if (FENCE_LINE.test(line)) { - inFence = !inFence - return line - } - return inFence ? line : line.replace(LINK_REFERENCE_DEFINITION_LINE, '$1\\[') - }) - .join('\n') -} diff --git a/src/renderer/src/components/native-chat/native-chat-user-turn-render.test.tsx b/src/renderer/src/components/native-chat/native-chat-user-turn-render.test.tsx new file mode 100644 index 000000000000..a8306e20f1a9 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-user-turn-render.test.tsx @@ -0,0 +1,57 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { NativeChatMessageList } from './NativeChatMessageList' +import type { NativeChatLiveSession } from './use-native-chat-live-session' + +function sessionOf(messages: NativeChatMessage[]): NativeChatLiveSession { + return { + messages, + status: 'ready', + sessionId: 's1', + agent: 'claude', + hasMore: false, + loadingEarlier: false, + loadEarlier: () => {}, + readPhase: 'ready' + } +} + +function userTurn(text: string): NativeChatMessage { + return { + id: 'u1', + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: 1, + source: 'transcript' + } +} + +function renderedText(text: string): string { + return renderToStaticMarkup( + + ).replace(/<[^>]*>/g, '') +} + +describe('user turn rendering', () => { + // Wiring guard: the row must opt out of link reference definitions, or a + // marker line the transcript rules deliberately preserved still renders as + // an empty bubble. + it.each([ + '[Image #1]: /tmp/a.png', + '- [Image #1]: /tmp/a.png', + '> [Image #1]: /tmp/a.png', + '[Image #1\nstill mine]: /tmp/a.png' + ])('shows a definition-shaped user line on screen', (typed) => { + expect(renderedText(typed)).toContain('[Image #1') + }) + + it('shows a literal marker the user typed without an attachment', () => { + expect(renderedText('keep [Image #1] literal')).toContain('keep [Image #1] literal') + }) +}) diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx index 8673baedefad..4db2ed354114 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx @@ -5,6 +5,7 @@ import remarkBreaks from 'remark-breaks' import rehypeRaw from 'rehype-raw' import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' import { cn } from '@/lib/utils' +import { remarkDisableDefinitions } from './remark-disable-definitions' import { compactCommentMarkdownComponents, createCompactCommentMarkdownComponents, @@ -186,6 +187,9 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & { onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean expandImages?: boolean + /** Set when `content` is prose the user typed, not Markdown they authored, so + * a `[label]: target` line renders instead of silently disappearing. */ + disableLinkDefinitions?: boolean } // Why forwardRef + rest props: Radix's HoverCardTrigger asChild merges a ref @@ -201,6 +205,7 @@ const CommentMarkdown = React.memo( onLinkClick, allowFileUriLinks = false, expandImages = false, + disableLinkDefinitions = false, ...rest }, ref @@ -217,10 +222,12 @@ const CommentMarkdown = React.memo( ? createDocumentCommentMarkdownComponents(onLinkClick) : createCompactCommentMarkdownComponents(onLinkClick, expandImages) }, [expandImages, variant, onLinkClick]) - const activeRemarkPlugins = React.useMemo( - () => (githubRepo ? [...remarkPlugins, remarkGitHubReferences(githubRepo)] : remarkPlugins), - [githubRepo] - ) + const activeRemarkPlugins = React.useMemo(() => { + const active = githubRepo + ? [...remarkPlugins, remarkGitHubReferences(githubRepo)] + : remarkPlugins + return disableLinkDefinitions ? [...active, remarkDisableDefinitions] : active + }, [disableLinkDefinitions, githubRepo]) return (
+ ) + .replace(/<[^>]*>/g, '') + .trim() +} + +describe('disableLinkDefinitions', () => { + // Each of these renders to nothing at all without the opt-in — the user's + // whole line disappears, which is the defect, not a formatting nicety. + it.each([ + ['at the top level', '[Image #1]: /tmp/a.png', '[Image #1]: /tmp/a.png'], + ['inside a bullet', '- [Image #1]: /tmp/a.png', '[Image #1]: /tmp/a.png'], + ['inside an ordered item', '1. [Image #1]: /tmp/a.png', '[Image #1]: /tmp/a.png'], + ['inside a blockquote', '> [Image #1]: /tmp/a.png', '[Image #1]: /tmp/a.png'], + // A link label may span lines, so the swallow is not a single-line shape. + [ + 'with a wrapped label', + '[Image #1\nstill mine]: /tmp/a.png', + '[Image #1\nstill mine]: /tmp/a.png' + ] + ])('keeps a definition-shaped line %s', (_name, typed, expected) => { + expect(rendered(typed, false)).toBe('') + expect(rendered(typed, true)).toBe(expected) + }) + + it('keeps the sibling content a swallowed definition took with it', () => { + const typed = '- [Image #1]: /tmp/a.png\n- second' + + expect(rendered(typed, false)).toBe('second') + expect(rendered(typed, true)).toBe('[Image #1]: /tmp/a.png\nsecond') + }) + + it.each([ + 'keep [Image #1] literal', + 'plain prose with a colon: like this', + 'see [the docs](https://example.com) here', + 'see [the docs][d] here', + '```\n[Image #1]: /tmp/a.png\n```', + '~~~\ncode\n```\n[Image #1]: /tmp/a.png\n~~~', + '| a | b |\n| - | - |\n| 1 | 2 |' + ])('leaves content the pipeline already rendered untouched', (typed) => { + expect(rendered(typed, true)).toBe(rendered(typed, false)) + }) + + // The one intended difference: an authored definition stops being consumed, + // so its reference renders literally rather than as a resolved link. + it('renders an authored definition as the text it is', () => { + const typed = '[d]: https://example.com\n\nsee [the docs][d] here' + + expect(rendered(typed, false)).toBe('see the docs here') + expect(rendered(typed, true)).toBe('[d]: https://example.com\nsee [the docs][d] here') + }) +}) diff --git a/src/renderer/src/components/sidebar/remark-disable-definitions.ts b/src/renderer/src/components/sidebar/remark-disable-definitions.ts new file mode 100644 index 000000000000..67b1d46c9e0c --- /dev/null +++ b/src/renderer/src/components/sidebar/remark-disable-definitions.ts @@ -0,0 +1,19 @@ +import type { Processor } from 'unified' + +/** + * Stop CommonMark from reading `[label]: /some/target` as a link reference + * definition. + * + * Definitions render to nothing at all, so on content that is prose rather than + * authored Markdown — a chat turn the user typed — such a line silently deletes + * itself. Escaping the bracket in the source text only reaches lines at the top + * level; a definition inside a list item or blockquote, or one whose label wraps + * across lines, is still swallowed. Turning the construct off covers every + * container, and leaves inline links, reference *links*, and GFM intact. + */ +export function remarkDisableDefinitions(this: Processor): undefined { + const data = this.data() + const extensions = (data.micromarkExtensions ??= []) + extensions.push({ disable: { null: ['definition'] } }) + return undefined +} From 2b0a4aaa5a6af6750f892a1aec382c7ff486305e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:35:09 -0700 Subject: [PATCH 13/44] refactor(native-chat): hoist the prose remark plugin list to module scope Every chat row renders through one of the two lists, so build both once instead of allocating a fresh array per CommentMarkdown instance. --- src/renderer/src/components/sidebar/CommentMarkdown.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx index 4db2ed354114..7d2abee36b3b 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx @@ -62,6 +62,9 @@ const commentMarkdownFileUriUrlTransform: UrlTransform = (value, key, node) => { // remark-breaks converts single newlines to
, keeping backward compat // with existing plain-text comments that rely on newline formatting. const remarkPlugins = [remarkGfm, remarkBreaks] +// Every chat row renders one of these, so keep both lists module-level rather +// than allocating a fresh array per instance. +const proseRemarkPlugins = [...remarkPlugins, remarkDisableDefinitions] const GITHUB_REFERENCE_PATTERN = /(?:\b([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+))?#([1-9][0-9]*)\b/g @@ -223,10 +226,8 @@ const CommentMarkdown = React.memo( : createCompactCommentMarkdownComponents(onLinkClick, expandImages) }, [expandImages, variant, onLinkClick]) const activeRemarkPlugins = React.useMemo(() => { - const active = githubRepo - ? [...remarkPlugins, remarkGitHubReferences(githubRepo)] - : remarkPlugins - return disableLinkDefinitions ? [...active, remarkDisableDefinitions] : active + const base = disableLinkDefinitions ? proseRemarkPlugins : remarkPlugins + return githubRepo ? [...base, remarkGitHubReferences(githubRepo)] : base }, [disableLinkDefinitions, githubRepo]) return ( From 4cd957976e83ee3d86008e402ac9dc6c211b72c7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:05:43 -0700 Subject: [PATCH 14/44] fix(native-chat): cover footnote definitions and correct the doc comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GFM adds a second definition construct. `[^note]: I meant this literally` was still rendering an empty bubble on a user row — the same total content loss, one construct over — so disable `gfmFootnoteDefinition` alongside `definition`. The comment claimed reference links survive. Measured, they do not: they resolve *against* the definitions this removes, so `[d]`, `[d][]` and `![alt][d]` now render literally. That is the right reading for prose, but the comment said the opposite. Also pin that the untouched-content cases render something, since comparing the two prop values to each other is trivially true if the plugin ever stops attaching. --- .../remark-disable-definitions.test.tsx | 10 ++++++++ .../sidebar/remark-disable-definitions.ts | 23 +++++++++++-------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/sidebar/remark-disable-definitions.test.tsx b/src/renderer/src/components/sidebar/remark-disable-definitions.test.tsx index 0cb0e25094fd..659854876b8c 100644 --- a/src/renderer/src/components/sidebar/remark-disable-definitions.test.tsx +++ b/src/renderer/src/components/sidebar/remark-disable-definitions.test.tsx @@ -22,6 +22,12 @@ describe('disableLinkDefinitions', () => { ['inside a bullet', '- [Image #1]: /tmp/a.png', '[Image #1]: /tmp/a.png'], ['inside an ordered item', '1. [Image #1]: /tmp/a.png', '[Image #1]: /tmp/a.png'], ['inside a blockquote', '> [Image #1]: /tmp/a.png', '[Image #1]: /tmp/a.png'], + // GFM adds a second definition construct that swallows prose the same way. + [ + 'as a footnote definition', + '[^note]: I meant this literally', + '[^note]: I meant this literally' + ], // A link label may span lines, so the swallow is not a single-line shape. [ 'with a wrapped label', @@ -49,6 +55,10 @@ describe('disableLinkDefinitions', () => { '~~~\ncode\n```\n[Image #1]: /tmp/a.png\n~~~', '| a | b |\n| - | - |\n| 1 | 2 |' ])('leaves content the pipeline already rendered untouched', (typed) => { + // Why the second assertion: comparing the two prop values to each other is + // trivially true if the plugin ever stops attaching, so pin that these + // render something before trusting that they render the same thing. + expect(rendered(typed, true)).not.toBe('') expect(rendered(typed, true)).toBe(rendered(typed, false)) }) diff --git a/src/renderer/src/components/sidebar/remark-disable-definitions.ts b/src/renderer/src/components/sidebar/remark-disable-definitions.ts index 67b1d46c9e0c..b9fae1bf54da 100644 --- a/src/renderer/src/components/sidebar/remark-disable-definitions.ts +++ b/src/renderer/src/components/sidebar/remark-disable-definitions.ts @@ -1,19 +1,24 @@ import type { Processor } from 'unified' /** - * Stop CommonMark from reading `[label]: /some/target` as a link reference - * definition. + * Stop the parser from reading `[label]: /some/target` (and its GFM footnote + * sibling `[^label]: text`) as a definition. * - * Definitions render to nothing at all, so on content that is prose rather than - * authored Markdown — a chat turn the user typed — such a line silently deletes - * itself. Escaping the bracket in the source text only reaches lines at the top - * level; a definition inside a list item or blockquote, or one whose label wraps - * across lines, is still swallowed. Turning the construct off covers every - * container, and leaves inline links, reference *links*, and GFM intact. + * A definition renders to nothing at all, so on content that is prose rather + * than authored Markdown — a chat turn the user typed — such a line silently + * deletes itself. Escaping the bracket in the source text only reaches lines at + * the top level; a definition inside a list item or blockquote, or one whose + * label wraps across lines, is still swallowed. Turning the constructs off + * covers every container without rewriting a byte of what the user wrote. + * + * Inline links and the rest of GFM are untouched. Reference links necessarily + * render literally, because they resolve *against* the definitions this removes + * — which is the right reading for prose, where `[d]` is far more likely to be + * a bracket the user typed than a link they meant to define. */ export function remarkDisableDefinitions(this: Processor): undefined { const data = this.data() const extensions = (data.micromarkExtensions ??= []) - extensions.push({ disable: { null: ['definition'] } }) + extensions.push({ disable: { null: ['definition', 'gfmFootnoteDefinition'] } }) return undefined } From 445ba46ad193f29955c5fbc8883f8ac2d76327c9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:16:47 -0700 Subject: [PATCH 15/44] fix(native-chat): stop reading an invisible image run as user-typed markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker fold inferred "no visible `[Image: source: …]` run before this prompt ⇒ the `[Image #N]` markers are the user's own words". That is right for STA-4363 and wrong wherever the source run exists but cannot be seen, which regressed three paths. Read window. `boundNativeChatWindow` is a hard count slice re-applied on every append, so an aging session's head cuts through image runs and an already-read turn gained a literal `[Image #1]`. Only the head is ambiguous — anywhere else the run would be visible above the prompt — and the paging signal that resolves it already exists as `hasMore`, it was simply never plumbed. The fold now takes `hasEarlierHistory` and, at index 0 only, spends an unbounded marker budget: a partial run's visible image count is a floor, not its real size. Untruncated windows keep the ticket's rule, so a first-message literal marker still stands. Glue. Both user-text matchers returned null for any row carrying an image, so a folded image turn was invisible to the lost-Enter glue path and both optimistic bubbles — one a duplicate photo — persisted until pane close. They now yield the row's text with an image-count budget, which keeps the guard that a text-only row can never retire a send whose photo has not landed. Mobile render. A marker-only host echo captioned the user's own photo with a literal `[Image #1]`. A bound local preview is positive proof the turn echoes an image sent from this device, so its markers are placeholders; stripping is bounded by the images the turn actually shows. --- .../src/session/MobileNativeChatOverlay.tsx | 5 +- ...le-native-chat-image-transcript-markers.ts | 3 +- .../mobile-native-chat-render-data.test.ts | 31 ++++++++- .../session/mobile-native-chat-render-data.ts | 24 +++++-- .../native-chat-live-message-preparation.ts | 8 ++- .../native-chat-pending-occurrence.ts | 66 +++++++++++-------- .../native-chat/native-chat-pending.test.ts | 60 +++++++++++++++++ .../native-chat-session-assembler.ts | 10 ++- .../use-native-chat-assembled-messages.ts | 9 ++- .../use-native-chat-live-session.ts | 3 +- ...tive-chat-image-transcript-markers.test.ts | 64 ++++++++++++++++++ .../native-chat-image-transcript-markers.ts | 34 ++++++++-- 12 files changed, 268 insertions(+), 49 deletions(-) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 699f8a1614b9..6768598d4e7f 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -46,7 +46,10 @@ export function MobileNativeChatOverlay({ keyboardInset }: Props): React.JSX.Element | null { const session = controller.nativeChatSession - const folded = useMemo(() => foldMobileNativeChatMessages(session.messages), [session.messages]) + const folded = useMemo( + () => foldMobileNativeChatMessages(session.messages, session.hasMore), + [session.messages, session.hasMore] + ) const streaming = useMobileNativeChatStreamingBubble( folded, controller.nativeChatStreamingText, diff --git a/mobile/src/session/mobile-native-chat-image-transcript-markers.ts b/mobile/src/session/mobile-native-chat-image-transcript-markers.ts index 6196b6c725c3..02510fc601d6 100644 --- a/mobile/src/session/mobile-native-chat-image-transcript-markers.ts +++ b/mobile/src/session/mobile-native-chat-image-transcript-markers.ts @@ -13,5 +13,6 @@ export { normalizeImageTranscriptMessages, normalizeNativeChatUserText, normalizedNativeChatUserMessageText, - stripImagePromptMarker + stripImagePromptMarker, + stripImagePromptMarkersFromTextBlocks } from '../../../src/shared/native-chat-image-transcript-markers' diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index bbeb953b7937..263a40b37f6f 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -173,7 +173,10 @@ describe('buildMobileNativeChatTransientData', () => { ]) }) - it('restores a local preview without treating it as proof to strip marker text', () => { + // A marker-only turn is the documented normal echo shape for some hosts, and a + // bound preview is proof the markers stand in for the photo now beside them — + // captioning the user's own image with a literal `[Image #1]` is never right. + it('restores the local preview onto a marker-only transcript turn', () => { const result = buildMobileNativeChatTransientData({ folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]), streaming: null, @@ -181,12 +184,36 @@ describe('buildMobileNativeChatTransientData', () => { imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } }) + expect(result.data[0]?.blocks).toEqual([{ type: 'image-ref', url: 'file:///phone-photo.jpg' }]) + }) + + // Why: the bound preview only vouches for as many markers as it shows images. + it('keeps surplus marker text the previews cannot vouch for', () => { + const result = buildMobileNativeChatTransientData({ + folded: foldMobileNativeChatMessages([user('prompt', '[Image #1] next to [Image #2]')]), + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } + }) + expect(result.data[0]?.blocks).toEqual([ - { type: 'text', text: '[Image #1]' }, + { type: 'text', text: 'next to [Image #2]' }, { type: 'image-ref', url: 'file:///phone-photo.jpg' } ]) }) + // Why: no preview binding means no evidence, so the ticket's rule still holds. + it('leaves marker text alone when no preview is bound', () => { + const result = buildMobileNativeChatTransientData({ + folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]), + streaming: null, + pending: [], + imagePreviewsByMessageId: {} + }) + + expect(result.data[0]?.blocks).toEqual([{ type: 'text', text: '[Image #1]' }]) + }) + it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => { // Whether text streams at all is the gate's call // (`mobile-native-chat-streaming-gate.test.ts`); this only places it. diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 73a45db6962a..d6ad190b3c1b 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -6,7 +6,10 @@ import { import { stripNoiseMessages } from '../../../src/shared/native-chat-noise' import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold' import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' -import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers' +import { + normalizeImageTranscriptMessages, + stripImagePromptMarkersFromTextBlocks +} from './mobile-native-chat-image-transcript-markers' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' /** The centered empty-state copy for a chat with no messages, mirroring the @@ -43,10 +46,15 @@ export type MobileNativeChatPendingItem = { images?: string[] } -export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] { +export function foldMobileNativeChatMessages( + messages: NativeChatMessage[], + hasEarlierHistory = false +): NativeChatMessage[] { // Normalize first (desktop assembler parity): image marker turns fold into // image-ref blocks instead of rendering as raw `[Image: …]` text. - return foldToolMessages(stripNoiseMessages(normalizeImageTranscriptMessages(messages))) + return foldToolMessages( + stripNoiseMessages(normalizeImageTranscriptMessages(messages, { hasEarlierHistory })) + ) } /** Assemble the list data the chat renders: the folded transcript, then a @@ -82,7 +90,15 @@ export function buildMobileNativeChatTransientData({ blocks.push({ type: 'image-ref', url: previews[previewIndex] }) previewIndex += 1 } - return { ...message, blocks } + // Why: a bound preview is positive proof this turn echoes an image sent from + // this device (`findLandedImagePreviewEchoes` binds it to nothing else), so + // its markers are placeholders — no visible source run needed to vouch for + // them. Without this a marker-only host echo captions the user's own photo + // with a literal `[Image #1]`. Bounded by the images the turn now shows. + return { + ...message, + blocks: stripImagePromptMarkersFromTextBlocks(blocks, blocks.filter(isImageRefBlock).length) + } }) const data: NativeChatMessage[] = [ ...renderedFolded, diff --git a/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts index 3744c70d570f..72ca4c96e0d1 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts @@ -6,11 +6,12 @@ import { assembleNativeChatSession } from './native-chat-session-assembler' export function prepareNativeChatLiveMessages( messages: NativeChatMessage[], - agent: AgentType + agent: AgentType, + hasEarlierHistory = false ): NativeChatMessage[] { const commandNames = new Set(getVerifiedNativeChatCommands(agent).map((command) => command.name)) const surfaced = surfaceSkillInvocationUserTurns(messages, commandNames) - const normalized = normalizeImageTranscriptMessages(surfaced) + const normalized = normalizeImageTranscriptMessages(surfaced, { hasEarlierHistory }) if (!hasMixedSources(normalized)) { return normalized } @@ -18,7 +19,8 @@ export function prepareNativeChatLiveMessages( return assembleNativeChatSession({ sources: { transcript: surfaced }, sessionId: null, - agent + agent, + hasEarlierHistory }).messages } diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index a770df39cf5a..d9b9dbfe54d7 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -103,19 +103,27 @@ export function advancedNativeChatUserContentCounts( return advanced } -/** User texts that already have a later non-user turn (ready to prune echoes). */ +/** A transcript user row as the glue matcher sees it. `imageCount` is the budget + * that lets the row own image-carrying pendings — a folded image turn must stay + * glueable, but a text-only row must never consume a send that carried a photo. */ +export type NativeChatUserTextRow = { text: string; imageCount: number } + +function nativeChatUserTextRow(message: NativeChatMessage): NativeChatUserTextRow | null { + const text = nativeChatUserMessageMatchText(message) + return text ? { text, imageCount: nativeChatUserMessageImageEvidenceCount(message) } : null +} + +/** User rows that already have a later non-user turn (ready to prune echoes). */ export function advancedNativeChatUserTexts( messages: readonly NativeChatMessage[] -): readonly string[] { - const advanced: string[] = [] - const waiting: string[] = [] +): readonly NativeChatUserTextRow[] { + const advanced: NativeChatUserTextRow[] = [] + const waiting: NativeChatUserTextRow[] = [] for (const message of messages) { if (message.role === 'user') { - const text = message.blocks.some(isImageRefBlock) - ? null - : nativeChatUserMessageMatchText(message) - if (text) { - waiting.push(text) + const row = nativeChatUserTextRow(message) + if (row) { + waiting.push(row) } continue } @@ -125,20 +133,18 @@ export function advancedNativeChatUserTexts( return advanced } -/** All user texts (for hiding optimistic echoes once the turn exists). */ +/** All user rows (for hiding optimistic echoes once the turn exists). */ export function matchingNativeChatUserTexts( messages: readonly NativeChatMessage[] -): readonly string[] { - const texts: string[] = [] +): readonly NativeChatUserTextRow[] { + const rows: NativeChatUserTextRow[] = [] for (const message of messages) { - const text = message.blocks.some(isImageRefBlock) - ? null - : nativeChatUserMessageMatchText(message) - if (text) { - texts.push(text) + const row = message.role === 'user' ? nativeChatUserTextRow(message) : null + if (row) { + rows.push(row) } } - return texts + return rows } /** @@ -183,34 +189,40 @@ export function countLeadingPendingTextsGluedToUserText( * matches stay in the content-key/occurrence path so repeated prompts and * send boundaries keep their existing semantics. * - * `userTexts` must already be filtered to rows after the oldest entry's send + * `userRows` must already be filtered to rows after the oldest entry's send * boundary — this matcher has no clock of its own. */ export function selectPendingIndicesRepresentedByUserTexts( pending: readonly NativeChatPendingOccurrence[], - userTexts: readonly string[] + userRows: readonly NativeChatUserTextRow[] ): Set { const represented = new Set() - if (pending.length < 2 || userTexts.length === 0) { + if (pending.length < 2 || userRows.length === 0) { return represented } const remaining = pending.map((entry, index) => ({ index, text: nativeChatPendingMatchText(entry), - hasImages: Boolean(entry.imagePaths?.some(Boolean)) + imageCount: entry.imagePaths?.filter(Boolean).length ?? 0 })) - for (const userText of userTexts) { - const open = remaining.filter( - (entry) => !entry.hasImages && !represented.has(entry.index) && entry.text.length > 0 - ) + for (const row of userRows) { + const open = remaining.filter((entry) => !represented.has(entry.index) && entry.text.length > 0) const gluedCount = countLeadingPendingTextsGluedToUserText( open.map((entry) => entry.text), - userText + row.text ) // Why: gluedCount === 1 is an exact match — leave it to occurrence counting. if (gluedCount < 2) { continue } + // Why: a folded image turn must stay glueable, but a row can only own as many + // image-carrying sends as it actually shows evidence for. + const gluedImages = open + .slice(0, gluedCount) + .reduce((count, entry) => count + entry.imageCount, 0) + if (gluedImages > row.imageCount) { + continue + } for (let i = 0; i < gluedCount; i += 1) { const entry = open[i] if (!entry) { diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 38559f6e05f1..078878f8e121 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -219,6 +219,25 @@ function advancedGlueTranscript(row: string): NativeChatMessage[] { return [...glueTranscript(row), assistantMessage('glue-answer', 'done', 6100)] } +/** The shape `normalizeImageTranscriptMessages` folds an image send into. */ +function foldedImageGlueTranscript(paths: readonly string[], row: string): NativeChatMessage[] { + return [ + userMessage('glue-history', 'hello', 900), + GLUE_BOUNDARY, + { + id: 'glue-row', + role: 'user', + blocks: [ + ...paths.map((path) => ({ type: 'image-ref' as const, path })), + { type: 'text' as const, text: row } + ], + timestamp: 6000, + source: 'transcript' + }, + assistantMessage('glue-answer', 'done', 6100) + ] +} + describe('glued rapid sends', () => { it('retires both echoes when a lost Enter glued the pair into one row', () => { const pending = [gluePending('p1', 'tell me a joke'), gluePending('p2', 'continue')] @@ -248,6 +267,47 @@ describe('glued rapid sends', () => { ).toEqual([]) }) + // Why: the folded image turn is the only transcript evidence of a glued send + // that carried a photo. Ignoring rows with image blocks left both optimistic + // bubbles — one a duplicate of the user's photo — on screen indefinitely. + it('retires a glued pair whose first send carried an image', () => { + const pending = [ + { ...gluePending('p1', 'look at this'), imagePaths: ['/tmp/a.png'] }, + gluePending('p2', 'please') + ] + + expect( + prunePendingSends(pending, foldedImageGlueTranscript(['/tmp/a.png'], 'look at this please')) + ).toEqual([]) + }) + + it('hides a glued image pair as soon as the folded row lands', () => { + const pending = [ + { ...gluePending('p1', 'look at this'), imagePaths: ['/tmp/a.png'] }, + gluePending('p2', 'please') + ] + + expect( + pendingSendsAsMessages( + pending, + foldedImageGlueTranscript(['/tmp/a.png'], 'look at this please') + ) + ).toEqual([]) + }) + + // Why: the image budget. A row carrying no image evidence must never retire a + // send whose photo has not landed, or the preview vanishes before it arrives. + it('does not let a text-only row consume a send that carried an image', () => { + const pending = [ + { ...gluePending('p1', 'look at this'), imagePaths: ['/tmp/a.png'] }, + gluePending('p2', 'please') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('look at this please'))).toEqual( + pending + ) + }) + it('retires three prompts collapsed into one row with mixed boundaries', () => { const pending = [ gluePending('p1', 'first'), diff --git a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts index 2b318554fc7a..a1db155bde9a 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts @@ -27,6 +27,9 @@ export type AssembleNativeChatSessionInput = { * 'working', or 'error' when out-of-band signals apply. */ status?: NativeChatSessionStatus error?: string + /** Forwarded to the image-marker fold: true when the transcript read window has + * older messages above it, so its head can sit inside a trimmed image run. */ + hasEarlierHistory?: boolean } // Why: a turn can surface from several sources with different ids (a hook event @@ -132,15 +135,16 @@ export function compareMessages(a: NativeChatMessage, b: NativeChatMessage): num export function assembleNativeChatSession( input: AssembleNativeChatSessionInput ): NativeChatSession { - const { sources, sessionId, agent, status, error } = input + const { sources, sessionId, agent, status, error, hasEarlierHistory } = input // Process highest priority first so a later, lower-priority duplicate is // dropped rather than overwriting. Within a source, order is preserved. const ordered: NativeChatMessage[] = [ - ...normalizeImageTranscriptMessages(sources.transcript ?? []), + ...normalizeImageTranscriptMessages(sources.transcript ?? [], { hasEarlierHistory }), ...(sources.hook ?? []), // Scrape segments carry the same raw `[Image: source: …]` markers (e.g. from - // scrollback before the transcript loads), so normalize them too. + // scrollback before the transcript loads), so normalize them too. `hasMore` + // describes transcript pagination only, so it is deliberately not applied here. ...normalizeImageTranscriptMessages(sources.scrape ?? []) ] diff --git a/src/renderer/src/components/native-chat/use-native-chat-assembled-messages.ts b/src/renderer/src/components/native-chat/use-native-chat-assembled-messages.ts index 7ec4beab5247..644efb0ba0e6 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-assembled-messages.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-assembled-messages.ts @@ -43,9 +43,12 @@ export function useNativeChatAssembledMessages(args: { sessionId: string | null baseMessages: readonly NativeChatMessage[] appended: NativeChatMessage[] + /** True while older history is still pageable, so the window head can sit + * inside an image run whose source turns were trimmed. */ + hasEarlierHistory?: boolean }): { assembledMessages: NativeChatMessage[]; normalizedMessages: NativeChatMessage[] } { const committedCacheRef = useRef(null) - const { agent, sessionId, baseMessages, appended } = args + const { agent, sessionId, baseMessages, appended, hasEarlierHistory = false } = args const assembly = useMemo(() => { const committed = committedCacheRef.current @@ -79,8 +82,8 @@ export function useNativeChatAssembledMessages(args: { }, [assembly]) const normalizedMessages = useMemo( - () => prepareNativeChatLiveMessages(assembly.assembledMessages, agent), - [agent, assembly.assembledMessages] + () => prepareNativeChatLiveMessages(assembly.assembledMessages, agent, hasEarlierHistory), + [agent, assembly.assembledMessages, hasEarlierHistory] ) return { assembledMessages: assembly.assembledMessages, normalizedMessages } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index 6044da306732..c701f61929de 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -309,7 +309,8 @@ export function useNativeChatLiveSession( agent, sessionId, baseMessages, - appended + appended, + hasEarlierHistory: hasMore }) return useMemo(() => { diff --git a/src/shared/native-chat-image-transcript-markers.test.ts b/src/shared/native-chat-image-transcript-markers.test.ts index 16ffff4267bc..e688b7e50ee5 100644 --- a/src/shared/native-chat-image-transcript-markers.test.ts +++ b/src/shared/native-chat-image-transcript-markers.test.ts @@ -343,3 +343,67 @@ describe('normalizeImageTranscriptMessages', () => { expect(normalizeImageTranscriptMessages([assistant])).toEqual([assistant]) }) }) + +// The read window is a hard count slice (`boundNativeChatWindow`), so as a session +// ages past the limit its head cuts straight through `[Image: source: …]` runs. +// Without the paging signal the fold reads a trimmed run as "the user typed these +// markers" and leaks agent-internal markup into a message they already sent. +describe('normalizeImageTranscriptMessages with earlier history above the window', () => { + it('strips markers from a head prompt whose whole source run was trimmed', () => { + const out = normalizeImageTranscriptMessages([userText('b', '[Image #1] what do you see')], { + hasEarlierHistory: true + }) + expect(out[0]!.blocks).toEqual([{ type: 'text', text: 'what do you see' }]) + }) + + it('spends an unbounded budget on a run that starts at the window head', () => { + const out = normalizeImageTranscriptMessages( + [ + userText('a', '[Image: source: /tmp/b.png]'), + userText('b', '[Image #1] [Image #2] compare these') + ], + { hasEarlierHistory: true } + ) + expect(out).toHaveLength(1) + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/b.png' }, + { type: 'text', text: 'compare these' } + ]) + }) + + // Why: only the head is ambiguous. Anywhere else the source run would be visible + // above the prompt, so its absence really is proof the markers are user text — + // this is the STA-4363 case and it must survive the paging signal. + it('keeps literal markers verbatim when the turn is not at the window head', () => { + const out = normalizeImageTranscriptMessages( + [ + { ...userText('a', 'hello'), role: 'assistant' as const }, + userText('b', 'use [Image #1] as a label') + ], + { hasEarlierHistory: true } + ) + expect(out[1]!.blocks).toEqual([{ type: 'text', text: 'use [Image #1] as a label' }]) + }) + + it('keeps a head prompt verbatim when no older history exists', () => { + const out = normalizeImageTranscriptMessages([userText('b', '[Image #1] what do you see')], { + hasEarlierHistory: false + }) + expect(out[0]!.blocks).toEqual([{ type: 'text', text: '[Image #1] what do you see' }]) + }) + + it('still bounds the strip by image count for a run below the head', () => { + const out = normalizeImageTranscriptMessages( + [ + { ...userText('z', 'earlier'), role: 'assistant' as const }, + userText('a', '[Image: source: /tmp/b.png]'), + userText('b', '[Image #1] compare with the [Image #2] I mentioned') + ], + { hasEarlierHistory: true } + ) + expect(out[1]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/b.png' }, + { type: 'text', text: 'compare with the [Image #2] I mentioned' } + ]) + }) +}) diff --git a/src/shared/native-chat-image-transcript-markers.ts b/src/shared/native-chat-image-transcript-markers.ts index e27006f0a3ac..f9e88a926d8f 100644 --- a/src/shared/native-chat-image-transcript-markers.ts +++ b/src/shared/native-chat-image-transcript-markers.ts @@ -101,7 +101,7 @@ export function nativeChatUserMessageMatchText(message: NativeChatMessage): stri /** `limit` is the number of images the run actually carried. Only that many * markers can be placeholders; the rest are text the user typed. */ -function stripImagePromptMarkersFromTextBlocks( +export function stripImagePromptMarkersFromTextBlocks( blocks: readonly NativeChatBlock[], limit: number ): NativeChatBlock[] { @@ -163,11 +163,29 @@ export function nativeChatUserMessageImageEvidenceCount(message: NativeChatMessa return Math.max(imageRefCount, countImagePromptMarkers(message)) } +export type NormalizeImageTranscriptOptions = { + /** + * True when older messages exist above this window. The read window is a hard + * count slice, so its head can sit *inside* an image run whose + * `[Image: source: …]` turns were trimmed. Markers at the head are then + * un-anchorable evidence rather than proof the user typed them, and stripping + * is the safer read: leaving them shows agent-internal markup in a message the + * user already sent, and the run's real image count is no longer knowable, so + * the count bound can't apply either. + * + * Only the head is ambiguous — a marker turn anywhere else in the window would + * have its source run visible above it, so its absence there is real evidence. + */ + hasEarlierHistory?: boolean +} + /** Claude records image paths as source turns followed by a prompt carrying * image markers. Merge the whole run back into one native user turn. */ export function normalizeImageTranscriptMessages( - messages: readonly NativeChatMessage[] + messages: readonly NativeChatMessage[], + options?: NormalizeImageTranscriptOptions ): NativeChatMessage[] { + const headMayBeMidRun = options?.hasEarlierHistory === true let normalized: NativeChatMessage[] | null = null for (let index = 0; index < messages.length; index += 1) { const message = messages[index]! @@ -195,11 +213,14 @@ export function normalizeImageTranscriptMessages( prompt.source === message.source && hasImagePromptMarker(prompt) ) { + // Why: a run touching the window head may have lost earlier source turns, + // so the visible image count is a floor, not the run's real size. + const limit = headMayBeMidRun && index === 0 ? Number.POSITIVE_INFINITY : imagePaths.length normalized.push({ ...prompt, blocks: [ ...imagePaths.map((path) => ({ type: 'image-ref' as const, path })), - ...stripImagePromptMarkersFromTextBlocks(prompt.blocks, imagePaths.length) + ...stripImagePromptMarkersFromTextBlocks(prompt.blocks, limit) ] }) index = nextIndex @@ -211,7 +232,12 @@ export function normalizeImageTranscriptMessages( }) continue } - const blocks = removeEmptyFirstTextBlock(message.blocks) + // Why: the whole source run above the head can be trimmed away, leaving the + // prompt alone at index 0 with markers no anchor can vouch for. + const blocks = + headMayBeMidRun && index === 0 && hasImagePromptMarker(message) + ? stripImagePromptMarkersFromTextBlocks(message.blocks, Number.POSITIVE_INFINITY) + : removeEmptyFirstTextBlock(message.blocks) if (blocks === message.blocks) { normalized?.push(message) } else { From 2719292973adb7baf2d8d2aa316e9fc20c2bbfea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:33:35 -0700 Subject: [PATCH 16/44] test(native-chat): pin the paging signal end-to-end through the live path The fold's own tests pass the option directly, so a plumbing break anywhere between the session hook and the fold would leave them green while the fix did nothing in the app. Covers the mixed-source reassembly branch, which re-assembles instead of returning the first pass and so needs the signal handed on separately. --- ...e-chat-preassembled-session-parity.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts index eb7f2f0d617f..41d970eb93eb 100644 --- a/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts @@ -401,3 +401,33 @@ function mulberry32(seed: number): () => number { return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 } } + +// The paging signal is only worth anything if it survives the whole live path. +// A flag that stops at a call site is dead code the pure-function tests can't see. +describe('prepareNativeChatLiveMessages forwards the paging signal to the marker fold', () => { + const trimmedRun = [ + message('u1', { role: 'user', blocks: [{ type: 'text', text: '[Image #1] what do you see' }] }) + ] + + it('strips a head marker run when older history is still pageable', () => { + const out = prepareNativeChatLiveMessages(trimmedRun, 'claude', true) + expect(out[0]!.blocks).toEqual([{ type: 'text', text: 'what do you see' }]) + }) + + it('keeps the marker verbatim when the window holds the whole conversation', () => { + const out = prepareNativeChatLiveMessages(trimmedRun, 'claude', false) + expect(out[0]!.blocks).toEqual([{ type: 'text', text: '[Image #1] what do you see' }]) + }) + + // Why: the mixed-source branch re-assembles instead of returning the first + // pass, so it needs the signal handed on independently. + it('forwards the signal through the mixed-source reassembly branch', () => { + const mixed = [ + ...trimmedRun, + message('h1', { source: 'hook', timestamp: 5, blocks: [{ type: 'text', text: 'thinking' }] }) + ] + const out = prepareNativeChatLiveMessages(mixed, 'claude', true) + const user = out.find((m) => m.role === 'user') + expect(user!.blocks).toEqual([{ type: 'text', text: 'what do you see' }]) + }) +}) From c245f1c1f456a29341fec59062ecc391326a1f08 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:40:14 -0700 Subject: [PATCH 17/44] fix(native-chat): budget the glue row by image blocks, not marker text Review found the image budget was read from nativeChatUserMessageImageEvidenceCount, which counts literal `[Image #n]` text as image evidence. That is correct for content keys, but as a glue budget it let a row showing no photo retire a send that carried one: pendings {'a' + photo} and {'b [Image #1]'} against the text-only row 'a b [Image #1]' scored a budget of 1 and consumed both, erasing the user's preview before the photo landed. The budget now counts image-ref blocks only. Under-counting costs at most a lingering duplicate bubble; over-counting loses the photo. --- .../native-chat/native-chat-pending-occurrence.ts | 8 +++++++- .../native-chat/native-chat-pending.test.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index d9b9dbfe54d7..7c5de9d37f12 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -110,7 +110,13 @@ export type NativeChatUserTextRow = { text: string; imageCount: number } function nativeChatUserTextRow(message: NativeChatMessage): NativeChatUserTextRow | null { const text = nativeChatUserMessageMatchText(message) - return text ? { text, imageCount: nativeChatUserMessageImageEvidenceCount(message) } : null + // Why: image-ref blocks only, NOT `nativeChatUserMessageImageEvidenceCount` — + // that counts literal `[Image #n]` text as evidence, which is right for content + // keys but would let a row showing no photo retire a send that carried one, + // erasing the user's preview before it lands. Under-counting only costs a + // duplicate bubble; over-counting loses the photo. + const imageCount = message.blocks.filter(isImageRefBlock).length + return text ? { text, imageCount } : null } /** User rows that already have a later non-user turn (ready to prune echoes). */ diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 078878f8e121..7461df7201c7 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -308,6 +308,18 @@ describe('glued rapid sends', () => { ) }) + // Why: the row's budget counts image blocks, never `[Image #n]` text. Marker + // text is image *evidence* for content keys, but a row showing no photo must + // not retire a send that carried one — that erases the preview before it lands. + it('does not let literal marker text inflate a text-only row image budget', () => { + const pending = [ + { ...gluePending('p1', 'a'), imagePaths: ['/tmp/photo.png'] }, + gluePending('p2', 'b [Image #1]') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('a b [Image #1]'))).toEqual(pending) + }) + it('retires three prompts collapsed into one row with mixed boundaries', () => { const pending = [ gluePending('p1', 'first'), From ea4edc78e3e11b583e7815a2a8469483a618b67a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:42:30 -0700 Subject: [PATCH 18/44] fix(native-chat): stop the preview strip double-spending the fold's budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review caught the render-time strip re-breaking the very ticket it sits in. The fold already spends one marker per real `[Image: source: …]` turn and deliberately keeps any surplus as the user's own words. Charging the bound preview for those same images spent the budget twice: a turn folded to image-ref + "compare with the [Image #2] I mentioned" rendered as "compare with the I mentioned", deleting text the user typed. The budget now counts only path-less image blocks — the previews appended for a marker-only echo, which no source turn ever vouched for. --- .../mobile-native-chat-render-data.test.ts | 20 +++++++++++++++++++ .../session/mobile-native-chat-render-data.ts | 13 +++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index 263a40b37f6f..33f22306c905 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -202,6 +202,26 @@ describe('buildMobileNativeChatTransientData', () => { ]) }) + // Why: a block that already has a path came from a real source turn, so the + // fold spent that marker and deliberately kept the surplus one as the user's + // own words. Charging the preview for it again re-breaks STA-4363 at render. + it('does not re-strip a surplus marker the fold deliberately kept', () => { + const result = buildMobileNativeChatTransientData({ + folded: foldMobileNativeChatMessages([ + user('src', '[Image: source: /tmp/a.png]'), + user('prompt', '[Image #1] compare with the [Image #2] I mentioned') + ]), + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } + }) + + expect(result.data[0]?.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png', url: 'file:///phone-photo.jpg' }, + { type: 'text', text: 'compare with the [Image #2] I mentioned' } + ]) + }) + // Why: no preview binding means no evidence, so the ticket's rule still holds. it('leaves marker text alone when no preview is bound', () => { const result = buildMobileNativeChatTransientData({ diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index d6ad190b3c1b..5eb7313096f9 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -94,11 +94,14 @@ export function buildMobileNativeChatTransientData({ // this device (`findLandedImagePreviewEchoes` binds it to nothing else), so // its markers are placeholders — no visible source run needed to vouch for // them. Without this a marker-only host echo captions the user's own photo - // with a literal `[Image #1]`. Bounded by the images the turn now shows. - return { - ...message, - blocks: stripImagePromptMarkersFromTextBlocks(blocks, blocks.filter(isImageRefBlock).length) - } + // with a literal `[Image #1]`. + // + // Budget counts ONLY path-less blocks — the previews appended just above. A + // block with a path came from a real `[Image: source: …]` turn, so the fold + // already spent that marker; charging for it again would re-strip a surplus + // marker the fold deliberately preserved as the user's own words. + const unvouchedImages = blocks.filter((block) => isImageRefBlock(block) && !block.path).length + return { ...message, blocks: stripImagePromptMarkersFromTextBlocks(blocks, unvouchedImages) } }) const data: NativeChatMessage[] = [ ...renderedFolded, From 0f93e9511a883df0c3219af8cfc8f8ae5d8a6b2e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:48:33 -0700 Subject: [PATCH 19/44] fix(native-chat): keep image sends from blocking the glue prefix Self-review caught a regression in the round-1 glue change. Glue matches a LEADING run of pendings, so admitting image-carrying sends into the candidate list put them in that prefix. A photo-less transcript row can never consume an image send, so an unrelated queued photo stranded every text echo behind it: pendings {'photo caption'+photo, 'a', 'b'} against the row 'a b' retired nothing, where before the change it retired both text echoes. A row now only considers image sends when it has image evidence of its own. The budget check still bounds how many it can then own. --- .../native-chat/native-chat-pending-occurrence.ts | 10 +++++++++- .../native-chat/native-chat-pending.test.ts | 13 +++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index 7c5de9d37f12..28b44c874faf 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -212,7 +212,15 @@ export function selectPendingIndicesRepresentedByUserTexts( imageCount: entry.imagePaths?.filter(Boolean).length ?? 0 })) for (const row of userRows) { - const open = remaining.filter((entry) => !represented.has(entry.index) && entry.text.length > 0) + // Why: a photo-less row skips image sends entirely rather than letting one sit + // in the prefix and block the match — glue needs a LEADING run, so an + // unrelated image pending ahead of the queue would strand every echo behind it. + const open = remaining.filter( + (entry) => + !represented.has(entry.index) && + entry.text.length > 0 && + (entry.imageCount === 0 || row.imageCount > 0) + ) const gluedCount = countLeadingPendingTextsGluedToUserText( open.map((entry) => entry.text), row.text diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 7461df7201c7..1485dda272b1 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -320,6 +320,19 @@ describe('glued rapid sends', () => { expect(prunePendingSends(pending, advancedGlueTranscript('a b [Image #1]'))).toEqual(pending) }) + // Why: glue matches a LEADING run of pendings. Letting an unrelated image send + // sit in that prefix stranded every text echo queued behind it, because a + // photo-less row can never consume the image send that blocks the way. + it('retires a glued text pair queued behind an unrelated image send', () => { + const pending = [ + { ...gluePending('p0', 'photo caption'), imagePaths: ['/tmp/p.png'] }, + gluePending('p1', 'a'), + gluePending('p2', 'b') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('a b'))).toEqual([pending[0]]) + }) + it('retires three prompts collapsed into one row with mixed boundaries', () => { const pending = [ gluePending('p1', 'first'), From b489ca25c8ed3bdbf97f8bacf7fb64f42e8c9676 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:01:04 -0700 Subject: [PATCH 20/44] fix(native-chat): restore glue for marker-only echoes, drop gap matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A base-vs-head differential over eight glue shapes found three more defects. GAP MATCHING (a regression my own previous commit introduced, and one the PR already had in another form). Skipping a send the row cannot afford let glue match a NON-CONTIGUOUS subsequence: sends 'fix', 'the'+photo, 'bug' against a row reading 'fix bug' retired 'fix' and 'bug' even though a third send landed between them, so a bubble vanished while its send was still in flight. Base never did this. Every unretired send is a candidate again, in send order, and the budget rejects unaffordable groups instead of stepping over them. MARKER-ONLY HOST ECHOES. Some hosts echo an image send as bare '[Image #n]' text with no '[Image: source: …]' turn, so the markers are the only evidence the photo landed. Reading such a row literally matched nothing and left both optimistic bubbles — one a duplicate photo — on screen forever. A row now carries a second identity for that case, tried only after the literal reading fails, so a marker the user actually typed still matches verbatim first. Differential vs bf6dc6f now agrees on seven of eight shapes. The eighth is a deliberate improvement: base retired a photo-carrying send against a row showing no photo, erasing the preview before it landed. --- .../native-chat-pending-occurrence.ts | 69 ++++++++++++------- .../native-chat/native-chat-pending.test.ts | 52 ++++++++++++-- 2 files changed, 92 insertions(+), 29 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index 28b44c874faf..1979062fdd41 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -1,4 +1,5 @@ import { + countImagePromptMarkers, nativeChatUserMessageImageEvidenceCount, nativeChatUserMessageMatchText, nativeChatUserTextMatchText, @@ -103,20 +104,38 @@ export function advancedNativeChatUserContentCounts( return advanced } +/** One identity a row can be matched on: the text, and how many image-carrying + * sends that reading of the row can account for. */ +export type NativeChatUserTextIdentity = { text: string; imageCount: number } + /** A transcript user row as the glue matcher sees it. `imageCount` is the budget * that lets the row own image-carrying pendings — a folded image turn must stay * glueable, but a text-only row must never consume a send that carried a photo. */ -export type NativeChatUserTextRow = { text: string; imageCount: number } +export type NativeChatUserTextRow = NativeChatUserTextIdentity & { + /** Fallback reading for hosts that echo an image send as bare `[Image #n]` text + * with no `[Image: source: …]` turn. There the markers are the only evidence + * the photo landed, so they vouch for that many sends. Tried only after the + * literal reading fails, so a marker the user actually typed still matches + * verbatim first (STA-4363). */ + markerEcho?: NativeChatUserTextIdentity +} function nativeChatUserTextRow(message: NativeChatMessage): NativeChatUserTextRow | null { const text = nativeChatUserMessageMatchText(message) + if (!text) { + return null + } // Why: image-ref blocks only, NOT `nativeChatUserMessageImageEvidenceCount` — // that counts literal `[Image #n]` text as evidence, which is right for content // keys but would let a row showing no photo retire a send that carried one, // erasing the user's preview before it lands. Under-counting only costs a // duplicate bubble; over-counting loses the photo. const imageCount = message.blocks.filter(isImageRefBlock).length - return text ? { text, imageCount } : null + const markerCount = imageCount === 0 ? countImagePromptMarkers(message) : 0 + const strippedText = markerCount > 0 ? normalizedNativeChatUserMessageText(message) : null + return strippedText + ? { text, imageCount, markerEcho: { text: strippedText, imageCount: markerCount } } + : { text, imageCount } } /** User rows that already have a later non-user turn (ready to prune echoes). */ @@ -212,29 +231,31 @@ export function selectPendingIndicesRepresentedByUserTexts( imageCount: entry.imagePaths?.filter(Boolean).length ?? 0 })) for (const row of userRows) { - // Why: a photo-less row skips image sends entirely rather than letting one sit - // in the prefix and block the match — glue needs a LEADING run, so an - // unrelated image pending ahead of the queue would strand every echo behind it. - const open = remaining.filter( - (entry) => - !represented.has(entry.index) && - entry.text.length > 0 && - (entry.imageCount === 0 || row.imageCount > 0) - ) - const gluedCount = countLeadingPendingTextsGluedToUserText( - open.map((entry) => entry.text), - row.text - ) - // Why: gluedCount === 1 is an exact match — leave it to occurrence counting. - if (gluedCount < 2) { - continue + // Why: every unretired send stays a candidate, in send order. Dropping any of + // them here would let glue match a NON-CONTIGUOUS subsequence: with sends + // "fix", "the"+photo, "bug", a row reading "fix bug" would retire "fix" and + // "bug" even though a third send landed between them. The row can only be the + // glue of a contiguous run, so the budget below rejects unaffordable groups + // rather than skipping past them. + const open = remaining.filter((entry) => !represented.has(entry.index) && entry.text.length > 0) + const openTexts = open.map((entry) => entry.text) + let gluedCount = 0 + for (const identity of row.markerEcho ? [row, row.markerEcho] : [row]) { + const count = countLeadingPendingTextsGluedToUserText(openTexts, identity.text) + // Why: gluedCount === 1 is an exact match — leave it to occurrence counting. + if (count < 2) { + continue + } + // Why: a folded image turn must stay glueable, but a row can only own as many + // image-carrying sends as it actually shows evidence for. + const gluedImages = open.slice(0, count).reduce((total, entry) => total + entry.imageCount, 0) + if (gluedImages > identity.imageCount) { + continue + } + gluedCount = count + break } - // Why: a folded image turn must stay glueable, but a row can only own as many - // image-carrying sends as it actually shows evidence for. - const gluedImages = open - .slice(0, gluedCount) - .reduce((count, entry) => count + entry.imageCount, 0) - if (gluedImages > row.imageCount) { + if (gluedCount < 2) { continue } for (let i = 0; i < gluedCount; i += 1) { diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 1485dda272b1..fb3e7914acb5 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -320,17 +320,59 @@ describe('glued rapid sends', () => { expect(prunePendingSends(pending, advancedGlueTranscript('a b [Image #1]'))).toEqual(pending) }) - // Why: glue matches a LEADING run of pendings. Letting an unrelated image send - // sit in that prefix stranded every text echo queued behind it, because a - // photo-less row can never consume the image send that blocks the way. - it('retires a glued text pair queued behind an unrelated image send', () => { + // Why: some hosts echo an image send as bare `[Image #n]` text with no + // `[Image: source: …]` turn, so the markers are the only evidence the photo + // landed. Reading such a row literally left both echoes — one a duplicate + // photo — on screen forever. + it('retires a glued pair against a marker-only host echo row', () => { + const pending = [ + { ...gluePending('p1', 'look'), imagePaths: ['/tmp/a.png'] }, + gluePending('p2', 'here') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('[Image #1] look here'))).toEqual([]) + }) + + it('retires a glued pair when the host echo puts the marker last', () => { + const pending = [ + { ...gluePending('p1', 'look'), imagePaths: ['/tmp/a.png'] }, + gluePending('p2', 'here') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('look here [Image #1]'))).toEqual([]) + }) + + // Why: the literal reading is tried FIRST, so a marker the user actually typed + // still matches verbatim and never falls through to the marker-echo reading. + it('matches a user-typed marker literally rather than as an image echo', () => { + const pending = [gluePending('p1', 'what is [Image #1]'), gluePending('p2', 'about')] + + expect(prunePendingSends(pending, advancedGlueTranscript('what is [Image #1] about'))).toEqual( + [] + ) + }) + + // Why: glue is a CONTIGUOUS leading run. Skipping a send the row cannot afford + // would match a non-contiguous subsequence and retire echoes the row never + // represented — the bubble vanishes though its send is still in flight. + it('does not glue across an image send that landed between two text sends', () => { + const pending = [ + gluePending('p0', 'fix'), + { ...gluePending('p1', 'the'), imagePaths: ['/tmp/p.png'] }, + gluePending('p2', 'bug') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('fix bug'))).toEqual(pending) + }) + + it('does not let a row glue past a leading image send it cannot afford', () => { const pending = [ { ...gluePending('p0', 'photo caption'), imagePaths: ['/tmp/p.png'] }, gluePending('p1', 'a'), gluePending('p2', 'b') ] - expect(prunePendingSends(pending, advancedGlueTranscript('a b'))).toEqual([pending[0]]) + expect(prunePendingSends(pending, advancedGlueTranscript('a b'))).toEqual(pending) }) it('retires three prompts collapsed into one row with mixed boundaries', () => { From 416a00bdbe23e46541088cbfd75659c3bcc5ee21 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:09:37 -0700 Subject: [PATCH 21/44] fix(native-chat): make a textless send a glue barrier, not a skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review showed the contiguity guarantee had a hole, and that my own comment asserted it as an invariant the code did not hold. `open` dropped every send whose match text was empty, so glue stepped straight over them: sends 'fix', an uncaptioned photo, 'bug' against a row reading 'fix bug' retired 'fix' and 'bug' around a send that was still open. Pasting a photo and pressing Enter with no caption is an ordinary flow, and a send whose whole text is '[Image #n]' normalizes to empty for the same reason, so this is reachable. Such a send can never appear in a glued row, but it still separates the sends either side of it. The scan now stops at it instead of filtering it out, and the comment describes what the code actually guarantees. Not a regression — base stepped over these too — but the PR is where the invariant is now claimed, so it is where it should hold. --- .../native-chat-pending-occurrence.ts | 25 +++++++++++----- .../native-chat/native-chat-pending.test.ts | 30 +++++++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index 1979062fdd41..01501948fc36 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -231,13 +231,24 @@ export function selectPendingIndicesRepresentedByUserTexts( imageCount: entry.imagePaths?.filter(Boolean).length ?? 0 })) for (const row of userRows) { - // Why: every unretired send stays a candidate, in send order. Dropping any of - // them here would let glue match a NON-CONTIGUOUS subsequence: with sends - // "fix", "the"+photo, "bug", a row reading "fix bug" would retire "fix" and - // "bug" even though a third send landed between them. The row can only be the - // glue of a contiguous run, so the budget below rejects unaffordable groups - // rather than skipping past them. - const open = remaining.filter((entry) => !represented.has(entry.index) && entry.text.length > 0) + // Why: a row can only be the glue of a CONTIGUOUS run of sends, so the scan + // walks them in send order and stops at the first one the row cannot contain. + // Filtering instead of stopping would match a non-contiguous subsequence and + // retire echoes whose sends are still in flight — with "fix", "the"+photo, + // "bug" a row reading "fix bug" would retire "fix" and "bug" around the + // photo. A send with no match text (an uncaptioned photo, or one whose whole + // text was an `[Image #n]` placeholder) can never appear in a glued row, but + // it still SEPARATES the sends either side of it, so it is a barrier too. + const open: typeof remaining = [] + for (const entry of remaining) { + if (represented.has(entry.index)) { + continue + } + if (entry.text.length === 0) { + break + } + open.push(entry) + } const openTexts = open.map((entry) => entry.text) let gluedCount = 0 for (const identity of row.markerEcho ? [row, row.markerEcho] : [row]) { diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index fb3e7914acb5..e5f17240c18f 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -320,6 +320,36 @@ describe('glued rapid sends', () => { expect(prunePendingSends(pending, advancedGlueTranscript('a b [Image #1]'))).toEqual(pending) }) + // Why: an uncaptioned photo send has no match text, so it can never appear in a + // glued row — but it still separates the sends either side of it. Skipping it + // would retire those two echoes while their sends are still in flight. + it('does not glue across an uncaptioned image send', () => { + const pending = [ + gluePending('p0', 'fix'), + { ...gluePending('p1', ''), imagePaths: ['/tmp/p.png'] }, + gluePending('p2', 'bug') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('fix bug'))).toEqual(pending) + }) + + // Why: a send whose whole text is the placeholder normalizes to empty too. + it('does not glue across a marker-only image send', () => { + const pending = [ + gluePending('p0', 'fix'), + { ...gluePending('p1', '[Image #1]'), imagePaths: ['/tmp/p.png'] }, + gluePending('p2', 'bug') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('fix bug'))).toEqual(pending) + }) + + it('does not glue across a whitespace-only send', () => { + const pending = [gluePending('p0', 'fix'), gluePending('p1', ' '), gluePending('p2', 'bug')] + + expect(prunePendingSends(pending, advancedGlueTranscript('fix bug'))).toEqual(pending) + }) + // Why: some hosts echo an image send as bare `[Image #n]` text with no // `[Image: source: …]` turn, so the markers are the only evidence the photo // landed. Reading such a row literally left both echoes — one a duplicate From dac5273f93b955eb16def400a524d11d6a6d94cb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:17:27 -0700 Subject: [PATCH 22/44] fix(native-chat): name the window head by id, not by list position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the index-0 rule keying off a row that is not always the window head, because callers hand the fold lists that were merged across sources and re-sorted. `compareMessages` maps a null timestamp to NEGATIVE_INFINITY, so index 0 is whatever sorts first, not the oldest paginated row: - A scrape or hook row with no timestamp sorts to index 0 and had its markers stripped, even though the assembler comment claimed scrape was exempt. Those sources are not paginated, so their markers are never un-anchorable — this was STA-4363 re-applied to an exempted source. - For a transcript whose rows carry no timestamps at all, the sort falls through to id order, so index 0 is the lexicographically smallest id. The rule fired on an arbitrary row. The option is now the head row's id, resolved by the session hook from the paginated read itself (`baseMessages[0]`), and the fold matches on identity. Position never enters into it. Known and accepted: `hasMoreNativeChatHistory` is `returnedCount >= limit`, so a transcript of exactly the window size reports more history when there is none, and a literal marker on that one row is still stripped. That over-estimate predates this work and is the same inference old hosts have always used. --- .../src/session/MobileNativeChatOverlay.tsx | 6 +++- .../session/mobile-native-chat-render-data.ts | 4 +-- .../native-chat-live-message-preparation.ts | 6 ++-- ...e-chat-preassembled-session-parity.test.ts | 8 ++--- .../native-chat-session-assembler.ts | 16 +++++---- .../use-native-chat-assembled-messages.ts | 13 +++---- .../use-native-chat-live-session.ts | 4 ++- ...tive-chat-image-transcript-markers.test.ts | 35 +++++++++++++++---- .../native-chat-image-transcript-markers.ts | 33 ++++++++++------- 9 files changed, 81 insertions(+), 44 deletions(-) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 6768598d4e7f..d1e53813d354 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -47,7 +47,11 @@ export function MobileNativeChatOverlay({ }: Props): React.JSX.Element | null { const session = controller.nativeChatSession const folded = useMemo( - () => foldMobileNativeChatMessages(session.messages, session.hasMore), + () => + foldMobileNativeChatMessages( + session.messages, + session.hasMore ? session.messages[0]?.id : undefined + ), [session.messages, session.hasMore] ) const streaming = useMobileNativeChatStreamingBubble( diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 5eb7313096f9..98e4069bbae7 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -48,12 +48,12 @@ export type MobileNativeChatPendingItem = { export function foldMobileNativeChatMessages( messages: NativeChatMessage[], - hasEarlierHistory = false + windowHeadMessageId?: string ): NativeChatMessage[] { // Normalize first (desktop assembler parity): image marker turns fold into // image-ref blocks instead of rendering as raw `[Image: …]` text. return foldToolMessages( - stripNoiseMessages(normalizeImageTranscriptMessages(messages, { hasEarlierHistory })) + stripNoiseMessages(normalizeImageTranscriptMessages(messages, { windowHeadMessageId })) ) } diff --git a/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts index 72ca4c96e0d1..00886437fcb9 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts @@ -7,11 +7,11 @@ import { assembleNativeChatSession } from './native-chat-session-assembler' export function prepareNativeChatLiveMessages( messages: NativeChatMessage[], agent: AgentType, - hasEarlierHistory = false + windowHeadMessageId?: string ): NativeChatMessage[] { const commandNames = new Set(getVerifiedNativeChatCommands(agent).map((command) => command.name)) const surfaced = surfaceSkillInvocationUserTurns(messages, commandNames) - const normalized = normalizeImageTranscriptMessages(surfaced, { hasEarlierHistory }) + const normalized = normalizeImageTranscriptMessages(surfaced, { windowHeadMessageId }) if (!hasMixedSources(normalized)) { return normalized } @@ -20,7 +20,7 @@ export function prepareNativeChatLiveMessages( sources: { transcript: surfaced }, sessionId: null, agent, - hasEarlierHistory + windowHeadMessageId }).messages } diff --git a/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts index 41d970eb93eb..ed8047a734f7 100644 --- a/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts @@ -409,13 +409,13 @@ describe('prepareNativeChatLiveMessages forwards the paging signal to the marker message('u1', { role: 'user', blocks: [{ type: 'text', text: '[Image #1] what do you see' }] }) ] - it('strips a head marker run when older history is still pageable', () => { - const out = prepareNativeChatLiveMessages(trimmedRun, 'claude', true) + it('strips the named window-head run when older history is still pageable', () => { + const out = prepareNativeChatLiveMessages(trimmedRun, 'claude', 'u1') expect(out[0]!.blocks).toEqual([{ type: 'text', text: 'what do you see' }]) }) it('keeps the marker verbatim when the window holds the whole conversation', () => { - const out = prepareNativeChatLiveMessages(trimmedRun, 'claude', false) + const out = prepareNativeChatLiveMessages(trimmedRun, 'claude') expect(out[0]!.blocks).toEqual([{ type: 'text', text: '[Image #1] what do you see' }]) }) @@ -426,7 +426,7 @@ describe('prepareNativeChatLiveMessages forwards the paging signal to the marker ...trimmedRun, message('h1', { source: 'hook', timestamp: 5, blocks: [{ type: 'text', text: 'thinking' }] }) ] - const out = prepareNativeChatLiveMessages(mixed, 'claude', true) + const out = prepareNativeChatLiveMessages(mixed, 'claude', 'u1') const user = out.find((m) => m.role === 'user') expect(user!.blocks).toEqual([{ type: 'text', text: 'what do you see' }]) }) diff --git a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts index a1db155bde9a..568cd25da13e 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts @@ -27,9 +27,10 @@ export type AssembleNativeChatSessionInput = { * 'working', or 'error' when out-of-band signals apply. */ status?: NativeChatSessionStatus error?: string - /** Forwarded to the image-marker fold: true when the transcript read window has - * older messages above it, so its head can sit inside a trimmed image run. */ - hasEarlierHistory?: boolean + /** Forwarded to the image-marker fold: id of the transcript read window's + * oldest row when older history exists above it, so that row alone can sit + * inside a trimmed image run. */ + windowHeadMessageId?: string } // Why: a turn can surface from several sources with different ids (a hook event @@ -135,16 +136,17 @@ export function compareMessages(a: NativeChatMessage, b: NativeChatMessage): num export function assembleNativeChatSession( input: AssembleNativeChatSessionInput ): NativeChatSession { - const { sources, sessionId, agent, status, error, hasEarlierHistory } = input + const { sources, sessionId, agent, status, error, windowHeadMessageId } = input // Process highest priority first so a later, lower-priority duplicate is // dropped rather than overwriting. Within a source, order is preserved. const ordered: NativeChatMessage[] = [ - ...normalizeImageTranscriptMessages(sources.transcript ?? [], { hasEarlierHistory }), + ...normalizeImageTranscriptMessages(sources.transcript ?? [], { windowHeadMessageId }), ...(sources.hook ?? []), // Scrape segments carry the same raw `[Image: source: …]` markers (e.g. from - // scrollback before the transcript loads), so normalize them too. `hasMore` - // describes transcript pagination only, so it is deliberately not applied here. + // scrollback before the transcript loads), so normalize them too. The window + // head is a transcript row, so it can never name a scrape row — scrape is not + // paginated and its markers are never un-anchorable. ...normalizeImageTranscriptMessages(sources.scrape ?? []) ] diff --git a/src/renderer/src/components/native-chat/use-native-chat-assembled-messages.ts b/src/renderer/src/components/native-chat/use-native-chat-assembled-messages.ts index 644efb0ba0e6..523af35792e9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-assembled-messages.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-assembled-messages.ts @@ -43,12 +43,13 @@ export function useNativeChatAssembledMessages(args: { sessionId: string | null baseMessages: readonly NativeChatMessage[] appended: NativeChatMessage[] - /** True while older history is still pageable, so the window head can sit - * inside an image run whose source turns were trimmed. */ - hasEarlierHistory?: boolean + /** Id of the read window's oldest row while older history is still pageable, + * so that row alone can sit inside an image run whose source turns were + * trimmed. Undefined when the window holds the whole conversation. */ + windowHeadMessageId?: string }): { assembledMessages: NativeChatMessage[]; normalizedMessages: NativeChatMessage[] } { const committedCacheRef = useRef(null) - const { agent, sessionId, baseMessages, appended, hasEarlierHistory = false } = args + const { agent, sessionId, baseMessages, appended, windowHeadMessageId } = args const assembly = useMemo(() => { const committed = committedCacheRef.current @@ -82,8 +83,8 @@ export function useNativeChatAssembledMessages(args: { }, [assembly]) const normalizedMessages = useMemo( - () => prepareNativeChatLiveMessages(assembly.assembledMessages, agent, hasEarlierHistory), - [agent, assembly.assembledMessages, hasEarlierHistory] + () => prepareNativeChatLiveMessages(assembly.assembledMessages, agent, windowHeadMessageId), + [agent, assembly.assembledMessages, windowHeadMessageId] ) return { assembledMessages: assembly.assembledMessages, normalizedMessages } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index c701f61929de..f40468322a6b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -310,7 +310,9 @@ export function useNativeChatLiveSession( sessionId, baseMessages, appended, - hasEarlierHistory: hasMore + // Why: the oldest row of the paginated read, not a position in the merged + // list — see NormalizeImageTranscriptOptions. + windowHeadMessageId: hasMore ? baseMessages[0]?.id : undefined }) return useMemo(() => { diff --git a/src/shared/native-chat-image-transcript-markers.test.ts b/src/shared/native-chat-image-transcript-markers.test.ts index e688b7e50ee5..fb7a7ba21def 100644 --- a/src/shared/native-chat-image-transcript-markers.test.ts +++ b/src/shared/native-chat-image-transcript-markers.test.ts @@ -351,7 +351,7 @@ describe('normalizeImageTranscriptMessages', () => { describe('normalizeImageTranscriptMessages with earlier history above the window', () => { it('strips markers from a head prompt whose whole source run was trimmed', () => { const out = normalizeImageTranscriptMessages([userText('b', '[Image #1] what do you see')], { - hasEarlierHistory: true + windowHeadMessageId: 'b' }) expect(out[0]!.blocks).toEqual([{ type: 'text', text: 'what do you see' }]) }) @@ -362,7 +362,7 @@ describe('normalizeImageTranscriptMessages with earlier history above the window userText('a', '[Image: source: /tmp/b.png]'), userText('b', '[Image #1] [Image #2] compare these') ], - { hasEarlierHistory: true } + { windowHeadMessageId: 'a' } ) expect(out).toHaveLength(1) expect(out[0]!.blocks).toEqual([ @@ -380,15 +380,13 @@ describe('normalizeImageTranscriptMessages with earlier history above the window { ...userText('a', 'hello'), role: 'assistant' as const }, userText('b', 'use [Image #1] as a label') ], - { hasEarlierHistory: true } + { windowHeadMessageId: 'a' } ) expect(out[1]!.blocks).toEqual([{ type: 'text', text: 'use [Image #1] as a label' }]) }) it('keeps a head prompt verbatim when no older history exists', () => { - const out = normalizeImageTranscriptMessages([userText('b', '[Image #1] what do you see')], { - hasEarlierHistory: false - }) + const out = normalizeImageTranscriptMessages([userText('b', '[Image #1] what do you see')], {}) expect(out[0]!.blocks).toEqual([{ type: 'text', text: '[Image #1] what do you see' }]) }) @@ -399,11 +397,34 @@ describe('normalizeImageTranscriptMessages with earlier history above the window userText('a', '[Image: source: /tmp/b.png]'), userText('b', '[Image #1] compare with the [Image #2] I mentioned') ], - { hasEarlierHistory: true } + { windowHeadMessageId: 'z' } ) expect(out[1]!.blocks).toEqual([ { type: 'image-ref', path: '/tmp/b.png' }, { type: 'text', text: 'compare with the [Image #2] I mentioned' } ]) }) + + // Why: the head is named by id, never by position. Callers pass lists that were + // merged across sources and re-sorted, so index 0 is whichever row sorts first: + // an unpaginated scrape/hook row, or — for a transcript with no timestamps — + // the lexicographically smallest id. Stripping either would delete user text. + it('ignores a row that merely sorts first but is not the named head', () => { + const out = normalizeImageTranscriptMessages( + [ + { ...userText('aaa-scrape', 'typed [Image #1] literally'), source: 'scrape' as const }, + userText('zzz-head', '[Image #1] what do you see') + ], + { windowHeadMessageId: 'zzz-head' } + ) + expect(out[0]!.blocks).toEqual([{ type: 'text', text: 'typed [Image #1] literally' }]) + expect(out[1]!.blocks).toEqual([{ type: 'text', text: 'what do you see' }]) + }) + + it('does nothing when the named head is not in the list', () => { + const out = normalizeImageTranscriptMessages([userText('b', '[Image #1] what do you see')], { + windowHeadMessageId: 'some-other-row' + }) + expect(out[0]!.blocks).toEqual([{ type: 'text', text: '[Image #1] what do you see' }]) + }) }) diff --git a/src/shared/native-chat-image-transcript-markers.ts b/src/shared/native-chat-image-transcript-markers.ts index f9e88a926d8f..d7619fe0f358 100644 --- a/src/shared/native-chat-image-transcript-markers.ts +++ b/src/shared/native-chat-image-transcript-markers.ts @@ -165,18 +165,24 @@ export function nativeChatUserMessageImageEvidenceCount(message: NativeChatMessa export type NormalizeImageTranscriptOptions = { /** - * True when older messages exist above this window. The read window is a hard - * count slice, so its head can sit *inside* an image run whose - * `[Image: source: …]` turns were trimmed. Markers at the head are then - * un-anchorable evidence rather than proof the user typed them, and stripping - * is the safer read: leaving them shows agent-internal markup in a message the - * user already sent, and the run's real image count is no longer knowable, so - * the count bound can't apply either. + * Id of the oldest row of the paginated transcript window, and only when older + * history exists above it. That row — alone — can sit *inside* an image run + * whose `[Image: source: …]` turns were trimmed away by the hard count slice. + * Its markers are then un-anchorable evidence rather than proof the user typed + * them, and stripping is the safer read: leaving them shows agent-internal + * markup in a message the user already sent, and the run's real image count is + * no longer knowable, so the count bound cannot apply either. * - * Only the head is ambiguous — a marker turn anywhere else in the window would - * have its source run visible above it, so its absence there is real evidence. + * Nowhere else is ambiguous — a marker turn further down would have its source + * run visible above it, so its absence there is real evidence. + * + * Deliberately an id, not a positional flag. Callers hand this function lists + * that have already been merged across sources and re-sorted, where index 0 is + * whichever row sorts first — a scrape or hook row that was never paginated, or + * for a transcript that carries no timestamps (some agents), the row with the + * lexicographically smallest id. Neither is the window head. */ - hasEarlierHistory?: boolean + windowHeadMessageId?: string } /** Claude records image paths as source turns followed by a prompt carrying @@ -185,7 +191,7 @@ export function normalizeImageTranscriptMessages( messages: readonly NativeChatMessage[], options?: NormalizeImageTranscriptOptions ): NativeChatMessage[] { - const headMayBeMidRun = options?.hasEarlierHistory === true + const windowHeadMessageId = options?.windowHeadMessageId let normalized: NativeChatMessage[] | null = null for (let index = 0; index < messages.length; index += 1) { const message = messages[index]! @@ -215,7 +221,8 @@ export function normalizeImageTranscriptMessages( ) { // Why: a run touching the window head may have lost earlier source turns, // so the visible image count is a floor, not the run's real size. - const limit = headMayBeMidRun && index === 0 ? Number.POSITIVE_INFINITY : imagePaths.length + const limit = + message.id === windowHeadMessageId ? Number.POSITIVE_INFINITY : imagePaths.length normalized.push({ ...prompt, blocks: [ @@ -235,7 +242,7 @@ export function normalizeImageTranscriptMessages( // Why: the whole source run above the head can be trimmed away, leaving the // prompt alone at index 0 with markers no anchor can vouch for. const blocks = - headMayBeMidRun && index === 0 && hasImagePromptMarker(message) + message.id === windowHeadMessageId && hasImagePromptMarker(message) ? stripImagePromptMarkersFromTextBlocks(message.blocks, Number.POSITIVE_INFINITY) : removeEmptyFirstTextBlock(message.blocks) if (blocks === message.blocks) { From d9d9d446473225cfb2539b52af93da4018e8ed91 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:20:50 -0700 Subject: [PATCH 23/44] fix(native-chat): only let a textless send break a glue run it is inside MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the barrier from the previous commit blocking groups it does not separate. A send with no match text ahead of the run separates nothing, but breaking on it emptied the candidate list and killed glue matching for every row: sends [uncaptioned photo, 'a', 'b'] against a row reading 'a b' retired nothing where base retired both text echoes. That one is not transient. A photo-only send whose host echo is a bare '[Image #n]' text row never retires on the exact-match path either, so the block persists and strands every later glued pair for the life of the pane. Such a send now only ends the scan once a run has started, which keeps the case the barrier was added for — 'fix', photo, 'bug' must not let a row reading 'fix bug' retire around a send still in flight. Also drops a dead `represented` guard in the same loop: retired entries are spliced out of `remaining`, so the scan never sees one. --- .../native-chat-pending-occurrence.ts | 26 +++++++++------- .../native-chat/native-chat-pending.test.ts | 30 +++++++++++++++++++ 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index 01501948fc36..3e7bb16fdc49 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -232,20 +232,24 @@ export function selectPendingIndicesRepresentedByUserTexts( })) for (const row of userRows) { // Why: a row can only be the glue of a CONTIGUOUS run of sends, so the scan - // walks them in send order and stops at the first one the row cannot contain. - // Filtering instead of stopping would match a non-contiguous subsequence and - // retire echoes whose sends are still in flight — with "fix", "the"+photo, - // "bug" a row reading "fix bug" would retire "fix" and "bug" around the - // photo. A send with no match text (an uncaptioned photo, or one whose whole - // text was an `[Image #n]` placeholder) can never appear in a glued row, but - // it still SEPARATES the sends either side of it, so it is a barrier too. + // walks `remaining` in send order (retired sends are spliced out below, so it + // is already the open list) and stops where the run breaks. + // + // A send with no match text — an uncaptioned photo, or one whose whole text + // was an `[Image #n]` placeholder — can never appear in a glued row. Ahead of + // the run it separates nothing, so skip it: treating it as a barrier there + // emptied `open` and killed glue for every row, and since such a send can sit + // unretired indefinitely that stranded every later pair for the pane's life. + // Once a run has started it does separate what follows, so it ends the scan — + // otherwise "fix", photo, "bug" would let a row reading "fix bug" retire + // "fix" and "bug" around a send that is still in flight. const open: typeof remaining = [] for (const entry of remaining) { - if (represented.has(entry.index)) { - continue - } if (entry.text.length === 0) { - break + if (open.length > 0) { + break + } + continue } open.push(entry) } diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index e5f17240c18f..cf7b3c1f759b 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -320,6 +320,36 @@ describe('glued rapid sends', () => { expect(prunePendingSends(pending, advancedGlueTranscript('a b [Image #1]'))).toEqual(pending) }) + // Why: a textless send AHEAD of the glued pair separates nothing, so it must not + // block them. Treating it as a barrier there emptied the candidate list and + // killed glue for every row — and because such a send can sit unretired + // indefinitely, it stranded every later pair for the life of the pane. + it('retires a glued pair queued behind a leading uncaptioned image send', () => { + const pending = [ + { ...gluePending('p0', ''), imagePaths: ['/tmp/p.png'] }, + gluePending('p1', 'a'), + gluePending('p2', 'b') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('a b'))).toEqual([pending[0]]) + }) + + it('retires a glued pair queued behind a leading marker-only image send', () => { + const pending = [ + { ...gluePending('p0', '[Image #1]'), imagePaths: ['/tmp/p.png'] }, + gluePending('p1', 'a'), + gluePending('p2', 'b') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('a b'))).toEqual([pending[0]]) + }) + + it('retires a glued pair queued behind a leading whitespace-only send', () => { + const pending = [gluePending('p0', ' '), gluePending('p1', 'a'), gluePending('p2', 'b')] + + expect(prunePendingSends(pending, advancedGlueTranscript('a b'))).toEqual([pending[0]]) + }) + // Why: an uncaptioned photo send has no match text, so it can never appear in a // glued row — but it still separates the sends either side of it. Skipping it // would retire those two echoes while their sends are still in flight. From f4fb51be1ea8e16e5eab12b992cf54c489c3cf22 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:30:32 -0700 Subject: [PATCH 24/44] fix(native-chat): budget the mobile preview strip by what it appended MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the budget counting path-less image blocks as a stand-in for "previews we just appended". Those are not the same thing. Some agents emit an inline image as an image-ref carrying a url and no path, so a user turn that already showed one inflated the budget by one and the strip destroyed a marker the user had typed: a turn rendering an inline image plus "look [Image #2] typed" came out as "look typed", with the surplus marker gone. The budget now counts what the append loop actually appended — the previews left over once every image block the turn already had has taken one. A block the turn already carried has had its marker spent already, by the fold for an `[Image: source: …]` run or by the host for an inline image. Reachable on the agent this handoff was written for: its user turns can carry an inline image url with no path, which is the exact shape. --- .../mobile-native-chat-render-data.test.ts | 28 +++++++++++++++++++ .../session/mobile-native-chat-render-data.ts | 15 ++++++---- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index 33f22306c905..e85effc1850a 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -222,6 +222,34 @@ describe('buildMobileNativeChatTransientData', () => { ]) }) + // Why: some agents emit an inline image as a url with NO path, so "path-less" + // does not mean "a preview we just appended". Charging the budget for a block + // the turn already carried destroys a marker the user actually typed. + it('does not charge the budget for a path-less image the turn already carried', () => { + const result = buildMobileNativeChatTransientData({ + folded: [ + { + id: 'prompt', + role: 'user', + blocks: [ + { type: 'image-ref', url: 'https://host/a.png' }, + { type: 'text', text: 'look [Image #2] typed' } + ], + timestamp: 1, + source: 'transcript' + } + ], + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///local/b.jpg'] } + }) + + expect(result.data[0]?.blocks).toEqual([ + { type: 'image-ref', url: 'file:///local/b.jpg' }, + { type: 'text', text: 'look [Image #2] typed' } + ]) + }) + // Why: no preview binding means no evidence, so the ticket's rule still holds. it('leaves marker text alone when no preview is bound', () => { const result = buildMobileNativeChatTransientData({ diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 98e4069bbae7..36a0eaa79ef1 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -86,6 +86,9 @@ export function buildMobileNativeChatTransientData({ previewIndex += 1 return url ? { ...block, url } : block }) + // Previews left over once every image block the turn already had has taken + // one. Only these add an image the turn was not already showing. + const appendedPreviews = Math.max(0, previews.length - previewIndex) while (previewIndex < previews.length) { blocks.push({ type: 'image-ref', url: previews[previewIndex] }) previewIndex += 1 @@ -96,12 +99,12 @@ export function buildMobileNativeChatTransientData({ // them. Without this a marker-only host echo captions the user's own photo // with a literal `[Image #1]`. // - // Budget counts ONLY path-less blocks — the previews appended just above. A - // block with a path came from a real `[Image: source: …]` turn, so the fold - // already spent that marker; charging for it again would re-strip a surplus - // marker the fold deliberately preserved as the user's own words. - const unvouchedImages = blocks.filter((block) => isImageRefBlock(block) && !block.path).length - return { ...message, blocks: stripImagePromptMarkersFromTextBlocks(blocks, unvouchedImages) } + // Budget is what was APPENDED, not what looks path-less. A block the turn + // already carried has already had its marker spent — by the fold for a + // `[Image: source: …]` run, or by the host for an inline image, which some + // agents emit as a url with no path. Charging for those would re-strip a + // marker the user actually typed. + return { ...message, blocks: stripImagePromptMarkersFromTextBlocks(blocks, appendedPreviews) } }) const data: NativeChatMessage[] = [ ...renderedFolded, From bf2d56d5b151e49842a1962a6d0f86c2db2fe5ed Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:37:47 -0700 Subject: [PATCH 25/44] fix(native-chat): restore the earlier-history guard on the window-head rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Naming the window head by id dropped a guard the boolean had provided for free. `windowHeadMessageId` is undefined in the ordinary no-earlier-history case, so a bare `message.id === windowHeadMessageId` matched any row whose own id was missing and Infinity-stripped it — firing the rule when there is no window head at all, and erasing a marker the user typed. Local decoders always mint an id, but the remote read casts host frames without validating per-message fields, so an id-less row can reach the fold. Blast radius was one row either way, since both the desktop assembler and the mobile merger dedup by id. Both sites now go through one predicate that requires a head to have been named at all. --- .../native-chat-image-transcript-markers.test.ts | 14 ++++++++++++++ src/shared/native-chat-image-transcript-markers.ts | 11 ++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/shared/native-chat-image-transcript-markers.test.ts b/src/shared/native-chat-image-transcript-markers.test.ts index fb7a7ba21def..d393ec6eeb4d 100644 --- a/src/shared/native-chat-image-transcript-markers.test.ts +++ b/src/shared/native-chat-image-transcript-markers.test.ts @@ -421,6 +421,20 @@ describe('normalizeImageTranscriptMessages with earlier history above the window expect(out[1]!.blocks).toEqual([{ type: 'text', text: 'what do you see' }]) }) + // Why: the remote read casts host frames without validating per-message fields, + // so a row can reach the fold with no id. Comparing ids alone let that row match + // an absent option and strip a marker the user typed, with no window head at all. + it('never treats an id-less row as the head when no head is named', () => { + // Cast because the type says `id: string`; only the unvalidated remote read + // can produce this, which is exactly why the guard has to exist at runtime. + const idLess = { + ...userText('x', 'what does [Image #1] mean?'), + id: undefined + } as unknown as NativeChatMessage + const out = normalizeImageTranscriptMessages([idLess], {}) + expect(out[0]!.blocks).toEqual([{ type: 'text', text: 'what does [Image #1] mean?' }]) + }) + it('does nothing when the named head is not in the list', () => { const out = normalizeImageTranscriptMessages([userText('b', '[Image #1] what do you see')], { windowHeadMessageId: 'some-other-row' diff --git a/src/shared/native-chat-image-transcript-markers.ts b/src/shared/native-chat-image-transcript-markers.ts index d7619fe0f358..53f0de530f06 100644 --- a/src/shared/native-chat-image-transcript-markers.ts +++ b/src/shared/native-chat-image-transcript-markers.ts @@ -192,6 +192,12 @@ export function normalizeImageTranscriptMessages( options?: NormalizeImageTranscriptOptions ): NativeChatMessage[] { const windowHeadMessageId = options?.windowHeadMessageId + // Why: absent option means there is no window head at all, so the rule must not + // fire. Comparing ids alone would let a row whose own id is missing — the remote + // read casts host frames without validating per-message fields — satisfy + // `message.id === undefined` and strip a marker the user typed. + const isWindowHead = (candidate: NativeChatMessage): boolean => + windowHeadMessageId !== undefined && candidate.id === windowHeadMessageId let normalized: NativeChatMessage[] | null = null for (let index = 0; index < messages.length; index += 1) { const message = messages[index]! @@ -221,8 +227,7 @@ export function normalizeImageTranscriptMessages( ) { // Why: a run touching the window head may have lost earlier source turns, // so the visible image count is a floor, not the run's real size. - const limit = - message.id === windowHeadMessageId ? Number.POSITIVE_INFINITY : imagePaths.length + const limit = isWindowHead(message) ? Number.POSITIVE_INFINITY : imagePaths.length normalized.push({ ...prompt, blocks: [ @@ -242,7 +247,7 @@ export function normalizeImageTranscriptMessages( // Why: the whole source run above the head can be trimmed away, leaving the // prompt alone at index 0 with markers no anchor can vouch for. const blocks = - message.id === windowHeadMessageId && hasImagePromptMarker(message) + isWindowHead(message) && hasImagePromptMarker(message) ? stripImagePromptMarkersFromTextBlocks(message.blocks, Number.POSITIVE_INFINITY) : removeEmptyFirstTextBlock(message.blocks) if (blocks === message.blocks) { From a9c7b95e1034122620d3728f9b075cff78b96814 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:12:14 -0700 Subject: [PATCH 26/44] fix(native-chat): take the paging signal from the host, not from the count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hasEarlierHistory` now decides whether a head turn's `[Image #n]` run is the user's own words, and it is derived from `hasMore`. That flag was inferred as `returnedCount >= requestedLimit`, which cannot tell "the window is exactly full" from "there is more behind it". Harmless while it only drove a load-earlier affordance — one wasted read — but not once it changes what a message says. Measured: `hasMoreNativeChatHistory(500, 500)` is true, and a transcript of exactly 500 turns whose first message is `[Image #1] is what I typed` renders as `is what I typed` — this ticket's own defect, at the one turn the fix exists to protect. The subscribe snapshot carries an exact flag and usually overrides the read seed, but `loadEarlier` has no snapshot behind it, so paging to an exact multiple of the window makes the false positive permanent for that session. The exact answer already exists and already crosses the wire: the tail reader decides it by reading one turn past the limit (`transcript-tail-reader.ts` — `chronological.length > limit`), the local IPC handler returns it, and the runtime RPC sends it. Only the renderer threw it away — the preload result type never declared the field and the runtime parser dropped it. No new wire content: this reads a field the host already publishes. An older remote host simply omits it and the count inference still applies, so mixed versions keep today's behaviour rather than regressing. --- src/preload/api/native-chat-api.ts | 4 ++++ .../native-chat-pagination.test.ts | 14 +++++++++++ .../native-chat/native-chat-pagination.ts | 24 +++++++++++++++---- .../native-chat-runtime-contract.ts | 5 +++- .../use-native-chat-live-session.ts | 4 ++-- 5 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/preload/api/native-chat-api.ts b/src/preload/api/native-chat-api.ts index 01eac44bde6d..52b6091d6ee0 100644 --- a/src/preload/api/native-chat-api.ts +++ b/src/preload/api/native-chat-api.ts @@ -9,6 +9,10 @@ export type NativeChatReadSessionResult = | { messages: NativeChatMessage[] lifecycle?: NativeChatTurnLifecycle + /** Whether the transcript really holds turns older than this window. The + * host knows exactly (it reads one past the limit); absent from an older + * remote host, which leaves the caller to infer it from the count. */ + hasMore?: boolean } | { error: string; notFound?: true } diff --git a/src/renderer/src/components/native-chat/native-chat-pagination.test.ts b/src/renderer/src/components/native-chat/native-chat-pagination.test.ts index 49f119e0aec8..a6c79ad0b2e8 100644 --- a/src/renderer/src/components/native-chat/native-chat-pagination.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pagination.test.ts @@ -27,4 +27,18 @@ describe('hasMoreNativeChatHistory', () => { expect(hasMoreNativeChatHistory(120, 300)).toBe(false) expect(hasMoreNativeChatHistory(0, 300)).toBe(false) }) + + // The count inference cannot tell "the window is exactly full" from "there is + // more behind it". That was harmless while this only drove a load-earlier + // affordance, but it now also decides whether a head turn's `[Image #n]` + // markers are read as the user's own words, so the host's exact answer wins. + it('prefers the host’s reported answer over the count inference', () => { + expect(hasMoreNativeChatHistory(300, 300, false)).toBe(false) + expect(hasMoreNativeChatHistory(120, 300, true)).toBe(true) + }) + + it('falls back to the count when an older host reports nothing', () => { + expect(hasMoreNativeChatHistory(300, 300, undefined)).toBe(true) + expect(hasMoreNativeChatHistory(120, 300, undefined)).toBe(false) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-pagination.ts b/src/renderer/src/components/native-chat/native-chat-pagination.ts index 9d42907d7780..6465401a0454 100644 --- a/src/renderer/src/components/native-chat/native-chat-pagination.ts +++ b/src/renderer/src/components/native-chat/native-chat-pagination.ts @@ -13,9 +13,23 @@ export function nextNativeChatLimit(currentLimit: number): number { return currentLimit + NATIVE_CHAT_PAGE } -/** Whether an older page may still exist: the last read filled the window, so - * there could be more behind it. If the read returned fewer than requested we - * reached the head of the transcript and there is nothing older to load. */ -export function hasMoreNativeChatHistory(returnedCount: number, requestedLimit: number): boolean { - return returnedCount >= requestedLimit +/** + * Whether an older page may still exist. + * + * `reported` is the host's own answer, which is exact — it reads one turn past + * the limit to decide. Prefer it whenever it is present; an older remote host + * omits it, and only then do we infer from the count. + * + * The inference is deliberately conservative and NOT exact: a transcript whose + * length is exactly the requested limit fills the window without anything + * behind it, and still reports true. That is safe for the "load earlier" + * affordance (one wasted read) but not for anything that changes what a message + * says, so callers doing the latter should rely on the reported value. + */ +export function hasMoreNativeChatHistory( + returnedCount: number, + requestedLimit: number, + reported?: boolean +): boolean { + return reported ?? returnedCount >= requestedLimit } diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts b/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts index 329c93f852ff..15b1e69458ae 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts @@ -47,7 +47,10 @@ export function parseRuntimeNativeChatReadSessionResult( const lifecycle = parseRuntimeNativeChatTurnLifecycle(record.lifecycle) return { messages: record.messages as NativeChatAppendedMessages, - ...(lifecycle ? { lifecycle } : {}) + ...(lifecycle ? { lifecycle } : {}), + // Kept only when the host actually sent it; an older runtime omits the + // field and the caller falls back to inferring it from the count. + ...(typeof record.hasMore === 'boolean' ? { hasMore: record.hasMore } : {}) } } if (typeof record.error === 'string') { diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index f40468322a6b..a2c89f822d1a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -192,7 +192,7 @@ export function useNativeChatLiveSession( const messages = result?.messages ?? [] transcriptLifecycleControl.replace(result?.lifecycle) setRead({ phase: 'ready', messages }) - setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current)) + setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current, result?.hasMore)) }) .catch((err: unknown) => { if (!cancelled && latestEnabled.current && !frameArrived) { @@ -281,7 +281,7 @@ export function useNativeChatLiveSession( // Read results are an ordered tail: replace the base list so the older page prepends in order; live appends stay separate. setRead({ phase: 'ready', messages: result.messages }) transcriptLifecycleControl.replaceFromPagination(result.lifecycle, lifecycleRevision) - setHasMore(hasMoreNativeChatHistory(result.messages.length, nextLimit)) + setHasMore(hasMoreNativeChatHistory(result.messages.length, nextLimit, result.hasMore)) }) .catch(() => { // Swallow a rejected "load more" read: keep the already-loaded transcript intact rather than surface the rejection. From 3508e54053e9e1d3d6c5c43efd053fb1b6a76ebc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:16:01 -0700 Subject: [PATCH 27/44] test(native-chat): pin the runtime paging flag across the RPC hop The parser had no coverage at all, and it is the one place the host's exact paging answer can be silently dropped on the way to a decision that changes what a user's message says. Also pins that a non-boolean cannot force an answer and that an older host's omission still falls through to the count inference. --- .../native-chat-runtime-contract.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/renderer/src/components/native-chat/native-chat-runtime-contract.test.ts diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-contract.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-contract.test.ts new file mode 100644 index 000000000000..ec718ddb03f7 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-runtime-contract.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { parseRuntimeNativeChatReadSessionResult } from './native-chat-runtime-contract' + +// The paging flag decides whether a head turn's `[Image #n]` run reads as the +// user's own words, so it has to survive the runtime hop rather than be +// re-inferred from the message count on this side. +describe('parseRuntimeNativeChatReadSessionResult', () => { + it('keeps the host’s paging answer when it sends one', () => { + expect(parseRuntimeNativeChatReadSessionResult({ messages: [], hasMore: true })).toEqual({ + messages: [], + hasMore: true + }) + expect(parseRuntimeNativeChatReadSessionResult({ messages: [], hasMore: false })).toEqual({ + messages: [], + hasMore: false + }) + }) + + it('omits it for an older host, leaving the caller to infer from the count', () => { + expect(parseRuntimeNativeChatReadSessionResult({ messages: [] })).toEqual({ messages: [] }) + }) + + it('ignores a non-boolean, so a malformed payload cannot force a paging answer', () => { + expect(parseRuntimeNativeChatReadSessionResult({ messages: [], hasMore: 'yes' })).toEqual({ + messages: [] + }) + }) + + it('passes an error result through untouched', () => { + expect(parseRuntimeNativeChatReadSessionResult({ error: 'boom', notFound: true })).toEqual({ + error: 'boom', + notFound: true + }) + }) +}) From b10835948676c1a7d395a30ca2fe0dda7bd69d3e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:58:10 -0700 Subject: [PATCH 28/44] fix(native-chat): gate the marker fold on the host's paging answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review refuted the reasoning behind riding the merged `hasMore` here. I had assumed the local read omits the flag and only the stream snapshot carries it, so gating on the reported value would leave the fold off until the snapshot landed and flip every truncated session's text mid-load. The declared return type erases the field, but the value is there at runtime and survives the IPC hop, and both other transports keep it on the initial read too. Measured: a 10-turn transcript read at limit 10 reports hasMore false, exactly, at a full window. So the flip I was avoiding does not exist, and the alternative is strictly safer. `hasMore` merges the host's answer with a count inference that reports true for a window filled to exactly the limit. That is fine for the "load earlier" affordance — one wasted read — but the fold changes what a message SAYS, and a false positive there erases an `[Image #n]` the user typed. That is a regression against a base with no window-head rule at all, not merely today's behaviour, and mobile's window is 40, which makes an exact fill far more reachable than desktop's 300. The fold now rides a separate flag set only when the host actually answered. Against a host predating the field it stays off, which is exactly what base did. `hasMore` itself is unchanged, so the load-earlier affordance keeps its conservative inference. --- .../src/session/MobileNativeChatOverlay.tsx | 4 +- .../session/use-mobile-native-chat-session.ts | 15 ++++++++ .../use-native-chat-live-session.test.ts | 37 +++++++++++++++++++ .../use-native-chat-live-session.ts | 17 ++++++++- 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index d1e53813d354..77e2eb9675dc 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -50,9 +50,9 @@ export function MobileNativeChatOverlay({ () => foldMobileNativeChatMessages( session.messages, - session.hasMore ? session.messages[0]?.id : undefined + session.earlierHistoryConfirmed ? session.messages[0]?.id : undefined ), - [session.messages, session.hasMore] + [session.messages, session.earlierHistoryConfirmed] ) const streaming = useMobileNativeChatStreamingBubble( folded, diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index a79b5648dd7f..48b56d1e04b3 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -26,6 +26,13 @@ export type MobileNativeChatSession = { error?: string /** True when an older page may exist (the last read filled the window). */ hasMore: boolean + /** True only when the HOST said older history exists. `hasMore` falls back to + * a count inference that reports true for a window filled to exactly the + * limit; that is fine for the "load earlier" affordance but not for the + * marker fold, which changes what a message says — a false positive there + * erases an `[Image #n]` the user typed. The window limit here is 40, so the + * exact-fill case is far more reachable than on desktop. */ + earlierHistoryConfirmed: boolean /** Whether an older-history page is currently loading. */ loadingEarlier: boolean /** Grow the window to page in older history. */ @@ -88,6 +95,7 @@ export function useMobileNativeChatSession(args: { const status = settled ? settled.status : initialStatus const [error, setError] = useState(undefined) const [hasMore, setHasMore] = useState(false) + const [earlierHistoryConfirmed, setEarlierHistoryConfirmed] = useState(false) const [loadingEarlier, setLoadingEarlier] = useState(false) const loadingEarlierRef = useRef(false) const beforeOffsetRef = useRef(null) @@ -130,6 +138,7 @@ export function useMobileNativeChatSession(args: { setList([]) setError(undefined) setHasMore(false) + setEarlierHistoryConfirmed(false) beforeOffsetRef.current = null if (!client || !agent) { return @@ -182,10 +191,12 @@ export function useMobileNativeChatSession(args: { limitRef.current = INITIAL_LIMIT beforeOffsetRef.current = applied.beforeOffset ?? null setHasMore(applied.hasMore ?? applied.messages.length >= INITIAL_LIMIT) + setEarlierHistoryConfirmed(applied.hasMore === true) } setMessages(applied.messages) if (!applied.windowReplaced && applied.hasMore != null) { setHasMore(applied.hasMore) + setEarlierHistoryConfirmed(applied.hasMore === true) } if (!applied.windowReplaced && applied.beforeOffset != null) { beforeOffsetRef.current = applied.beforeOffset @@ -220,6 +231,7 @@ export function useMobileNativeChatSession(args: { const pageLimit = nextLimit - limitRef.current if (pageLimit <= 0) { setHasMore(false) + setEarlierHistoryConfirmed(false) return } const beforeOffset = beforeOffsetRef.current @@ -255,10 +267,12 @@ export function useMobileNativeChatSession(args: { setHasMore( nextLimit < MAX_MESSAGES && (result.hasMore ?? result.messages.length >= pageLimit) ) + setEarlierHistoryConfirmed(result.hasMore === true) } else { // Older runtimes ignore the cursor and return the growing tail. setList(result.messages) setHasMore(result.messages.length >= nextLimit) + setEarlierHistoryConfirmed(false) } } finally { // A late page from a prior tab must not unlock the current tab's request. @@ -289,6 +303,7 @@ export function useMobileNativeChatSession(args: { transcriptLoading: status === 'loading', error, hasMore, + earlierHistoryConfirmed, loadingEarlier, loadEarlier } diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index 3d3a3206cc56..1ae21496727e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -706,4 +706,41 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { expect(latest?.error).toBeUndefined() expect(latest?.messages.map((m) => m.id)).toContain('a-late') }) + // The marker fold changes what a MESSAGE SAYS, so it rides the host's reported + // paging flag rather than `hasMore`, which falls back to a count inference that + // reports true for a window filled to exactly the limit. A false positive there + // erases an `[Image #n]` the user typed — a regression against a pre-fold base. + function headWindow(): NativeChatMessage[] { + return [ + user('u-head', '[Image #1] what do you see'), + ...Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT - 1 }, (_unused, n) => + assistant(`m-${n}`, 't') + ) + ] + } + + it('keeps a head marker when the host never reported older history', async () => { + const transport = getMockTransport('env-1', { autoSnapshot: false }) + // A host predating the field: the window is exactly full, so the count + // inference says there is more, but nothing confirmed it. + transport.readSession.mockResolvedValueOnce({ messages: headWindow() }) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + + expect(latest?.hasMore).toBe(true) + expect(latest?.messages.find((m) => m.id === 'u-head')?.blocks).toEqual([ + { type: 'text', text: '[Image #1] what do you see' } + ]) + }) + + it('strips a head marker once the host confirms older history', async () => { + const transport = getMockTransport('env-1', { autoSnapshot: false }) + transport.readSession.mockResolvedValueOnce({ messages: headWindow(), hasMore: true }) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + + expect(latest?.messages.find((m) => m.id === 'u-head')?.blocks).toEqual([ + { type: 'text', text: 'what do you see' } + ]) + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index a2c89f822d1a..6a059ab7844f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -94,6 +94,15 @@ export function useNativeChatLiveSession( ) const [read, setRead] = useState({ phase: 'loading' }) const [hasMore, setHasMore] = useState(false) + // Why: `hasMore` merges the host's answer with a count inference, and the + // inference is not exact — a window filled to exactly the limit reports true + // with nothing behind it. That is fine for the "load earlier" affordance (one + // wasted read) but not for the marker fold, which changes what a message SAYS: + // a false positive there erases an `[Image #n]` the user typed. So the fold + // rides the host's reported value alone. Every transport carries it on the + // initial read; only a host predating the field omits it, and there the fold + // simply stays off, which is what this branch's base did. + const [earlierHistoryConfirmed, setEarlierHistoryConfirmed] = useState(false) const [loadingEarlier, setLoadingEarlier] = useState(false) const [transcriptLifecycle, transcriptLifecycleControl] = useNativeChatTranscriptLifecycle() // The active read window; raised by loadEarlier to page in older history. @@ -140,6 +149,7 @@ export function useNativeChatLiveSession( replaceList(appendMergerRef.current, []) setAppended([]) setHasMore(false) + setEarlierHistoryConfirmed(false) } return () => undefined } @@ -150,6 +160,7 @@ export function useNativeChatLiveSession( replaceList(appendMergerRef.current, []) setAppended([]) setHasMore(false) + setEarlierHistoryConfirmed(false) return () => undefined } @@ -168,6 +179,7 @@ export function useNativeChatLiveSession( replaceList(appendMergerRef.current, []) setAppended([]) setHasMore(false) + setEarlierHistoryConfirmed(false) // Independent initial seed in case subscribe never delivers a snapshot; applied only until an authoritative frame lands so a live snapshot wins. function loadSession(attempt: number): void { @@ -193,6 +205,7 @@ export function useNativeChatLiveSession( transcriptLifecycleControl.replace(result?.lifecycle) setRead({ phase: 'ready', messages }) setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current, result?.hasMore)) + setEarlierHistoryConfirmed(result?.hasMore === true) }) .catch((err: unknown) => { if (!cancelled && latestEnabled.current && !frameArrived) { @@ -232,6 +245,7 @@ export function useNativeChatLiveSession( setAppended([]) setRead({ phase: 'ready', messages: appendMergerRef.current.list }) setHasMore(frame.hasMore) + setEarlierHistoryConfirmed(frame.hasMore === true) return } transcriptLifecycleControl.append(frame.lifecycle) @@ -282,6 +296,7 @@ export function useNativeChatLiveSession( setRead({ phase: 'ready', messages: result.messages }) transcriptLifecycleControl.replaceFromPagination(result.lifecycle, lifecycleRevision) setHasMore(hasMoreNativeChatHistory(result.messages.length, nextLimit, result.hasMore)) + setEarlierHistoryConfirmed(result.hasMore === true) }) .catch(() => { // Swallow a rejected "load more" read: keep the already-loaded transcript intact rather than surface the rejection. @@ -312,7 +327,7 @@ export function useNativeChatLiveSession( appended, // Why: the oldest row of the paginated read, not a position in the merged // list — see NormalizeImageTranscriptOptions. - windowHeadMessageId: hasMore ? baseMessages[0]?.id : undefined + windowHeadMessageId: earlierHistoryConfirmed ? baseMessages[0]?.id : undefined }) return useMemo(() => { From 219829c9d5d1ee1a9c9a2864854a0819e2716c5f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:03:48 -0700 Subject: [PATCH 29/44] fix(mobile-native-chat): type-guard the host's paging answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both mobile hops cast the wire payload without validating per-field types, so a host sending a non-boolean `hasMore` decided paging: `result.hasMore ?? …` yields a truthy string unchanged, and `applied.hasMore != null` accepts it. The desktop runtime parser already rejects this; mobile is the half that never got the guard. That mattered more once the marker fold started reading this signal, because the fold changes what a message says. The fold itself is already safe — it gates on an explicit `=== true` — so this closes the remaining exposure: the load-earlier affordance and the declared `hasMore: boolean` contract. A non-boolean now reads as "the host did not answer", which falls through to the count inference exactly as an older host does. --- .../use-mobile-native-chat-session.test.ts | 21 ++++++++++++++++ .../session/use-mobile-native-chat-session.ts | 24 +++++++++++++------ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index 2ff97a3370c5..8575ec867348 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import type { RpcClient } from '../transport/rpc-client' import { + reportedHasMore, useMobileNativeChatSession, type MobileNativeChatSession } from './use-mobile-native-chat-session' @@ -634,3 +635,23 @@ describe('useMobileNativeChatSession transcriptLoading', () => { }) }) }) + +// Both mobile hops cast the wire payload without validating per-field types, so +// the host's paging answer has to be type-checked here or a non-boolean decides +// it. That matters beyond the scroll affordance: the marker fold reads this to +// decide whether a window-head `[Image #n]` is the user's text or a placeholder. +describe('reportedHasMore', () => { + it('takes the host answer only when it is a real boolean', () => { + expect(reportedHasMore(true)).toBe(true) + expect(reportedHasMore(false)).toBe(false) + }) + + it('reads anything else as unanswered rather than a paging verdict', () => { + // 'no' and 0 are the dangerous ones: truthiness would invert or force them. + expect(reportedHasMore('no')).toBeUndefined() + expect(reportedHasMore(0)).toBeUndefined() + expect(reportedHasMore(1)).toBeUndefined() + expect(reportedHasMore(null)).toBeUndefined() + expect(reportedHasMore(undefined)).toBeUndefined() + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 48b56d1e04b3..6dcdeb39a96d 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -41,6 +41,14 @@ export type MobileNativeChatSession = { // Small first page for a fast first paint; grows by a page as the user scrolls. const INITIAL_LIMIT = 40 + +/** The host's paging answer, or undefined when it did not give one. Both mobile + * hops cast the wire payload without validating per-field types, so a + * non-boolean must read as "unanswered" rather than force a paging verdict — + * the same guard the desktop runtime parser enforces. */ +export function reportedHasMore(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined +} const PAGE = 60 const MAX_MESSAGES = 2000 @@ -190,13 +198,14 @@ export function useMobileNativeChatSession(args: { // overlapping reconnect replay keeps the paged-in history and limit. limitRef.current = INITIAL_LIMIT beforeOffsetRef.current = applied.beforeOffset ?? null - setHasMore(applied.hasMore ?? applied.messages.length >= INITIAL_LIMIT) - setEarlierHistoryConfirmed(applied.hasMore === true) + setHasMore(reportedHasMore(applied.hasMore) ?? applied.messages.length >= INITIAL_LIMIT) + setEarlierHistoryConfirmed(reportedHasMore(applied.hasMore) === true) } setMessages(applied.messages) - if (!applied.windowReplaced && applied.hasMore != null) { - setHasMore(applied.hasMore) - setEarlierHistoryConfirmed(applied.hasMore === true) + const appliedHasMore = reportedHasMore(applied.hasMore) + if (!applied.windowReplaced && appliedHasMore !== undefined) { + setHasMore(appliedHasMore) + setEarlierHistoryConfirmed(appliedHasMore) } if (!applied.windowReplaced && applied.beforeOffset != null) { beforeOffsetRef.current = applied.beforeOffset @@ -265,9 +274,10 @@ export function useMobileNativeChatSession(args: { beforeOffsetRef.current = result.beforeOffset setList([...result.messages, ...mergerRef.current.list]) setHasMore( - nextLimit < MAX_MESSAGES && (result.hasMore ?? result.messages.length >= pageLimit) + nextLimit < MAX_MESSAGES && + (reportedHasMore(result.hasMore) ?? result.messages.length >= pageLimit) ) - setEarlierHistoryConfirmed(result.hasMore === true) + setEarlierHistoryConfirmed(reportedHasMore(result.hasMore) === true) } else { // Older runtimes ignore the cursor and return the growing tail. setList(result.messages) From 2da1f54828357a4a4684d64b966eede5c219e30e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:08:35 -0700 Subject: [PATCH 30/44] fix(native-chat): keep the host's paging answer distinct from the inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gating the marker fold on the reported answer was defeated on the first snapshot frame. Both remote adapters build that frame as `hasMore: frame.hasMore ?? messages.length >= limit`, so an inference is baked in before the hook sees it and `hasMore === true` cannot say who decided. Only the first snapshot is affected — the later-snapshot and replacement branches use `?? false` — but that is the frame that lands on first paint. The effect was the flip this gate was meant to prevent, in the unsafe direction: against a host predating the field the read seed correctly leaves the fold off, then the snapshot turns it on, and a marker the user typed disappears a beat after the pane renders. Frames now carry the host's answer separately from the value the affordance uses. Every host-derived construction sets it — this process computes it, so it always reports; the adapters pass it through only when the host actually sent a boolean. Absence therefore means "unanswered" on every path, and the fold reads that instead. Additive optional field on an IPC payload and on the runtime frame, so an older host simply omits it and the fold stays off, which is what base did. --- src/main/ipc/native-chat.ts | 12 +++++++ src/preload/api/native-chat-api.ts | 7 ++++ .../native-chat-session-transport.test.ts | 29 +++++++++++++++ .../native-chat-session-transport.ts | 12 +++++++ .../use-native-chat-live-session.test.ts | 35 +++++++++++++++++++ .../use-native-chat-live-session.ts | 5 ++- src/renderer/src/web/web-preload-api.ts | 12 +++++++ 7 files changed, 111 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 4ef0f48b8726..f7f3e46b6e3b 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -62,6 +62,10 @@ export type NativeChatAppendedPayload = { type: 'snapshot' messages: NativeChatMessage[] hasMore: boolean + /** This host computed the answer, so it is always reported here. Remote + * adapters fold a count inference into `hasMore` instead, and callers + * that change what a message says have to tell the two apart. */ + hasMoreReported?: boolean error?: string lifecycle?: NativeChatTurnLifecycle } @@ -69,6 +73,8 @@ export type NativeChatAppendedPayload = { type: 'replacement' messages: NativeChatMessage[] hasMore: boolean + /** See the snapshot variant. */ + hasMoreReported?: boolean lifecycle?: NativeChatTurnLifecycle } | { @@ -193,6 +199,11 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg type: 'snapshot', messages, hasMore, + // Why: this host computed the answer (it reads one turn past the limit), + // so mark it as reported. Remote adapters fold a count inference into + // `hasMore`, and callers that change what a message says need to tell + // the two apart. + hasMoreReported: hasMore, ...(error ? { error } : {}), ...(lifecycle ? { lifecycle } : {}) } @@ -209,6 +220,7 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg type: 'replacement', messages, hasMore, + hasMoreReported: hasMore, ...(lifecycle ? { lifecycle } : {}) } } satisfies NativeChatAppendedPayload) diff --git a/src/preload/api/native-chat-api.ts b/src/preload/api/native-chat-api.ts index 52b6091d6ee0..b3b6b758ca00 100644 --- a/src/preload/api/native-chat-api.ts +++ b/src/preload/api/native-chat-api.ts @@ -24,6 +24,11 @@ export type NativeChatSubscriptionFrame = type: 'snapshot' messages: NativeChatMessage[] hasMore: boolean + /** Set only when the HOST answered. `hasMore` above may be a count + * inference filled in by a remote adapter, which reports true for a + * window filled to exactly the limit; that is fine for the load-earlier + * affordance but not for anything that changes what a message says. */ + hasMoreReported?: boolean error?: string lifecycle?: NativeChatTurnLifecycle } @@ -31,6 +36,8 @@ export type NativeChatSubscriptionFrame = type: 'replacement' messages: NativeChatMessage[] hasMore: boolean + /** See the snapshot variant: set only when the HOST answered. */ + hasMoreReported?: boolean lifecycle?: NativeChatTurnLifecycle } | { diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts index 47832b108974..4c57e2f36941 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts @@ -202,8 +202,37 @@ describe('runtime subscribe', () => { expect(onFrame).toHaveBeenNthCalledWith(3, { type: 'replacement', messages: [message('m-replacement')], + hasMore: true, + // The host sent a real boolean here, unlike frames 1 and 2. + hasMoreReported: true + }) + }) + + // Why: the first snapshot folds a count inference into `hasMore` when the host + // omits it, so `hasMore` alone cannot say whether the host answered. Callers + // that change what a message SAYS — the image-marker fold — read the reported + // field instead, and a guessed value must never look like an answer to them. + it('does not report a paging answer the host never gave', async () => { + markRuntimeEnvironmentCompatible(ENV) + const { deliver } = stubSubscribe() + const onFrame = vi.fn() + const transport = getNativeChatSessionTransport(ENV) + + transport.subscribe( + { subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1', limit: 2 }, + onFrame + ) + await Promise.resolve() + + // Exactly `limit` rows and no hasMore: the inference says true, nobody said so. + deliver({ type: 'snapshot', messages: [message('m-1'), message('m-2')] }) + + expect(onFrame).toHaveBeenNthCalledWith(1, { + type: 'snapshot', + messages: [message('m-1'), message('m-2')], hasMore: true }) + expect(onFrame.mock.calls[0]?.[0]).not.toHaveProperty('hasMoreReported') }) it('validates lifecycle metadata on runtime stream frames', async () => { diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.ts index 0fe22211db0c..4e1653bd856b 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.ts @@ -148,6 +148,12 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess type: 'snapshot', messages: frame.messages, hasMore: frame.hasMore ?? frame.messages.length >= (limit ?? 300), + // Why: the line above folds an inference into `hasMore`. Keep the + // host's own answer separately for callers that change what a + // message says rather than just a scroll affordance. + ...(typeof frame.hasMore === 'boolean' + ? { hasMoreReported: frame.hasMore } + : {}), ...(frame.error ? { error: frame.error } : {}), ...(lifecycle ? { lifecycle } : {}) }) @@ -156,6 +162,9 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess type: 'snapshot', messages: frame.messages, hasMore: frame.hasMore ?? false, + ...(typeof frame.hasMore === 'boolean' + ? { hasMoreReported: frame.hasMore } + : {}), ...(frame.error ? { error: frame.error } : {}), ...(lifecycle ? { lifecycle } : {}) }) @@ -166,6 +175,9 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess type: 'replacement', messages: frame.messages, hasMore: frame.hasMore ?? false, + ...(typeof frame.hasMore === 'boolean' + ? { hasMoreReported: frame.hasMore } + : {}), ...(lifecycle ? { lifecycle } : {}) } : { diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index 1ae21496727e..d41c9d7e2d4b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -739,6 +739,41 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + expect(latest?.messages.find((m) => m.id === 'u-head')?.blocks).toEqual([ + { type: 'text', text: 'what do you see' } + ]) + }) + // Why: a remote adapter's FIRST snapshot folds a count inference into + // `hasMore`, so the frame path needs the same guard as the read path — a + // guessed value must not strip a marker the user typed. + it('keeps a head marker when a snapshot frame only inferred older history', async () => { + const transport = getMockTransport('env-1', { autoSnapshot: false }) + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + + await act(async () => { + // hasMore true, but no reported field: nobody answered. + transport.emit({ type: 'snapshot', messages: headWindow(), hasMore: true }) + }) + + expect(latest?.hasMore).toBe(true) + expect(latest?.messages.find((m) => m.id === 'u-head')?.blocks).toEqual([ + { type: 'text', text: '[Image #1] what do you see' } + ]) + }) + + it('strips a head marker when the snapshot frame carries the host answer', async () => { + const transport = getMockTransport('env-1', { autoSnapshot: false }) + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + + await act(async () => { + transport.emit({ + type: 'snapshot', + messages: headWindow(), + hasMore: true, + hasMoreReported: true + }) + }) + expect(latest?.messages.find((m) => m.id === 'u-head')?.blocks).toEqual([ { type: 'text', text: 'what do you see' } ]) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index 6a059ab7844f..bc23035eda0b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -245,7 +245,10 @@ export function useNativeChatLiveSession( setAppended([]) setRead({ phase: 'ready', messages: appendMergerRef.current.list }) setHasMore(frame.hasMore) - setEarlierHistoryConfirmed(frame.hasMore === true) + // Why: `frame.hasMore` on a remote adapter's FIRST snapshot folds in a + // count inference, so it cannot say whether the host answered. Absence + // of the reported field means unanswered on every path. + setEarlierHistoryConfirmed(frame.hasMoreReported === true) return } transcriptLifecycleControl.append(frame.lifecycle) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 06622bbca93f..644ee2b56f11 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1348,6 +1348,12 @@ function createNativeChatApi(): NativeChatApi { type: 'snapshot', messages: result.messages, hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300), + // Why: the line above folds an inference into `hasMore`. Keep the + // host's own answer separately for callers that change what a + // message says rather than just a scroll affordance. + ...(typeof result.hasMore === 'boolean' + ? { hasMoreReported: result.hasMore } + : {}), ...(result.error ? { error: result.error } : {}), ...(lifecycle ? { lifecycle } : {}) }) @@ -1356,6 +1362,9 @@ function createNativeChatApi(): NativeChatApi { type: 'snapshot', messages: result.messages, hasMore: result.hasMore ?? false, + ...(typeof result.hasMore === 'boolean' + ? { hasMoreReported: result.hasMore } + : {}), ...(result.error ? { error: result.error } : {}), ...(lifecycle ? { lifecycle } : {}) }) @@ -1366,6 +1375,9 @@ function createNativeChatApi(): NativeChatApi { type: 'replacement', messages: result.messages, hasMore: result.hasMore ?? false, + ...(typeof result.hasMore === 'boolean' + ? { hasMoreReported: result.hasMore } + : {}), ...(lifecycle ? { lifecycle } : {}) } : { From 8d9d881b07e5b5323f1a850d04f73f6137f08eaa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:10:09 -0700 Subject: [PATCH 31/44] docs(mobile-native-chat): describe what the paging flag actually guarantees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed the flag is true only when the host said so. A replay that invalidates the cursor synthesizes it too, and that is correct — a bounded window which dropped its oldest row really does have history behind it. What the flag actually excludes is a count standing in for knowledge, which is the distinction the marker fold depends on. --- .../session/use-mobile-native-chat-session.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 6dcdeb39a96d..de4195c03cf3 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -26,12 +26,17 @@ export type MobileNativeChatSession = { error?: string /** True when an older page may exist (the last read filled the window). */ hasMore: boolean - /** True only when the HOST said older history exists. `hasMore` falls back to - * a count inference that reports true for a window filled to exactly the - * limit; that is fine for the "load earlier" affordance but not for the - * marker fold, which changes what a message says — a false positive there - * erases an `[Image #n]` the user typed. The window limit here is 40, so the - * exact-fill case is far more reachable than on desktop. */ + /** True when older history is KNOWN to exist, rather than guessed from a full + * window. `hasMore` falls back to a count inference that reports true for a + * window filled to exactly the limit; that is fine for the "load earlier" + * affordance but not for the marker fold, which changes what a message says — + * a false positive there erases an `[Image #n]` the user typed, and the limit + * here is 40, so an exact fill is far likelier than on desktop. + * + * Usually that knowledge is the host's own answer. It is not always: a replay + * that invalidates the cursor synthesizes it, because a bounded window that + * dropped its oldest row really does have history behind it. Both are facts + * about the transcript; neither is a count standing in for one. */ earlierHistoryConfirmed: boolean /** Whether an older-history page is currently loading. */ loadingEarlier: boolean From d6ade8479a4fb5e2552a32ce0c30fb4385d96a9f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:08:22 -0700 Subject: [PATCH 32/44] fix(native-chat): let the IPC read result carry the paging flag in its type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local handler was annotated `Promise`, which has no `hasMore`. The field reached the renderer anyway — a returned call result is not a fresh object literal, so excess-property checking never looked at it — but the type said otherwise, and nothing type-links this result to the preload shape that now reads it. Any later refactor that built the result inline would have dropped the flag silently, and the renderer would have fallen back to inferring paging from the row count: the exact defect the flag exists to close. Covered end-to-end from a real on-disk transcript, including the case the count inference cannot express — a window that is exactly full reports `hasMore: false`. Also pins, at the call sites a pure-function test cannot reach: - the window-head marker rule through the live session hook, so a call site that stops forwarding the host's paging answer fails rather than silently reverting to "older history always exists" - the two rules re-homed onto #14936's modules: a marker-only send that carried no attachment still earns a real boundary, and a photo turn whose caption happens to spell a run cannot retire the typed sends around it Mutating each re-homed line turns 3, 3 and 4 tests red respectively, so the merge landed live code rather than dead code. --- .../session/MobileNativeChatOverlay.test.ts | 60 +++++++++++++- ...obile-native-chat-pending-baseline.test.ts | 12 +++ ...ile-native-chat-pending-retirement.test.ts | 21 +++++ src/main/ipc/native-chat.test.ts | 47 +++++++++++ src/main/ipc/native-chat.ts | 9 +- .../use-native-chat-live-session.test.ts | 83 +++++++++++++++++++ 6 files changed, 229 insertions(+), 3 deletions(-) diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index b43a7c79f3b4..9aec90c09989 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -24,12 +24,19 @@ type Tick = { streamingText?: string streamLive?: boolean identity?: string + /** The host's paging answer, which decides whether the oldest visible row is + * a window head that may have lost its image-source run. */ + hasMore?: boolean } function overlayElement(tick: Tick): ReturnType { const controller = { showNativeChat: tick.show ?? true, - nativeChatSession: { messages: tick.messages ?? [], status: 'ready' }, + nativeChatSession: { + messages: tick.messages ?? [], + status: 'ready', + hasMore: tick.hasMore ?? false + }, nativeChatAgent: 'claude', nativeChatAgentWorking: tick.streamLive ?? false, nativeChatStreamingText: tick.streamingText, @@ -172,3 +179,54 @@ describe('MobileNativeChatOverlay streaming gate', () => { expect(streaming()).toBeNull() }) }) + +// STA-4363, mobile half. The window-head rule strips a head turn's `[Image #n]` +// run because the read window may have trimmed the source turns that vouch for +// it — true only while older history exists. Desktop derives the head from its +// read list, mobile from the merged list, so pin mobile's own derivation: a +// pure-function test of the fold cannot see this call site stop guarding it. +describe('MobileNativeChatOverlay window-head marker rule', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function markerTurn(id: string, text: string, timestamp: number): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } + } + + async function foldedById(hasMore: boolean): Promise> { + await act(async () => { + renderer = create( + overlayElement({ + hasMore, + messages: [ + markerTurn('u-head', '[Image #1] hello', 1), + markerTurn('u-tail', '[Image #2] bye', 2) + ] + }) + ) + }) + const folded = renderer!.root.findAll((node) => node.type === 'ChatView')[0].props + .folded as NativeChatMessage[] + return new Map(folded.map((message) => [message.id, message.blocks])) + } + + it('keeps the head turn’s literal markers when the window holds the whole conversation', async () => { + expect((await foldedById(false)).get('u-head')).toEqual([ + { type: 'text', text: '[Image #1] hello' } + ]) + }) + + it('strips the head turn’s markers only while older history is still pageable', async () => { + expect((await foldedById(true)).get('u-head')).toEqual([{ type: 'text', text: 'hello' }]) + }) + + it('leaves a turn below the head literal even while older history exists', async () => { + expect((await foldedById(true)).get('u-tail')).toEqual([ + { type: 'text', text: '[Image #2] bye' } + ]) + }) +}) 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 1abdc714e849..3bf2384a2c01 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts @@ -161,4 +161,16 @@ describe('rebaseMobileNativeChatPendingBaselines', () => { expect(rebased[0]?.baselineTailMessageId).toBe(null) expect(rebased[0]?.baselineResolved).toBe(true) }) + + // A send that carried no image owns its `[Image #n]` run as literal text, so it + // is a text send like any other and needs the boundary. Keying it through the + // marker-stripping rule would empty it, read it as an image echo, and strand it + // as a permanent glue barrier for its neighbours (STA-4363). + it('gives a marker-only send with no attachment a real boundary', () => { + const rebased = rebaseMobileNativeChatPendingBaselines(history, [ + unresolved('p1', '[Image #1]') + ]) + expect(rebased[0]?.baselineTailMessageId).toBe('m2') + expect(rebased[0]?.baselineResolved).toBe(true) + }) }) diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts index 1c865e2853c5..2fedb83ebb96 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts @@ -237,6 +237,27 @@ describe('selectGluedPendingIds', () => { expect(retiredIds(messages, [pendingSend('p1', 'one', 'm1')])).toEqual([]) }) + // An image row is the echo of an image send, and image sends never glue. Keying + // it by its caption would let a photo turn — whose caption happens to spell the + // run — retire typed sends it has nothing to do with, erasing their bubbles. + it('never lets a photo turn’s caption glue unrelated typed sends', () => { + const messages = [ + assistantTurn('m1', 'ready', 1000), + { + id: 'm2', + role: 'user' as const, + blocks: [ + { type: 'image-ref' as const, path: '/tmp/a.png' }, + { type: 'text' as const, text: 'one two' } + ], + timestamp: 5000, + source: 'transcript' as const + } + ] + const pending = [pendingSend('p1', 'one', 'm1'), pendingSend('p2', 'two', 'm1')] + expect(retiredIds(messages, pending)).toEqual([]) + }) + // The cursor slides past a head that cannot match, so a turn may try several // start positions — but one inspection budget covers the whole slide, keeping // the work linear in the run length rather than quadratic. The attempt already diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index 4d0e1765a44a..53baf39b25ef 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -164,6 +164,53 @@ describe('nativeChat:readSession handler', () => { } }) + // The renderer decides from this flag whether a head turn's `[Image #n]` run is + // the user's own words (STA-4363); counting returned rows cannot tell an exactly + // full window from a trimmed one. The reader answers exactly, so the handler has + // to carry it — nothing type-links this result to the preload shape that reads it. + it('reports the host’s exact paging answer, including for an exactly full window', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-hasmore-')) + tempRoots.push(root) + const projectDir = join(root, '.claude', 'projects', '-repo') + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'sess-hasmore.jsonl'), + jsonLines( + [1, 2, 3].map((n) => ({ + type: 'user', + uuid: `u-${n}`, + timestamp: `2026-06-01T10:00:0${n}.000Z`, + message: { role: 'user', content: `m${n}` } + })) + ) + ) + + const previousHome = process.env.HOME + process.env.HOME = root + try { + const trimmed = (await invokeReadSession({ + agent: 'claude', + sessionId: 'sess-hasmore', + limit: 2 + })) as { hasMore?: boolean } + expect(trimmed.hasMore).toBe(true) + + const exactlyFull = (await invokeReadSession({ + agent: 'claude', + sessionId: 'sess-hasmore', + limit: 3 + })) as { messages: unknown[]; hasMore?: boolean } + expect(exactlyFull.messages).toHaveLength(3) + expect(exactlyFull.hasMore).toBe(false) + } finally { + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + } + }) + it('emits snapshot and appended frames and tears down on destroy', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-sub-')) tempRoots.push(root) diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index f7f3e46b6e3b..c04b7d1cfcd7 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -5,7 +5,6 @@ import type { NativeChatTurnLifecycle } from '../../shared/native-chat-types' import { clearNativeChatTranscriptCache } from '../native-chat/transcript-read-cache' -import type { ReadTranscriptResult } from '../native-chat/transcript-reader' import { subscribeNativeChatTranscript, readNativeChatTranscriptTail, @@ -32,7 +31,13 @@ export type NativeChatReadSessionArgs = { // either the main process or the message list. Pagination raises this limit. const DESKTOP_READ_WINDOW = 300 -async function readSession(args: NativeChatReadSessionArgs): Promise { +// Why: the annotation was `ReadTranscriptResult`, which has no `hasMore` — the +// field survived only because a returned call result skips excess-property +// checking. The renderer now decides from it whether a head turn's `[Image #n]` +// run is the user's own words, so let inference carry the reader's real shape. +async function readSession( + args: NativeChatReadSessionArgs +): Promise>> { const { agent, sessionId } = args // Clamp to a positive window; default to the desktop window for the first page. const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index d41c9d7e2d4b..5ddb0dc9b894 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -779,3 +779,86 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { ]) }) }) + +// STA-4363: the window-head rule strips a head turn's `[Image #n]` run because the +// source turns that would vouch for it may have been trimmed by the read window. +// That only holds while older history actually exists — otherwise the head is the +// start of the conversation and its markers are the user's own words. The host's +// paging answer is what tells the two apart, and a pure-function test cannot see a +// call site that stops forwarding it. +describe('useNativeChatLiveSession — window-head marker rule', () => { + const AGENT = 'claude' as const + const SESSION = 'sess-window-head' + const PANE = 'pane-window-head' + const roots: Root[] = [] + let latest: NativeChatLiveSession | null = null + + function Probe(props: UseNativeChatLiveSessionArgs): null { + latest = useNativeChatLiveSession(props) + return null + } + + function markerTurn(id: string, text: string, timestamp: number): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } + } + + /** Renders a two-turn window whose head and tail both carry a literal marker. */ + async function blocksById(hasMore: boolean): Promise> { + const container = document.createElement('div') + const root = createRoot(container) + roots.push(root) + const transport = getMockTransport('env-1') + await act(async () => { + root.render( + createElement(Probe, { + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + runtimeEnvironmentId: 'env-1' + }) + ) + await Promise.resolve() + await Promise.resolve() + }) + await act(async () => { + transport.emit({ + type: 'snapshot', + messages: [ + markerTurn('u-head', '[Image #1] hello', 1), + markerTurn('u-tail', '[Image #2] bye', 2) + ], + hasMore + }) + }) + return new Map((latest?.messages ?? []).map((message) => [message.id, message.blocks])) + } + + beforeEach(() => { + useAppStore.setState({ agentStatusByPaneKey: {} }) + }) + + afterEach(() => { + for (const root of roots.splice(0)) { + act(() => root.unmount()) + } + latest = null + vi.clearAllMocks() + resetMockTransports() + }) + + it('keeps a head turn’s literal markers when the window holds the whole conversation', async () => { + expect((await blocksById(false)).get('u-head')).toEqual([ + { type: 'text', text: '[Image #1] hello' } + ]) + }) + + it('strips the head turn’s markers only while older history is still pageable', async () => { + expect((await blocksById(true)).get('u-head')).toEqual([{ type: 'text', text: 'hello' }]) + }) + + it('leaves a turn below the head literal even while older history exists', async () => { + expect((await blocksById(true)).get('u-tail')).toEqual([ + { type: 'text', text: '[Image #2] bye' } + ]) + }) +}) From f1508427b13c25547abe93a815c9338a9bf1e762 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:11:31 -0700 Subject: [PATCH 33/44] test(mobile-native-chat): drive the overlay fold from the confirmed paging flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window-head cases were driving `hasMore`, which the overlay no longer reads. `hasMore` still merges the host's answer with a count inference that reports true for a window filled to exactly the limit; the fold rides `earlierHistoryConfirmed`, which is set only when the host actually answered. Pins that separation at the call site a pure-function test cannot reach: an inferred paging answer the host never confirmed must leave a head marker alone. Riding the inference there erases an `[Image #n]` typed at the true start of the conversation — this ticket's own defect — and mobile's 40-turn window makes the exact fill reachable. Pointing the overlay back at `hasMore` turns 2 tests red. --- .../session/MobileNativeChatOverlay.test.ts | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index 9aec90c09989..8e1a0e31bc37 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -24,9 +24,13 @@ type Tick = { streamingText?: string streamLive?: boolean identity?: string - /** The host's paging answer, which decides whether the oldest visible row is - * a window head that may have lost its image-source run. */ + /** The load-earlier affordance, which may over-report on a window filled to + * exactly the limit. Deliberately NOT what the fold reads. */ hasMore?: boolean + /** The host's own paging answer. Only this may decide whether the oldest + * visible row is a window head that lost its image-source run — an inferred + * true here would erase an `[Image #n]` the user typed. */ + earlierHistoryConfirmed?: boolean } function overlayElement(tick: Tick): ReturnType { @@ -35,7 +39,8 @@ function overlayElement(tick: Tick): ReturnType { nativeChatSession: { messages: tick.messages ?? [], status: 'ready', - hasMore: tick.hasMore ?? false + hasMore: tick.hasMore ?? false, + earlierHistoryConfirmed: tick.earlierHistoryConfirmed ?? false }, nativeChatAgent: 'claude', nativeChatAgentWorking: tick.streamLive ?? false, @@ -197,11 +202,11 @@ describe('MobileNativeChatOverlay window-head marker rule', () => { return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } } - async function foldedById(hasMore: boolean): Promise> { + async function foldedById(earlierHistoryConfirmed: boolean): Promise> { await act(async () => { renderer = create( overlayElement({ - hasMore, + earlierHistoryConfirmed, messages: [ markerTurn('u-head', '[Image #1] hello', 1), markerTurn('u-tail', '[Image #2] bye', 2) @@ -224,6 +229,25 @@ describe('MobileNativeChatOverlay window-head marker rule', () => { expect((await foldedById(true)).get('u-head')).toEqual([{ type: 'text', text: 'hello' }]) }) + // `hasMore` also reports true for a window filled to exactly the limit, where + // nothing older exists. Riding it here would erase a marker the user typed at + // the true start of the conversation — the ticket's own defect. Mobile's window + // is 40, so that exact fill is reachable. + it('does not strip on an inferred paging answer the host never confirmed', async () => { + await act(async () => { + renderer = create( + overlayElement({ + hasMore: true, + earlierHistoryConfirmed: false, + messages: [markerTurn('u-head', '[Image #1] hello', 1)] + }) + ) + }) + const folded = renderer!.root.findAll((node) => node.type === 'ChatView')[0].props + .folded as NativeChatMessage[] + expect(folded[0]?.blocks).toEqual([{ type: 'text', text: '[Image #1] hello' }]) + }) + it('leaves a turn below the head literal even while older history exists', async () => { expect((await foldedById(true)).get('u-tail')).toEqual([ { type: 'text', text: '[Image #2] bye' } From 41a81e0756062a922f3f0291c3ecb5db4f9635fe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:14:11 -0700 Subject: [PATCH 34/44] test(native-chat): follow the snapshot frame's reported paging field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three subscribe-lifecycle cases assert the whole snapshot frame with toHaveBeenCalledWith, so adding `hasMoreReported` to it turned them red on the branch head — an exact-shape assertion fails on any added field, including a deliberately optional one. The window-head cases also still drove `hasMore`, which no longer gates the fold; they now emit the reported field the fold actually reads, so the case cannot pass on a value the host never answered. --- src/main/ipc/native-chat-subscribe-lifecycle.test.ts | 6 +++++- .../native-chat/use-native-chat-live-session.test.ts | 7 +++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts index 1c90227cfd21..5a29a284162a 100644 --- a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -277,6 +277,8 @@ describe('nativeChat subscribe lifecycle', () => { type: 'snapshot', messages: [], hasMore: false, + // This host computes the answer, so the snapshot always marks it reported. + hasMoreReported: false, error: 'Transcript unavailable' } }) @@ -295,7 +297,8 @@ describe('nativeChat subscribe lifecycle', () => { frame: { type: 'snapshot', messages: [], - hasMore: false + hasMore: false, + hasMoreReported: false } }) }) @@ -315,6 +318,7 @@ describe('nativeChat subscribe lifecycle', () => { type: 'snapshot', messages: [], hasMore: false, + hasMoreReported: false, lifecycle } }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index 5ddb0dc9b894..260990e268eb 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -803,7 +803,7 @@ describe('useNativeChatLiveSession — window-head marker rule', () => { } /** Renders a two-turn window whose head and tail both carry a literal marker. */ - async function blocksById(hasMore: boolean): Promise> { + async function blocksById(earlierHistoryConfirmed: boolean): Promise> { const container = document.createElement('div') const root = createRoot(container) roots.push(root) @@ -827,7 +827,10 @@ describe('useNativeChatLiveSession — window-head marker rule', () => { markerTurn('u-head', '[Image #1] hello', 1), markerTurn('u-tail', '[Image #2] bye', 2) ], - hasMore + hasMore: earlierHistoryConfirmed, + // Only the host's own answer may gate the fold; `hasMore` alone folds in + // a count inference that over-reports on an exactly full window. + hasMoreReported: earlierHistoryConfirmed }) }) return new Map((latest?.messages ?? []).map((message) => [message.id, message.blocks])) From c667553a5b5a1987f3f612ea761e4a5d85fb2c26 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:19:11 -0700 Subject: [PATCH 35/44] fix(mobile-native-chat): let an echo the host left empty reconcile an image send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitting echo lookup into literal and image maps, and requiring the row's evidence count to cover the send, dropped a shape the base matched: a user turn the host echoed with no content at all. Its evidence count is 0, so an image-only send could never claim it. Measured against the merge base, which used one map keyed by `normalizedUserText(message) ?? ''` with no count requirement at all: base imageCount 1 vs empty echo -> landed HEAD imageCount 1 vs empty echo -> not landed (imageCount 0 still lands) So this branch introduced it; it is not inherited. The user-visible end is a send that stays held while the sibling preview-binding path — which accepts exactly this shape — has already put the photo on screen, until the 20s deadline warns "delivery unknown" about a message that plainly landed. An empty turn testifies to no particular number of images, so it now satisfies any count rather than being read as evidence of zero. It still keys under '', which is what keeps the reach narrow: only a captionless send can match it, and a captioned one is still refused because the turn says nothing about a caption. That is the base's own reach, restored, rather than a wider one. --- ...mobile-native-chat-draft-reconcile.test.ts | 67 +++++++++++++++++++ .../mobile-native-chat-draft-reconcile.ts | 33 ++++++--- 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts index b18b6b5393dc..3d7b9d30477b 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts @@ -25,6 +25,73 @@ function pending(id: string, images: string[], expectedOccurrence = 1): PendingI } describe('mobile native chat image preview reconciliation', () => { + // Some hosts echo an image send as a user turn they kept no content for. The + // preview-binding path already accepts that shape and shows the photo, so the + // send must reconcile against it too — otherwise the send stays held and the + // 20s deadline warns "delivery unknown" about a message that plainly landed. + // Keyed under '' either way, so only a captionless send can reach it. + it('reconciles an image-only send against an echo the host left empty', () => { + const messages = [ + { + id: 'base', + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: 'ready' }], + timestamp: null, + source: 'transcript' as const + }, + { + id: 'echo', + role: 'user' as const, + blocks: [], + timestamp: null, + source: 'transcript' as const + } + ] + const unconfirmed: UnconfirmedSend = { + draftKey: 'draft', + pendingKey: 'pending-key', + text: '', + normalizedText: '', + imageCount: 1, + baselineTailMessageId: 'base', + deadline: null + } + + expect(findLandedUnconfirmedSends(messages, [unconfirmed])).toEqual([unconfirmed]) + }) + + // The empty turn says nothing about a caption, so a send that carried one must + // not claim it — that would retire a bubble whose text never landed. + it('does not let an empty echo claim a captioned image send', () => { + const messages = [ + { + id: 'base', + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: 'ready' }], + timestamp: null, + source: 'transcript' as const + }, + { + id: 'echo', + role: 'user' as const, + blocks: [], + timestamp: null, + source: 'transcript' as const + } + ] + const unconfirmed: UnconfirmedSend = { + draftKey: 'draft', + pendingKey: 'pending-key', + text: 'look at this', + normalizedText: 'look at this', + imageCount: 1, + baselineTailMessageId: 'base', + deadline: null + } + + expect(findLandedUnconfirmedSends(messages, [unconfirmed])).toEqual([]) + }) + it('reconciles a trailing-marker echo and hands its preview to that echo', () => { const messages = [ userText('source', '[Image: source: /tmp/a.png]'), diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 6512d9c88617..afd8062afeed 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -245,11 +245,16 @@ export function findLandedUnconfirmedSends( // (`[Image: source: …]` or no text) keys under '' so an empty-text send can // claim it. const messageIndexById = new Map(messages.map((message, index) => [message.id, index])) - const literalMessagesByText = new Map< - string, - { id: string; index: number; imageCount: number }[] - >() - const imageMessagesByText = new Map() + type EchoCandidate = { + id: string + index: number + imageCount: number + /** The host kept no content for this turn, so it testifies to no particular + * number of images — it can stand for a send of any size. */ + countUnknown?: boolean + } + const literalMessagesByText = new Map() + const imageMessagesByText = new Map() for (const message of normalizeImageTranscriptMessages(messages)) { const index = messageIndexById.get(message.id) if (index === undefined) { @@ -260,14 +265,26 @@ export function findLandedUnconfirmedSends( } const isImageSource = isImageSourceUserTurn(message) const imageCount = nativeChatUserMessageImageEvidenceCount(message) - const candidate = { id: message.id, index, imageCount } + // A turn the host echoed with nothing at all is the compatibility shape for + // an image send it kept no text for. Its evidence count is 0, so requiring + // the count to cover the send would hold that send open until the deadline + // fired a false "delivery unknown" — while the sibling preview-binding path + // accepts exactly this shape and binds the photo. Keys under '' either way, + // so only a captionless send can reach it. + const countUnknown = message.blocks.length === 0 + const candidate: EchoCandidate = { + id: message.id, + index, + imageCount, + ...(countUnknown ? { countUnknown } : {}) + } if (!message.blocks.some(isImageRefBlock)) { const literalKey = isImageSource ? '' : (nativeChatUserMessageMatchText(message) ?? '') const current = literalMessagesByText.get(literalKey) ?? [] current.push(candidate) literalMessagesByText.set(literalKey, current) } - if (imageCount > 0) { + if (imageCount > 0 || countUnknown) { const imageKey = isImageSource ? '' : (normalizedUserText(message) ?? '') const current = imageMessagesByText.get(imageKey) ?? [] current.push(candidate) @@ -290,7 +307,7 @@ export function findLandedUnconfirmedSends( ?.find( (message) => message.index > tailIndex && - message.imageCount >= entry.imageCount && + (message.countUnknown === true || message.imageCount >= entry.imageCount) && !claimedMessageIds.has(message.id) ) if (echo) { From b3e65c5fe0ace00883398e7aed1407945fc3f848 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:16:27 -0700 Subject: [PATCH 36/44] fix(native-chat): stop one transcript row retiring two sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row like `[Image #1] fix` reads two ways: the user's own marker text, or a host echoing a one-image send captioned "fix". The key minter returned BOTH readings and each count builder credited each, so a single turn vouched for two different pending sends. The second key is the one a photo-carrying send owns, so a row rendering no photo retired a send that carried one and `prunePendingSends` deleted it from the cache. If that send's own turn never landed the photo and its caption were gone with no trace. The two readings are byte-identical in the row, so nothing about the row alone can separate them — but they do not need to be separated there. A send that carried no image owns the literal reading, so the pending list says which readings are spoken for, and that set does not vary with the per-send boundary each count is sliced at. When a send is waiting on the literal reading, that reading wins and the row stops there; otherwise the marker reading stands, which keeps a marker-only host echo retiring its send. The count builders take the pending list rather than a precomputed set, which keeps both call sites to one line each. The set is small (the queue is capped) and rebuilt per pending, which is cheaper than it looks and much easier to follow. Bounded to the two desktop modules — mobile has its own retirement path and imports neither — and it leaves every boundary function, the occurrence bookkeeping and the glue arm untouched. Known residual, unchanged by this: a text-only send whose text equals a photo send's caption still resolves to one row. Both readings are defensible there and base lost the photo too, so it is not a regression. --- .../native-chat-pending-occurrence.ts | 51 ++++++++++++++++--- .../native-chat/native-chat-pending.test.ts | 37 ++++++++++++++ .../native-chat/native-chat-pending.ts | 12 ++--- 3 files changed, 86 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index 3e7bb16fdc49..19285418e4eb 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -46,7 +46,31 @@ export function nativeChatPendingContentKey( return imagePaths.length > 0 ? `images:${JSON.stringify(imagePaths)}` : 'empty' } -function nativeChatUserMessageContentKeys(message: NativeChatMessage): readonly string[] { +/** + * The literal content keys some pending send is waiting on. A send that carried + * no image owns its `[Image #n]` run as text, so its key is the literal reading + * of a row — the only thing that can tell that reading apart from the + * marker-as-placeholder one, which is byte-identical in the row itself. + * + * Computed once from the whole pending list: it says which readings are spoken + * for, which never varies with the per-send boundary each count is sliced at. + */ +export function literalContentKeysClaimedByPendings( + pending: readonly Pick[] +): ReadonlySet { + const claimed = new Set() + for (const entry of pending) { + if (!entry.imagePaths?.some(Boolean)) { + claimed.add(nativeChatPendingContentKey(entry)) + } + } + return claimed +} + +function nativeChatUserMessageContentKeys( + message: NativeChatMessage, + claimedLiteralKeys?: ReadonlySet +): readonly string[] { if (message.role !== 'user') { return [] } @@ -54,7 +78,18 @@ function nativeChatUserMessageContentKeys(message: NativeChatMessage): readonly const matchText = nativeChatUserMessageMatchText(message) ?? '' const hasImageRefs = message.blocks.some(isImageRefBlock) if (matchText && !hasImageRefs) { - keys.add(nativeChatTextContentKey(matchText, 0)) + const literalKey = nativeChatTextContentKey(matchText, 0) + keys.add(literalKey) + // Why: one turn is ONE send, but this row can be read two ways — the user's + // own `[Image #n]` text, or a host echoing an image send as that marker. + // Minting both let a single row retire two pendings, so a row showing no + // photo could retire a send that carried one and delete the user's preview + // for good. When a send is actually waiting on the literal reading, that + // reading wins and the row stops there; otherwise the marker reading stands, + // which is what keeps a marker-only host echo matching. + if (claimedLiteralKeys?.has(literalKey)) { + return [...keys] + } } const imageCount = nativeChatUserMessageImageEvidenceCount(message) const imageText = normalizedNativeChatUserMessageText(message) @@ -73,11 +108,13 @@ function nativeChatUserMessageContentKeys(message: NativeChatMessage): readonly } export function matchingNativeChatUserContentCounts( - messages: readonly NativeChatMessage[] + messages: readonly NativeChatMessage[], + pending?: readonly Pick[] ): Map { + const claimedLiteralKeys = pending && literalContentKeysClaimedByPendings(pending) const counts = new Map() for (const message of messages) { - for (const key of nativeChatUserMessageContentKeys(message)) { + for (const key of nativeChatUserMessageContentKeys(message, claimedLiteralKeys)) { counts.set(key, (counts.get(key) ?? 0) + 1) } } @@ -85,13 +122,15 @@ export function matchingNativeChatUserContentCounts( } export function advancedNativeChatUserContentCounts( - messages: readonly NativeChatMessage[] + messages: readonly NativeChatMessage[], + pending?: readonly Pick[] ): Map { + const claimedLiteralKeys = pending && literalContentKeysClaimedByPendings(pending) const advanced = new Map() const waiting = new Map() for (const message of messages) { if (message.role === 'user') { - for (const key of nativeChatUserMessageContentKeys(message)) { + for (const key of nativeChatUserMessageContentKeys(message, claimedLiteralKeys)) { waiting.set(key, (waiting.get(key) ?? 0) + 1) } continue diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index cf7b3c1f759b..2bf8e04b1cbd 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -123,6 +123,43 @@ describe('prunePendingSends', () => { expect(next).toEqual([]) }) + // Why: a row like `[Image #1] fix` reads two ways — the user's own text, or a + // host echoing a 1-image send captioned "fix". Crediting BOTH readings let one + // turn retire two sends, so the photo send was pruned against a row rendering + // no photo and the preview was deleted for good. + it('does not let one row retire both a literal-marker send and a photo send', () => { + const boundary = assistantMessage('m0', 'ok', 1) + const pending = [ + { ...pendingOf('p-literal', '[Image #1] fix'), afterMessageId: 'm0' }, + { ...pendingOf('p-photo', 'fix'), afterMessageId: 'm0', imagePaths: ['/tmp/shot.png'] } + ] + const transcript = [ + boundary, + userMessage('m1', '[Image #1] fix', 2), + assistantMessage('m2', 'done', 3) + ] + + // The literal send is claimed by its own turn; the photo send has not landed. + expect(prunePendingSends(pending, transcript).map((entry) => entry.id)).toEqual(['p-photo']) + }) + + // Why: the marker reading is what retires a send on hosts that echo an image + // send as bare marker text with no source turn. It must survive, and it does + // whenever no other send is waiting on the row's literal reading. + it('still retires a photo send against a marker-only echo row', () => { + const boundary = assistantMessage('m0', 'ok', 1) + const pending = [ + { ...pendingOf('p-photo', 'fix'), afterMessageId: 'm0', imagePaths: ['/tmp/shot.png'] } + ] + const transcript = [ + boundary, + userMessage('m1', '[Image #1] fix', 2), + assistantMessage('m2', 'done', 3) + ] + + expect(prunePendingSends(pending, transcript)).toEqual([]) + }) + it('drops an attachment-only pending send once its image turn advances', () => { const pending = [{ ...pendingOf('p1', ''), imagePaths: ['/tmp/first.png', '/tmp/second.png'] }] const transcript = [ diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index c322656f1526..bd39b331eb4a 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -162,10 +162,8 @@ export function prunePendingSends( const exactKeep = pending.map((entry) => { const contentKey = nativeChatPendingContentKey(entry) const key = nativeChatPendingMatchKey(entry) - const available = - advancedNativeChatUserContentCounts(messagesAfterPendingBoundary(messages, entry)).get( - contentKey - ) ?? 0 + const inScope = messagesAfterPendingBoundary(messages, entry) + const available = advancedNativeChatUserContentCounts(inScope, pending).get(contentKey) ?? 0 const used = consumed.get(key) ?? 0 const occurrence = nativeChatPendingOccurrence(entry, used) consumed.set(key, Math.max(used, occurrence)) @@ -206,10 +204,8 @@ export function pendingSendsAsMessages( const exactVisible = pending.map((entry) => { const contentKey = nativeChatPendingContentKey(entry) const key = nativeChatPendingMatchKey(entry) - const represented = - matchingNativeChatUserContentCounts( - messagesAfterPendingBoundary(existingMessages, entry) - ).get(contentKey) ?? 0 + const inScope = messagesAfterPendingBoundary(existingMessages, entry) + const represented = matchingNativeChatUserContentCounts(inScope, pending).get(contentKey) ?? 0 const used = consumed.get(key) ?? 0 const occurrence = nativeChatPendingOccurrence(entry, used) consumed.set(key, Math.max(used, occurrence)) From 96bfcb4eaa083fc32d109516752a32eaec49f4d1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:25:54 -0700 Subject: [PATCH 37/44] fix(native-chat): require the window to keep growing before offering load-earlier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taking the host's paging answer for the affordance was too strong in one direction. A reported `false` is exact and rightly ends pagination. A reported `true` says older history exists — not that the next read can reach it. The runtime RPC host clamps the requested window to 2000 turns (`MOBILE_NATIVE_CHAT_MAX_WINDOW`), so past that it keeps answering `hasMore: true` while returning the same capped tail. Riding that answer left a "Load earlier" button that re-read the whole window on every scroll near the top and never grew it — a regression the count rule alone did not have, since a short read ended pagination on its own. So a reported `false` still short-circuits, sparing the wasted read the count rule costs on an exactly-full window, but a reported `true` has to be confirmed by the read actually filling the window it asked for. The fold is unaffected: it never went through this function, and reads the host's answer directly. --- .../native-chat-pagination.test.ts | 19 ++++++++---- .../native-chat/native-chat-pagination.ts | 23 ++++++++++----- .../use-native-chat-live-session.test.ts | 29 +++++++++++++++++++ 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-pagination.test.ts b/src/renderer/src/components/native-chat/native-chat-pagination.test.ts index a6c79ad0b2e8..51def754314c 100644 --- a/src/renderer/src/components/native-chat/native-chat-pagination.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pagination.test.ts @@ -29,12 +29,21 @@ describe('hasMoreNativeChatHistory', () => { }) // The count inference cannot tell "the window is exactly full" from "there is - // more behind it". That was harmless while this only drove a load-earlier - // affordance, but it now also decides whether a head turn's `[Image #n]` - // markers are read as the user's own words, so the host's exact answer wins. - it('prefers the host’s reported answer over the count inference', () => { + // more behind it", so a reported `false` ends pagination without the wasted + // read the count rule alone costs. + it('takes the host’s reported answer when it ends pagination', () => { expect(hasMoreNativeChatHistory(300, 300, false)).toBe(false) - expect(hasMoreNativeChatHistory(120, 300, true)).toBe(true) + }) + + // A reported `true` says history exists, not that the next read can reach it. + // The runtime RPC host clamps the window to 2000 turns and then answers `true` + // forever while returning the same capped tail; trusting it alone left a + // "Load earlier" button that re-read the whole window and never grew it. + it('still ends pagination when the read came back short of the window', () => { + expect(hasMoreNativeChatHistory(120, 300, true)).toBe(false) + expect(hasMoreNativeChatHistory(2000, 2100, true)).toBe(false) + // The window is still growing, so keep paging. + expect(hasMoreNativeChatHistory(300, 300, true)).toBe(true) }) it('falls back to the count when an older host reports nothing', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-pagination.ts b/src/renderer/src/components/native-chat/native-chat-pagination.ts index 6465401a0454..a9dc8af4a210 100644 --- a/src/renderer/src/components/native-chat/native-chat-pagination.ts +++ b/src/renderer/src/components/native-chat/native-chat-pagination.ts @@ -16,20 +16,27 @@ export function nextNativeChatLimit(currentLimit: number): number { /** * Whether an older page may still exist. * + * This drives the load-earlier affordance only. Whether older history exists is + * necessary but not sufficient: the next read has to be able to REACH it, so a + * short read still ends pagination. The runtime RPC host clamps the window to + * 2000 turns, and past that it keeps answering `hasMore: true` while returning + * the same capped tail — taking its word alone leaves a "Load earlier" button + * that re-reads the whole window on every scroll and never loads anything. + * * `reported` is the host's own answer, which is exact — it reads one turn past - * the limit to decide. Prefer it whenever it is present; an older remote host - * omits it, and only then do we infer from the count. + * the limit to decide — so a reported `false` ends pagination immediately, + * sparing the wasted read the count rule alone costs on an exactly-full window. * - * The inference is deliberately conservative and NOT exact: a transcript whose - * length is exactly the requested limit fills the window without anything - * behind it, and still reports true. That is safe for the "load earlier" - * affordance (one wasted read) but not for anything that changes what a message - * says, so callers doing the latter should rely on the reported value. + * The count inference is deliberately conservative and NOT exact: a transcript + * whose length is exactly the requested limit fills the window without anything + * behind it, and still reports true. That is safe here (one wasted read) but not + * for anything that changes what a message says — those callers take the host's + * reported value directly rather than going through this function. */ export function hasMoreNativeChatHistory( returnedCount: number, requestedLimit: number, reported?: boolean ): boolean { - return reported ?? returnedCount >= requestedLimit + return reported === false ? false : returnedCount >= requestedLimit } diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index 260990e268eb..1cc1bc84600b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -455,6 +455,35 @@ describe('useNativeChatLiveSession — transport routing', () => { expect(latest?.status).toBe('ready') }) + // The runtime RPC host clamps the read window to 2000 turns, then keeps + // answering `hasMore: true` while returning the same capped tail. Taking that + // answer as the affordance would leave a "Load earlier" button that re-reads + // the whole window on every scroll near the top and never grows it. + it('stops offering load-earlier once the host stops growing the window', async () => { + const transport = getMockTransport('env-1') + const capped = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, n) => + assistant(`capped-${n}`, 'old') + ) + await render({ + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + runtimeEnvironmentId: 'env-1' + }) + await act(async () => transport.emit({ type: 'snapshot', messages: capped, hasMore: true })) + expect(latest?.hasMore).toBe(true) + + // The host truthfully has more, but it cannot hand any of it over: the page + // request asked for a wider window and got back the same clamped tail. + transport.readSession.mockImplementationOnce(() => + Promise.resolve({ messages: capped, hasMore: true }) + ) + await act(async () => latest?.loadEarlier()) + + expect(latest?.hasMore).toBe(false) + expect(latest?.messages).toHaveLength(NATIVE_CHAT_INITIAL_LIMIT) + }) + it('does not let an older pagination read rewind a live completion', async () => { useAppStore.setState({ agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } From a97878b0d8718cde37144e84633c64194aed8105 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:46:24 -0700 Subject: [PATCH 38/44] test(native-chat): follow the reported paging field into the web preload frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more exact-shape assertions broke on the additive `hasMoreReported` field — the web preload snapshot frame, and (already fixed) the IPC subscribe frames. Both assert the whole frame, so any added field fails them. Third instance of this class on the branch; the lesson is that a narrow suite filter hides it, since neither file sits under a `native-chat` path. Also drops a redundant `any` constituent the changed-code quality gate caught: `react-test-renderer` ships no usable types here, so annotating the handle as `ReactTestRenderer | null` makes the union redundant. Each case now owns its renderer with an inferred type, which needs no annotation at all. And corrects the note on literalContentKeysClaimedByPendings: it is not computed once — each count builder rebuilds it, which is sound because the set does not vary with the boundary each count is sliced at, and the queue is capped at 8. --- .../session/MobileNativeChatOverlay.test.ts | 52 ++++++++----------- .../native-chat-pending-occurrence.ts | 6 ++- .../web-preload-api-agent-providers.test.ts | 3 ++ 3 files changed, 30 insertions(+), 31 deletions(-) diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index 8e1a0e31bc37..b8aa759c99b4 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -191,31 +191,31 @@ describe('MobileNativeChatOverlay streaming gate', () => { // read list, mobile from the merged list, so pin mobile's own derivation: a // pure-function test of the fold cannot see this call site stop guarding it. describe('MobileNativeChatOverlay window-head marker rule', () => { - let renderer: ReactTestRenderer | null = null - - afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - }) - function markerTurn(id: string, text: string, timestamp: number): NativeChatMessage { return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } } + /** Mounts the overlay, reads the rows it handed the chat view, unmounts. Each + * case owns its renderer, so nothing leaks between them. Inferred rather than + * annotated: `react-test-renderer` ships no usable types here, so naming the + * handle in a union with null yields a redundant `any` constituent. */ + async function foldedFor(tick: Tick): Promise { + const mounted = create(overlayElement(tick)) + await act(async () => {}) + const folded = mounted.root.findAll((node) => node.type === 'ChatView')[0].props + .folded as NativeChatMessage[] + mounted.unmount() + return folded + } + async function foldedById(earlierHistoryConfirmed: boolean): Promise> { - await act(async () => { - renderer = create( - overlayElement({ - earlierHistoryConfirmed, - messages: [ - markerTurn('u-head', '[Image #1] hello', 1), - markerTurn('u-tail', '[Image #2] bye', 2) - ] - }) - ) + const folded = await foldedFor({ + earlierHistoryConfirmed, + messages: [ + markerTurn('u-head', '[Image #1] hello', 1), + markerTurn('u-tail', '[Image #2] bye', 2) + ] }) - const folded = renderer!.root.findAll((node) => node.type === 'ChatView')[0].props - .folded as NativeChatMessage[] return new Map(folded.map((message) => [message.id, message.blocks])) } @@ -234,17 +234,11 @@ describe('MobileNativeChatOverlay window-head marker rule', () => { // the true start of the conversation — the ticket's own defect. Mobile's window // is 40, so that exact fill is reachable. it('does not strip on an inferred paging answer the host never confirmed', async () => { - await act(async () => { - renderer = create( - overlayElement({ - hasMore: true, - earlierHistoryConfirmed: false, - messages: [markerTurn('u-head', '[Image #1] hello', 1)] - }) - ) + const folded = await foldedFor({ + hasMore: true, + earlierHistoryConfirmed: false, + messages: [markerTurn('u-head', '[Image #1] hello', 1)] }) - const folded = renderer!.root.findAll((node) => node.type === 'ChatView')[0].props - .folded as NativeChatMessage[] expect(folded[0]?.blocks).toEqual([{ type: 'text', text: '[Image #1] hello' }]) }) diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index 19285418e4eb..a05754bdfe2c 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -52,8 +52,10 @@ export function nativeChatPendingContentKey( * of a row — the only thing that can tell that reading apart from the * marker-as-placeholder one, which is byte-identical in the row itself. * - * Computed once from the whole pending list: it says which readings are spoken - * for, which never varies with the per-send boundary each count is sliced at. + * Derived from the whole pending list rather than the row: it says which readings + * are spoken for, which never varies with the per-send boundary each count is + * sliced at. That invariance is why each count builder can rebuild it — the queue + * is capped at 8 — instead of threading a shared "row is spent" state through. */ export function literalContentKeysClaimedByPendings( pending: readonly Pick[] diff --git a/src/renderer/src/web/web-preload-api-agent-providers.test.ts b/src/renderer/src/web/web-preload-api-agent-providers.test.ts index 1dc55f984ff6..d17441514ba7 100644 --- a/src/renderer/src/web/web-preload-api-agent-providers.test.ts +++ b/src/renderer/src/web/web-preload-api-agent-providers.test.ts @@ -82,6 +82,9 @@ describe('web native chat preload API', () => { type: 'snapshot', messages: [message], hasMore: false, + // The host answered, so the adapter marks the answer as reported rather + // than leaving `hasMore` indistinguishable from its count inference. + hasMoreReported: false, lifecycle } ]) From e7dcb24b45244e15d00f0d57a4e11b8d1c36578f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:53:25 -0700 Subject: [PATCH 39/44] fix(mobile-native-chat): name the window head from the read, not the live list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay derived the head from `session.messages[0]`, which slides. Mobile bounds the merged list to 40 on every live append, so one ordinary append past the window trims the front and the row that slides up becomes the "head" — while `earlierHistoryConfirmed` stays true from the initial snapshot, because the appended path never carries a paging answer. Measured: a row rendering `keep [Image #1] literal` a moment earlier silently became `keep literal`. That is this ticket's own defect, on the oldest visible row, firing on any agent run past 40 turns. The head is now recorded where the host actually answers — snapshot, replacement and the cursor page — and never from an append. When a live trim drops the recorded row, the id matches nothing on screen and no row is treated as a head: nothing visible is then known to sit mid-run, so nothing may lose its markers. Losing the strip for a genuinely trimmed run is the safe side of that trade; the other side rewrites a message the user has already read. Desktop needed no change — it already names the head from its read list, which the append merger copies rather than aliases. The overlay test alone could not catch this: it drives a fabricated session, so it only proves the overlay honours the value. Pointing the hook back at the live head leaves it green and turns the new hook-level case red. --- .../session/MobileNativeChatOverlay.test.ts | 21 ++++++++++++++- .../src/session/MobileNativeChatOverlay.tsx | 8 ++---- .../use-mobile-native-chat-session.test.ts | 26 +++++++++++++++++++ .../session/use-mobile-native-chat-session.ts | 20 ++++++++++++++ 4 files changed, 68 insertions(+), 7 deletions(-) diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index b8aa759c99b4..fa8adb90eadb 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -31,6 +31,8 @@ type Tick = { * visible row is a window head that lost its image-source run — an inferred * true here would erase an `[Image #n]` the user typed. */ earlierHistoryConfirmed?: boolean + /** The row the host answered about, while it is still on screen. */ + windowHeadMessageId?: string } function overlayElement(tick: Tick): ReturnType { @@ -40,7 +42,8 @@ function overlayElement(tick: Tick): ReturnType { messages: tick.messages ?? [], status: 'ready', hasMore: tick.hasMore ?? false, - earlierHistoryConfirmed: tick.earlierHistoryConfirmed ?? false + earlierHistoryConfirmed: tick.earlierHistoryConfirmed ?? false, + windowHeadMessageId: tick.windowHeadMessageId }, nativeChatAgent: 'claude', nativeChatAgentWorking: tick.streamLive ?? false, @@ -211,6 +214,7 @@ describe('MobileNativeChatOverlay window-head marker rule', () => { async function foldedById(earlierHistoryConfirmed: boolean): Promise> { const folded = await foldedFor({ earlierHistoryConfirmed, + windowHeadMessageId: earlierHistoryConfirmed ? 'u-head' : undefined, messages: [ markerTurn('u-head', '[Image #1] hello', 1), markerTurn('u-tail', '[Image #2] bye', 2) @@ -242,6 +246,21 @@ describe('MobileNativeChatOverlay window-head marker rule', () => { expect(folded[0]?.blocks).toEqual([{ type: 'text', text: '[Image #1] hello' }]) }) + // A live append past the window trims the front, so the row that slides up was + // already on screen rendered literally. Re-reading it as a window head would + // rewrite a message the user had seen — the ticket's own defect, on mobile's + // 40-turn window, during any active agent run. + it('does not strip a row that only became the head after a live trim', async () => { + const folded = await foldedFor({ + earlierHistoryConfirmed: true, + // The recorded head has been trimmed away; nothing on screen is known to + // sit mid-run any more. + windowHeadMessageId: 'u-trimmed-away', + messages: [markerTurn('u-head', '[Image #1] hello', 1)] + }) + expect(folded[0]?.blocks).toEqual([{ type: 'text', text: '[Image #1] hello' }]) + }) + it('leaves a turn below the head literal even while older history exists', async () => { expect((await foldedById(true)).get('u-tail')).toEqual([ { type: 'text', text: '[Image #2] bye' } diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 77e2eb9675dc..386d0ed4728d 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -47,12 +47,8 @@ export function MobileNativeChatOverlay({ }: Props): React.JSX.Element | null { const session = controller.nativeChatSession const folded = useMemo( - () => - foldMobileNativeChatMessages( - session.messages, - session.earlierHistoryConfirmed ? session.messages[0]?.id : undefined - ), - [session.messages, session.earlierHistoryConfirmed] + () => foldMobileNativeChatMessages(session.messages, session.windowHeadMessageId), + [session.messages, session.windowHeadMessageId] ) const streaming = useMobileNativeChatStreamingBubble( folded, diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index 8575ec867348..550b50abf284 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -382,6 +382,32 @@ describe('useMobileNativeChatSession', () => { expect(state?.messages.map((entry) => entry.id)).toEqual(['early-b', 'base-c']) }) + // The window head is what licenses stripping a row's `[Image #n]` markers, so + // it has to name the row the HOST answered about. Live appends trim the front + // locally (the window is 40), and the row that slides up was already on screen + // rendered literally — naming it would rewrite a message the user had read, + // which is this ticket's own defect. + it('stops naming a window head once a live append trims the one the host answered about', async () => { + const sendRequest = vi.fn() + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + const base = Array.from({ length: 40 }, (_unused, n) => message(`base-${n}`)) + await act(async () => + emit({ type: 'snapshot', messages: base, hasMore: true, beforeOffset: 1 }) + ) + + expect(state?.windowHeadMessageId).toBe('base-0') + + // One ordinary append past the window drops `base-0` off the front. + await act(async () => emit({ type: 'appended', messages: [message('live-0')] })) + + expect(state?.messages.some((entry) => entry.id === 'base-0')).toBe(false) + expect(state?.windowHeadMessageId).toBeUndefined() + }) + it('rejects a cursor page invalidated by live trim and retries with a growing tail', async () => { let resolveCursorPage: (response: unknown) => void = () => {} const sendRequest = vi diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index de4195c03cf3..2bbb3a580371 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -38,6 +38,10 @@ export type MobileNativeChatSession = { * dropped its oldest row really does have history behind it. Both are facts * about the transcript; neither is a count standing in for one. */ earlierHistoryConfirmed: boolean + /** The oldest row of the window the host answered about, while it is still on + * screen. Only that row may have lost the `[Image: source: …]` turns that + * vouch for its markers; undefined means no row may lose any. */ + windowHeadMessageId: string | undefined /** Whether an older-history page is currently loading. */ loadingEarlier: boolean /** Grow the window to page in older history. */ @@ -109,6 +113,11 @@ export function useMobileNativeChatSession(args: { const [error, setError] = useState(undefined) const [hasMore, setHasMore] = useState(false) const [earlierHistoryConfirmed, setEarlierHistoryConfirmed] = useState(false) + // The oldest row of the window the host answered about. Recorded from reads + // only: a live append trims the front locally, and the row that slides up was + // already on screen rendered literally, so re-reading it as a window head + // would rewrite a message the user had already seen. + const [windowHeadMessageId, setWindowHeadMessageId] = useState(undefined) const [loadingEarlier, setLoadingEarlier] = useState(false) const loadingEarlierRef = useRef(false) const beforeOffsetRef = useRef(null) @@ -152,6 +161,7 @@ export function useMobileNativeChatSession(args: { setError(undefined) setHasMore(false) setEarlierHistoryConfirmed(false) + setWindowHeadMessageId(undefined) beforeOffsetRef.current = null if (!client || !agent) { return @@ -205,6 +215,7 @@ export function useMobileNativeChatSession(args: { beforeOffsetRef.current = applied.beforeOffset ?? null setHasMore(reportedHasMore(applied.hasMore) ?? applied.messages.length >= INITIAL_LIMIT) setEarlierHistoryConfirmed(reportedHasMore(applied.hasMore) === true) + setWindowHeadMessageId(applied.messages[0]?.id) } setMessages(applied.messages) const appliedHasMore = reportedHasMore(applied.hasMore) @@ -246,6 +257,7 @@ export function useMobileNativeChatSession(args: { if (pageLimit <= 0) { setHasMore(false) setEarlierHistoryConfirmed(false) + setWindowHeadMessageId(undefined) return } const beforeOffset = beforeOffsetRef.current @@ -283,11 +295,13 @@ export function useMobileNativeChatSession(args: { (reportedHasMore(result.hasMore) ?? result.messages.length >= pageLimit) ) setEarlierHistoryConfirmed(reportedHasMore(result.hasMore) === true) + setWindowHeadMessageId(result.messages[0]?.id) } else { // Older runtimes ignore the cursor and return the growing tail. setList(result.messages) setHasMore(result.messages.length >= nextLimit) setEarlierHistoryConfirmed(false) + setWindowHeadMessageId(undefined) } } finally { // A late page from a prior tab must not unlock the current tab's request. @@ -319,6 +333,12 @@ export function useMobileNativeChatSession(args: { error, hasMore, earlierHistoryConfirmed, + // Undefined once a live trim drops the recorded head: nothing on screen is + // then known to sit mid-image-run, so no row may lose its markers. + windowHeadMessageId: + earlierHistoryConfirmed && visibleMessages.some((m) => m.id === windowHeadMessageId) + ? windowHeadMessageId + : undefined, loadingEarlier, loadEarlier } From 13b9f82e377843f773730b73dad3e9c6dc1c233a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:18:52 -0700 Subject: [PATCH 40/44] test(mobile-native-chat): give the send-origin mock its real parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asserting on `captureSendOrigin.mock.calls[0][0]` against a `vi.fn(() => …)` declared with no parameters types `calls` as the empty tuple, so the index is TS2493. The test passes at runtime and mobile `tsc` excludes test files, so neither CI nor the local typecheck ever saw it; a full type pass does. Introduced here when the assertion moved off `toHaveBeenCalledWith` to stop pinning the arity after `captureSendOrigin` gained an images parameter. --- .../session/use-mobile-native-chat-message-send.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index 5e3aeb744bc2..4c899fd03a0b 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -38,7 +38,12 @@ describe('useMobileNativeChatMessageSend', () => { let renderer: ReactTestRenderer | null = null let api: Send | null = null const acceptSend = vi.fn() - const captureSendOrigin = vi.fn(() => ({ draftKey: 'k', pendingKey: 'p' }) as never) + // Declares the real parameters so `mock.calls[0][0]` is typed; a no-arg mock + // gives `calls` an empty tuple, which mobile `tsc` never surfaces (it excludes + // test files) but a full type pass does. + const captureSendOrigin = vi.fn( + (_text: string, _images?: readonly string[]) => ({ draftKey: 'k', pendingKey: 'p' }) as never + ) const clearDraftForSend = vi.fn() const restoreRejectedDraft = vi.fn() const holdUnconfirmedSend = vi.fn() From 5011bf0b8de19f01c1847ff50fad0a3f40474228 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:54:15 -0700 Subject: [PATCH 41/44] fix(native-chat): count the photos a row shows, not the markers it kept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `nativeChatUserMessageImageEvidenceCount` took `max(imageRefCount, markerCount)`. That was equivalent while every marker was stripped from every turn, so a folded image row could never keep one. This branch bounds the strip to the size of the run it anchors to, so a row legitimately retains the markers the user typed — and the max then reads them as extra photos. Measured, through the real fold: a source turn for one image followed by `[Image #1] look [Image #2] [Image #3]` folds to one image-ref plus two surviving markers, and reports evidence for two photos while rendering one. Its content key is then the key a two-photo send owns, so that send retires against a row showing one image and `prunePendingSends` drops it from the bounded cache with the user's photos in it. At the merge base the same input reports one. Structure now wins wherever it exists; markers stay the fallback for a host that echoes an image send as a bare `[Image #n]` with no source turn, which is the case they were added for and which is untouched (it has no image-ref blocks). Under-counting only costs a duplicate bubble; over-counting loses the photo — the same trade `nativeChatUserTextRow` already makes on the glue arm, which was guarded against exactly this and whose sibling call site this is. Differential over 163,856 base-vs-head cases: cases where head loses a photo the base kept drop from 408 to 288, with no new losses in the other direction and no case of one transcript row retiring two sends. --- .../native-chat/native-chat-pending.test.ts | 23 ++++++++++++++ ...tive-chat-image-transcript-markers.test.ts | 30 +++++++++++++++++++ .../native-chat-image-transcript-markers.ts | 17 ++++++++++- 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 2bf8e04b1cbd..fdb9319f7ecc 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -95,6 +95,29 @@ describe('prunePendingSends', () => { expect(next).toEqual([]) }) + // The strip is bounded to the run it anchors to, so a folded image row keeps + // surplus markers the user typed. Reading those as photos let this row vouch + // for two sends and retire the two-photo one, which `prunePendingSends` then + // dropped from the bounded cache — the user's photos gone with no trace. + it('does not let one rendered photo retire a send that carried two', () => { + const folded: NativeChatMessage = { + id: 'm1', + role: 'user', + blocks: [ + { type: 'image-ref', path: '/tmp/x.png' }, + { type: 'text', text: 'look [Image #2] [Image #3]' } + ], + timestamp: 1, + source: 'transcript' + } + const pending = [ + { ...pendingOf('p-one', 'look'), imagePaths: ['/tmp/a.png'] }, + { ...pendingOf('p-two', 'look'), imagePaths: ['/tmp/b.png', '/tmp/c.png'] } + ] + const next = prunePendingSends(pending, [folded, assistantMessage('m2', 'ok')]) + expect(next.map((entry) => entry.id)).toEqual(['p-two']) + }) + it('drops an attachment pending send once a trailing-marker prompt advances', () => { const pending = [ { ...pendingOf('p1', 'what do you see'), imagePaths: ['/Users/me/Downloads/3d.png'] } diff --git a/src/shared/native-chat-image-transcript-markers.test.ts b/src/shared/native-chat-image-transcript-markers.test.ts index d393ec6eeb4d..e2df1c475909 100644 --- a/src/shared/native-chat-image-transcript-markers.test.ts +++ b/src/shared/native-chat-image-transcript-markers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { NativeChatMessage } from './native-chat-types' import { isImageSourceUserTurn, + nativeChatUserMessageImageEvidenceCount, nativeChatUserMessageMatchText, nativeChatUserTextMatchText, normalizeImageTranscriptMessages, @@ -442,3 +443,32 @@ describe('normalizeImageTranscriptMessages with earlier history above the window expect(out[0]!.blocks).toEqual([{ type: 'text', text: '[Image #1] what do you see' }]) }) }) + +// The strip is bounded to the size of the run it anchors to, so a folded image +// row can now KEEP markers the user typed. Counting those as photos would let a +// row rendering one image vouch for two sends and retire the wrong one. +describe('nativeChatUserMessageImageEvidenceCount', () => { + it('counts the photos a row shows, not the markers it kept', () => { + const [row] = normalizeImageTranscriptMessages([ + userText('a', '[Image: source: /tmp/x.png]'), + userText('b', '[Image #1] look [Image #2] [Image #3]') + ]) + // The fold spent its one-image budget and left the two surplus markers. + expect(row!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/x.png' }, + { type: 'text', text: 'look [Image #2] [Image #3]' } + ]) + expect(nativeChatUserMessageImageEvidenceCount(row!)).toBe(1) + }) + + it('still reads markers as evidence when the turn shows no image at all', () => { + // A host that echoes an image send as bare `[Image #n]` with no source turn + // leaves the markers as the only proof the photos landed. + expect(nativeChatUserMessageImageEvidenceCount(userText('c', '[Image #1] [Image #2]'))).toBe(2) + }) + + it('ignores an assistant turn', () => { + const assistantTurn = { ...userText('d', '[Image #1]'), role: 'assistant' as const } + expect(nativeChatUserMessageImageEvidenceCount(assistantTurn)).toBe(0) + }) +}) diff --git a/src/shared/native-chat-image-transcript-markers.ts b/src/shared/native-chat-image-transcript-markers.ts index 53f0de530f06..73bba2867f8f 100644 --- a/src/shared/native-chat-image-transcript-markers.ts +++ b/src/shared/native-chat-image-transcript-markers.ts @@ -155,12 +155,27 @@ export function countImagePromptMarkers(message: NativeChatMessage): number { ) } +/** + * How many image-carrying sends a row can vouch for. + * + * Structure wins wherever it exists; markers are the fallback for a host that + * echoes an image send as bare `[Image #n]` with no `[Image: source: …]` turn, + * never a top-up on a row already showing its photos. Taking the larger of the + * two used to be equivalent, because every marker was stripped from every turn — + * but the strip is now bounded to the size of the run it anchors to, so a row can + * legitimately keep markers the user typed. Counting those as photos let a row + * rendering one image retire a send that carried two, and `prunePendingSends` + * then dropped that send from the bounded cache with the user's photos in it. + * + * Under-counting only costs a duplicate bubble; over-counting loses the photo. + * That is the same trade `nativeChatUserTextRow` already makes on the glue arm. + */ export function nativeChatUserMessageImageEvidenceCount(message: NativeChatMessage): number { if (message.role !== 'user') { return 0 } const imageRefCount = message.blocks.filter(isImageRefBlock).length - return Math.max(imageRefCount, countImagePromptMarkers(message)) + return imageRefCount > 0 ? imageRefCount : countImagePromptMarkers(message) } export type NormalizeImageTranscriptOptions = { From 6612c0103db961b19b58e2a09bfb9b71ac4fa018 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:02:20 -0700 Subject: [PATCH 42/44] test(mobile-native-chat): cover the images argument the send hook forwards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useMobileNativeChatMessageSend` gained `captureSendOrigin(text, images)` on this branch, and nothing asserted the second argument. The assertion that used to pin the call was narrowed to the first argument when the parameter was added, and every other test in the area calls `captureSendOrigin` directly from the drafts hook, so the wiring between the two was uncovered. Measured: dropping `images` from the call turns exactly ONE test red out of 1240 in the mobile session suite — this one. Without it an image send captures `imageCount: 0`, so its origin keys as a markerless text send, an `[Image #n]` caption stays literal, and the optimistic echo can never match its own landed turn — a photo bubble queued forever. --- .../use-mobile-native-chat-message-send.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index 4c899fd03a0b..096525174ebd 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -205,6 +205,16 @@ describe('useMobileNativeChatMessageSend', () => { expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 }) }) + // Without the image list the origin keys as a markerless text send, so an + // `[Image #n]` caption stays literal and the echo can never match its own turn. + it('hands the attached images to the send origin', async () => { + mount(() => null) + await act(async () => { + await api!.send('[Image #1] look', ['file:///a.png']) + }) + expect(captureSendOrigin.mock.calls[0]![1]).toEqual(['file:///a.png']) + }) + it('does not resolve a composer seed from a question-card answer', async () => { mount(() => ({ text: DRAFT, createdAt: 1 })) await act(async () => { From 575ac845ea59dffeed978a9724dabde6b4f31a01 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:06:03 -0700 Subject: [PATCH 43/44] docs(mobile-native-chat): name the host clamp the message cap is coupled to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile's `MAX_MESSAGES` and the runtime RPC host's `MOBILE_NATIVE_CHAT_MAX_WINDOW` are both 2000, and the load-earlier affordance only terminates because they are equal. Nothing said so at either site, so raising one alone would silently restore a "Load earlier" button that re-reads the whole window on every press and never loads anything — the failure this branch already fixed once from the other direction. Comment only; no behaviour change. --- mobile/src/session/use-mobile-native-chat-session.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 2bbb3a580371..7387b14674a2 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -59,6 +59,11 @@ export function reportedHasMore(value: unknown): boolean | undefined { return typeof value === 'boolean' ? value : undefined } const PAGE = 60 +// Must not exceed the host's own clamp (`MOBILE_NATIVE_CHAT_MAX_WINDOW`, +// src/main/runtime/rpc/methods/native-chat.ts). Past that the host keeps +// answering `hasMore: true` while returning the same capped tail, so raising +// this alone leaves a "Load earlier" button that re-reads the whole window on +// every press and never loads anything. const MAX_MESSAGES = 2000 type ReadSessionResult = From 4d5711c165082b1e920be348016e834c292054a2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:28:15 -0700 Subject: [PATCH 44/44] Revert "docs(mobile-native-chat): name the host clamp the message cap is coupled to" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts 575ac845ea. The coupling it documented does not exist, and the comment asserted a mechanism the code does not have. The claim was that load-earlier terminates only because `MAX_MESSAGES` (2000) equals the runtime host's `MOBILE_NATIVE_CHAT_MAX_WINDOW` (2000), so raising the client cap alone would leave a button that re-reads the same capped tail forever. Measured against the real hook, both paths refute it: - the cursor path asks for a PAGE (`pageLimit`, 60) with a `beforeOffset` on every read, never the whole window, so the host's window clamp cannot bite at all; - the growing-tail path terminates through its own count inference — driven against a host clamped to 100 while the client cap stayed 2000, the affordance went false after two presses (limits 100 then 160), where the coupling claim predicts it stays true forever. An unclamped control arm kept it true for all eight presses, so that arm was observing the clamp and not an unrelated stop. Termination comes from `nextLimit < MAX_MESSAGES` and the `pageLimit <= 0` early return, neither of which involves the host. There is no invariant here worth naming, so the comment goes rather than gets reworded. The claim was inherited from an earlier residual note and committed without being checked, which is the error the review loop exists to catch. --- mobile/src/session/use-mobile-native-chat-session.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 7387b14674a2..2bbb3a580371 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -59,11 +59,6 @@ export function reportedHasMore(value: unknown): boolean | undefined { return typeof value === 'boolean' ? value : undefined } const PAGE = 60 -// Must not exceed the host's own clamp (`MOBILE_NATIVE_CHAT_MAX_WINDOW`, -// src/main/runtime/rpc/methods/native-chat.ts). Past that the host keeps -// answering `hasMore: true` while returning the same capped tail, so raising -// this alone leaves a "Load earlier" button that re-reads the whole window on -// every press and never loads anything. const MAX_MESSAGES = 2000 type ReadSessionResult =