diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts index 235ef836301..66eb6b127db 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' import { + MAX_GLUE_SPAN, retireLandedMobileNativeChatPending, selectGluedPendingIds } from './mobile-native-chat-pending-retirement' @@ -187,6 +188,19 @@ describe('selectGluedPendingIds', () => { expect(retiredIds(messages, pending)).toEqual([]) }) + // A head that can never match must not freeze the run behind it: one stuck + // bubble would otherwise disable glue retirement for the rest of the session. + it('glues a later pair even when the head of the run can never match', () => { + const messages = [ + userTurn('m0', 'unrelated', 1000), + userTurn('m1', 'fix the bug', 5000), + userTurn('m2', 'ship the fix', 6000) + ] + const stuckHead = pendingSend('p0', 'bug', 'm0') + const pair = [pendingSend('p1', 'ship the', 'm0'), pendingSend('p2', 'fix', 'm0')] + expect(retiredIds(messages, [stuckHead, ...pair])).toEqual(['p1', 'p2']) + }) + it('skips a caption-less image echo instead of gluing it', () => { const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)] const pending = [ @@ -214,7 +228,10 @@ describe('selectGluedPendingIds', () => { expect(retiredIds(messages, [pendingSend('p1', 'one', 'm1')])).toEqual([]) }) - it('inspects each pending segment at most once per transcript turn', () => { + // The cursor slides past a head that cannot match, so a turn may try several + // start positions — but each try is capped at MAX_GLUE_SPAN segments, which + // keeps the work linear in the run length rather than quadratic. + it('keeps segment inspection linear in the run length per transcript turn', () => { const pendingCount = 96 const turnCount = 12 const text = `${'a '.repeat(pendingCount)}x` @@ -233,7 +250,7 @@ describe('selectGluedPendingIds', () => { } finally { startsWith.mockRestore() } - expect(segmentChecks).toBeLessThanOrEqual(turnCount * pendingCount) + expect(segmentChecks).toBeLessThanOrEqual(turnCount * pendingCount * MAX_GLUE_SPAN) }) }) diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.ts b/mobile/src/session/mobile-native-chat-pending-retirement.ts index 43bf544537a..c990e348cbd 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.ts @@ -8,6 +8,10 @@ import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pendin const SPACE = ' ' const NO_PENDING_IDS: ReadonlySet = new Set() +// The host shares one input line, not an unbounded one: real glue is two or +// three sends. Capping the span keeps the cursor slide below linear-per-turn +// instead of quadratic when a long run of identical sends is outstanding. +export const MAX_GLUE_SPAN = 8 type UserTurn = { index: number; text: string } type GlueSegment = { text: string; tail: number } | null @@ -61,14 +65,27 @@ export function selectGluedPendingIds( if (cursor >= runEnd - 1) { break } - const matched = matchGluedRun(turn, segments, cursor, runEnd) + // A send that can never match must not freeze the run behind it. One + // permanently unretirable head — a pair whose own glued row arrived with + // the read, or a send the count pass claimed against an older row — would + // otherwise disable glue retirement for every later pair, for the rest of + // the session. Slide past it; the cursor stays monotonic, so a later turn + // can never claim a send an earlier one already took. + let start = cursor + let matched = 0 + for (; start <= runEnd - 2; start++) { + matched = matchGluedRun(turn, segments, start, runEnd) + if (matched > 0) { + break + } + } if (matched === 0) { continue } - for (let index = cursor; index < cursor + matched; index++) { + for (let index = start; index < start + matched; index++) { retired.add(pending[index]!.id) } - cursor += matched + cursor = start + matched } runStart = runEnd + 1 } @@ -84,7 +101,8 @@ function matchGluedRun( ): number { let at = 0 let matched = 0 - for (let index = start; index < end; index++) { + const limit = Math.min(end, start + MAX_GLUE_SPAN) + for (let index = start; index < limit; index++) { const segment = segments[index]! // Every send carries its OWN boundary: a row that already existed when this // send was issued can never be part of its echo, however well it reads. diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 2597a615cc5..784f025847b 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -291,9 +291,12 @@ export function useMobileNativeChatDrafts(args: { if (pending.length === 0) { return } - // This pass runs before the rebase below, so a send captured with no - // boundary would claim any image turn in the read — binding the user's fresh - // photo to an old one. It waits a tick and claims against a real tail. + // Only judge a send against a read known to be this session's. Note this + // does NOT give an image echo a boundary — the rebase deliberately leaves + // those on whatever they captured — so a caption-less photo sent before any + // read settled can still claim an older photo turn, exactly as it does on + // main. Fixing that needs a tail that excludes older image turns without + // excluding the send's own echo, which is a separate change. const landedImagePreviews = findLandedImagePreviewEchoes( messages, pending.filter((item) => item.baselineResolved)