From 41aaa9ddd9d02d0e8ca9bf7a461873a3ab2efe03 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:31:12 -0700 Subject: [PATCH] fix(mobile): keep the streaming gate alive across chat/terminal toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate lived in MobileNativeChatView, but MobileNativeChatOverlay returns null whenever the user peeks at the terminal — that unmounts the view and throws the baseline away, so the repeated-prefix reply was swallowed again on the way back. Move the gate (and the fold memo it reads) up to the overlay, which stays mounted across those toggles. While hidden the transcript is empty and the throttled stream reports no text, which the gate would have read as "idle" and re-anchored on. Pass the agent's working state so a textless tick inside a live segment holds the baseline instead. The scope key is now keyed off the tab rather than the view-gated chat resolution, so it survives the toggle too; streamIdentity keeps its exact previous value because the delayed-send guards compare against it. Also drops a dead disjunct in the caught-up test: a null baseline is already unequal to every real tail id. --- .../session/MobileNativeChatOverlay.test.ts | 172 ++++++++++++++++++ .../src/session/MobileNativeChatOverlay.tsx | 20 +- .../src/session/MobileNativeChatView.test.ts | 42 ++--- mobile/src/session/MobileNativeChatView.tsx | 27 +-- .../mobile-native-chat-streaming-gate.test.ts | 49 ++++- .../mobile-native-chat-streaming-gate.ts | 21 ++- .../use-mobile-native-chat-controller.test.ts | 80 ++++++++ .../use-mobile-native-chat-controller.ts | 19 +- ...use-mobile-native-chat-streaming-bubble.ts | 13 +- 9 files changed, 375 insertions(+), 68 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatOverlay.test.ts 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.