import { createElement } from 'react' import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileNativeChatView } from './MobileNativeChatView' vi.mock('react-native', () => ({ ActivityIndicator: 'ActivityIndicator', FlatList: 'FlatList', Pressable: 'Pressable', StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, Text: 'Text', View: 'View' })) vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) })) vi.mock('react-native-gesture-handler', () => { const chain = { runOnJS: () => chain, onStart: () => chain, onUpdate: () => chain } return { Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain }, GestureDetector: 'GestureDetector', GestureHandlerRootView: 'GestureHandlerRootView' } }) vi.mock('lucide-react-native', () => ({ ArrowDown: 'ArrowDown', ChevronsDownUp: 'ChevronsDownUp', ChevronsUpDown: 'ChevronsUpDown', Square: 'Square' })) vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) vi.mock('./MobileAgentWorkingIndicator', () => ({ MobileAgentWorkingIndicator: 'WorkingIndicator' })) // Stand-in composer: exposes the view's `handleSend` through a pressable, which is // the only composer behaviour these banner tests exercise. vi.mock('./MobileNativeChatComposer', async () => { const React = await import('react') return { MobileNativeChatComposer: (props: { onSend: (text: string) => Promise disabled?: boolean placeholder?: string }) => React.createElement('Composer', { ...props, accessibilityLabel: 'Send message', onPress: () => props.onSend('hi') }) } }) type Overrides = { messages?: Parameters[0]['messages'] folded?: Parameters[0]['folded'] streaming?: string | null sendErrorMessage?: string | null onClearSendError?: () => void inputLockReason?: 'disconnected' | 'waiting' | null onSend?: (text: string) => Promise pending?: Parameters[0]['pending'] structuredActivityUi?: boolean agentWorking?: boolean sendSurfaceId?: string } 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: [], folded: [], status: 'ready', streaming: null, onSend: vi.fn().mockResolvedValue(true), sendSurfaceId: 'tab-a', getSendCompletionGeneration: () => 0, pending: [], composerText: '', onComposerTextChange: vi.fn(), ...overrides }) } describe('MobileNativeChatView', () => { let renderer: ReactTestRenderer | null = null afterEach(() => { act(() => renderer?.unmount()) renderer = null }) async function render(overrides: Overrides = {}): Promise { await act(async () => { renderer = create(chatViewElement(overrides)) }) } 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 renderedRow(id: string): ReturnType { const list = renderer!.root.find((node) => node.type === 'FlatList') const data = list.props.data as NativeChatMessage[] const index = data.findIndex((row) => row.id === id) return list.props.renderItem({ item: data[index], index }) } function banners(): ReactTestInstance[] { return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert') } function composer(): ReactTestInstance { return renderer!.root.find((node) => node.type === 'Composer') } function bannerText(): string { const [alert, ...rest] = banners() expect(rest).toHaveLength(0) return alert .findAll((node) => node.type === 'Text') .map((node) => node.props.children) .join('') } async function pressSend(): Promise { const composer = renderer!.root.find((node) => node.type === 'Composer') as { props: { onPress: () => Promise } } await act(async () => { await composer.props.onPress() }) } it('renders the route-reported failure verbatim', async () => { await render({ sendErrorMessage: 'Permission reply failed' }) expect(banners()).toHaveLength(1) expect(bannerText()).toContain('Permission reply failed') }) it('does not duplicate the route banner when the composer rejects', async () => { const onClearSendError = vi.fn() await render({ onSend: vi.fn().mockResolvedValue(false), inputLockReason: 'disconnected', sendErrorMessage: 'Stop failed', onClearSendError }) await pressSend() expect(onClearSendError).not.toHaveBeenCalled() expect(banners()).toHaveLength(1) expect(bannerText()).toContain('Stop failed') expect(bannerText()).toBe('Stop failed') }) it('retires the route-owned banner once a send is accepted', async () => { const onClearSendError = vi.fn() await render({ sendErrorMessage: 'Stop failed', onClearSendError }) await pressSend() expect(onClearSendError).toHaveBeenCalledOnce() }) // 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({ folded, streaming: 'The tests' }) expect(listIds()).toEqual(['a1', 'streaming']) }) it('renders an accepted optimistic image send without a queued state', async () => { await render({ pending: [{ id: 'pending-1', text: 'look', images: ['file:///phone-photo.jpg'] }] }) expect(listIds()).toEqual(['pending-1']) expect(renderedRow('pending-1').props).not.toHaveProperty('queued') }) it('keeps a visible lock through a subscribed-end lease blip', async () => { vi.useFakeTimers() try { await render({ inputLockReason: 'waiting' }) await act(async () => vi.advanceTimersByTime(600)) expect(composer().props.disabled).toBe(true) await update({ inputLockReason: null }) expect(composer().props.disabled).toBe(true) await act(async () => vi.advanceTimersByTime(300)) await update({ inputLockReason: 'waiting' }) await act(async () => vi.advanceTimersByTime(600)) expect(composer().props.disabled).toBe(true) expect(composer().props.placeholder).toBe('Waiting for terminal…') } finally { vi.useRealTimers() } }) it('unlocks after the lease stays ready', async () => { vi.useFakeTimers() try { await render({ inputLockReason: 'waiting' }) await act(async () => vi.advanceTimersByTime(600)) await update({ inputLockReason: null }) await act(async () => vi.advanceTimersByTime(599)) expect(composer().props.disabled).toBe(true) await act(async () => vi.advanceTimersByTime(1)) expect(composer().props.disabled).toBe(false) expect(composer().props.placeholder).toBe('Message, @files, /commands') } finally { vi.useRealTimers() } }) describe('structured turn status wiring', () => { const userTurn = (id: string, text: string): NativeChatMessage => ({ id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 0, source: 'transcript' }) function rowProps(id: string): Record { return (renderedRow(id) as { props: Record }).props } function workingIndicators(): ReactTestInstance[] { return renderer!.root.findAll((node) => node.type === 'WorkingIndicator') } it('gives the live user turn a status row and drops the three-dot indicator', async () => { const folded = [userTurn('u1', 'go')] await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) const props = rowProps('u1') expect(props.structuredActivityUi).toBe(true) expect(props.turnStatus).toMatchObject({ thinking: true, workedSeconds: null }) expect(props.activeTurnIsWorking).toBe(true) expect(workingIndicators()).toHaveLength(0) }) it('keeps the bridge lane on the three-dot indicator with no turn status', async () => { const folded = [userTurn('u1', 'go')] await render({ messages: folded, folded, agentWorking: true }) const props = rowProps('u1') expect(props.structuredActivityUi).toBe(false) expect(props.turnStatus).toBeNull() expect(props.activeTurnIsWorking).toBe(false) expect(workingIndicators()).toHaveLength(1) }) it('settles the finished turn to a tappable duration', async () => { const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'done')] await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) expect(rowProps('u1').turnStatus).toMatchObject({ thinking: false, workedSeconds: null }) await update({ messages: folded, folded, structuredActivityUi: true, agentWorking: false }) const settled = rowProps('u1') expect(settled.turnStatus).toMatchObject({ thinking: false }) expect((settled.turnStatus as { workedSeconds: number | null }).workedSeconds).toBeTypeOf( 'number' ) expect(settled.onToggleTurn).toBeTypeOf('function') expect(settled.activeTurnIsWorking).toBe(false) }) it('hangs no status row on an assistant row', async () => { const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'done')] await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) expect(rowProps('a1').turnStatus).toBeNull() // The assistant row still belongs to the live turn, so its tool row stays visible. expect(rowProps('a1').activeTurnIsWorking).toBe(true) }) it('does not carry a running turn clock across chat surfaces', async () => { vi.useFakeTimers() try { vi.setSystemTime(1_000) const firstTab = [userTurn('u1', 'first')] await render({ messages: firstTab, folded: firstTab, structuredActivityUi: true, agentWorking: true, sendSurfaceId: 'host\0worktree\0tab-a' }) expect(rowProps('u1').turnStatus).toMatchObject({ startedAt: 1_000 }) vi.setSystemTime(12_000) const secondTab = [userTurn('u2', 'second')] await update({ messages: secondTab, folded: secondTab, structuredActivityUi: true, agentWorking: true, sendSurfaceId: 'host\0worktree\0tab-b' }) expect(rowProps('u2').turnStatus).toMatchObject({ startedAt: 12_000 }) } finally { vi.useRealTimers() } }) it('does not treat pre-user history as part of the live turn', async () => { const history = [ assistantTurn('a0', 'before the first prompt'), userTurn('u1', 'go'), assistantTurn('a1', 'working') ] await render({ messages: history, folded: history, structuredActivityUi: true, agentWorking: true }) expect(rowProps('a0').activeTurnIsWorking).toBe(false) expect(rowProps('a1').activeTurnIsWorking).toBe(true) }) }) })