From 838cb67ffe7859a2e27d39fb173a1b91748212a0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:59:56 -0700 Subject: [PATCH] fix(mobile): keep repeated-prefix chat replies streaming Text alone can't tell "the transcript caught up with this stream" from "a new reply repeats the previous turn's prefix", so the old suppress-on- prefix rule swallowed genuine repeated replies. A stateful gate remembers which transcript tail predates the current stream segment and hides the bubble only when that tail moved during the segment, scoped to the active host/workspace/tab/session so a swapped chat can't inherit a baseline. Refs STA-3333. --- .../src/session/MobileNativeChatOverlay.tsx | 1 + .../src/session/MobileNativeChatView.test.ts | 78 ++++++++-- mobile/src/session/MobileNativeChatView.tsx | 13 +- .../session/mobile-native-chat-render-data.ts | 46 ++---- .../mobile-native-chat-streaming-gate.test.ts | 138 ++++++++++++++++++ .../mobile-native-chat-streaming-gate.ts | 82 +++++++++++ .../use-mobile-native-chat-controller.ts | 3 + ...use-mobile-native-chat-streaming-bubble.ts | 24 +++ 8 files changed, 342 insertions(+), 43 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-streaming-gate.test.ts create mode 100644 mobile/src/session/mobile-native-chat-streaming-gate.ts create mode 100644 mobile/src/session/use-mobile-native-chat-streaming-bubble.ts diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index b8f9cd9277b..cf3e8a3a0dc 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -49,6 +49,7 @@ export function MobileNativeChatOverlay({ agent={controller.nativeChatAgent} agentWorking={controller.nativeChatAgentWorking} streamingText={controller.nativeChatStreamingText} + streamIdentity={controller.nativeChatStreamIdentity} onStop={controller.handleNativeChatStop} ask={controller.nativeChatAsk} onAnswerAsk={controller.handleNativeChatAnswerAsk} diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 86ead424b90..4ba5a7a94a9 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -1,6 +1,7 @@ import { createElement } from 'react' import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileNativeChatView } from './MobileNativeChatView' vi.mock('react-native', () => ({ @@ -58,6 +59,9 @@ vi.mock('./MobileNativeChatComposer', async () => { }) type Overrides = { + messages?: Parameters[0]['messages'] + streamingText?: string + streamIdentity?: string sendErrorMessage?: string | null onClearSendError?: () => void inputLockReason?: 'disconnected' | 'waiting' | null @@ -75,6 +79,23 @@ function suppressRendererWarning(): () => void { return () => spy.mockRestore() } +function assistantTurn(id: string, text: string): NativeChatMessage { + return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' } +} + +function chatViewElement(overrides: Overrides): ReturnType { + return createElement(MobileNativeChatView, { + messages: [], + status: 'ready', + streamIdentity: 'test-stream', + onSend: vi.fn().mockResolvedValue(true), + pending: [], + composerText: '', + onComposerTextChange: vi.fn(), + ...overrides + }) +} + describe('MobileNativeChatView send-error banner', () => { let renderer: ReactTestRenderer | null = null @@ -91,23 +112,25 @@ describe('MobileNativeChatView send-error banner', () => { const restore = suppressRendererWarning() try { await act(async () => { - renderer = create( - createElement(MobileNativeChatView, { - messages: [], - status: 'ready', - onSend: overrides.onSend ?? vi.fn().mockResolvedValue(true), - pending: [], - composerText: '', - onComposerTextChange: vi.fn(), - ...overrides - }) - ) + renderer = create(chatViewElement(overrides)) }) } finally { restore() } } + async function update(overrides: Overrides = {}): Promise { + await act(async () => { + renderer?.update(chatViewElement(overrides)) + }) + } + + /** Ids of the rows the list is currently rendering. */ + function listIds(): string[] { + const list = renderer!.root.find((node) => node.type === 'FlatList') + return (list.props.data as { id: string }[]).map((row) => row.id) + } + function banners(): ReactTestInstance[] { return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert') } @@ -161,4 +184,37 @@ describe('MobileNativeChatView send-error banner', () => { expect(onClearSendError).toHaveBeenCalledOnce() }) + + it('keeps streaming a reply that repeats the previous turn as a prefix', async () => { + const prior = [assistantTurn('a1', 'The tests pass.')] + await render({ messages: prior }) + expect(listIds()).toEqual(['a1']) + + await update({ messages: prior, streamingText: 'The tests' }) + + expect(listIds()).toEqual(['a1', 'streaming']) + }) + + it('drops the streaming bubble once the reply lands as its own turn', async () => { + const prior = [assistantTurn('a1', 'Done.')] + await render({ messages: prior }) + await update({ messages: prior, streamingText: 'Done.' }) + expect(listIds()).toEqual(['a1', 'streaming']) + + await update({ + messages: [...prior, assistantTurn('a2', 'Done.')], + streamingText: 'Done.' + }) + + expect(listIds()).toEqual(['a1', 'a2']) + }) + + it("does not carry one chat's baseline into another stream identity", async () => { + const prior = [assistantTurn('a1', 'Shared answer text')] + await render({ messages: prior, streamIdentity: 'tab-a' }) + + await update({ messages: prior, streamingText: 'Shared answer', streamIdentity: 'tab-b' }) + + expect(listIds()).toEqual(['a1']) + }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index f14efc1f25f..15ff124849d 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -21,6 +21,7 @@ import { type MobileNativeChatPendingItem } from './mobile-native-chat-render-data' import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' +import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-streaming-bubble' import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture' import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' @@ -50,6 +51,8 @@ type Props = { /** Live partial assistant text while a turn is still streaming (from the agent * status hook). Shown as an in-progress bubble until the transcript catches up. */ streamingText?: string + /** Scopes streaming catch-up state to the active host/workspace/tab/session. */ + streamIdentity: string hasMore?: boolean loadingEarlier?: boolean onLoadEarlier?: () => void @@ -108,6 +111,7 @@ export function MobileNativeChatView({ agentWorking, onStop, streamingText, + streamIdentity, hasMore, loadingEarlier, onLoadEarlier, @@ -166,9 +170,14 @@ export function MobileNativeChatView({ // route-owned optimistic queued messages. Memoize on the same deps so the // downstream autoscroll effects/`renderItem` keep referential stability. const foldedMessages = useMemo(() => foldMobileNativeChatMessages(messages), [messages]) + const streaming = useMobileNativeChatStreamingBubble( + foldedMessages, + streamingText, + streamIdentity + ) const { data } = useMemo( - () => buildMobileNativeChatTransientData({ folded: foldedMessages, streamingText, pending }), - [foldedMessages, streamingText, pending] + () => buildMobileNativeChatTransientData({ folded: foldedMessages, streaming, pending }), + [foldedMessages, streaming, pending] ) // Follow the tail as the conversation grows and keep the newest message above diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 068050fc43d..252fdbb66fd 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -7,6 +7,10 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { foldToolMessages } from './mobile-native-chat-blocks' import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers' import { stripNoiseMessages } from './mobile-native-chat-noise' +import { + createMobileNativeChatStreamingGate, + deriveMobileNativeChatStreaming +} from './mobile-native-chat-streaming-gate' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' /** The centered empty-state copy for a chat with no messages, mirroring the @@ -46,7 +50,9 @@ export type MobileNativeChatPendingItem = { /** Derive the list data from the raw transcript: fold tool turns into the * assistant turn, optionally append a synthetic streaming bubble, then the * route-owned optimistic "queued" messages at the tail. Returns the - * intermediate `folded`/`streaming` so the caller can memoize on them. */ + * intermediate `folded`/`streaming` so the caller can memoize on them. + * One-shot form: derives the streaming bubble through a fresh gate (no + * cross-tick memory); the live view threads its own gate instead. */ export function buildMobileNativeChatData({ messages, streamingText, @@ -57,7 +63,12 @@ export function buildMobileNativeChatData({ pending: MobileNativeChatPendingItem[] }): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { const folded = foldMobileNativeChatMessages(messages) - return buildMobileNativeChatTransientData({ folded, streamingText, pending }) + const { streaming } = deriveMobileNativeChatStreaming( + createMobileNativeChatStreamingGate(), + folded, + streamingText + ) + return buildMobileNativeChatTransientData({ folded, streaming, pending }) } export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] { @@ -68,16 +79,14 @@ export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): Nat export function buildMobileNativeChatTransientData({ folded, - streamingText, + streaming, pending }: { folded: NativeChatMessage[] - streamingText?: string + /** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */ + streaming: string | null pending: MobileNativeChatPendingItem[] }): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { - // Only show the streaming bubble while its text leads the transcript — once the - // real assistant turn lands with the same text, drop the synthetic one. - const streaming = deriveStreaming(folded, streamingText) const data: NativeChatMessage[] = [ ...folded, ...(streaming @@ -106,26 +115,3 @@ export function buildMobileNativeChatTransientData({ ] return { folded, streaming, data } } - -function deriveStreaming(folded: NativeChatMessage[], streamingText?: string): string | null { - const text = streamingText?.trim() - if (!text) { - return null - } - const last = folded[folded.length - 1] - const lastText = - last?.role === 'assistant' - ? last.blocks - .filter((b) => b.type === 'text') - .map((b) => (b.type === 'text' ? b.text : '')) - .join('') - .trim() - : '' - // Hide the synthetic bubble only once the real turn has landed leading with the - // streamed text. A bare length compare would suppress a short new reply behind a - // longer previous turn; a completed prior turn won't start with the new prefix. - if (lastText.startsWith(text)) { - return null - } - return text -} diff --git a/mobile/src/session/mobile-native-chat-streaming-gate.test.ts b/mobile/src/session/mobile-native-chat-streaming-gate.test.ts new file mode 100644 index 00000000000..db398519a0f --- /dev/null +++ b/mobile/src/session/mobile-native-chat-streaming-gate.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + createMobileNativeChatStreamingGate, + deriveMobileNativeChatStreaming, + type MobileNativeChatStreamingGate +} from './mobile-native-chat-streaming-gate' + +function assistant(id: string, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: 0, + source: 'transcript' + } +} + +/** Run a sequence of (folded, streamingText) ticks through one gate. */ +function run(ticks: { folded: NativeChatMessage[]; text?: string }[]): { + gate: MobileNativeChatStreamingGate + results: (string | null)[] +} { + let gate = createMobileNativeChatStreamingGate() + const results: (string | null)[] = [] + for (const tick of ticks) { + const step = deriveMobileNativeChatStreaming(gate, tick.folded, tick.text) + gate = step.gate + results.push(step.streaming) + } + return { gate, results } +} + +describe('deriveMobileNativeChatStreaming', () => { + it('shows a genuine reply that repeats the previous turn as a prefix', () => { + const prior = [assistant('a1', 'The tests pass.')] + const { results } = run([ + { folded: prior }, // idle tick anchors the pre-stream tail + { folded: prior, text: 'The' }, + { folded: prior, text: 'The tests' }, + { folded: prior, text: 'The tests pass.' } + ]) + expect(results).toEqual([null, 'The', 'The tests', 'The tests pass.']) + }) + + it('hides the bubble once the real turn lands leading with the streamed text', () => { + const prior = [assistant('a1', 'earlier turn')] + const landed = [...prior, assistant('a2', 'fresh answer with a tail')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'fresh answer' }, + { folded: landed, text: 'fresh answer' } + ]) + expect(results).toEqual([null, 'fresh answer', null]) + }) + + it('suppresses an identical repeated reply once its own turn lands', () => { + const prior = [assistant('a1', 'Done.')] + const landed = [...prior, assistant('a2', 'Done.')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'Done.' }, // repeated-prefix reply stays visible + { folded: landed, text: 'Done.' } // its own turn landed — hide + ]) + expect(results).toEqual([null, 'Done.', null]) + }) + + it('keeps hiding for the rest of a segment after the turn lands', () => { + const prior = [assistant('a1', 'earlier')] + const landed = [...prior, assistant('a2', 'answer body')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'answer' }, + { folded: landed, text: 'answer' }, + { folded: landed, text: 'answer bo' } + ]) + expect(results).toEqual([null, 'answer', null, null]) + }) + + it('re-anchors when a new reply part replaces the stream mid-turn', () => { + const prior = [assistant('a1', 'context')] + const partOneLanded = [...prior, assistant('a2', 'part one full text')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'part one' }, + { folded: partOneLanded, text: 'part one' }, // caught up — hide + // Part two is not an extension of part one: new segment, new baseline. + { folded: partOneLanded, text: 'part' } + ]) + expect(results).toEqual([null, 'part one', null, 'part']) + }) + + it('falls back to suppress-on-prefix when mounted mid-stream', () => { + // No idle tick ever observed: a duplicate bubble is worse than briefly + // hiding a mount-coincident repeated reply. + const landed = [assistant('a1', 'flushed part still streaming in status')] + const { results } = run([{ folded: landed, text: 'flushed part' }]) + expect(results).toEqual([null]) + }) + + it('is idempotent for a repeated tick', () => { + const prior = [assistant('a1', 'The tests pass.')] + const first = run([{ folded: prior }, { folded: prior, text: 'The tests' }]) + const again = deriveMobileNativeChatStreaming(first.gate, prior, 'The tests') + expect(again.streaming).toBe('The tests') + expect(again.gate).toBe(first.gate) + }) + + it('drops a prior chat baseline when the stream identity changes', () => { + // The other chat's tail must not license showing a bubble here — a swapped + // scope resets to the mid-stream fallback rather than reusing its baseline. + const repeatedId = [assistant('a1', 'new answer landed')] + let gate = createMobileNativeChatStreamingGate('tab-a') + gate = deriveMobileNativeChatStreaming(gate, repeatedId, undefined, 'tab-a').gate + + const switched = deriveMobileNativeChatStreaming(gate, repeatedId, 'new answer', 'tab-b') + + expect(switched.streaming).toBeNull() + expect(switched.gate.scopeKey).toBe('tab-b') + expect(switched.gate.baselineTailId).toBeNull() + }) + + it('returns null for empty or whitespace streaming text', () => { + const prior = [assistant('a1', 'x')] + expect(run([{ folded: prior, text: ' ' }]).results).toEqual([null]) + expect(run([{ folded: prior }]).results).toEqual([null]) + }) + + it('shows the first reply of an empty chat and hides it once the turn lands', () => { + const landed = [assistant('a1', 'Hello there')] + const { results } = run([ + { folded: [] }, + { folded: [], text: 'Hello' }, + { folded: landed, text: 'Hello' } + ]) + expect(results).toEqual([null, 'Hello', null]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-streaming-gate.ts b/mobile/src/session/mobile-native-chat-streaming-gate.ts new file mode 100644 index 00000000000..6267393cadc --- /dev/null +++ b/mobile/src/session/mobile-native-chat-streaming-gate.ts @@ -0,0 +1,82 @@ +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' + +/** Decides whether the live streaming preview should render as a synthetic + * bubble. Text alone can't tell "the transcript caught up with this stream" + * from "a new reply happens to repeat the previous turn's prefix" — the old + * prefix test swallowed genuine repeated-prefix replies. The gate keeps the + * transcript tail observed when the current stream segment began: the bubble + * hides only when the tail MOVED during the segment and leads with the + * streamed text (the real turn landed), never for an older identical turn. */ +export type MobileNativeChatStreamingGate = { + /** Chat/session identity this baseline belongs to. */ + scopeKey: string | null + /** Streamed text seen on the previous tick ('' while idle). */ + prevText: string + /** Folded tail message id when the current segment began; null while the + * gate has never observed an idle tick (mounted mid-stream), where the + * legacy suppress-on-prefix rule applies. */ + baselineTailId: string | null +} + +export function createMobileNativeChatStreamingGate( + scopeKey: string | null = null +): MobileNativeChatStreamingGate { + return { scopeKey, prevText: '', baselineTailId: null } +} + +function assistantTailText(tail: NativeChatMessage | undefined): string { + if (!tail || tail.role !== 'assistant') { + return '' + } + return tail.blocks + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join('') + .trim() +} + +// Reuses the incoming gate object when nothing moved, so a caller can detect +// "no change" by reference (and a render-time state adjustment can settle). +function advanceGate( + gate: MobileNativeChatStreamingGate, + prevText: string, + baselineTailId: string | null +): MobileNativeChatStreamingGate { + return gate.prevText === prevText && gate.baselineTailId === baselineTailId + ? gate + : { ...gate, prevText, baselineTailId } +} + +/** Advance the gate one tick and derive the visible streaming text (null hides + * the bubble). Pure and idempotent for a repeated (text, tail) pair, so a + * re-render without new data cannot flip the decision. */ +export function deriveMobileNativeChatStreaming( + gate: MobileNativeChatStreamingGate, + folded: readonly NativeChatMessage[], + streamingText: string | undefined, + scopeKey: string | null = gate.scopeKey +): { gate: MobileNativeChatStreamingGate; streaming: string | null } { + const scopedGate = + gate.scopeKey === scopeKey ? gate : createMobileNativeChatStreamingGate(scopeKey) + const text = streamingText?.trim() ?? '' + const tail = folded.at(-1) + const tailId = tail?.id ?? null + if (!text) { + // Idle: keep anchoring the baseline to the live tail so the next segment + // knows which tail predates it. + return { gate: advanceGate(scopedGate, '', tailId), streaming: null } + } + // A stream that is not an extension of the previous tick is a new segment + // (next reply part); re-anchor to the tail that predates it. + const segmentStart = scopedGate.prevText !== '' && !text.startsWith(scopedGate.prevText) + const baselineTailId = segmentStart ? tailId : scopedGate.baselineTailId + const tailLeadsWithStream = assistantTailText(tail).startsWith(text) + // baselineTailId === null: mounted mid-stream with no pre-stream observation — + // fall back to suppress-on-prefix (a duplicate bubble is worse than briefly + // hiding an improbable mount-coincident repeated reply). + const caughtUp = tailLeadsWithStream && (baselineTailId === null || tailId !== baselineTailId) + return { + gate: advanceGate(scopedGate, text, baselineTailId), + streaming: caughtUp ? null : text + } +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index fd0a564b19c..f611b83a436 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -49,6 +49,8 @@ export type MobileNativeChatController = { nativeChatSession: ReturnType nativeChatAgentWorking: boolean nativeChatStreamingText?: string + /** Host/workspace/tab/session identity for stateful streaming suppression. */ + nativeChatStreamIdentity: string nativeChatPermission: ReturnType nativeChatQuestion: ReturnType nativeChatAsk: ReturnType @@ -273,6 +275,7 @@ export function useMobileNativeChatController(args: { nativeChatSession, nativeChatAgentWorking, nativeChatStreamingText, + nativeChatStreamIdentity: streamIdentity, nativeChatPermission, nativeChatQuestion, nativeChatAsk, diff --git a/mobile/src/session/use-mobile-native-chat-streaming-bubble.ts b/mobile/src/session/use-mobile-native-chat-streaming-bubble.ts new file mode 100644 index 00000000000..511ed91be36 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-streaming-bubble.ts @@ -0,0 +1,24 @@ +import { useState } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + createMobileNativeChatStreamingGate, + deriveMobileNativeChatStreaming +} from './mobile-native-chat-streaming-gate' + +/** Live streaming-bubble text for the chat list. The gate remembers which + * transcript tail predates the current stream segment, so a reply that repeats + * the previous turn's prefix still shows while streaming. */ +export function useMobileNativeChatStreamingBubble( + folded: readonly NativeChatMessage[], + streamingText: string | undefined, + scopeKey: string +): string | null { + const [gate, setGate] = useState(() => createMobileNativeChatStreamingGate(scopeKey)) + const step = deriveMobileNativeChatStreaming(gate, folded, streamingText, scopeKey) + if (step.gate !== gate) { + // Render-time state adjustment (derived-state pattern): the advance is + // idempotent for a repeated (text, tail) pair, so this settles in one pass. + setGate(step.gate) + } + return step.streaming +}