From 32125e6fe858b30a004417080114f934b17cfb35 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:59:10 -0700 Subject: [PATCH 01/11] fix(mobile-native-chat): reland glued pending retirement without the two revert causes Relands #14665 (reverted by #14819). #14665 retired mobile pending bubbles when two fast sends landed as one transcript row, but shipped two regressions; both are fixed here rather than re-applied and hoped for. 1. A rejected send restored a TRIMMED composer. #14665 reassigned `text` to `text.trimEnd()` at the top of `sendMessage` and then used that one value for both the bytes on the wire and the composer restore, so a rejection put back less than the user typed. The draft and the payload are now separate values: `draftText` is what the user typed and is what `clearDraftForSend` / `restoreRejectedDraft` see; only the transported `text` is trimmed. 2. Sends issued during hydration were stranded forever. #14665 persisted `glueBaselineTrusted: false` on any send captured while the transcript was still loading and never cleared it, so that send could never retire and stood as a permanent glue barrier for its neighbours. A hydration-time baseline is now a placeholder (`baselineResolved: false`) that the first authoritative read rebases onto real rows, ordinals included, instead of a permanent disqualification. That is STA-4492. The intended behavior is unchanged: one transcript user turn retires a run of 2+ adjacent text-only pending sends only when it exactly spells their normalized concatenation, every send is bounded by its OWN transcript tail, and exact landings, image echoes and unresolved tails stay barriers. No wire change: `baselineResolved` and the baseline tail are client-local React state in `pendingBySession` and are never exchanged with a host. The only client->host difference is trailing whitespace no longer being written onto the agent's input line, over the existing `terminal.send` params. Refs STA-4482, STA-4492. Original PR #14665, revert #14819. --- ...obile-native-chat-pending-baseline.test.ts | 112 +++++++ .../mobile-native-chat-pending-baseline.ts | 52 ++++ .../mobile-native-chat-pending-echo.ts | 7 + ...ile-native-chat-pending-retirement.test.ts | 287 ++++++++++++++++++ .../mobile-native-chat-pending-retirement.ts | 148 +++++++++ .../use-mobile-native-chat-controller.test.ts | 3 +- ...e-native-chat-drafts-glued-pending.test.ts | 229 ++++++++++++++ .../session/use-mobile-native-chat-drafts.ts | 42 +-- ...se-mobile-native-chat-message-send.test.ts | 65 +++- .../use-mobile-native-chat-message-send.ts | 13 +- 10 files changed, 923 insertions(+), 35 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-pending-baseline.test.ts create mode 100644 mobile/src/session/mobile-native-chat-pending-baseline.ts create mode 100644 mobile/src/session/mobile-native-chat-pending-retirement.test.ts create mode 100644 mobile/src/session/mobile-native-chat-pending-retirement.ts create mode 100644 mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.test.ts b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts new file mode 100644 index 000000000000..f331f9b1762d --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline' +import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' + +function userTurn(id: string, text: string, timestamp: number): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } +} + +function assistantTurn(id: string, text: string, timestamp: number): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript' + } +} + +function unresolved( + id: string, + text: string, + expectedOccurrence = 1 +): MobileNativeChatPendingMessage { + return { id, text, expectedOccurrence, baselineTailMessageId: null, baselineResolved: false } +} + +describe('rebaseMobileNativeChatPendingBaselines', () => { + const history = [userTurn('m1', 'run the tests', 1000), assistantTurn('m2', 'passed', 1100)] + + it('returns the same array when every baseline is already resolved', () => { + const pending: MobileNativeChatPendingMessage[] = [ + { + id: 'p1', + text: 'hi', + expectedOccurrence: 1, + baselineTailMessageId: 'm2', + baselineResolved: true + } + ] + expect(rebaseMobileNativeChatPendingBaselines(history, pending)).toBe(pending) + }) + + it('pins an unresolved send to the loaded tail and past the rows it never saw', () => { + expect( + rebaseMobileNativeChatPendingBaselines(history, [unresolved('p1', 'run the tests')]) + ).toEqual([ + { + id: 'p1', + text: 'run the tests', + // The one row already in history is not this send's echo; it waits for the second. + expectedOccurrence: 2, + baselineTailMessageId: 'm2', + baselineResolved: true + } + ]) + }) + + it('pins to null when the authoritative transcript is empty', () => { + expect(rebaseMobileNativeChatPendingBaselines([], [unresolved('p1', 'hi')])).toEqual([ + { + id: 'p1', + text: 'hi', + expectedOccurrence: 1, + baselineTailMessageId: null, + baselineResolved: true + } + ]) + }) + + it('gives identical queued sends consecutive ordinals', () => { + expect( + rebaseMobileNativeChatPendingBaselines(history, [ + unresolved('p1', 'run the tests'), + unresolved('p2', 'run the tests') + ]).map((item) => item.expectedOccurrence) + ).toEqual([2, 3]) + }) + + it('normalizes whitespace when counting a send against the loaded rows', () => { + expect( + rebaseMobileNativeChatPendingBaselines(history, [unresolved('p1', ' run the tests \n')])[0] + ?.expectedOccurrence + ).toBe(2) + }) + + it('leaves already-resolved neighbours untouched but still counts them', () => { + const resolved: MobileNativeChatPendingMessage = { + id: 'p1', + text: 'run the tests', + expectedOccurrence: 9, + baselineTailMessageId: 'm1', + baselineResolved: true + } + const rebased = rebaseMobileNativeChatPendingBaselines(history, [ + resolved, + unresolved('p2', 'run the tests') + ]) + expect(rebased[0]).toBe(resolved) + expect(rebased[1]?.expectedOccurrence).toBe(3) + }) + + it('ordinals caption-less image echoes among themselves, not against text rows', () => { + const images = { ...unresolved('p1', ''), images: ['file:///a.png'] } + const rebased = rebaseMobileNativeChatPendingBaselines(history, [ + images, + { ...unresolved('p2', ''), images: ['file:///b.png'] } + ]) + expect(rebased.map((item) => item.expectedOccurrence)).toEqual([1, 2]) + expect(rebased.map((item) => item.baselineTailMessageId)).toEqual(['m2', 'm2']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.ts b/mobile/src/session/mobile-native-chat-pending-baseline.ts new file mode 100644 index 000000000000..4396cf97bc25 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-baseline.ts @@ -0,0 +1,52 @@ +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + countUserTextOccurrences, + normalizeReconcileText +} from './mobile-native-chat-draft-reconcile' +import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' + +/** + * Pin the sends issued while the transcript was still hydrating onto the first + * authoritative read of this session's history. + * + * Such a send has no usable baseline at capture time: `messages` was empty, or + * still the previously active tab's, so both its tail and its occurrence count + * describe somebody else's transcript. Rebasing them here — rather than marking + * the send permanently untrustworthy, which stranded it as a queued bubble and + * as a glue barrier for its neighbours for the rest of the session — leaves it + * matching exactly the rows that arrive from now on. + */ +export function rebaseMobileNativeChatPendingBaselines( + messages: readonly NativeChatMessage[], + current: MobileNativeChatPendingMessage[] +): MobileNativeChatPendingMessage[] { + if (current.every((item) => item.baselineResolved)) { + return current + } + const baselineTailMessageId = messages.at(-1)?.id ?? null + const earlierByText = new Map() + let earlierImageEchoes = 0 + return current.map((item) => { + const normalized = normalizeReconcileText(item.text) + const earlier = normalized === '' ? earlierImageEchoes : (earlierByText.get(normalized) ?? 0) + if (normalized === '') { + earlierImageEchoes += item.images?.length ? 1 : 0 + } else { + earlierByText.set(normalized, earlier + 1) + } + if (item.baselineResolved) { + return item + } + return { + ...item, + baselineTailMessageId, + baselineResolved: true, + // Ordinals stay relative to the queue: earlier still-pending sends of the + // same text claim the earlier rows, so this one waits for its own. + expectedOccurrence: + normalized === '' + ? earlier + 1 + : countUserTextOccurrences(messages, normalized) + earlier + 1 + } + }) +} diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index b18136a9d473..7466eae905f7 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -5,6 +5,11 @@ export type MobileNativeChatPendingMessage = { /** Local preview URIs carried by the send for its optimistic echo. */ images?: string[] baselineTailMessageId: string | null + /** Whether the transcript this baseline was captured from was already this + * session's own history. A send issued mid-hydration is captured unresolved + * and rebased onto the first authoritative read instead of reconciling + * against rows that may belong to another tab. */ + baselineResolved: boolean } export type MobileNativeChatSendOrigin = { @@ -13,6 +18,7 @@ export type MobileNativeChatSendOrigin = { normalizedText: string baselineOccurrences: number baselineTailMessageId: string | null + baselineResolved: boolean } type PendingByKey = Record @@ -56,6 +62,7 @@ export function appendMobileNativeChatPending( ? expectedImageEchoOrdinal : origin.baselineOccurrences + earlierOutstanding + 1, baselineTailMessageId: origin.baselineTailMessageId, + baselineResolved: origin.baselineResolved, ...(images?.length ? { images } : {}) } ] diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts new file mode 100644 index 000000000000..10981b1dee53 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' +import { + retireLandedMobileNativeChatPending, + selectGluedPendingIds +} from './mobile-native-chat-pending-retirement' + +const NO_IMAGE_ECHOES: ReadonlySet = new Set() + +function userTurn(id: string, text: string, timestamp: number): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } +} + +function assistantTurn(id: string, text: string, timestamp: number): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript' + } +} + +/** A send captured with `tail` as the last transcript message it could see. */ +function pendingSend( + id: string, + text: string, + tail: string | null, + expectedOccurrence = 1, + baselineResolved = true +): MobileNativeChatPendingMessage { + return { id, text, expectedOccurrence, baselineTailMessageId: tail, baselineResolved } +} + +function retiredIds( + messages: readonly NativeChatMessage[], + pending: readonly MobileNativeChatPendingMessage[] +): string[] { + return [...selectGluedPendingIds(messages, pending)].sort() +} + +describe('selectGluedPendingIds', () => { + it('retires both bubbles when two rapid sends collapse into one user row', () => { + const messages = [ + assistantTurn('m1', 'ready', 1000), + userTurn('m2', 'run the tests again', 5000) + ] + const pending = [pendingSend('p1', 'run the tests', 'm1'), pendingSend('p2', 'again', 'm1')] + expect(retiredIds(messages, pending)).toEqual(['p1', 'p2']) + }) + + it('retires a glued row that concatenated with no separator at all', () => { + const messages = [ + assistantTurn('m1', 'ready', 1000), + userTurn('m2', 'run the testsagain', 5000) + ] + const pending = [pendingSend('p1', 'run the tests', 'm1'), pendingSend('p2', 'again', 'm1')] + expect(retiredIds(messages, pending)).toEqual(['p1', 'p2']) + }) + + it('retires three collapsed prompts in one row', () => { + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two three', 5000)] + const pending = [ + pendingSend('p1', 'one', 'm1'), + pendingSend('p2', 'two', 'm1'), + pendingSend('p3', 'three', 'm1') + ] + expect(retiredIds(messages, pending)).toEqual(['p1', 'p2', 'p3']) + }) + + it('matches across tab, newline and repeated-space separators', () => { + const messages = [ + assistantTurn('m1', 'ready', 1000), + userTurn('m2', 'run the tests\t\n again', 5000) + ] + const pending = [ + pendingSend('p1', ' run the tests\n', 'm1'), + pendingSend('p2', '\tagain ', 'm1') + ] + expect(retiredIds(messages, pending)).toEqual(['p1', 'p2']) + }) + + it('matches prompts that themselves contain the separator', () => { + const messages = [ + assistantTurn('m1', 'ready', 1000), + userTurn('m2', 'fix the bug and run the tests', 5000) + ] + const pending = [ + pendingSend('p1', 'fix the bug', 'm1'), + pendingSend('p2', 'and run the tests', 'm1') + ] + expect(retiredIds(messages, pending)).toEqual(['p1', 'p2']) + }) + + it('matches when one pending is a strict prefix of the next', () => { + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'run run the tests', 5000)] + const pending = [pendingSend('p1', 'run', 'm1'), pendingSend('p2', 'run the tests', 'm1')] + expect(retiredIds(messages, pending)).toEqual(['p1', 'p2']) + }) + + it('matches identical prompts sent twice and three times', () => { + const twice = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'hi hi', 5000)] + expect( + retiredIds(twice, [pendingSend('p1', 'hi', 'm1', 1), pendingSend('p2', 'hi', 'm1', 2)]) + ).toEqual(['p1', 'p2']) + + const thrice = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'hi hi hi', 5000)] + expect( + retiredIds(thrice, [ + pendingSend('p1', 'hi', 'm1', 1), + pendingSend('p2', 'hi', 'm1', 2), + pendingSend('p3', 'hi', 'm1', 3) + ]) + ).toEqual(['p1', 'p2', 'p3']) + }) + + it('retires two separate glued rows against their own runs', () => { + const messages = [ + assistantTurn('m1', 'ready', 1000), + userTurn('m2', 'one two', 5000), + userTurn('m3', 'three four', 6000) + ] + const pending = [ + pendingSend('p1', 'one', 'm1'), + pendingSend('p2', 'two', 'm1'), + pendingSend('p3', 'three', 'm2'), + pendingSend('p4', 'four', 'm2') + ] + expect(retiredIds(messages, pending)).toEqual(['p1', 'p2', 'p3', 'p4']) + }) + + // FP1: the concatenation already exists in history, older than the sends. + it('never lets a transcript row that predates the sends retire them', () => { + const messages = [ + userTurn('m1', 'run the tests again', 1), + assistantTurn('m2', 'done', 2), + assistantTurn('m3', 'anything else?', 3) + ] + const pending = [pendingSend('p1', 'run the tests', 'm3'), pendingSend('p2', 'again', 'm3')] + expect(retiredIds(messages, pending)).toEqual([]) + }) + + // FP2: an old turn reads like the concatenation of two much later sends. + it('never lets an older turn retire sends captured after it', () => { + const messages = [userTurn('m1', 'fix the bug', 1000), assistantTurn('m2', 'fixed', 1100)] + const pending = [pendingSend('p1', 'fix the', 'm2'), pendingSend('p2', 'bug', 'm2')] + expect(retiredIds(messages, pending)).toEqual([]) + }) + + it('rejects a row the pending run only prefixes', () => { + const messages = [ + assistantTurn('m1', 'ready', 1000), + userTurn('m2', 'run the tests again with coverage', 5000) + ] + const pending = [pendingSend('p1', 'run the tests', 'm1'), pendingSend('p2', 'again', 'm1')] + expect(retiredIds(messages, pending)).toEqual([]) + }) + + it('rejects a run that only partly spells the row', () => { + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'run the tests', 5000)] + const pending = [pendingSend('p1', 'run the tests', 'm1'), pendingSend('p2', 'again', 'm1')] + // A lone exact match is an ordinary landing, not glue. + expect(retiredIds(messages, pending)).toEqual([]) + }) + + it('rejects glue when a send in the run cannot resolve its own tail', () => { + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)] + const pending = [pendingSend('p1', 'one', 'm1'), pendingSend('p2', 'two', 'paginated-away')] + expect(retiredIds(messages, pending)).toEqual([]) + }) + + it('treats a null tail as "transcript was empty", so any row can glue', () => { + const messages = [userTurn('m1', 'one two', 5000)] + const pending = [pendingSend('p1', 'one', null), pendingSend('p2', 'two', null)] + expect(retiredIds(messages, pending)).toEqual(['p1', 'p2']) + }) + + // Unresolved baselines are held out of matching until the drafts hook rebases + // them onto the first authoritative read — see mobile-native-chat-pending-baseline. + it('holds out sends whose baseline has not been rebased onto a real transcript', () => { + const messages = [userTurn('m1', 'one two', 1000)] + const pending = [ + pendingSend('p1', 'one', null, 1, false), + pendingSend('p2', 'two', null, 1, false) + ] + expect(retiredIds(messages, pending)).toEqual([]) + }) + + it('skips a caption-less image echo instead of gluing it', () => { + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)] + const pending = [ + pendingSend('p1', '', 'm1'), + pendingSend('p2', 'one', 'm1'), + pendingSend('p3', 'two', 'm1') + ] + expect(retiredIds(messages, pending)).toEqual(['p2', 'p3']) + }) + + it('keeps a captioned image echo and its neighbors until the preview is rebound', () => { + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)] + const pending = [ + { ...pendingSend('p1', 'one', 'm1'), images: ['file:///a.png'] }, + pendingSend('p2', 'two', 'm1') + ] + expect(retiredIds(messages, pending)).toEqual([]) + expect( + retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES).map((item) => item.id) + ).toEqual(['p1', 'p2']) + }) + + it('does nothing for a single pending send', () => { + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)] + expect(retiredIds(messages, [pendingSend('p1', 'one', 'm1')])).toEqual([]) + }) + + it('inspects each pending segment at most once per transcript turn', () => { + const pendingCount = 96 + const turnCount = 12 + const text = `${'a '.repeat(pendingCount)}x` + const messages = [ + assistantTurn('tail', 'ready', 1000), + ...Array.from({ length: turnCount }, (_, index) => userTurn(`m${index}`, text, 2000 + index)) + ] + const pending = Array.from({ length: pendingCount }, (_, index) => + pendingSend(`p${index}`, 'a', 'tail', index + 1) + ) + const startsWith = vi.spyOn(String.prototype, 'startsWith') + let segmentChecks = 0 + try { + expect(retiredIds(messages, pending)).toEqual([]) + segmentChecks = startsWith.mock.calls.length + } finally { + startsWith.mockRestore() + } + expect(segmentChecks).toBeLessThanOrEqual(turnCount * pendingCount) + }) +}) + +describe('retireLandedMobileNativeChatPending', () => { + it('retires exact landings by ordinal and glued rows in the same pass', () => { + const messages = [ + assistantTurn('m1', 'ready', 1000), + userTurn('m2', 'standalone', 4000), + userTurn('m3', 'run the tests again', 5000) + ] + const pending = [ + pendingSend('p0', 'standalone', 'm1'), + pendingSend('p1', 'run the tests', 'm2'), + pendingSend('p2', 'again', 'm2') + ] + expect(retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES)).toEqual([]) + }) + + it('keeps sends whose glue candidate predates them', () => { + const messages = [userTurn('m1', 'fix the bug', 1000), assistantTurn('m2', 'fixed', 1100)] + const pending = [pendingSend('p1', 'fix the', 'm2'), pendingSend('p2', 'bug', 'm2')] + expect( + retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES).map((item) => item.id) + ).toEqual(['p1', 'p2']) + }) + + it('does not glue former neighbors across an exact-landed send', () => { + const messages = [ + assistantTurn('m0', 'ready', 1000), + userTurn('m1', 'middle', 2000), + userTurn('m2', 'one two', 3000) + ] + const pending = [ + pendingSend('p1', 'one', 'm0'), + pendingSend('p2', 'middle', 'm0'), + pendingSend('p3', 'two', 'm0') + ] + expect( + retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES).map((item) => item.id) + ).toEqual(['p1', 'p3']) + }) + + it('keeps image echoes until their preview is rebound', () => { + const messages = [assistantTurn('m1', 'ready', 1000)] + const pending = [{ ...pendingSend('p1', 'photo', 'm1'), images: ['file:///a.png'] }] + expect( + retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES).map((item) => item.id) + ).toEqual(['p1']) + expect(retireLandedMobileNativeChatPending(messages, pending, new Set(['p1']))).toEqual([]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.ts b/mobile/src/session/mobile-native-chat-pending-retirement.ts new file mode 100644 index 000000000000..43bf544537a4 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-retirement.ts @@ -0,0 +1,148 @@ +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + countImageSourceTurnsAfter, + normalizeReconcileText, + normalizedUserText +} from './mobile-native-chat-draft-reconcile' +import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' + +const SPACE = ' ' +const NO_PENDING_IDS: ReadonlySet = new Set() + +type UserTurn = { index: number; text: string } +type GlueSegment = { text: string; tail: number } | null + +/** Pending ids represented by post-send transcript rows that glued adjacent sends. */ +export function selectGluedPendingIds( + messages: readonly NativeChatMessage[], + pending: readonly MobileNativeChatPendingMessage[], + excludedPendingIds: ReadonlySet = NO_PENDING_IDS +): ReadonlySet { + const retired = new Set() + if (pending.length < 2) { + return retired + } + const messageIndexById = new Map() + const turns: UserTurn[] = [] + for (const [index, message] of messages.entries()) { + messageIndexById.set(message.id, index) + const text = normalizedUserText(message) + if (text) { + turns.push({ index, text }) + } + } + const segments: GlueSegment[] = pending.map((item) => { + const text = normalizeReconcileText(item.text) + const tail = + item.baselineTailMessageId === null + ? -1 + : (messageIndexById.get(item.baselineTailMessageId) ?? null) + return excludedPendingIds.has(item.id) || + !item.baselineResolved || + item.images?.length || + text === '' || + tail === null + ? null + : { text, tail } + }) + + // Barriers preserve original adjacency after exact landings retire. + let runStart = 0 + while (runStart < pending.length) { + while (runStart < pending.length && segments[runStart] === null) { + runStart += 1 + } + let runEnd = runStart + while (runEnd < pending.length && segments[runEnd] !== null) { + runEnd += 1 + } + let cursor = runStart + for (const turn of turns) { + if (cursor >= runEnd - 1) { + break + } + const matched = matchGluedRun(turn, segments, cursor, runEnd) + if (matched === 0) { + continue + } + for (let index = cursor; index < cursor + matched; index++) { + retired.add(pending[index]!.id) + } + cursor += matched + } + runStart = runEnd + 1 + } + return retired +} + +/** Length of the exact glued run at `start`, or zero. */ +function matchGluedRun( + turn: UserTurn, + segments: readonly GlueSegment[], + start: number, + end: number +): number { + let at = 0 + let matched = 0 + for (let index = start; index < end; index++) { + const segment = segments[index]! + // Every send carries its OWN boundary: a row that already existed when this + // send was issued can never be part of its echo, however well it reads. + if (turn.index <= segment.tail) { + return 0 + } + if (at > 0 && turn.text[at] === SPACE) { + at += 1 + } + if (!turn.text.startsWith(segment.text, at)) { + return 0 + } + at += segment.text.length + matched += 1 + if (at === turn.text.length) { + // A lone exact match is an ordinary landing, which the count pass owns. + return matched > 1 ? matched : 0 + } + } + return 0 +} + +/** Retires exact and glued transcript landings while preserving pending order. */ +export function retireLandedMobileNativeChatPending( + messages: readonly NativeChatMessage[], + current: MobileNativeChatPendingMessage[], + landedImagePendingIds: ReadonlySet +): MobileNativeChatPendingMessage[] { + const landedCounts = new Map() + for (const message of messages) { + const text = normalizedUserText(message) + if (text) { + landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1) + } + } + const landedPendingIds = new Set() + for (const item of current) { + if (landedImagePendingIds.has(item.id)) { + landedPendingIds.add(item.id) + continue + } + // Keep image echoes until their local preview reaches the authoritative message. + // An unresolved baseline has nothing to count against yet — `messages` is not + // known to be the transcript this send was issued into. + if (item.images?.length || !item.baselineResolved) { + continue + } + const landed = + item.text.trim() === '' + ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) >= + item.expectedOccurrence + : (landedCounts.get(normalizeReconcileText(item.text)) ?? 0) >= item.expectedOccurrence + if (landed) { + landedPendingIds.add(item.id) + } + } + const glued = selectGluedPendingIds(messages, current, landedPendingIds) + return landedPendingIds.size === 0 && glued.size === 0 + ? current + : current.filter((item) => !landedPendingIds.has(item.id) && !glued.has(item.id)) +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index c04b560798d2..a6308d08be5c 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -96,7 +96,8 @@ const ORIGIN = { pendingKey: 'h\0w\0tab-1\0session-1', normalizedText: 'look', baselineOccurrences: 0, - baselineTailMessageId: null + baselineTailMessageId: null, + baselineResolved: true } describe('useMobileNativeChatController handleNativeChatSend', () => { diff --git a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts new file mode 100644 index 000000000000..d0df3f0faaa8 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -0,0 +1,229 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { buildMobileNativeChatTransientData } from './mobile-native-chat-render-data' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +type DraftState = ReturnType +type TestRenderer = { + unmount(): void + update(element: ReturnType): void +} + +function userTurn(id: string, text: string, timestamp: number): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } +} + +function assistantTurn(id: string, text: string, timestamp: number): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript' + } +} + +/** Texts of the optimistic bubbles the chat list actually renders. */ +function renderedPendingTexts( + messages: NativeChatMessage[], + state: DraftState | null +): (string | undefined)[] { + const { data } = buildMobileNativeChatTransientData({ + folded: messages, + streaming: null, + pending: state?.pending ?? [] + }) + return data + .filter((message) => !messages.some((existing) => existing.id === message.id)) + .map((message) => message.blocks.find((block) => block.type === 'text')?.text) +} + +describe('useMobileNativeChatDrafts glued pending sends', () => { + let renderer: TestRenderer | null = null + let state: DraftState | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + }) + + function Harness({ + messages, + transcriptLoading = false + }: { + messages: NativeChatMessage[] + transcriptLoading?: boolean + }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: 'tab', + sessionId: 'session', + messages, + transcriptLoading + }) + return null + } + + async function mount(messages: NativeChatMessage[], transcriptLoading = false): Promise { + await act(async () => { + renderer = create(createElement(Harness, { messages, transcriptLoading })) + }) + } + + async function update(messages: NativeChatMessage[], transcriptLoading = false): Promise { + await act(async () => renderer?.update(createElement(Harness, { messages, transcriptLoading }))) + } + + function send(text: string): void { + const origin = state?.captureSendOrigin(text) + if (origin) { + state?.acceptSend(origin, text) + } + } + + /** Two composer sends fired before either transcript echo lands. */ + function rapidSend(first: string, second: string): void { + act(() => { + send(first) + send(second) + }) + } + + const pendingTexts = (): (string | undefined)[] | undefined => + state?.pending.map((item) => item.text) + + it('retires both bubbles when the rapid sends land as one glued user row', async () => { + const history = [assistantTurn('m1', 'ready', 1000)] + await mount(history) + rapidSend('run the tests', 'again') + expect(pendingTexts()).toEqual(['run the tests', 'again']) + + const glued = [...history, userTurn('m2', 'run the tests again', 5000)] + await update(glued) + expect(state?.pending).toEqual([]) + expect(renderedPendingTexts(glued, state)).toEqual([]) + }) + + // FP1: the concatenation already sits in history, older than the sends. + it('keeps and renders both bubbles when the matching row predates the sends', async () => { + const history = [ + userTurn('m1', 'run the tests again', 1000), + assistantTurn('m2', 'done', 2000), + assistantTurn('m3', 'anything else?', 3000) + ] + await mount(history) + rapidSend('run the tests', 'again') + + await update([...history]) + expect(pendingTexts()).toEqual(['run the tests', 'again']) + expect(renderedPendingTexts(history, state)).toEqual(['run the tests', 'again']) + }) + + // FP2: an older turn reads like the concatenation of two much later sends. + it('keeps and renders both bubbles when an older turn spells them out', async () => { + const history = [userTurn('m1', 'fix the bug', 1000), assistantTurn('m2', 'fixed', 1100)] + await mount(history) + rapidSend('fix the', 'bug') + + await update([...history]) + expect(pendingTexts()).toEqual(['fix the', 'bug']) + expect(renderedPendingTexts(history, state)).toEqual(['fix the', 'bug']) + }) + + it('still retires each bubble on its own when the sends do not glue', async () => { + const history = [assistantTurn('m1', 'ready', 1000)] + await mount(history) + rapidSend('run the tests', 'again') + + const separate = [ + ...history, + userTurn('m2', 'run the tests', 5000), + userTurn('m3', 'again', 5100) + ] + await update(separate) + expect(state?.pending).toEqual([]) + }) + + it('keeps the second bubble when only the first send has echoed', async () => { + const history = [assistantTurn('m1', 'ready', 1000)] + await mount(history) + rapidSend('run the tests', 'again') + + const partial = [...history, userTurn('m2', 'run the tests', 5000)] + await update(partial) + expect(pendingTexts()).toEqual(['again']) + expect(renderedPendingTexts(partial, state)).toEqual(['again']) + }) + + // STA-4492 / #14819: sends issued mid-hydration used to be marked permanently + // untrusted, so their glued row could never retire them and they also barred + // their neighbours from matching. The first authoritative read rebases them + // instead: history that arrives with it is still not theirs, but everything + // after it is. + describe('sends issued while the transcript is still hydrating', () => { + const loadedHistory = [userTurn('m1', 'run the tests again', 1000)] + + it('keeps both bubbles when the loaded history only looks like their glue', async () => { + await mount([], true) + rapidSend('run the tests', 'again') + + await update(loadedHistory) + expect(pendingTexts()).toEqual(['run the tests', 'again']) + expect(renderedPendingTexts(loadedHistory, state)).toEqual(['run the tests', 'again']) + }) + + it('retires both bubbles once their glued row lands after hydration', async () => { + await mount([], true) + rapidSend('run the tests', 'again') + await update(loadedHistory) + + const glued = [...loadedHistory, userTurn('m2', 'run the tests again', 5000)] + await update(glued) + expect(state?.pending).toEqual([]) + expect(renderedPendingTexts(glued, state)).toEqual([]) + }) + + it('retires a hydration-time send on its own exact echo', async () => { + await mount([], true) + act(() => send('run the tests')) + await update(loadedHistory) + expect(pendingTexts()).toEqual(['run the tests']) + + const echoed = [...loadedHistory, userTurn('m2', 'run the tests', 5000)] + await update(echoed) + expect(state?.pending).toEqual([]) + }) + + it('stops barring a later pair from gluing once its own echo lands', async () => { + await mount([], true) + act(() => send('hold this')) + await update(loadedHistory) + rapidSend('fix the', 'bug') + + const landed = [ + ...loadedHistory, + userTurn('m2', 'hold this', 5000), + userTurn('m3', 'fix the bug', 5100) + ] + await update(landed) + expect(state?.pending).toEqual([]) + }) + + it('does not retire a hydration-time send against history it never saw', async () => { + await mount([], true) + act(() => send('run the tests')) + + // The read lands carrying an older identical prompt: not this send's echo. + const olderIdentical = [ + userTurn('m1', 'run the tests', 1000), + assistantTurn('m2', 'passed', 1100) + ] + await update(olderIdentical) + expect(pendingTexts()).toEqual(['run the tests']) + }) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 55fff5b42e39..2a68bb6bd062 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -1,16 +1,16 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { - countImageSourceTurnsAfter, countUserTextOccurrences, findLandedImagePreviewEchoes, findLandedUnconfirmedSends, mergeLandedImagePreviewEchoes, migrateImagePreviewMessageIds, normalizeReconcileText, - normalizedUserText, type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' +import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline' +import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' import { appendMobileNativeChatPending, combineMobileNativeChatPending, @@ -138,10 +138,13 @@ export function useMobileNativeChatDrafts(args: { pendingKey, normalizedText, baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText), - baselineTailMessageId: messagesRef.current.at(-1)?.id ?? null + baselineTailMessageId: messagesRef.current.at(-1)?.id ?? null, + // A hydrating transcript is not yet this session's, so this baseline is + // a placeholder the first authoritative read rebases. + baselineResolved: !transcriptLoading } }, - [draftKey, pendingKey] + [draftKey, pendingKey, transcriptLoading] ) // Why: over relay the send RPC can take seconds (or lose only its ack), and a @@ -291,28 +294,13 @@ 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) - } - } - // 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 item.text.trim() === '' - ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) < - item.expectedOccurrence - : (landedCounts.get(normalizeReconcileText(item.text)) ?? 0) < item.expectedOccurrence - }) - if (next.length === current.length) { + // Rebase before retiring: a hydration-time send has to own a real boundary + // before any row can be judged against it. + const rebased = transcriptLoading + ? current + : rebaseMobileNativeChatPendingBaselines(messages, current) + const next = retireLandedMobileNativeChatPending(messages, rebased, landedImagePendingIds) + if (next === current) { return previous } if (next.length > 0) { @@ -322,7 +310,7 @@ export function useMobileNativeChatDrafts(args: { delete remaining[pendingKey] return remaining }) - }, [messages, pending, pendingKey]) + }, [messages, pending, pendingKey, transcriptLoading]) return { composerText: draftKey ? (drafts[draftKey] ?? '') : '', 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 dd67cf46da8b..e22c601df315 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 @@ -4,6 +4,8 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// Type-only, so it is erased before the `./mobile-native-chat-send` mock below applies. +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' const sendWithOutcome = vi.fn() const clearInputWrite = vi.fn() @@ -36,6 +38,9 @@ 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) + const clearDraftForSend = vi.fn() + const restoreRejectedDraft = vi.fn() const holdUnconfirmedSend = vi.fn() const onCommandSend = vi.fn() const commandSendRef = { current: onCommandSend } @@ -55,10 +60,10 @@ describe('useMobileNativeChatMessageSend', () => { deviceTokenRef: { current: 'device' }, agentRef, commandSendRef, - captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never, + captureSendOrigin, readSeededLaunchDraftSeed, - clearDraftForSend: () => {}, - restoreRejectedDraft: () => {}, + clearDraftForSend, + restoreRejectedDraft, acceptSend, holdUnconfirmedSend, onSendError @@ -71,10 +76,12 @@ describe('useMobileNativeChatMessageSend', () => { } const sentArgs = (): { + text?: string clearInputFirst?: boolean resolvedLaunchDraft?: { text: string; createdAt: number } } => sendWithOutcome.mock.calls[0]![0] as { + text?: string clearInputFirst?: boolean resolvedLaunchDraft?: { text: string; createdAt: number } } @@ -89,6 +96,9 @@ describe('useMobileNativeChatMessageSend', () => { typeCommandWithOutcome.mockReset() typeCommandWithOutcome.mockResolvedValue('accepted') acceptSend.mockReset() + captureSendOrigin.mockClear() + clearDraftForSend.mockReset() + restoreRejectedDraft.mockReset() holdUnconfirmedSend.mockReset() onCommandSend.mockReset() commandSendRef.current = onCommandSend @@ -381,4 +391,53 @@ describe('useMobileNativeChatMessageSend', () => { expect(acquireMobileNativeChatTerminalWrite('term')).toBe(true) releaseMobileNativeChatTerminalWrite('term') }) + + // The host writes these bytes verbatim, so whitespace the match key drops must + // not reach the agent's input line and glue the next rapid send onto it (#14262). + it('trims trailing whitespace without changing leading prompt content', async () => { + mount(() => null) + await act(async () => { + await api!.send(' run the tests \n') + }) + expect(sentArgs().text).toBe(' run the tests') + expect(captureSendOrigin).toHaveBeenCalledWith(' run the tests') + expect(acceptSend.mock.calls[0]![1]).toBe(' run the tests') + }) + + it('trims trailing whitespace on a question answer too', async () => { + mount(() => null) + await act(async () => { + await api!.answerQuestion('\n 1 ') + }) + expect(sentArgs().text).toBe('\n 1') + }) + + // #14819: the trim is a wire concern only. A rejected send hands the composer + // back to the user, and it has to be the draft they typed, blank lines included. + it('restores the untrimmed draft when the send is rejected', async () => { + const draft = 'first line\n\nsecond line\n\n' + sendWithOutcome.mockResolvedValue('rejected') + mount(() => null) + + await act(async () => { + await api!.send(draft) + }) + + expect(sentArgs().text).toBe('first line\n\nsecond line') + expect(restoreRejectedDraft.mock.calls[0]![1]).toBe(draft) + expect(clearDraftForSend.mock.calls[0]![1]).toBe(draft) + }) + + it('restores the untrimmed draft when the launch-draft pre-clear is rejected', async () => { + const draft = 'ship it ' + clearInputWrite.mockResolvedValue(false) + mount(() => ({ text: DRAFT, createdAt: 1 })) + + await act(async () => { + await api!.send(draft) + }) + + expect(sendWithOutcome).not.toHaveBeenCalled() + expect(restoreRejectedDraft.mock.calls[0]![1]).toBe(draft) + }) }) 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..53b914657a33 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -85,12 +85,17 @@ export function useMobileNativeChatMessageSend(args: { const sendMessage = useCallback( async ( - text: string, + draftText: string, images: string[] | undefined, syncComposer: boolean, recordControlSend: boolean, sharedDeadline?: number ): Promise => { + // The host writes trailing whitespace verbatim onto the agent's input line, + // where it can glue the next rapid send onto this one (#14262). Only the + // bytes that go out are trimmed: `draftText` is what the user typed, and a + // rejected send has to put back exactly that (#14819). + const text = draftText.trimEnd() const handle = handleRef.current const origin = captureSendOrigin(text) const agent = agentRef.current @@ -124,7 +129,7 @@ export function useMobileNativeChatMessageSend(args: { // round trip is visible, and a lost ack must not strand the sent prompt // in the box. Only a definite rejection puts the text back. if (syncComposer) { - clearDraftForSend(origin, text) + clearDraftForSend(origin, draftText) } // Why: a parked launch draft is routinely multi-line, and one Ctrl+U clears // only one logical line. Size the clear to the text Orca injected, with @@ -150,7 +155,7 @@ export function useMobileNativeChatMessageSend(args: { }) if (!cleared) { if (syncComposer) { - restoreRejectedDraft(origin, text) + restoreRejectedDraft(origin, draftText) } onSendError('Message not sent') return 'rejected' @@ -204,7 +209,7 @@ export function useMobileNativeChatMessageSend(args: { } if (outcome === 'rejected') { if (syncComposer) { - restoreRejectedDraft(origin, text) + restoreRejectedDraft(origin, draftText) } onSendError('Message not sent') return 'rejected' From 4beb8547ee97636381c42ef6e92da8b8f10de1cf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:10:16 -0700 Subject: [PATCH 02/11] fix(mobile-native-chat): let the untrimmed draft reach the send seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer sent `value.trimEnd()`, so the raw draft never reached `sendMessage` and a rejected send still handed back a trimmed composer — the split of `draftText` from the transported `text` had nothing to restore. Pass the draft through; the seam already owns the wire trim. Also pins the array-identity contract of `retireLandedMobileNativeChatPending`: the drafts effect early-outs on `next === current`, and nothing tested it. --- mobile/src/session/MobileNativeChatComposer.test.ts | 8 +++++--- mobile/src/session/MobileNativeChatComposer.tsx | 4 +++- .../session/mobile-native-chat-pending-retirement.test.ts | 8 ++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts index 1d1b0288467a..3c8afe2cefd6 100644 --- a/mobile/src/session/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -84,7 +84,9 @@ describe('MobileNativeChatComposer', () => { await act(async () => sendButton().props.onPress()) - expect(onSend).toHaveBeenCalledWith(' hello') + // Verbatim: the send seam trims for the wire, so a rejected send can hand + // back the draft byte-for-byte (#14819). + expect(onSend).toHaveBeenCalledWith(' hello ') expect(onChangeText).not.toHaveBeenCalled() }) @@ -120,7 +122,7 @@ describe('MobileNativeChatComposer', () => { ) }) await act(async () => sendButton().props.onPress()) - expect(onSend).toHaveBeenCalledWith(' /clear is prose') + expect(onSend).toHaveBeenCalledWith(' /clear is prose ') }) it('locks the option pickers while a composer send is in flight', async () => { @@ -219,7 +221,7 @@ describe('MobileNativeChatComposer', () => { await act(async () => sendButton().props.onPress()) - expect(onSend).toHaveBeenCalledWith(' hello') + expect(onSend).toHaveBeenCalledWith(' hello ') expect(onChangeText).not.toHaveBeenCalled() }) diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index 9b289b4b6e81..ee937369ea7e 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -151,7 +151,9 @@ export function MobileNativeChatComposer({ sendingRef.current = true setSending(true) try { - const accepted = await onSend(value.trimEnd()) + // Raw, not trimmed: the send seam owns the wire trim, and a rejection has + // to hand the user back exactly what they typed (#14819). + const accepted = await onSend(value) if (accepted) { setCursor(0) } 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 10981b1dee53..235ef836301d 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts @@ -252,6 +252,14 @@ describe('retireLandedMobileNativeChatPending', () => { expect(retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES)).toEqual([]) }) + // The drafts effect early-outs on `next === current`; a fresh array on the + // no-op path would re-enter it on every transcript frame. + it('returns the input array itself when nothing retires', () => { + const messages = [assistantTurn('m1', 'ready', 1000)] + const pending = [pendingSend('p1', 'one', 'm1'), pendingSend('p2', 'two', 'm1')] + expect(retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES)).toBe(pending) + }) + it('keeps sends whose glue candidate predates them', () => { const messages = [userTurn('m1', 'fix the bug', 1000), assistantTurn('m2', 'fixed', 1100)] const pending = [pendingSend('p1', 'fix the', 'm2'), pendingSend('p2', 'bug', 'm2')] From 0cd84bfde1116fa8be9dcedd64463146547a1196 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:17:32 -0700 Subject: [PATCH 03/11] docs(mobile-native-chat): name the hydration rebase's residual ambiguity --- mobile/src/session/mobile-native-chat-pending-baseline.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.ts b/mobile/src/session/mobile-native-chat-pending-baseline.ts index 4396cf97bc25..d381d9e60a0a 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.ts @@ -15,6 +15,13 @@ import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pendin * the send permanently untrustworthy, which stranded it as a queued bubble and * as a glue barrier for its neighbours for the rest of the session — leaves it * matching exactly the rows that arrive from now on. + * + * Residual: if this read was taken AFTER the send already echoed — a re-subscribe + * on tab switch or reconnect, when the first load never completed — its own echo + * is counted as history and the bubble waits for a row that will not come. The + * transport writes keystrokes into a TUI, so there is no client message id to + * tell the two apart; the send is bounded instead by serializing submits per PTY + * so the glue never forms. */ export function rebaseMobileNativeChatPendingBaselines( messages: readonly NativeChatMessage[], From bcd7a001d19713932c421b3a020c393bb18cfdc2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:34:37 -0700 Subject: [PATCH 04/11] fix(mobile-native-chat): stop the hydration rebase stranding a send on its own echo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebasing recounted the send's ordinal against the first authoritative read. That read can already carry the send's own echo — a re-subscribe after a tab switch or reconnect returns whatever exists now — so the ordinal landed one past anything the transcript could supply. The bubble never cleared, it stayed a live segment at the head of its run so no later pair could glue either, and `earlierOutstanding` carried the inflation onto the next send of the same text. Only the tail needs recovering; the ordinal was already counted against an empty transcript, which is right for "no history was known". A caption-less image echo keeps its captured tail, since it counts turns after it. `baselineResolved` also has to mean "captured against a settled read", not merely "not loading": a read that failed hands back an empty list that reads as an empty conversation, and the null tail then let any row the successful read finally brought glue-retire those sends. --- ...obile-native-chat-pending-baseline.test.ts | 43 ++++----- .../mobile-native-chat-pending-baseline.ts | 61 +++++-------- .../use-mobile-native-chat-controller.ts | 3 +- ...e-native-chat-drafts-glued-pending.test.ts | 89 ++++++++++++++++--- .../session/use-mobile-native-chat-drafts.ts | 28 +++--- 5 files changed, 143 insertions(+), 81 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.test.ts b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts index f331f9b1762d..f421cbd388c3 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts @@ -41,15 +41,16 @@ describe('rebaseMobileNativeChatPendingBaselines', () => { expect(rebaseMobileNativeChatPendingBaselines(history, pending)).toBe(pending) }) - it('pins an unresolved send to the loaded tail and past the rows it never saw', () => { + it('gives an unresolved send the loaded tail as its glue boundary', () => { expect( rebaseMobileNativeChatPendingBaselines(history, [unresolved('p1', 'run the tests')]) ).toEqual([ { id: 'p1', text: 'run the tests', - // The one row already in history is not this send's echo; it waits for the second. - expectedOccurrence: 2, + // NOT recounted against this read: the read may already carry this send's + // own echo, and counting it as history would strand the bubble forever. + expectedOccurrence: 1, baselineTailMessageId: 'm2', baselineResolved: true } @@ -68,23 +69,16 @@ describe('rebaseMobileNativeChatPendingBaselines', () => { ]) }) - it('gives identical queued sends consecutive ordinals', () => { + it('keeps the ordinals the queue already assigned', () => { expect( rebaseMobileNativeChatPendingBaselines(history, [ - unresolved('p1', 'run the tests'), - unresolved('p2', 'run the tests') + unresolved('p1', 'run the tests', 1), + unresolved('p2', 'run the tests', 2) ]).map((item) => item.expectedOccurrence) - ).toEqual([2, 3]) - }) - - it('normalizes whitespace when counting a send against the loaded rows', () => { - expect( - rebaseMobileNativeChatPendingBaselines(history, [unresolved('p1', ' run the tests \n')])[0] - ?.expectedOccurrence - ).toBe(2) + ).toEqual([1, 2]) }) - it('leaves already-resolved neighbours untouched but still counts them', () => { + it('leaves already-resolved neighbours untouched', () => { const resolved: MobileNativeChatPendingMessage = { id: 'p1', text: 'run the tests', @@ -94,19 +88,28 @@ describe('rebaseMobileNativeChatPendingBaselines', () => { } const rebased = rebaseMobileNativeChatPendingBaselines(history, [ resolved, - unresolved('p2', 'run the tests') + unresolved('p2', 'run the tests', 2) ]) expect(rebased[0]).toBe(resolved) - expect(rebased[1]?.expectedOccurrence).toBe(3) + expect(rebased[1]).toEqual({ + id: 'p2', + text: 'run the tests', + expectedOccurrence: 2, + baselineTailMessageId: 'm2', + baselineResolved: true + }) }) - it('ordinals caption-less image echoes among themselves, not against text rows', () => { + // An image echo counts image turns AFTER its tail, so moving the tail onto this + // read would discard an echo the read already carries. + it('keeps a caption-less image echo on its captured tail', () => { const images = { ...unresolved('p1', ''), images: ['file:///a.png'] } const rebased = rebaseMobileNativeChatPendingBaselines(history, [ images, - { ...unresolved('p2', ''), images: ['file:///b.png'] } + { ...unresolved('p2', '', 2), images: ['file:///b.png'] } ]) expect(rebased.map((item) => item.expectedOccurrence)).toEqual([1, 2]) - expect(rebased.map((item) => item.baselineTailMessageId)).toEqual(['m2', 'm2']) + expect(rebased.map((item) => item.baselineTailMessageId)).toEqual([null, null]) + expect(rebased.every((item) => item.baselineResolved)).toBe(true) }) }) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.ts b/mobile/src/session/mobile-native-chat-pending-baseline.ts index d381d9e60a0a..995e3a6a6505 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.ts @@ -1,27 +1,29 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { - countUserTextOccurrences, - normalizeReconcileText -} from './mobile-native-chat-draft-reconcile' +import { normalizeReconcileText } from './mobile-native-chat-draft-reconcile' import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' /** - * Pin the sends issued while the transcript was still hydrating onto the first - * authoritative read of this session's history. + * Give the sends issued before this session's history was known the boundary + * they never had, on the first authoritative read. * - * Such a send has no usable baseline at capture time: `messages` was empty, or - * still the previously active tab's, so both its tail and its occurrence count - * describe somebody else's transcript. Rebasing them here — rather than marking - * the send permanently untrustworthy, which stranded it as a queued bubble and - * as a glue barrier for its neighbours for the rest of the session — leaves it - * matching exactly the rows that arrive from now on. + * Such a send saw no transcript, so it has no row it can judge candidates + * against — which held it out of matching entirely, stranding it as a queued + * bubble and, worse, as a permanent glue barrier for its neighbours. Recovering + * the tail is all that is needed: the ordinal was already counted against an + * empty transcript, which is exactly right for "no history was known". * - * Residual: if this read was taken AFTER the send already echoed — a re-subscribe - * on tab switch or reconnect, when the first load never completed — its own echo - * is counted as history and the bubble waits for a row that will not come. The - * transport writes keystrokes into a TUI, so there is no client message id to - * tell the two apart; the send is bounded instead by serializing submits per PTY - * so the glue never forms. + * The ordinal is deliberately NOT recounted against this read. The read can + * already carry the send's own echo — a re-subscribe after a tab switch or a + * reconnect returns whatever exists now — and nothing local separates that echo + * from an identical older prompt: the transport writes keystrokes into a TUI and + * carries no message id, and row timestamps come from the host while the send + * time comes from the phone. Counting it as history puts the ordinal one past + * anything the transcript can supply, which strands the bubble for the rest of + * the session and stops its whole run from ever gluing. + * + * A caption-less image echo keeps its captured tail: it reconciles by counting + * image turns AFTER that tail, so pinning the tail past this read would throw + * away an echo the read already carries. */ export function rebaseMobileNativeChatPendingBaselines( messages: readonly NativeChatMessage[], @@ -31,29 +33,12 @@ export function rebaseMobileNativeChatPendingBaselines( return current } const baselineTailMessageId = messages.at(-1)?.id ?? null - const earlierByText = new Map() - let earlierImageEchoes = 0 return current.map((item) => { - const normalized = normalizeReconcileText(item.text) - const earlier = normalized === '' ? earlierImageEchoes : (earlierByText.get(normalized) ?? 0) - if (normalized === '') { - earlierImageEchoes += item.images?.length ? 1 : 0 - } else { - earlierByText.set(normalized, earlier + 1) - } if (item.baselineResolved) { return item } - return { - ...item, - baselineTailMessageId, - baselineResolved: true, - // Ordinals stay relative to the queue: earlier still-pending sends of the - // same text claim the earlier rows, so this one waits for its own. - expectedOccurrence: - normalized === '' - ? earlier + 1 - : countUserTextOccurrences(messages, normalized) + earlier + 1 - } + return item.images?.length || normalizeReconcileText(item.text) === '' + ? { ...item, baselineResolved: true } + : { ...item, baselineResolved: true, baselineTailMessageId } }) } diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 46012dadbe41..27ad8049c1dd 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -111,7 +111,8 @@ export function useMobileNativeChatController(args: { // a null is indistinguishable from a host retraction, and peeking at the // terminal view would permanently decline the prefill. chatActive: showNativeChat, - transcriptLoading: nativeChatSession.transcriptLoading + transcriptLoading: nativeChatSession.transcriptLoading, + transcriptSettled: nativeChatSession.status === 'ready' }) const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null diff --git a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts index d0df3f0faaa8..7a1cf46cbe79 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -52,10 +52,12 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { function Harness({ messages, - transcriptLoading = false + transcriptLoading = false, + transcriptSettled = !transcriptLoading }: { messages: NativeChatMessage[] transcriptLoading?: boolean + transcriptSettled?: boolean }): null { state = useMobileNativeChatDrafts({ hostId: 'host', @@ -63,19 +65,30 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { tabId: 'tab', sessionId: 'session', messages, - transcriptLoading + transcriptLoading, + transcriptSettled }) return null } - async function mount(messages: NativeChatMessage[], transcriptLoading = false): Promise { + async function mount( + messages: NativeChatMessage[], + transcriptLoading = false, + transcriptSettled = !transcriptLoading + ): Promise { await act(async () => { - renderer = create(createElement(Harness, { messages, transcriptLoading })) + renderer = create(createElement(Harness, { messages, transcriptLoading, transcriptSettled })) }) } - async function update(messages: NativeChatMessage[], transcriptLoading = false): Promise { - await act(async () => renderer?.update(createElement(Harness, { messages, transcriptLoading }))) + async function update( + messages: NativeChatMessage[], + transcriptLoading = false, + transcriptSettled = !transcriptLoading + ): Promise { + await act(async () => + renderer?.update(createElement(Harness, { messages, transcriptLoading, transcriptSettled })) + ) } function send(text: string): void { @@ -213,17 +226,71 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { expect(state?.pending).toEqual([]) }) - it('does not retire a hydration-time send against history it never saw', async () => { + // The read that resolves a hydration-time send can already contain that + // send's own echo — a re-subscribe returns whatever exists now — and nothing + // local tells that echo from an identical older prompt. Claiming the row is + // the safe direction: being wrong costs one round trip of invisibility, + // while holding costs a queued bubble that never clears and a run that can + // never glue again. + it('retires a hydration-time send whose echo arrives with the read', async () => { await mount([], true) act(() => send('run the tests')) - // The read lands carrying an older identical prompt: not this send's echo. + const echoed = [userTurn('m1', 'run the tests', 1000), assistantTurn('m2', 'passed', 1100)] + await update(echoed) + expect(state?.pending).toEqual([]) + }) + + // The same read seen one reconnect later: the send left, the agent echoed it, + // and only then did the first authoritative read land. + it('retires a hydration-time send after a reconnect delivered the read late', async () => { + await mount([], true) + act(() => send('run the tests')) + // Still loading: the first read never arrived before the drop. + await update([], true) + + const afterReconnect = [ + userTurn('m1', 'run the tests', 5000), + assistantTurn('m2', 'ok', 5100) + ] + await update(afterReconnect) + expect(state?.pending).toEqual([]) + + // Nothing later resurrects it, and the run stays glue-capable. + await update([...afterReconnect, assistantTurn('m3', 'all green', 6000)]) + expect(state?.pending).toEqual([]) + }) + }) + + // A read that failed is not a boundary: `messages` is empty because the history + // is unknown, not because the conversation is. Without that distinction the + // empty list reads as "the transcript was empty", and any row the successful + // read finally brings can claim these sends. + describe('sends issued before any read settled', () => { + it('keeps both bubbles when the late read carries only an older glue-alike', async () => { + await mount([], false, false) + rapidSend('fix the', 'bug') + const olderIdentical = [ - userTurn('m1', 'run the tests', 1000), - assistantTurn('m2', 'passed', 1100) + userTurn('m1', 'fix the bug', 1000), + assistantTurn('m2', 'fixed', 1100) ] await update(olderIdentical) - expect(pendingTexts()).toEqual(['run the tests']) + expect(pendingTexts()).toEqual(['fix the', 'bug']) + expect(renderedPendingTexts(olderIdentical, state)).toEqual(['fix the', 'bug']) + }) + + it('still retires them once their own glued row lands', async () => { + await mount([], false, false) + rapidSend('fix the', 'bug') + const olderIdentical = [ + userTurn('m1', 'fix the bug', 1000), + assistantTurn('m2', 'fixed', 1100) + ] + await update(olderIdentical) + + await update([...olderIdentical, userTurn('m3', 'fix the bug', 5000)]) + expect(state?.pending).toEqual([]) }) }) }) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 2a68bb6bd062..64cdf7cdc993 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -47,6 +47,10 @@ export function useMobileNativeChatDrafts(args: { * transcript still belongs to the previously active tab), so it cannot be * trusted to decline or retire the seed. */ transcriptLoading?: boolean + /** `messages` is this session's own settled history — so an empty one really + * is an empty conversation, not a read that failed or never ran. Only then + * does a send's captured tail describe a real boundary. */ + transcriptSettled?: boolean }): { composerText: string setComposerText: Dispatch> @@ -80,7 +84,8 @@ export function useMobileNativeChatDrafts(args: { launchDraft, launchDraftCreatedAt, chatActive = true, - transcriptLoading + transcriptLoading, + transcriptSettled = !transcriptLoading } = args const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId) const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null @@ -139,12 +144,13 @@ export function useMobileNativeChatDrafts(args: { normalizedText, baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText), baselineTailMessageId: messagesRef.current.at(-1)?.id ?? null, - // A hydrating transcript is not yet this session's, so this baseline is - // a placeholder the first authoritative read rebases. - baselineResolved: !transcriptLoading + // Only a settled read makes this a boundary. Anything else — hydrating, + // or a read that failed — hands back an empty list that reads as "the + // conversation was empty", which lets any row claim this send later. + baselineResolved: transcriptSettled } }, - [draftKey, pendingKey, transcriptLoading] + [draftKey, pendingKey, transcriptSettled] ) // Why: over relay the send RPC can take seconds (or lose only its ack), and a @@ -294,11 +300,11 @@ export function useMobileNativeChatDrafts(args: { } setPendingBySession((previous) => { const current = previous[pendingKey] ?? [] - // Rebase before retiring: a hydration-time send has to own a real boundary - // before any row can be judged against it. - const rebased = transcriptLoading - ? current - : rebaseMobileNativeChatPendingBaselines(messages, current) + // Rebase before retiring: a send captured before the history was known has + // to own a real boundary before any row can be judged against it. + const rebased = transcriptSettled + ? rebaseMobileNativeChatPendingBaselines(messages, current) + : current const next = retireLandedMobileNativeChatPending(messages, rebased, landedImagePendingIds) if (next === current) { return previous @@ -310,7 +316,7 @@ export function useMobileNativeChatDrafts(args: { delete remaining[pendingKey] return remaining }) - }, [messages, pending, pendingKey, transcriptLoading]) + }, [messages, pending, pendingKey, transcriptSettled]) return { composerText: draftKey ? (drafts[draftKey] ?? '') : '', From 67529be450e6d0e65f6bb5d25b40d678d72ced6c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:40:52 -0700 Subject: [PATCH 05/11] test(mobile-native-chat): pin that a resolved hydration send leaves its run glue-capable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A held send sits as a live segment at the head of its run, so the cursor can never reach a later pair — the stuck bubble takes the whole feature down with it. Goes red against the ordinal recount. --- ...ile-native-chat-drafts-glued-pending.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts index 7a1cf46cbe79..3079691adb24 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -260,6 +260,23 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { await update([...afterReconnect, assistantTurn('m3', 'all green', 6000)]) expect(state?.pending).toEqual([]) }) + + // A send held here would sit as a live segment at the head of its run, so + // the cursor could never reach a later pair — the stuck bubble would take + // the whole feature down with it for the rest of the session. + it('leaves a later pair able to glue after the late read resolved the first send', async () => { + await mount([], true) + act(() => send('run the tests')) + const afterReconnect = [userTurn('m1', 'run the tests', 5000)] + await update(afterReconnect) + expect(state?.pending).toEqual([]) + + rapidSend('fix the', 'bug') + const glued = [...afterReconnect, userTurn('m2', 'fix the bug', 6000)] + await update(glued) + expect(state?.pending).toEqual([]) + expect(renderedPendingTexts(glued, state)).toEqual([]) + }) }) // A read that failed is not a boundary: `messages` is empty because the history From 4db02de8a0e6867e8930aec777925f7bea7c103e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:56:43 -0700 Subject: [PATCH 06/11] fix(mobile-native-chat): pin an image echo that captured no tail, and require the settled flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A caption-less image echo keeps its captured tail because it counts image turns after it — but a send issued before any history was known captured null, which counts from the top of the transcript. An old image turn then claimed the send and bound the user's fresh photo to it, leaving the just-sent turn with no preview. A null tail is not a boundary worth preserving, so pin those too. `transcriptSettled` was optional and defaulted to the gate it replaced, so any caller that omitted it silently got the pre-fix behaviour. Required now, and threaded through every harness. --- ...obile-native-chat-pending-baseline.test.ts | 25 ++++++++++++++----- .../mobile-native-chat-pending-baseline.ts | 11 +++++--- ...e-native-chat-drafts-glued-pending.test.ts | 8 +++--- ...le-native-chat-drafts-launch-draft.test.ts | 7 ++++-- .../use-mobile-native-chat-drafts.test.ts | 7 ++++-- .../session/use-mobile-native-chat-drafts.ts | 4 +-- 6 files changed, 43 insertions(+), 19 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.test.ts b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts index f421cbd388c3..8ec1671b0a1b 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts @@ -100,16 +100,29 @@ describe('rebaseMobileNativeChatPendingBaselines', () => { }) }) - // An image echo counts image turns AFTER its tail, so moving the tail onto this - // read would discard an echo the read already carries. - it('keeps a caption-less image echo on its captured tail', () => { - const images = { ...unresolved('p1', ''), images: ['file:///a.png'] } + // An image echo counts image turns AFTER its tail, so moving a real tail onto + // this read would discard an echo the read already carries. + it('keeps a caption-less image echo on the tail it actually captured', () => { + const images = { + ...unresolved('p1', ''), + baselineTailMessageId: 'm1', + images: ['file:///a.png'] + } + const rebased = rebaseMobileNativeChatPendingBaselines(history, [images]) + expect(rebased[0]?.baselineTailMessageId).toBe('m1') + expect(rebased[0]?.expectedOccurrence).toBe(1) + expect(rebased[0]?.baselineResolved).toBe(true) + }) + + // A null tail counts from the top of the transcript, so an image turn already + // in this read would claim the send and take the user's fresh photo with it. + it('pins a caption-less image echo that captured no tail at all', () => { const rebased = rebaseMobileNativeChatPendingBaselines(history, [ - images, + { ...unresolved('p1', ''), images: ['file:///a.png'] }, { ...unresolved('p2', '', 2), images: ['file:///b.png'] } ]) + expect(rebased.map((item) => item.baselineTailMessageId)).toEqual(['m2', 'm2']) expect(rebased.map((item) => item.expectedOccurrence)).toEqual([1, 2]) - expect(rebased.map((item) => item.baselineTailMessageId)).toEqual([null, null]) expect(rebased.every((item) => item.baselineResolved)).toBe(true) }) }) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.ts b/mobile/src/session/mobile-native-chat-pending-baseline.ts index 995e3a6a6505..99d7cc2134d6 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.ts @@ -21,9 +21,11 @@ import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pendin * anything the transcript can supply, which strands the bubble for the rest of * the session and stops its whole run from ever gluing. * - * A caption-less image echo keeps its captured tail: it reconciles by counting - * image turns AFTER that tail, so pinning the tail past this read would throw - * away an echo the read already carries. + * A caption-less image echo keeps a captured tail it actually has: it reconciles + * by counting image turns AFTER that tail, so moving the tail onto this read + * would throw away an echo the read already carries. A null tail is not such a + * boundary — it counts from the top of the transcript, so an old image turn can + * claim the send and bind the user's fresh photo to it. Those get pinned too. */ export function rebaseMobileNativeChatPendingBaselines( messages: readonly NativeChatMessage[], @@ -37,7 +39,8 @@ export function rebaseMobileNativeChatPendingBaselines( if (item.baselineResolved) { return item } - return item.images?.length || normalizeReconcileText(item.text) === '' + const countsAfterItsOwnTail = item.images?.length || normalizeReconcileText(item.text) === '' + return countsAfterItsOwnTail && item.baselineTailMessageId !== null ? { ...item, baselineResolved: true } : { ...item, baselineResolved: true, baselineTailMessageId } }) diff --git a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts index 3079691adb24..2b89164ac067 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -229,9 +229,11 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { // The read that resolves a hydration-time send can already contain that // send's own echo — a re-subscribe returns whatever exists now — and nothing // local tells that echo from an identical older prompt. Claiming the row is - // the safe direction: being wrong costs one round trip of invisibility, - // while holding costs a queued bubble that never clears and a run that can - // never glue again. + // the lesser evil, not a free one: normally the real echo lands a round trip + // later, but a send that never landed and was claimed by an older identical + // row stops being tracked at all — worst for short repeats ("yes", "1"). + // Holding instead costs a queued bubble that never clears AND a run that can + // never glue again, for the rest of the session. it('retires a hydration-time send whose echo arrives with the read', async () => { await mount([], true) act(() => send('run the tests')) diff --git a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts index 665d3ac1f2fe..0c8d3bf674a2 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts @@ -36,7 +36,8 @@ describe('useMobileNativeChatDrafts launch draft', () => { launchDraft = null, launchDraftCreatedAt = null, chatActive = true, - transcriptLoading = false + transcriptLoading = false, + transcriptSettled = !transcriptLoading }: { tabId: string sessionId?: string | null @@ -45,6 +46,7 @@ describe('useMobileNativeChatDrafts launch draft', () => { launchDraftCreatedAt?: number | null chatActive?: boolean transcriptLoading?: boolean + transcriptSettled?: boolean }): null { state = useMobileNativeChatDrafts({ hostId: 'host', @@ -55,7 +57,8 @@ describe('useMobileNativeChatDrafts launch draft', () => { launchDraft, launchDraftCreatedAt, chatActive, - transcriptLoading + transcriptLoading, + transcriptSettled }) return null } diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index daf451e53f92..2a5ff2d54c61 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -42,7 +42,8 @@ describe('useMobileNativeChatDrafts', () => { messages = [], launchDraft = null, chatActive = true, - transcriptLoading = false + transcriptLoading = false, + transcriptSettled = !transcriptLoading }: { tabId: string sessionId?: string | null @@ -50,6 +51,7 @@ describe('useMobileNativeChatDrafts', () => { launchDraft?: string | null chatActive?: boolean transcriptLoading?: boolean + transcriptSettled?: boolean }): null { state = useMobileNativeChatDrafts({ hostId: 'host', @@ -59,7 +61,8 @@ describe('useMobileNativeChatDrafts', () => { messages, launchDraft, chatActive, - transcriptLoading + transcriptLoading, + transcriptSettled }) return null } diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 64cdf7cdc993..e11df7339870 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -50,7 +50,7 @@ export function useMobileNativeChatDrafts(args: { /** `messages` is this session's own settled history — so an empty one really * is an empty conversation, not a read that failed or never ran. Only then * does a send's captured tail describe a real boundary. */ - transcriptSettled?: boolean + transcriptSettled: boolean }): { composerText: string setComposerText: Dispatch> @@ -85,7 +85,7 @@ export function useMobileNativeChatDrafts(args: { launchDraftCreatedAt, chatActive = true, transcriptLoading, - transcriptSettled = !transcriptLoading + transcriptSettled } = args const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId) const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null From 4877dccc12e3234e6b04d0062942efe878395bad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:07:29 -0700 Subject: [PATCH 07/11] fix(mobile-native-chat): stop an unbounded send claiming an image turn already in the read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image-preview pass runs before the rebase, so a send captured with no boundary matched any image turn the settled read carried — binding the user's freshly attached photo to an old one and retiring the bubble through landedImagePendingIds, which short-circuits the retirement path entirely. Pinning the tail in the rebase could not help: the claim was already made. Such an entry now waits one tick and claims against a real tail. --- ...e-native-chat-drafts-glued-pending.test.ts | 27 +++++++++++++++++++ .../session/use-mobile-native-chat-drafts.ts | 8 +++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts index 2b89164ac067..bcea1f1054fb 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -25,6 +25,16 @@ function assistantTurn(id: string, text: string, timestamp: number): NativeChatM } } +function imageTurn(id: string, path: string, timestamp: number): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text: `[Image: source: ${path}]` }], + timestamp, + source: 'transcript' + } +} + /** Texts of the optimistic bubbles the chat list actually renders. */ function renderedPendingTexts( messages: NativeChatMessage[], @@ -299,6 +309,23 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { expect(renderedPendingTexts(olderIdentical, state)).toEqual(['fix the', 'bug']) }) + // The preview pass runs before the rebase, so an entry with no boundary + // would bind the user's fresh photo to whatever image turn the read carries. + it('does not bind a photo sent before the read to an image turn already in it', async () => { + await mount([], false, false) + act(() => { + const origin = state?.captureSendOrigin('') + if (origin) { + state?.acceptSend(origin, '', ['file:///new.png']) + } + }) + + const olderImage = [imageTurn('m1', '/old/photo.png', 1000)] + await update(olderImage) + expect(state?.imagePreviewsByMessageId).toEqual({}) + expect(state?.pending.map((item) => item.images)).toEqual([['file:///new.png']]) + }) + it('still retires them once their own glued row lands', async () => { await mount([], false, false) rapidSend('fix the', 'bug') diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index e11df7339870..2597a615cc58 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -291,7 +291,13 @@ export function useMobileNativeChatDrafts(args: { if (pending.length === 0) { return } - const landedImagePreviews = findLandedImagePreviewEchoes(messages, pending) + // This pass runs before the rebase below, so a send captured with no + // boundary would claim any image turn in the read — binding the user's fresh + // photo to an old one. It waits a tick and claims against a real tail. + const landedImagePreviews = findLandedImagePreviewEchoes( + messages, + pending.filter((item) => item.baselineResolved) + ) const landedImagePendingIds = new Set(landedImagePreviews.map((preview) => preview.pendingId)) if (landedImagePreviews.length > 0) { setImagePreviewsBySession((previous) => From 4402da00b41decb9806a85c87b489d9616cef1c2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:14:06 -0700 Subject: [PATCH 08/11] fix(mobile-native-chat): never move a boundary the send already captured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unsettled read still shows this session's own retained history — a reconnect or a failed read keeps the conversation on screen rather than blanking it — so sends made across one already own a correct tail. The rebase overwrote it with the tail of the read that followed, which sits at or after their own glued row, so `turn.index <= segment.tail` rejected every turn and the pair stayed queued for the session, blocking every later pair in the run. Pin only a send that captured no tail at all. A captioned image echo is now left alone entirely: it binds its preview by an ordinal counted over the whole transcript, so supplying a tail without recounting left it matching nothing, forever. --- ...obile-native-chat-pending-baseline.test.ts | 27 +++++++++++ .../mobile-native-chat-pending-baseline.ts | 47 ++++++++++--------- ...e-native-chat-drafts-glued-pending.test.ts | 16 +++++++ 3 files changed, 68 insertions(+), 22 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.test.ts b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts index 8ec1671b0a1b..63f4448f845a 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts @@ -100,6 +100,22 @@ describe('rebaseMobileNativeChatPendingBaselines', () => { }) }) + // An unsettled read still shows this session's own retained history, so a send + // made across a reconnect already owns a real boundary. Moving it onto the read + // that follows pushes it past the send's own echo and strands the bubble. + it('never moves a tail the send actually captured', () => { + const captured: MobileNativeChatPendingMessage = { + id: 'p1', + text: 'run the tests', + expectedOccurrence: 1, + baselineTailMessageId: 'm1', + baselineResolved: false + } + const rebased = rebaseMobileNativeChatPendingBaselines(history, [captured]) + expect(rebased[0]?.baselineTailMessageId).toBe('m1') + expect(rebased[0]?.baselineResolved).toBe(true) + }) + // An image echo counts image turns AFTER its tail, so moving a real tail onto // this read would discard an echo the read already carries. it('keeps a caption-less image echo on the tail it actually captured', () => { @@ -114,6 +130,17 @@ describe('rebaseMobileNativeChatPendingBaselines', () => { expect(rebased[0]?.baselineResolved).toBe(true) }) + // A captioned image echo binds by an ordinal counted over the whole transcript. + // Pinning a tail without recounting leaves it matching nothing, forever. + it('leaves a captioned image echo entirely alone', () => { + const rebased = rebaseMobileNativeChatPendingBaselines(history, [ + { ...unresolved('p1', 'here'), images: ['file:///a.png'] } + ]) + expect(rebased[0]?.baselineTailMessageId).toBe(null) + expect(rebased[0]?.expectedOccurrence).toBe(1) + expect(rebased[0]?.baselineResolved).toBe(true) + }) + // A null tail counts from the top of the transcript, so an image turn already // in this read would claim the send and take the user's fresh photo with it. it('pins a caption-less image echo that captured no tail at all', () => { diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.ts b/mobile/src/session/mobile-native-chat-pending-baseline.ts index 99d7cc2134d6..39a6acf1c852 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.ts @@ -3,29 +3,31 @@ import { normalizeReconcileText } from './mobile-native-chat-draft-reconcile' import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' /** - * Give the sends issued before this session's history was known the boundary - * they never had, on the first authoritative read. + * Give the sends that never saw a transcript the boundary they lack, on the + * first settled read. * - * Such a send saw no transcript, so it has no row it can judge candidates - * against — which held it out of matching entirely, stranding it as a queued - * bubble and, worse, as a permanent glue barrier for its neighbours. Recovering - * the tail is all that is needed: the ordinal was already counted against an - * empty transcript, which is exactly right for "no history was known". + * Such a send has no row to judge candidates against — which held it out of + * matching entirely, stranding it as a queued bubble and, worse, as a permanent + * glue barrier for its neighbours. * - * The ordinal is deliberately NOT recounted against this read. The read can - * already carry the send's own echo — a re-subscribe after a tab switch or a - * reconnect returns whatever exists now — and nothing local separates that echo - * from an identical older prompt: the transport writes keystrokes into a TUI and - * carries no message id, and row timestamps come from the host while the send - * time comes from the phone. Counting it as history puts the ordinal one past - * anything the transcript can supply, which strands the bubble for the rest of - * the session and stops its whole run from ever gluing. + * Only a send that captured NO tail is pinned. An unsettled read still shows + * this session's own retained history (see `createNativeChatTranscriptRetention` + * — a reconnect or a failed read keeps the conversation on screen rather than + * blanking it), so a send made then already owns a correct boundary. Moving it + * onto this read would push it past the send's own echo and strand the bubble + * for good. * - * A caption-less image echo keeps a captured tail it actually has: it reconciles - * by counting image turns AFTER that tail, so moving the tail onto this read - * would throw away an echo the read already carries. A null tail is not such a - * boundary — it counts from the top of the transcript, so an old image turn can - * claim the send and bind the user's fresh photo to it. Those get pinned too. + * The ordinal is deliberately NOT recounted. The read can already carry the + * send's own echo — a re-subscribe returns whatever exists now — and nothing + * local separates that echo from an identical older prompt: the transport writes + * keystrokes into a TUI and carries no message id, and row timestamps come from + * the host while the send time comes from the phone. Counting it as history puts + * the ordinal one past anything the transcript can supply. + * + * Which is why a CAPTIONED image echo is left alone entirely: it binds its + * preview by an ordinal counted over the whole transcript, so its tail and its + * ordinal have to describe the same read. Supplying one without the other leaves + * it matching nothing, forever. Everything else matches relative to its tail. */ export function rebaseMobileNativeChatPendingBaselines( messages: readonly NativeChatMessage[], @@ -39,8 +41,9 @@ export function rebaseMobileNativeChatPendingBaselines( if (item.baselineResolved) { return item } - const countsAfterItsOwnTail = item.images?.length || normalizeReconcileText(item.text) === '' - return countsAfterItsOwnTail && item.baselineTailMessageId !== null + const bindsByAbsoluteOrdinal = + Boolean(item.images?.length) && normalizeReconcileText(item.text) !== '' + return item.baselineTailMessageId !== null || bindsByAbsoluteOrdinal ? { ...item, baselineResolved: true } : { ...item, baselineResolved: true, baselineTailMessageId } }) diff --git a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts index bcea1f1054fb..80359fe3c697 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -273,6 +273,22 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { expect(state?.pending).toEqual([]) }) + // A reconnect leaves this session's own retained history on screen, so sends + // made across it own a real boundary already. Moving it onto the read that + // follows would push it past their own glued row and strand both bubbles. + it('retires a pair sent across a reconnect whose glued row arrives with the read', async () => { + const retained = [userTurn('m1', 'earlier prompt', 1000), assistantTurn('m2', 'sure', 1100)] + await mount(retained) + // The read drops; the retained history stays visible and is still ours. + await update(retained, true) + rapidSend('run the tests', 'again') + + const settled = [...retained, userTurn('m3', 'run the tests again', 5000)] + await update(settled) + expect(state?.pending).toEqual([]) + expect(renderedPendingTexts(settled, state)).toEqual([]) + }) + // A send held here would sit as a live segment at the head of its run, so // the cursor could never reach a later pair — the stuck bubble would take // the whole feature down with it for the rest of the session. From 947ccc3ef9ac25cbdfb92ca8e20d67141b41a746 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:35:39 -0700 Subject: [PATCH 09/11] fix(mobile-native-chat): supply a boundary only to a text-bearing send An image echo reconciles by counting turns AFTER its tail and has no other retirement path, so the tail supplied from a read that already carried its own echo excluded the very row it was waiting for: the "Queued" photo bubble stuck for the life of the session and the transcript row rendered as bare marker text with no photo. A regression against main, and against the earlier revision of this fix that pinned only captioned echoes. The glue matcher is the only consumer a supplied tail helps. Everything that reconciles relative to its own tail keeps whatever it captured. --- ...obile-native-chat-pending-baseline.test.ts | 17 ++++++--- .../mobile-native-chat-pending-baseline.ts | 17 +++++---- ...e-native-chat-drafts-glued-pending.test.ts | 35 ++++++++++--------- 3 files changed, 41 insertions(+), 28 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.test.ts b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts index 63f4448f845a..1abdc714e849 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts @@ -141,15 +141,24 @@ describe('rebaseMobileNativeChatPendingBaselines', () => { expect(rebased[0]?.baselineResolved).toBe(true) }) - // A null tail counts from the top of the transcript, so an image turn already - // in this read would claim the send and take the user's fresh photo with it. - it('pins a caption-less image echo that captured no tail at all', () => { + // A supplied tail would sit at or after this send's own echo, and an image + // echo counts turns AFTER its tail — so it would exclude the very row it is + // waiting for, and image entries have no other retirement path. + it('never supplies a tail to a caption-less image echo either', () => { const rebased = rebaseMobileNativeChatPendingBaselines(history, [ { ...unresolved('p1', ''), images: ['file:///a.png'] }, { ...unresolved('p2', '', 2), images: ['file:///b.png'] } ]) - expect(rebased.map((item) => item.baselineTailMessageId)).toEqual(['m2', 'm2']) + expect(rebased.map((item) => item.baselineTailMessageId)).toEqual([null, null]) expect(rebased.map((item) => item.expectedOccurrence)).toEqual([1, 2]) expect(rebased.every((item) => item.baselineResolved)).toBe(true) }) + + // Same rule for a text-empty entry with no images: it reconciles by counting + // image-source turns after its tail, so a supplied tail strands it too. + it('never supplies a tail to a text-empty entry', () => { + const rebased = rebaseMobileNativeChatPendingBaselines(history, [unresolved('p1', ' ')]) + expect(rebased[0]?.baselineTailMessageId).toBe(null) + expect(rebased[0]?.baselineResolved).toBe(true) + }) }) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.ts b/mobile/src/session/mobile-native-chat-pending-baseline.ts index 39a6acf1c852..903bf9d8f324 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.ts @@ -24,10 +24,13 @@ import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pendin * the host while the send time comes from the phone. Counting it as history puts * the ordinal one past anything the transcript can supply. * - * Which is why a CAPTIONED image echo is left alone entirely: it binds its - * preview by an ordinal counted over the whole transcript, so its tail and its - * ordinal have to describe the same read. Supplying one without the other leaves - * it matching nothing, forever. Everything else matches relative to its tail. + * And only a TEXT-bearing send is given one. The glue matcher is the sole + * consumer that a supplied tail helps; every other one is harmed by it. An image + * echo reconciles by counting turns AFTER its tail, so a tail taken from a read + * that already carries its echo excludes that echo and the bubble can never + * retire — image entries have no other retirement path. A captioned one is worse + * still: it binds by an ordinal counted over the whole transcript, so a tail + * without a matching recount leaves it claiming nothing at all. */ export function rebaseMobileNativeChatPendingBaselines( messages: readonly NativeChatMessage[], @@ -41,9 +44,9 @@ export function rebaseMobileNativeChatPendingBaselines( if (item.baselineResolved) { return item } - const bindsByAbsoluteOrdinal = - Boolean(item.images?.length) && normalizeReconcileText(item.text) !== '' - return item.baselineTailMessageId !== null || bindsByAbsoluteOrdinal + const reconcilesAgainstItsOwnTail = + Boolean(item.images?.length) || normalizeReconcileText(item.text) === '' + return item.baselineTailMessageId !== null || reconcilesAgainstItsOwnTail ? { ...item, baselineResolved: true } : { ...item, baselineResolved: true, baselineTailMessageId } }) diff --git a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts index 80359fe3c697..b5d1f92ec82f 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -292,6 +292,24 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { // A send held here would sit as a live segment at the head of its run, so // the cursor could never reach a later pair — the stuck bubble would take // the whole feature down with it for the rest of the session. + // An image entry retires ONLY by binding its preview, so a supplied tail + // that excluded its own echo would leave the bubble — and the photo — stuck + // for the life of the session. + it('retires a photo whose image echo arrives with the read', async () => { + await mount([], true) + act(() => { + const origin = state?.captureSendOrigin('') + if (origin) { + state?.acceptSend(origin, '', ['file:///new.png']) + } + }) + + const withEcho = [assistantTurn('m1', 'ready', 1000), imageTurn('m2', '/tmp/new.png', 5000)] + await update(withEcho) + expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ m2: ['file:///new.png'] }) + }) + it('leaves a later pair able to glue after the late read resolved the first send', async () => { await mount([], true) act(() => send('run the tests')) @@ -325,23 +343,6 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { expect(renderedPendingTexts(olderIdentical, state)).toEqual(['fix the', 'bug']) }) - // The preview pass runs before the rebase, so an entry with no boundary - // would bind the user's fresh photo to whatever image turn the read carries. - it('does not bind a photo sent before the read to an image turn already in it', async () => { - await mount([], false, false) - act(() => { - const origin = state?.captureSendOrigin('') - if (origin) { - state?.acceptSend(origin, '', ['file:///new.png']) - } - }) - - const olderImage = [imageTurn('m1', '/old/photo.png', 1000)] - await update(olderImage) - expect(state?.imagePreviewsByMessageId).toEqual({}) - expect(state?.pending.map((item) => item.images)).toEqual([['file:///new.png']]) - }) - it('still retires them once their own glued row lands', async () => { await mount([], false, false) rapidSend('fix the', 'bug') From db003d66e08bd012331a1697e82a76553616f05d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:54:33 -0700 Subject: [PATCH 10/11] fix(mobile-native-chat): stop one unmatchable send freezing glue for the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The match cursor only advanced on a hit, so a head that could never match — a pair whose glued row arrived with the read, or a send the count pass claimed against an older row — froze the run behind it and every later rapid pair became permanently unretirable. Two cases previously disclosed as bounded were not bounded at all. Slide past a non-matching head, keeping the cursor monotonic so a later turn can never take a send an earlier one claimed. The slide widens the search, so a span cap keeps the work linear in the run length instead of quadratic; the existing budget test now asserts that bound rather than the old one it silently broke. Re-fuzzed at 250k seeds: the boundary guarantee still holds. Also corrects a comment that claimed the preview-pass filter made a photo claim against a real tail. It does not — an image echo keeps whatever tail it captured, so a caption-less photo can still bind to an older photo turn, as on main. --- ...ile-native-chat-pending-retirement.test.ts | 21 +++++++++++++-- .../mobile-native-chat-pending-retirement.ts | 26 ++++++++++++++++--- .../session/use-mobile-native-chat-drafts.ts | 9 ++++--- 3 files changed, 47 insertions(+), 9 deletions(-) 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 235ef836301d..66eb6b127dba 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' import { + MAX_GLUE_SPAN, retireLandedMobileNativeChatPending, selectGluedPendingIds } from './mobile-native-chat-pending-retirement' @@ -187,6 +188,19 @@ describe('selectGluedPendingIds', () => { expect(retiredIds(messages, pending)).toEqual([]) }) + // A head that can never match must not freeze the run behind it: one stuck + // bubble would otherwise disable glue retirement for the rest of the session. + it('glues a later pair even when the head of the run can never match', () => { + const messages = [ + userTurn('m0', 'unrelated', 1000), + userTurn('m1', 'fix the bug', 5000), + userTurn('m2', 'ship the fix', 6000) + ] + const stuckHead = pendingSend('p0', 'bug', 'm0') + const pair = [pendingSend('p1', 'ship the', 'm0'), pendingSend('p2', 'fix', 'm0')] + expect(retiredIds(messages, [stuckHead, ...pair])).toEqual(['p1', 'p2']) + }) + it('skips a caption-less image echo instead of gluing it', () => { const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)] const pending = [ @@ -214,7 +228,10 @@ describe('selectGluedPendingIds', () => { expect(retiredIds(messages, [pendingSend('p1', 'one', 'm1')])).toEqual([]) }) - it('inspects each pending segment at most once per transcript turn', () => { + // The cursor slides past a head that cannot match, so a turn may try several + // start positions — but each try is capped at MAX_GLUE_SPAN segments, which + // keeps the work linear in the run length rather than quadratic. + it('keeps segment inspection linear in the run length per transcript turn', () => { const pendingCount = 96 const turnCount = 12 const text = `${'a '.repeat(pendingCount)}x` @@ -233,7 +250,7 @@ describe('selectGluedPendingIds', () => { } finally { startsWith.mockRestore() } - expect(segmentChecks).toBeLessThanOrEqual(turnCount * pendingCount) + expect(segmentChecks).toBeLessThanOrEqual(turnCount * pendingCount * MAX_GLUE_SPAN) }) }) diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.ts b/mobile/src/session/mobile-native-chat-pending-retirement.ts index 43bf544537a4..c990e348cbd5 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.ts @@ -8,6 +8,10 @@ import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pendin const SPACE = ' ' const NO_PENDING_IDS: ReadonlySet = new Set() +// The host shares one input line, not an unbounded one: real glue is two or +// three sends. Capping the span keeps the cursor slide below linear-per-turn +// instead of quadratic when a long run of identical sends is outstanding. +export const MAX_GLUE_SPAN = 8 type UserTurn = { index: number; text: string } type GlueSegment = { text: string; tail: number } | null @@ -61,14 +65,27 @@ export function selectGluedPendingIds( if (cursor >= runEnd - 1) { break } - const matched = matchGluedRun(turn, segments, cursor, runEnd) + // A send that can never match must not freeze the run behind it. One + // permanently unretirable head — a pair whose own glued row arrived with + // the read, or a send the count pass claimed against an older row — would + // otherwise disable glue retirement for every later pair, for the rest of + // the session. Slide past it; the cursor stays monotonic, so a later turn + // can never claim a send an earlier one already took. + let start = cursor + let matched = 0 + for (; start <= runEnd - 2; start++) { + matched = matchGluedRun(turn, segments, start, runEnd) + if (matched > 0) { + break + } + } if (matched === 0) { continue } - for (let index = cursor; index < cursor + matched; index++) { + for (let index = start; index < start + matched; index++) { retired.add(pending[index]!.id) } - cursor += matched + cursor = start + matched } runStart = runEnd + 1 } @@ -84,7 +101,8 @@ function matchGluedRun( ): number { let at = 0 let matched = 0 - for (let index = start; index < end; index++) { + const limit = Math.min(end, start + MAX_GLUE_SPAN) + for (let index = start; index < limit; index++) { const segment = segments[index]! // Every send carries its OWN boundary: a row that already existed when this // send was issued can never be part of its echo, however well it reads. diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 2597a615cc58..784f025847bf 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -291,9 +291,12 @@ export function useMobileNativeChatDrafts(args: { if (pending.length === 0) { return } - // This pass runs before the rebase below, so a send captured with no - // boundary would claim any image turn in the read — binding the user's fresh - // photo to an old one. It waits a tick and claims against a real tail. + // Only judge a send against a read known to be this session's. Note this + // does NOT give an image echo a boundary — the rebase deliberately leaves + // those on whatever they captured — so a caption-less photo sent before any + // read settled can still claim an older photo turn, exactly as it does on + // main. Fixing that needs a tail that excludes older image turns without + // excluding the send's own echo, which is a separate change. const landedImagePreviews = findLandedImagePreviewEchoes( messages, pending.filter((item) => item.baselineResolved) From 8df86a56ed14522ff4d192f1d70a6212cef3aca6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:05:22 -0700 Subject: [PATCH 11/11] fix(mobile-native-chat): stop the span cap stranding a long glued run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capping each match attempt at 8 segments did not truncate a longer glue, it rejected it outright: a row spelling 9+ sends exhausted the loop without reaching the end of the text and returned zero, so none of the nine retired — and each stuck send then inflated `earlierOutstanding` for the next send of the same text. Nothing bounds how many sends pile onto the agent's input line; accumulation ends when the agent accepts input again, not at any fixed count. One inspection budget now covers the whole slide instead. The first attempt spans the entire run and always fits, so a genuine glue is never truncated; only a run of identical prefix-matching sends can exhaust the budget, which is exactly the case that should be cheap. The in-flight attempt may overshoot the remainder — that is what makes the guarantee hold — so the budget test asserts the real ceiling. Re-fuzzed at 250k seeds with runs past the budget. --- ...ile-native-chat-pending-retirement.test.ts | 19 +++++++--- .../mobile-native-chat-pending-retirement.ts | 35 +++++++++++-------- 2 files changed, 36 insertions(+), 18 deletions(-) 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 66eb6b127dba..1c865e2853c5 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' import { - MAX_GLUE_SPAN, + GLUE_SLIDE_BUDGET, retireLandedMobileNativeChatPending, selectGluedPendingIds } from './mobile-native-chat-pending-retirement' @@ -201,6 +201,15 @@ describe('selectGluedPendingIds', () => { expect(retiredIds(messages, [stuckHead, ...pair])).toEqual(['p1', 'p2']) }) + // Nothing bounds how many sends pile onto the agent's input line, so the + // slide's budget must never truncate a genuine glue. + it('retires a glued run far longer than the slide budget', () => { + const words = Array.from({ length: 12 }, (_, index) => `w${index}`) + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', words.join(' '), 5000)] + const pending = words.map((word, index) => pendingSend(`p${index}`, word, 'm1')) + expect(retiredIds(messages, pending)).toHaveLength(words.length) + }) + it('skips a caption-less image echo instead of gluing it', () => { const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)] const pending = [ @@ -229,8 +238,10 @@ describe('selectGluedPendingIds', () => { }) // The cursor slides past a head that cannot match, so a turn may try several - // start positions — but each try is capped at MAX_GLUE_SPAN segments, which - // keeps the work linear in the run length rather than quadratic. + // start positions — but one inspection budget covers the whole slide, keeping + // the work linear in the run length rather than quadratic. The attempt already + // in flight is allowed to overshoot the remaining budget, deliberately: that is + // what guarantees a genuine long glue is never truncated. it('keeps segment inspection linear in the run length per transcript turn', () => { const pendingCount = 96 const turnCount = 12 @@ -250,7 +261,7 @@ describe('selectGluedPendingIds', () => { } finally { startsWith.mockRestore() } - expect(segmentChecks).toBeLessThanOrEqual(turnCount * pendingCount * MAX_GLUE_SPAN) + expect(segmentChecks).toBeLessThanOrEqual(turnCount * (2 * pendingCount + GLUE_SLIDE_BUDGET)) }) }) diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.ts b/mobile/src/session/mobile-native-chat-pending-retirement.ts index c990e348cbd5..ffdde70ddd4d 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.ts @@ -8,10 +8,13 @@ import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pendin const SPACE = ' ' const NO_PENDING_IDS: ReadonlySet = new Set() -// The host shares one input line, not an unbounded one: real glue is two or -// three sends. Capping the span keeps the cursor slide below linear-per-turn -// instead of quadratic when a long run of identical sends is outstanding. -export const MAX_GLUE_SPAN = 8 +// Slack the cursor slide may spend re-trying later start positions, on top of +// one free pass over the run. Nothing bounds how many sends accumulate on the +// agent's input line — that ends when the agent accepts input again — so the +// budget must never truncate a genuine glue: the first attempt covers the whole +// run and always fits. It only stops a run of identical prefix-matching sends +// from making the scan quadratic. +export const GLUE_SLIDE_BUDGET = 8 type UserTurn = { index: number; text: string } type GlueSegment = { text: string; tail: number } | null @@ -71,10 +74,13 @@ export function selectGluedPendingIds( // otherwise disable glue retirement for every later pair, for the rest of // the session. Slide past it; the cursor stays monotonic, so a later turn // can never claim a send an earlier one already took. + let budget = runEnd - runStart + GLUE_SLIDE_BUDGET let start = cursor let matched = 0 - for (; start <= runEnd - 2; start++) { - matched = matchGluedRun(turn, segments, start, runEnd) + for (; start <= runEnd - 2 && budget > 0; start++) { + const attempt = matchGluedRun(turn, segments, start, runEnd) + budget -= attempt.inspected + matched = attempt.matched if (matched > 0) { break } @@ -92,37 +98,38 @@ export function selectGluedPendingIds( return retired } -/** Length of the exact glued run at `start`, or zero. */ +/** Length of the exact glued run at `start`, plus the segments it had to read. */ function matchGluedRun( turn: UserTurn, segments: readonly GlueSegment[], start: number, end: number -): number { +): { matched: number; inspected: number } { let at = 0 let matched = 0 - const limit = Math.min(end, start + MAX_GLUE_SPAN) - for (let index = start; index < limit; index++) { + let inspected = 0 + for (let index = start; index < end; index++) { const segment = segments[index]! + inspected += 1 // Every send carries its OWN boundary: a row that already existed when this // send was issued can never be part of its echo, however well it reads. if (turn.index <= segment.tail) { - return 0 + return { matched: 0, inspected } } if (at > 0 && turn.text[at] === SPACE) { at += 1 } if (!turn.text.startsWith(segment.text, at)) { - return 0 + return { matched: 0, inspected } } at += segment.text.length matched += 1 if (at === turn.text.length) { // A lone exact match is an ordinary landing, which the count pass owns. - return matched > 1 ? matched : 0 + return { matched: matched > 1 ? matched : 0, inspected } } } - return 0 + return { matched: 0, inspected } } /** Retires exact and glued transcript landings while preserving pending order. */