import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, 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' })) vi.mock('../platform/use-reduced-motion', () => ({ useReducedMotionEnabled: () => null })) vi.mock('./MobileNativeChatQueuedMessages', () => ({ MobileNativeChatQueuedMessages: 'Queued' })) vi.mock('./MobileNativeChatBackgroundTasks', () => ({ MobileNativeChatBackgroundTasks: 'BackgroundTasks' })) vi.mock('./MobileNativeChatVisual', () => ({ useMobileNativeChatVisualRenderer: () => null })) vi.mock('./MobileNativeChatRowOverlaysHost', () => ({ MobileNativeChatRowOverlaysHost: 'View' })) function assistantTurn(id: string, text: string): NativeChatMessage { return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' } } /** 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 /** The host says a person's Stop is ending the turn. */ stopping?: boolean acceptsImages?: boolean } function overlayElement(tick: Tick): ReturnType { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the overlay reads only these controller members; the rest of the controller is unreachable from it. const controller = { showNativeChat: tick.show ?? true, nativeChatSession: { messages: tick.messages ?? [], status: 'ready' }, nativeChatAgent: 'claude', nativeChatAcceptsImages: tick.acceptsImages ?? true, nativeChatAgentWorking: tick.streamLive ?? false, nativeChatTurnIndicator: { thinking: false, activityText: null, stopping: tick.stopping ?? false }, nativeChatStreamingText: tick.streamingText, nativeChatStreamLive: tick.streamLive ?? false, nativeChatStreamScopeKey: tick.identity ?? 'tab-a', chatPending: [], chatImagePreviewsByMessageId: {}, chatComposerText: '', setChatComposerText: vi.fn(), nativeChatQueued: { cards: [], send: vi.fn(), delete: vi.fn(), edit: vi.fn(), pause: null, resume: vi.fn(), sessionKey: 'session-a' } } 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(), sendSurfaceId: tick.identity ?? 'tab-a', sessionId: null, getSendCompletionGeneration: () => 0, keyboardInset: 0 }) } describe('MobileNativeChatOverlay streaming gate', () => { let renderer: ReactTestRenderer | null = null afterEach(() => { act(() => renderer?.unmount()) renderer = null }) async function render(tick: Tick): Promise { await act(async () => { renderer = create(overlayElement(tick)) }) } 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 session withholds its transcript until a fresh read // settles, so the throttled stream text returns a round trip ahead of it. await update({ messages: [], streamLive: true }) await update({ messages: [], streamingText: 'Done.', streamLive: true }) await update({ messages: prior, streamingText: 'Done.', streamLive: true }) expect(streaming()).toBe('Done.') }) it('keeps the bubble across a peek at the terminal taken between turns', async () => { // Same toggle, but taken while idle: the transcript empties before the next // turn starts, so the gate has to reject that empty tail as a baseline. const prior = [assistantTurn('a1', 'Done.')] await render({ messages: prior }) await update({ show: false, messages: [] }) await update({ show: false, messages: [], streamLive: true }) await update({ messages: [], streamLive: true }) await update({ messages: [], streamingText: 'Done.', streamLive: true }) await update({ messages: prior, streamingText: 'Done.', streamLive: true }) expect(streaming()).toBe('Done.') }) it('hides a repeated part whose own turn landed during a mid-turn gap', async () => { // Between parts the status frame carries no assistant text (a tool call), so // the stream goes textless while the turn is still live and the part that // just finished lands in the transcript. Re-anchoring on that tick would // adopt it as history and render it a second time. const prior = [assistantTurn('a1', 'Done.')] await render({ messages: prior }) await update({ messages: prior, streamingText: 'Done.', streamLive: true }) expect(streaming()).toBe('Done.') 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() }) }) describe("MobileNativeChatOverlay while a person's Stop ends the turn", () => { let renderer: ReactTestRenderer | null = null afterEach(() => { act(() => renderer?.unmount()) renderer = null }) async function steerHeld(tick: Tick): Promise { await act(async () => { renderer = create(overlayElement(tick)) }) const view = renderer!.root.find((node) => node.type === 'ChatView') // The tray's second child is the queued box; the first is the child-work strip. return view.props.composerTray?.content?.props?.children?.[1]?.props?.steerHeld } it("holds the queued cards' Steer while the agent works and the host says Stopping", async () => { expect(await steerHeld({ streamLive: true, stopping: true })).toBe(true) }) it('leaves Steer to the cards otherwise', async () => { expect(await steerHeld({ streamLive: true, stopping: false })).toBe(false) }) }) describe('MobileNativeChatOverlay attach button', () => { let renderer: ReactTestRenderer | null = null afterEach(() => { act(() => renderer?.unmount()) renderer = null }) it.each([true, false])('offers attach only when the agent takes images (%s)', async (accepts) => { await act(async () => { renderer = create(overlayElement({ acceptsImages: accepts })) }) const view = renderer!.root.find((node) => node.type === 'ChatView') expect(typeof view.props.onAttachImage === 'function').toBe(accepts) }) })