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 00000000000..58825fbd8df --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } 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 +): MobileNativeChatPendingMessage { + return { id, text, expectedOccurrence, baselineTailMessageId: tail } +} + +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']) + }) + + 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('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([]) + }) +}) + +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('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 00000000000..ed573b88425 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-retirement.ts @@ -0,0 +1,134 @@ +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 = ' ' + +/** A normalized user turn plus its transcript position, which bounds glue. */ +type UserTurn = { index: number; text: string } + +/** + * Ids retired by a single transcript turn that glued several rapid sends into + * one row (the host writes a send's body and its Enter ~500ms apart, so a second + * body can land on the same TUI input line before the first submits). + * + * Bounded to turns strictly AFTER each send's captured tail. Without that bound + * an older turn that happens to read like the concatenation retires a newer + * send, and a message the user really queued silently never renders. + */ +export function selectGluedPendingIds( + messages: readonly NativeChatMessage[], + pending: readonly MobileNativeChatPendingMessage[] +): 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 }) + } + } + // Image echoes carry no caption to concatenate; an unresolvable tail leaves the + // send unbounded. Either way the item can only end a run, never join one. + const segments = pending.map((item) => normalizeReconcileText(item.text)) + const tails = pending.map((item) => + item.baselineTailMessageId === null + ? -1 + : (messageIndexById.get(item.baselineTailMessageId) ?? null) + ) + + let cursor = 0 + for (const turn of turns) { + if (cursor >= pending.length - 1) { + break + } + for (let start = cursor; start < pending.length - 1; start++) { + const matched = matchGluedRun(turn, segments, tails, start) + if (matched === 0) { + continue + } + for (let index = start; index < start + matched; index++) { + retired.add(pending[index]!.id) + } + cursor = start + matched + break + } + } + return retired +} + +/** + * Length of the pending run starting at `start` that exactly spells out `turn`, + * or 0 when there is none. Greedy is provably equivalent to backtracking here: + * both sides are whitespace-normalized, so a segment can never begin with the + * optional single-space separator and consuming it is forced. + */ +function matchGluedRun( + turn: UserTurn, + segments: readonly string[], + tails: readonly (number | null)[], + start: number +): number { + let at = 0 + let matched = 0 + for (let index = start; index < segments.length; index++) { + const segment = segments[index]! + const tail = tails[index] + if (segment === '' || tail === null || turn.index <= tail) { + return 0 + } + if (at > 0 && turn.text[at] === SPACE) { + at += 1 + } + if (!turn.text.startsWith(segment, at)) { + return 0 + } + at += segment.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 +} + +/** Pending echoes that survive this transcript: exact-text landings retire by + * ordinal, and whatever is left gets one bounded pass for a glued row. */ +export function retireLandedMobileNativeChatPending( + messages: readonly NativeChatMessage[], + current: readonly 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) + } + } + // Image-only source-turn counts stay stable across reruns and ignore paginated history. + const survivors = 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 + }) + const glued = selectGluedPendingIds(messages, survivors) + return glued.size === 0 ? survivors : survivors.filter((item) => !glued.has(item.id)) +} 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 00000000000..1201eea88cf --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -0,0 +1,146 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } 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 + +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: ReactTestRenderer | null = null + let state: DraftState | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + }) + + function Harness({ messages }: { messages: NativeChatMessage[] }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: 'tab', + sessionId: 'session', + messages + }) + return null + } + + async function mount(messages: NativeChatMessage[]): Promise { + await act(async () => { + renderer = create(createElement(Harness, { messages })) + }) + } + + async function update(messages: NativeChatMessage[]): Promise { + await act(async () => renderer?.update(createElement(Harness, { messages }))) + } + + /** Two composer sends fired before either transcript echo lands. */ + function rapidSend(first: string, second: string): void { + act(() => { + const originFirst = state?.captureSendOrigin(first) + if (originFirst) { + state?.acceptSend(originFirst, first) + } + const originSecond = state?.captureSendOrigin(second) + if (originSecond) { + state?.acceptSend(originSecond, second) + } + }) + } + + 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(state?.pending.map((item) => item.text)).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(state?.pending.map((item) => item.text)).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(state?.pending.map((item) => item.text)).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(state?.pending.map((item) => item.text)).toEqual(['again']) + expect(renderedPendingTexts(partial, state)).toEqual(['again']) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 55fff5b42e3..98377607b6d 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -1,16 +1,15 @@ 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 { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' import { appendMobileNativeChatPending, combineMobileNativeChatPending, @@ -291,27 +290,7 @@ export function useMobileNativeChatDrafts(args: { } setPendingBySession((previous) => { const current = previous[pendingKey] ?? [] - const landedCounts = new Map() - for (const message of messages) { - const text = normalizedUserText(message) - if (text) { - landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1) - } - } - // 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 - }) + const next = retireLandedMobileNativeChatPending(messages, current, landedImagePendingIds) if (next.length === current.length) { return previous } 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 dd67cf46da8..9034b72da1a 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,7 @@ 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 holdUnconfirmedSend = vi.fn() const onCommandSend = vi.fn() const commandSendRef = { current: onCommandSend } @@ -55,7 +56,7 @@ describe('useMobileNativeChatMessageSend', () => { deviceTokenRef: { current: 'device' }, agentRef, commandSendRef, - captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never, + captureSendOrigin, readSeededLaunchDraftSeed, clearDraftForSend: () => {}, restoreRejectedDraft: () => {}, @@ -71,10 +72,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 +92,7 @@ describe('useMobileNativeChatMessageSend', () => { typeCommandWithOutcome.mockReset() typeCommandWithOutcome.mockResolvedValue('accepted') acceptSend.mockReset() + captureSendOrigin.mockClear() holdUnconfirmedSend.mockReset() onCommandSend.mockReset() commandSendRef.current = onCommandSend @@ -381,4 +385,32 @@ 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('writes the same trimmed body it reconciles against', 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 the body on a question answer too', async () => { + mount(() => null) + await act(async () => { + await api!.answerQuestion('\n 1 ') + }) + expect(sentArgs().text).toBe('1') + }) + + it('keeps interior newlines of a multi-line prompt', async () => { + mount(() => null) + await act(async () => { + await api!.send('\nfirst line\nsecond line\n') + }) + expect(sentArgs().text).toBe('first line\nsecond line') + }) }) 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 17f34b86d27..91f8a9d09cc 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,18 @@ export function useMobileNativeChatMessageSend(args: { const sendMessage = useCallback( async ( - text: string, + rawText: string, images: string[] | undefined, syncComposer: boolean, recordControlSend: boolean, sharedDeadline?: number ): Promise => { + // Why: the host writes these bytes verbatim, so surrounding whitespace lands + // on the agent's TUI input line while every reconciliation key is + // normalized. A rapid follow-up send then glues onto that residue and the + // submitted turn matches neither pending key. Trim once here so the wire + // text and the match key describe the same message for every send path. + const text = rawText.trim() const handle = handleRef.current const origin = captureSendOrigin(text) const agent = agentRef.current @@ -216,7 +222,7 @@ export function useMobileNativeChatMessageSend(args: { } else if (recordControlSend) { // The session-option catalog can recognize controls omitted from the // autocomplete catalog (for example Claude `/model` and `/fast`). - recordCommand(text.trim()) + recordCommand(text) } return 'accepted' },