diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts new file mode 100644 index 00000000000..a4910cd5afd --- /dev/null +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -0,0 +1,172 @@ +import { createElement } from 'react' +import { act, create, 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 { MobileNativeChatOverlay } from './MobileNativeChatOverlay' +import type { MobileNativeChatController } from './use-mobile-native-chat-controller' + +vi.mock('react-native', () => ({ + StyleSheet: { create: (styles: unknown) => styles, absoluteFillObject: {} }, + View: 'View' +})) + +vi.mock('./MobileNativeChatView', () => ({ MobileNativeChatView: 'ChatView' })) + +function assistantTurn(id: string, text: string): NativeChatMessage { + return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' } +} + +function suppressRendererWarning(): () => void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + return () => spy.mockRestore() +} + +/** One render of the route: chat visible or not, the transcript it currently + * holds, and the agent-status stream behind it. */ +type Tick = { + show?: boolean + messages?: NativeChatMessage[] + streamingText?: string + streamLive?: boolean + identity?: string +} + +function overlayElement(tick: Tick): ReturnType { + const controller = { + showNativeChat: tick.show ?? true, + nativeChatSession: { messages: tick.messages ?? [], status: 'ready' }, + nativeChatAgent: 'claude', + nativeChatAgentWorking: tick.streamLive ?? false, + nativeChatStreamingText: tick.streamingText, + nativeChatStreamLive: tick.streamLive ?? false, + nativeChatStreamScopeKey: tick.identity ?? 'tab-a', + chatPending: [], + chatComposerText: '', + setChatComposerText: vi.fn() + } as unknown as MobileNativeChatController + return createElement(MobileNativeChatOverlay, { + controller, + images: {} as never, + onMicPress: vi.fn(), + micActive: false, + dictationMode: 'toggle', + onMicPressIn: vi.fn(), + onMicPressOut: vi.fn(), + inputLockReason: null, + sendErrorMessage: null, + onClearSendError: vi.fn(), + keyboardInset: 0 + }) +} + +describe('MobileNativeChatOverlay streaming gate', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function render(tick: Tick): Promise { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(overlayElement(tick)) + }) + } finally { + restore() + } + } + + async function update(tick: Tick): Promise { + await act(async () => { + renderer?.update(overlayElement(tick)) + }) + } + + /** The bubble text handed to the chat list, or `'hidden'` when chat is off. */ + function streaming(): string | null | 'hidden' { + const views = renderer!.root.findAll((node) => node.type === 'ChatView') + return views.length === 0 ? 'hidden' : (views[0].props.streaming as string | null) + } + + 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(streaming()).toBeNull() + + await update({ messages: prior, streamingText: 'The tests', streamLive: true }) + + expect(streaming()).toBe('The tests') + }) + + 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.', streamLive: true }) + expect(streaming()).toBe('Done.') + + await update({ + messages: [...prior, assistantTurn('a2', 'Done.')], + streamingText: 'Done.', + streamLive: true + }) + + expect(streaming()).toBeNull() + }) + + it('keeps the bubble across a peek at the terminal view', async () => { + // Toggling to the terminal unmounts the chat list and unsubscribes its + // transcript. The gate lives above that boundary, so the baseline survives + // and the repeated-prefix reply keeps streaming on the way back. + const prior = [assistantTurn('a1', 'Done.')] + await render({ messages: prior }) + await update({ messages: prior, streamingText: 'Done.', streamLive: true }) + expect(streaming()).toBe('Done.') + + await update({ show: false, messages: [], streamLive: true }) + expect(streaming()).toBe('hidden') + // Back on chat: the throttled status trails the first re-render by a tick. + await update({ messages: prior, streamLive: true }) + await update({ messages: prior, streamingText: 'Done.', streamLive: true }) + + expect(streaming()).toBe('Done.') + }) + + it('still hides the bubble when the reply landed while chat was hidden', async () => { + const prior = [assistantTurn('a1', 'Done.')] + await render({ messages: prior }) + await update({ messages: prior, streamingText: 'Done.', streamLive: true }) + + await update({ show: false, messages: [], streamLive: true }) + const landed = [...prior, assistantTurn('a2', 'Done.')] + await update({ messages: landed, streamLive: true }) + await update({ messages: landed, streamingText: 'Done.', streamLive: true }) + + expect(streaming()).toBeNull() + }) + + it("does not carry one chat's baseline into another stream identity", async () => { + const prior = [assistantTurn('a1', 'Shared answer text')] + await render({ messages: prior, identity: 'tab-a' }) + + await update({ + messages: prior, + streamingText: 'Shared answer', + streamLive: true, + identity: 'tab-b' + }) + + expect(streaming()).toBeNull() + }) +}) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index cf3e8a3a0dc..77c5ab72fed 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -1,7 +1,10 @@ +import { useMemo } from 'react' import { StyleSheet, View } from 'react-native' import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView' +import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data' import type { MobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments' import type { MobileNativeChatController } from './use-mobile-native-chat-controller' +import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-streaming-bubble' type Props = { controller: MobileNativeChatController @@ -22,7 +25,9 @@ type Props = { } /** Keeps the terminal mounted underneath chat so its PTY subscription survives - * view toggles while the native surface owns the visible composer. */ + * view toggles while the native surface owns the visible composer. Also owns + * the streaming gate: this component stays mounted across those toggles, while + * the chat list below it does not. */ export function MobileNativeChatOverlay({ controller, images, @@ -36,20 +41,27 @@ export function MobileNativeChatOverlay({ onClearSendError, keyboardInset }: Props): React.JSX.Element | null { + const session = controller.nativeChatSession + const folded = useMemo(() => foldMobileNativeChatMessages(session.messages), [session.messages]) + const streaming = useMobileNativeChatStreamingBubble( + folded, + controller.nativeChatStreamingText, + controller.nativeChatStreamScopeKey, + controller.nativeChatStreamLive + ) if (!controller.showNativeChat) { return null } - const session = controller.nativeChatSession return ( { type Overrides = { messages?: Parameters[0]['messages'] - streamingText?: string - streamIdentity?: string + folded?: Parameters[0]['folded'] + streaming?: string | null sendErrorMessage?: string | null onClearSendError?: () => void inputLockReason?: 'disconnected' | 'waiting' | null @@ -86,8 +86,9 @@ function assistantTurn(id: string, text: string): NativeChatMessage { function chatViewElement(overrides: Overrides): ReturnType { return createElement(MobileNativeChatView, { messages: [], + folded: [], status: 'ready', - streamIdentity: 'test-stream', + streaming: null, onSend: vi.fn().mockResolvedValue(true), pending: [], composerText: '', @@ -96,7 +97,7 @@ function chatViewElement(overrides: Overrides): ReturnType }) } -describe('MobileNativeChatView send-error banner', () => { +describe('MobileNativeChatView', () => { let renderer: ReactTestRenderer | null = null beforeEach(() => { @@ -185,36 +186,15 @@ 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 }) + // The gate that decides `streaming` lives in MobileNativeChatOverlay, which + // outlives this view; see MobileNativeChatOverlay.test.ts. + it('appends the gated streaming bubble after the folded transcript', async () => { + const folded = [assistantTurn('a1', 'The tests pass.')] + await render({ folded }) expect(listIds()).toEqual(['a1']) - await update({ messages: prior, streamingText: 'The tests' }) + await update({ folded, streaming: '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 15ff124849d..1a2ae71ca66 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -16,12 +16,10 @@ import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-view-styles' import { buildMobileNativeChatTransientData, - foldMobileNativeChatMessages, mobileNativeChatEmptyState, 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' @@ -40,7 +38,10 @@ import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' type Props = { + /** Raw transcript, only for telling "still loading" from "loaded and empty". */ messages: NativeChatMessage[] + /** `messages` with noise stripped and tool turns folded in, from the overlay. */ + folded: NativeChatMessage[] status: MobileNativeChatStatus error?: string /** Resolved agent for this chat; names the empty-state copy (desktop parity). */ @@ -48,11 +49,9 @@ type Props = { agentWorking?: boolean /** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */ onStop?: () => void - /** 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 + /** Live partial assistant text to show as an in-progress bubble, already gated + * by the overlay against the transcript catching up. */ + streaming: string | null hasMore?: boolean loadingEarlier?: boolean onLoadEarlier?: () => void @@ -105,13 +104,13 @@ type Props = { export function MobileNativeChatView({ messages, + folded, status, error, agent, agentWorking, onStop, - streamingText, - streamIdentity, + streaming, hasMore, loadingEarlier, onLoadEarlier, @@ -169,15 +168,9 @@ export function MobileNativeChatView({ // `data` is the list source: folded transcript + synthetic streaming bubble + // 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, streaming, pending }), - [foldedMessages, streaming, pending] + () => buildMobileNativeChatTransientData({ folded, streaming, pending }), + [folded, streaming, pending] ) // Follow the tail as the conversation grows and keep the newest message above diff --git a/mobile/src/session/mobile-native-chat-streaming-gate.test.ts b/mobile/src/session/mobile-native-chat-streaming-gate.test.ts index db398519a0f..c7d88a14120 100644 --- a/mobile/src/session/mobile-native-chat-streaming-gate.test.ts +++ b/mobile/src/session/mobile-native-chat-streaming-gate.test.ts @@ -17,14 +17,16 @@ function assistant(id: string, text: string): NativeChatMessage { } /** Run a sequence of (folded, streamingText) ticks through one gate. */ -function run(ticks: { folded: NativeChatMessage[]; text?: string }[]): { +function run(ticks: { folded: NativeChatMessage[]; text?: string; live?: boolean }[]): { 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) + const step = deriveMobileNativeChatStreaming(gate, tick.folded, tick.text, { + streamLive: tick.live + }) gate = step.gate results.push(step.streaming) } @@ -77,6 +79,43 @@ describe('deriveMobileNativeChatStreaming', () => { expect(results).toEqual([null, 'answer', null, null]) }) + it('keeps the segment baseline through textless ticks while the turn is live', () => { + // Chat is hidden mid-stream: the transcript unsubscribes and the status + // stops reaching the gate, but the turn has not ended. + const prior = [assistant('a1', 'Done.')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'Done.', live: true }, + { folded: [], live: true }, + { folded: prior, text: 'Done.', live: true } + ]) + expect(results).toEqual([null, 'Done.', null, 'Done.']) + }) + + it('still hides after a hidden gap once the reply landed as its own turn', () => { + const prior = [assistant('a1', 'Done.')] + const landed = [...prior, assistant('a2', 'Done.')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'Done.', live: true }, + { folded: [], live: true }, + { folded: landed, text: 'Done.', live: true } + ]) + expect(results).toEqual([null, 'Done.', null, null]) + }) + + it('anchors on a textless tick once the turn ends', () => { + const prior = [assistant('a1', 'first answer')] + const landed = [...prior, assistant('a2', 'second answer')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'second answer', live: true }, + { folded: landed }, + { folded: landed, text: 'second answer', live: true } + ]) + expect(results).toEqual([null, 'second answer', null, 'second answer']) + }) + 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')] @@ -111,9 +150,11 @@ describe('deriveMobileNativeChatStreaming', () => { // 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 + gate = deriveMobileNativeChatStreaming(gate, repeatedId, undefined, { scopeKey: 'tab-a' }).gate - const switched = deriveMobileNativeChatStreaming(gate, repeatedId, 'new answer', 'tab-b') + const switched = deriveMobileNativeChatStreaming(gate, repeatedId, 'new answer', { + scopeKey: 'tab-b' + }) expect(switched.streaming).toBeNull() expect(switched.gate.scopeKey).toBe('tab-b') diff --git a/mobile/src/session/mobile-native-chat-streaming-gate.ts b/mobile/src/session/mobile-native-chat-streaming-gate.ts index 6267393cadc..3973460a4e6 100644 --- a/mobile/src/session/mobile-native-chat-streaming-gate.ts +++ b/mobile/src/session/mobile-native-chat-streaming-gate.ts @@ -54,14 +54,26 @@ export function deriveMobileNativeChatStreaming( gate: MobileNativeChatStreamingGate, folded: readonly NativeChatMessage[], streamingText: string | undefined, - scopeKey: string | null = gate.scopeKey + options: { + scopeKey?: string | null + /** Whether the agent is still mid-turn. A textless tick then means "no + * observation this render", not "the stream ended". */ + streamLive?: boolean + } = {} ): { gate: MobileNativeChatStreamingGate; streaming: string | null } { + const scopeKey = options.scopeKey === undefined ? gate.scopeKey : options.scopeKey const scopedGate = gate.scopeKey === scopeKey ? gate : createMobileNativeChatStreamingGate(scopeKey) const text = streamingText?.trim() ?? '' const tail = folded.at(-1) const tailId = tail?.id ?? null if (!text) { + if (options.streamLive && scopedGate.prevText !== '') { + // A gap inside a live segment (chat hidden, transcript reloading, throttle + // not caught up): re-anchoring here would adopt the reply that just landed + // as pre-stream history and render it a second time as a bubble. + return { gate: scopedGate, streaming: null } + } // 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 } @@ -71,10 +83,9 @@ export function deriveMobileNativeChatStreaming( 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) + // A null baseline (mounted mid-stream, never saw an idle tick) is unequal to + // every real tail id, so this degrades to the legacy suppress-on-prefix rule. + const caughtUp = tailLeadsWithStream && tailId !== baselineTailId return { gate: advanceGate(scopedGate, text, baselineTailId), streaming: caughtUp ? null : text 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 2b4fb0e9d0f..5afee16b891 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -421,3 +421,83 @@ describe('useMobileNativeChatController launch-draft wiring', () => { expect(draftsArgs.at(-1)).toMatchObject({ launchDraft: null, chatActive: true }) }) }) + +describe('useMobileNativeChatController streaming scope', () => { + let renderer: ReactTestRenderer | null = null + let controller: MobileNativeChatController | null = null + const clientStub = { sendRequest: vi.fn() } + + const workingTab = { + type: 'terminal', + id: 'tab-1', + terminal: 'term-1', + launchAgent: 'claude', + agentStatus: { + state: 'working', + agentType: 'claude', + providerSession: { id: 'session-1' } + }, + isActive: true + } + + function Harness(): null { + controller = useMobileNativeChatController({ + client: clientStub as unknown as RpcClient, + connState: 'connected', + hostId: 'h', + worktreeId: 'w', + activeSessionTab: workingTab as never, + activeSessionTabId: 'tab-1', + activeHandleRef: { current: 'term-1' }, + deviceTokenRef: { current: null }, + nativeChatTranscriptIsLocalReadable: true, + nativeChatInputLeaseReady: true, + onSendError: vi.fn(), + onSendResolved: vi.fn() + }) + return null + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + viewMode.isTabChatView = () => true + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { + if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { + return + } + original(...a) + }) + try { + act(() => { + renderer = create(createElement(Harness)) + }) + } finally { + spy.mockRestore() + } + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + controller = null + viewMode.isTabChatView = () => true + }) + + it('holds the stream scope and liveness while the user peeks at the terminal', () => { + // Both feed the streaming gate, which lives above the chat view's mount and + // must not be reset by a view toggle. + expect(controller?.showNativeChat).toBe(true) + const scopeKey = controller?.nativeChatStreamScopeKey + expect(scopeKey).toContain('session-1') + expect(controller?.nativeChatStreamLive).toBe(true) + + viewMode.isTabChatView = () => false + act(() => renderer?.update(createElement(Harness))) + + expect(controller?.showNativeChat).toBe(false) + expect(controller?.nativeChatAgentWorking).toBe(false) + expect(controller?.nativeChatStreamScopeKey).toBe(scopeKey) + expect(controller?.nativeChatStreamLive).toBe(true) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index f611b83a436..37f7b5399c2 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -49,8 +49,10 @@ export type MobileNativeChatController = { nativeChatSession: ReturnType nativeChatAgentWorking: boolean nativeChatStreamingText?: string - /** Host/workspace/tab/session identity for stateful streaming suppression. */ - nativeChatStreamIdentity: string + /** Agent mid-turn, regardless of whether chat is the visible view. */ + nativeChatStreamLive: boolean + /** Host/workspace/tab/session scope for stateful streaming suppression. */ + nativeChatStreamScopeKey: string nativeChatPermission: ReturnType nativeChatQuestion: ReturnType nativeChatAsk: ReturnType @@ -126,7 +128,12 @@ export function useMobileNativeChatController(args: { activeChatAgentRef.current = activeChatResolution?.agent ?? null const activeChatSessionId = activeChatResolution?.sessionId ?? null - const streamIdentity = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` + const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}` + const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` + // Same chat, but keyed off the tab rather than the view-gated resolution: + // `streamIdentity` goes session-less the moment the user peeks at the terminal, + // and a scope that flips on a view toggle throws the gate's baseline away. + const streamScopeKey = `${routeKey}\0${activeSessionTab?.agentStatus?.providerSession?.id ?? ''}\0${activeHandleRef.current ?? ''}` const nativeChatSession = useMobileNativeChatSession({ client, @@ -162,6 +169,9 @@ export function useMobileNativeChatController(args: { const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null const nativeChatAgentWorking = nativeChatStatus?.state === 'working' + // Deliberately not gated on the chat view being visible: the streaming gate + // has to tell "hidden mid-turn" from "the turn ended". + const nativeChatStreamLive = activeSessionTab?.agentStatus?.state === 'working' // Throttle the streaming bubble: OpenCode emits a status frame per streamed // part, and each one re-renders and re-parses the whole accumulated markdown. const nativeChatStreamingText = useThrottledLatestValue( @@ -275,7 +285,8 @@ export function useMobileNativeChatController(args: { nativeChatSession, nativeChatAgentWorking, nativeChatStreamingText, - nativeChatStreamIdentity: streamIdentity, + nativeChatStreamLive, + nativeChatStreamScopeKey: streamScopeKey, 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 index 511ed91be36..632fa0b2282 100644 --- a/mobile/src/session/use-mobile-native-chat-streaming-bubble.ts +++ b/mobile/src/session/use-mobile-native-chat-streaming-bubble.ts @@ -7,14 +7,21 @@ import { /** 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. */ + * the previous turn's prefix still shows while streaming. Call this from a + * component that outlives the chat list itself: the baseline has to survive the + * view toggles that unmount it, or the next segment reverts to prefix-matching. + * `streamLive` keeps those textless gaps from reading as an idle stream. */ export function useMobileNativeChatStreamingBubble( folded: readonly NativeChatMessage[], streamingText: string | undefined, - scopeKey: string + scopeKey: string, + streamLive: boolean ): string | null { const [gate, setGate] = useState(() => createMobileNativeChatStreamingGate(scopeKey)) - const step = deriveMobileNativeChatStreaming(gate, folded, streamingText, scopeKey) + const step = deriveMobileNativeChatStreaming(gate, folded, streamingText, { + scopeKey, + streamLive + }) 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.