From df2575543b9ce19c7be87e8aad4d532ece31f08c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:14:07 -0700 Subject: [PATCH] fix(mobile-native-chat): reland glued pending retirement with resolvable baselines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relands #14665 (reverted by #14819). Mobile retires an optimistic bubble only when a transcript user turn matches its normalized text at the expected ordinal, so two rapid sends the host glued into ONE row matched nothing and both bubbles pinned below every later reply for the rest of the session. #14819 reverted it for two launch blockers, both fixed here rather than restored: 1. A rejected send restored a TRIMMED composer. The trim belongs to the wire — trailing whitespace the host writes verbatim can glue the next rapid send — not to the draft. The send seam now keeps `draftText` as the user typed it and trims only the bytes that go out, so a rejection puts back the original draft, blank lines included. 2. Sends issued while the transcript was still hydrating were stamped `glueBaselineTrusted: false` FOREVER, so their glued row could never retire them and they barred their neighbours from matching too (STA-4492). A hydrating transcript is not this session's history, so the flag is now `baselineResolved` and it is transient: such a send is held out of matching until the first authoritative read, which rebases it onto that read's tail and re-derives its ordinal. It then reconciles like any other send — and, as a consequence, also stops being retired by history it never saw. Fixes STA-4482, STA-4492 Refs #14665, #14819, #14262 --- ...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 | 63 +++- .../use-mobile-native-chat-message-send.ts | 13 +- 10 files changed, 921 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..482cfa2978ef 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 @@ -36,6 +36,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 +58,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 +74,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 +94,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 +389,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'