fix(mobile-native-chat): retire pending bubbles glued into one transcript row

Mobile's native chat retires an optimistic pending bubble only when a
transcript user turn matches its normalized text at the expected ordinal.
When two rapid sends collapse into a single glued user row neither key
matches, so both bubbles pin below every newer reply for the rest of the
session — mobile has a parallel implementation with no glue handling at all.

Trim the send body once at the send seam so the bytes the host writes
verbatim and the reconciliation key describe the same message on every send
path, then add a bounded glue matcher: a greedy cursor walk that may only
consider transcript turns strictly AFTER each send's captured tail, so an
older turn that happens to read like the concatenation can never retire a
newer queued send.

Refs #14262
This commit is contained in:
Brennan Benson
2026-08-14 18:59:25 -07:00
parent 8b31910ac1
commit 286bbdf892
6 changed files with 548 additions and 26 deletions
@@ -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<string> = 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([])
})
})
@@ -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<string> {
const retired = new Set<string>()
if (pending.length < 2) {
return retired
}
const messageIndexById = new Map<string, number>()
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<string>
): MobileNativeChatPendingMessage[] {
const landedCounts = new Map<string, number>()
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))
}
@@ -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<typeof useMobileNativeChatDrafts>
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<void> {
await act(async () => {
renderer = create(createElement(Harness, { messages }))
})
}
async function update(messages: NativeChatMessage[]): Promise<void> {
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'])
})
})
@@ -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<string, number>()
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
}
@@ -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')
})
})
@@ -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<MobileNativeChatSendOutcome> => {
// 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'
},