diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 3bd06a8771b..35e01981fef 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -290,10 +290,10 @@ "oracle": "After acknowledged phone replies with stale host status, the exact card disappears and Send is enabled; unknown, refused or unverifiable-settlement replies retain enabled choices, while an older host's accepted reply dismisses like an acknowledged one; a same-occurrence view toggle retains pending acknowledgment and real replacements discard it. A slow 32 KiB SSH answer completes all chunks before Enter; cancellation preserves issued bytes but omits remaining groups. Paired settlement invokes the actual provider and lost callbacks remain unknown; older hosts deliver ordinary messages, launch and selector groups once and their accepted answers dismiss as before. An answered card stays hidden across a view remount; a collapsed card is a strip that frees the composer and expands back; an identical question in a new wait shows again; a composer send caught by a new card keeps its bubble without submitting or clearing the line under it.", "commands": [ "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.test.ts src/renderer/src/runtime/runtime-terminal-verified-input.test.ts src/renderer/src/components/native-chat/NativeChatComposer.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts src/main/runtime/runtime-terminal-writer.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts tests/e2e/native-chat-ssh-delivery.unit.test.ts --maxWorkers=2", - "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/mobile-native-chat-permission-send.test.ts mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/use-mobile-native-chat-answer-send.test.ts mobile/src/session/use-mobile-native-chat-message-send.test.ts mobile/src/session/mobile-native-chat-send.test.ts mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/MobileNativeChatPromptCard.test.tsx --maxWorkers=2", + "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.stop.test.ts mobile/src/session/MobileNativeChatView.failure-banner.test.ts mobile/src/session/MobileNativeChatView.rows.test.ts mobile/src/session/MobileNativeChatView.composer-lock.test.ts mobile/src/session/MobileNativeChatView.tail-follow.test.ts mobile/src/session/MobileNativeChatView.history-paging.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/mobile-native-chat-permission-send.test.ts mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/use-mobile-native-chat-answer-send.test.ts mobile/src/session/use-mobile-native-chat-message-send.test.ts mobile/src/session/mobile-native-chat-send.test.ts mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/MobileNativeChatPromptCard.test.tsx --maxWorkers=2", "ORCA_BACKGROUND_LAUNCH=1 pnpm test tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts --maxWorkers=1", "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts --maxWorkers=2", - "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts --maxWorkers=2" + "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.stop.test.ts mobile/src/session/MobileNativeChatView.failure-banner.test.ts mobile/src/session/MobileNativeChatView.rows.test.ts mobile/src/session/MobileNativeChatView.composer-lock.test.ts mobile/src/session/MobileNativeChatView.tail-follow.test.ts mobile/src/session/MobileNativeChatView.history-paging.test.ts --maxWorkers=2" ], "testFiles": [ "src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx", @@ -327,7 +327,12 @@ "mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts", "mobile/src/session/use-mobile-native-chat-controller.test.ts", "mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts", - "mobile/src/session/MobileNativeChatView.test.ts", + "mobile/src/session/MobileNativeChatView.stop.test.ts", + "mobile/src/session/MobileNativeChatView.failure-banner.test.ts", + "mobile/src/session/MobileNativeChatView.rows.test.ts", + "mobile/src/session/MobileNativeChatView.composer-lock.test.ts", + "mobile/src/session/MobileNativeChatView.tail-follow.test.ts", + "mobile/src/session/MobileNativeChatView.history-paging.test.ts", "mobile/src/session/mobile-prompt-controller.test.ts", "mobile/src/session/mobile-native-chat-permission-send.test.ts", "mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts", @@ -429,7 +434,7 @@ "date": "2026-10-05", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts --maxWorkers=2", + "command": "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.stop.test.ts mobile/src/session/MobileNativeChatView.failure-banner.test.ts mobile/src/session/MobileNativeChatView.rows.test.ts mobile/src/session/MobileNativeChatView.composer-lock.test.ts mobile/src/session/MobileNativeChatView.tail-follow.test.ts mobile/src/session/MobileNativeChatView.history-paging.test.ts --maxWorkers=2", "result": "passed", "durationSeconds": 10.32, "summary": "73 tests passed across four explicit files after the controller extraction, covering the production view/card/composer, occurrence dismissal, controller session scoping, unknown/refused replies and stopping active dictation." @@ -447,7 +452,7 @@ "date": "2026-10-06", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/mobile-native-chat-permission-send.test.ts mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/use-mobile-native-chat-answer-send.test.ts mobile/src/session/use-mobile-native-chat-message-send.test.ts mobile/src/session/mobile-native-chat-send.test.ts mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/MobileNativeChatPromptCard.test.tsx --maxWorkers=2", + "command": "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.stop.test.ts mobile/src/session/MobileNativeChatView.failure-banner.test.ts mobile/src/session/MobileNativeChatView.rows.test.ts mobile/src/session/MobileNativeChatView.composer-lock.test.ts mobile/src/session/MobileNativeChatView.tail-follow.test.ts mobile/src/session/MobileNativeChatView.history-paging.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/mobile-native-chat-permission-send.test.ts mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/use-mobile-native-chat-answer-send.test.ts mobile/src/session/use-mobile-native-chat-message-send.test.ts mobile/src/session/mobile-native-chat-send.test.ts mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/MobileNativeChatPromptCard.test.tsx --maxWorkers=2", "result": "passed", "durationSeconds": 1.19, "summary": "186 tests passed across 12 explicit files after the round-3 review fixes, including older-host answers dismissing like acknowledged ones, collapse that keeps the card mounted, one card per occurrence, wait-start question identity and only Send blocked." diff --git a/mobile/src/session/MobileNativeChatView.composer-lock.test.ts b/mobile/src/session/MobileNativeChatView.composer-lock.test.ts new file mode 100644 index 00000000000..c4a10df2f3a --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.composer-lock.test.ts @@ -0,0 +1,95 @@ +import { act } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import { + installChatViewHooks, + composer, + render, + update +} from './MobileNativeChatView.harness.test-fixture' + +describe('MobileNativeChatView: composer lock and prompt cards', () => { + installChatViewHooks() + + 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() + } + }) + + // Why: a terminal-backed send types into the agent's prompt and could answer it; drafting cannot. + describe('a prompt card owns Send in terminal-backed chat', () => { + const permission = { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] } + + it('blocks only Send while the card shows, and frees it when the card clears', async () => { + await render({ permission }) + expect(composer().props.sendDisabled).toBe(true) + expect(composer().props.disabled).toBe(false) + expect(composer().props.placeholder).toBe('Message, @files, /commands') + + await update({ permission: null }) + expect(composer().props.sendDisabled).toBe(false) + }) + + it('blocks Send for an ask and a heuristic question too', async () => { + await render({ + ask: { + questions: [{ question: 'Tabs?', multiSelect: false, options: [{ label: 'Tabs' }] }] + } + }) + expect(composer().props.sendDisabled).toBe(true) + await update({ + question: { question: 'Name?', options: [], multiSelect: false, optionTokens: [] } + }) + expect(composer().props.sendDisabled).toBe(true) + }) + + it('leaves a structured chat composer open: its host queues the send behind the prompt', async () => { + await render({ permission, structuredActivityUi: true }) + expect(composer().props.sendDisabled).toBe(false) + }) + }) +}) diff --git a/mobile/src/session/MobileNativeChatView.failure-banner.test.ts b/mobile/src/session/MobileNativeChatView.failure-banner.test.ts new file mode 100644 index 00000000000..2bcebade50a --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.failure-banner.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest' +import { + installChatViewHooks, + assistantTurn, + banners, + bannerText, + hostType, + listIds, + pressSend, + render, + root, + update +} from './MobileNativeChatView.harness.test-fixture' + +describe('MobileNativeChatView: send and read failure banners', () => { + installChatViewHooks() + + // A read no retry gets past takes the whole pane, even over a transcript already on screen: its + // words alone, and nothing that could only be refused again. + it('leaves only the words of a read that failed for good, over a loaded transcript', async () => { + const loaded = [assistantTurn('m1', 'earlier reply')] + const words = 'This chat was saved by a newer Orca. Update Orca to open it.' + const shown = (text: string) => + root().findAll((node) => hostType(node) === 'Text' && node.props.children === text) + const composers = () => root().findAll((node) => hostType(node) === 'Composer') + const lists = () => root().findAll((node) => hostType(node) === 'FlatList') + const failure = { + messages: loaded, + folded: loaded, + status: 'error' as const, + error: words, + canStop: true, + permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] }, + // A resend answered unknown says nothing beside the read's words. + sendErrorMessage: 'Message unconfirmed — check chat before retrying' + } + await render({ ...failure, readFailedFinally: true }) + expect(shown(words)).toHaveLength(1) + expect(composers()).toHaveLength(0) + expect(lists()).toHaveLength(0) + expect(banners()).toHaveLength(0) + expect(root().findAllByProps({ accessibilityLabel: 'Stop the agent' })).toHaveLength(0) + + // A failure that can clear keeps the transcript and the composer. + await update({ ...failure, error: "Orca couldn't open this chat's history right now." }) + expect(listIds()).toEqual(['m1']) + expect(composers()).toHaveLength(1) + expect(banners()).toHaveLength(1) + }) + + 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() + }) +}) diff --git a/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts b/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts new file mode 100644 index 00000000000..ba3f764c698 --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts @@ -0,0 +1,226 @@ +import { createElement, type ReactElement } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, expect, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MobileNativeChatView } from './MobileNativeChatView' + +const scrollToEnd = vi.hoisted(() => vi.fn()) +const scrollToOffset = vi.hoisted(() => vi.fn()) +const platform = vi.hoisted(() => ({ OS: 'ios' })) +const pageScroller = vi.hoisted(() => ({ scrollHeight: 0 })) + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + FlatList: React.forwardRef((props, ref) => { + const handle = { scrollToEnd, scrollToOffset, getScrollableNode: () => pageScroller } + React.useImperativeHandle(ref, () => handle, []) + return React.createElement('FlatList', props) + }), + Platform: platform, + 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('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +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') + }) + } +}) + +export 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 + turnIndicator?: Parameters[0]['turnIndicator'] + agentWorking?: boolean + canStop?: boolean + ask?: Parameters[0]['ask'] + question?: Parameters[0]['question'] + permission?: Parameters[0]['permission'] + sendSurfaceId?: string + keyboardInset?: number + hasMore?: boolean + onLoadEarlier?: () => void + status?: Parameters[0]['status'] + error?: string + readFailedFinally?: boolean + actionablePromptIds?: readonly string[] +} + +export 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, + getComposerEditGeneration: () => 0, + pending: [], + composerText: '', + onComposerTextChange: vi.fn(), + ...overrides + }) +} + +let renderer: ReactTestRenderer | null = null + +// Stubs the frame clock and tears down the mounted view; call once at the top of a test file. +export function installChatViewHooks(): void { + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => callback(0), 0) + ) + vi.stubGlobal('cancelAnimationFrame', (handle: ReturnType) => + clearTimeout(handle) + ) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + scrollToEnd.mockReset() + scrollToOffset.mockReset() + pageScroller.scrollHeight = 0 + vi.unstubAllGlobals() + }) +} + +export function root(): ReactTestInstance { + return renderer!.root +} + +// Mocked react-native components are string tags that ReactTestInstance's ElementType omits. +export function hostType(node: ReactTestInstance): unknown { + return node.type +} + +export async function render(overrides: Overrides = {}): Promise { + await act(async () => { + renderer = create(chatViewElement(overrides)) + }) +} + +export async function update(overrides: Overrides = {}): Promise { + await act(async () => { + renderer?.update(chatViewElement(overrides)) + }) +} + +// Ids of the rows the list is currently rendering. +export function listIds(): string[] { + return (list().props.data as { id: string }[]).map((row) => row.id) +} + +export function list(): ReactTestInstance { + return renderer!.root.find((node) => hostType(node) === 'FlatList') +} + +export function renderedRow(id: string): ReactElement> { + const listNode = list() + const data = listNode.props.data as NativeChatMessage[] + const index = data.findIndex((row) => row.id === id) + return listNode.props.renderItem({ item: data[index], index }) +} + +export function banners(): ReactTestInstance[] { + return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert') +} + +export function composer(): ReactTestInstance { + return renderer!.root.find((node) => hostType(node) === 'Composer') +} + +export function bannerText(): string { + const [alert, ...rest] = banners() + expect(rest).toHaveLength(0) + return alert + .findAll((node) => hostType(node) === 'Text') + .map((node) => node.props.children) + .join('') +} + +export async function pressSend(): Promise { + const composer = renderer!.root.find((node) => hostType(node) === 'Composer') + await act(async () => { + await composer.props.onPress() + }) +} + +export async function scrollAwayFromTail(): Promise { + await act(async () => { + list().props.onScrollBeginDrag?.({}) + list().props.onScroll({ + nativeEvent: { + contentOffset: { y: 200 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + }) +} + +// Vitest rejects exporting vi.hoisted bindings directly, so they are exported by name here. +export { scrollToEnd, scrollToOffset, platform, pageScroller } diff --git a/mobile/src/session/MobileNativeChatView.history-paging.test.ts b/mobile/src/session/MobileNativeChatView.history-paging.test.ts new file mode 100644 index 00000000000..f8533544664 --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.history-paging.test.ts @@ -0,0 +1,190 @@ +import { act } from 'react-test-renderer' +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { + installChatViewHooks, + assistantTurn, + list, + pageScroller, + platform, + render, + root, + scrollToEnd, + scrollToOffset, + update +} from './MobileNativeChatView.harness.test-fixture' + +describe('MobileNativeChatView: history paging and anchoring', () => { + installChatViewHooks() + + it('stops tail-follow before loading earlier history can resize the list', async () => { + const folded = [assistantTurn('a1', 'History')] + const onLoadEarlier = vi.fn() + await render({ folded, hasMore: true, onLoadEarlier }) + scrollToEnd.mockClear() + + act(() => { + list().props.onScrollBeginDrag?.({}) + list().props.onScroll({ + nativeEvent: { + contentOffset: { y: 40 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onContentSizeChange(320, 950) + }) + + expect(onLoadEarlier).toHaveBeenCalledOnce() + expect(scrollToEnd).not.toHaveBeenCalled() + expect(scrollToOffset).not.toHaveBeenCalled() + }) + + it('resumes following when the drag that paged in history ends at the tail', async () => { + vi.useFakeTimers() + try { + // A chat shorter than the screen: its top and its tail are the same place. + const shortChat = { + contentOffset: { y: 0 }, + contentSize: { height: 400 }, + layoutMeasurement: { height: 500 } + } + const folded = [assistantTurn('a1', 'Short history')] + const onLoadEarlier = vi.fn() + await render({ folded, hasMore: true, onLoadEarlier }) + + act(() => { + list().props.onScrollBeginDrag?.({}) + list().props.onScroll({ nativeEvent: shortChat }) + list().props.onScrollEndDrag?.({ nativeEvent: shortChat }) + }) + await act(async () => { + vi.advanceTimersByTime(200) + }) + act(() => list().props.onContentSizeChange(320, 950)) + + expect(onLoadEarlier).toHaveBeenCalledOnce() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 950 }) + expect( + root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') + ).toHaveLength(0) + } finally { + vi.useRealTimers() + } + }) + + it('pages in history only from a scroll the reader made', async () => { + const onLoadEarlier = vi.fn() + await render({ folded: [assistantTurn('a1', 'Latest')], hasMore: true, onLoadEarlier }) + const nearTop = { + nativeEvent: { + contentOffset: { y: 40 }, + contentSize: { height: 520 }, + layoutMeasurement: { height: 500 } + } + } + + // The tail pin of a chat barely taller than the screen reports an offset near the top too. + act(() => list().props.onScroll(nearTop)) + expect(onLoadEarlier).not.toHaveBeenCalled() + + act(() => { + list().props.onScrollBeginDrag?.({}) + list().props.onScroll(nearTop) + }) + expect(onLoadEarlier).toHaveBeenCalledOnce() + }) + + it('anchors the first visible row, which the header above it is not', async () => { + const folded = [assistantTurn('a1', 'History')] + await render({ folded, hasMore: true }) + expect(list().props.maintainVisibleContentPosition).toEqual({ minIndexForVisible: 1 }) + + await update({ folded, hasMore: false }) + expect(list().props.maintainVisibleContentPosition).toEqual({ minIndexForVisible: 0 }) + }) + + describe('on the page, where the list reports no drag or momentum', () => { + beforeEach(() => { + platform.OS = 'web' + }) + + afterEach(() => { + platform.OS = 'ios' + }) + + function scrolledTo(y: number, contentHeight: number) { + return { + nativeEvent: { + contentOffset: { y }, + contentSize: { height: contentHeight }, + layoutMeasurement: { height: 500 } + } + } + } + + it('stops following when the reader scrolls up, and resumes at the end', async () => { + await render({ folded: [assistantTurn('a1', 'Latest')] }) + act(() => { + list().props.onContentSizeChange(320, 1_000) + list().props.onScroll(scrolledTo(500, 1_000)) + }) + scrollToOffset.mockClear() + + // No drag event arrives on the page: the move up is all there is to go on. + act(() => { + list().props.onScroll(scrolledTo(300, 1_000)) + list().props.onContentSizeChange(320, 1_200) + }) + expect(scrollToOffset).not.toHaveBeenCalled() + expect( + root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') + ).toHaveLength(1) + + act(() => { + list().props.onScroll(scrolledTo(700, 1_200)) + list().props.onContentSizeChange(320, 1_400) + }) + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_400 }) + }) + + it('does not take its own pin, landing short of a grown end, for the reader', async () => { + await render({ folded: [assistantTurn('a1', 'Latest')] }) + act(() => { + list().props.onContentSizeChange(320, 1_000) + list().props.onScroll(scrolledTo(500, 1_000)) + }) + scrollToOffset.mockClear() + + // The pin reached the end it was sent to; the reply had grown 400 past it by then. + act(() => { + list().props.onScroll(scrolledTo(700, 1_600)) + list().props.onContentSizeChange(320, 1_600) + }) + + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_600 }) + }) + + it("opens another chat at its measured end, not the last chat's", async () => { + await render({ folded: [assistantTurn('a1', 'Chat A')] }) + act(() => list().props.onContentSizeChange(320, 1_000)) + pageScroller.scrollHeight = 2_500 + scrollToEnd.mockClear() + + await update({ folded: [assistantTurn('b1', 'Chat B')], sendSurfaceId: 'tab-b' }) + + expect(scrollToEnd).not.toHaveBeenCalled() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 2_500 }) + }) + + it('pages in history when the reader scrolls up to the top, not when a pin passes it', async () => { + const onLoadEarlier = vi.fn() + await render({ folded: [assistantTurn('a1', 'Latest')], hasMore: true, onLoadEarlier }) + + act(() => list().props.onScroll(scrolledTo(40, 540))) + expect(onLoadEarlier).not.toHaveBeenCalled() + + act(() => list().props.onScroll(scrolledTo(10, 540))) + expect(onLoadEarlier).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/mobile/src/session/MobileNativeChatView.rows.test.ts b/mobile/src/session/MobileNativeChatView.rows.test.ts new file mode 100644 index 00000000000..db2dc5bf15d --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.rows.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { + installChatViewHooks, + assistantTurn, + listIds, + render, + renderedRow, + update +} from './MobileNativeChatView.harness.test-fixture' + +describe('MobileNativeChatView: rows and the streaming bubble', () => { + installChatViewHooks() + + // 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('tells a row whether the host still waits on the prompt it stands for', async () => { + const rows = [assistantTurn('asked', 'Tea or coffee?'), assistantTurn('open', 'Which branch?')] + await render({ messages: rows, folded: rows, actionablePromptIds: ['open'] }) + expect(renderedRow('asked').props.awaiting).toBe(false) + expect(renderedRow('open').props.awaiting).toBe(true) + }) + + 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') + }) +}) diff --git a/mobile/src/session/MobileNativeChatView.stop.test.ts b/mobile/src/session/MobileNativeChatView.stop.test.ts new file mode 100644 index 00000000000..d5c919dd91b --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.stop.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + installChatViewHooks, + hostType, + render, + root, + update +} from './MobileNativeChatView.harness.test-fixture' + +describe('MobileNativeChatView: Stop control', () => { + installChatViewHooks() + + it("holds Stop and says Stopping while this phone's own Stop request is in flight", async () => { + await render({ + structuredActivityUi: true, + agentWorking: true, + canStop: true, + turnIndicator: { + thinking: false, + activityText: null, + stopping: true, + stopRequestInFlight: true + } + }) + const stop = root().find( + (node) => hostType(node) === 'Pressable' && node.props.accessibilityLabel === 'Stopping…' + ) + expect(stop.props.disabled).toBe(true) + }) + + it.each([ + ['queue', 'Queue a message to run after the stop'], + ['send', 'Send a message to run after the stop'] + ] as const)( + 'tells the composer a message sent now runs after the stop (%s)', + async (afterStop, placeholder) => { + await render({ + structuredActivityUi: true, + agentWorking: true, + canStop: true, + turnIndicator: { thinking: false, activityText: null, stopping: true, afterStop } + }) + const composer = root().find((node) => hostType(node) === 'Composer') + expect(composer.props.placeholder).toBe(placeholder) + } + ) + + // A Stop the provider took and never answered ends only at a repeat Stop. + it('keeps Stop for the repeat that escalates while the host alone says Stopping', async () => { + await render({ + structuredActivityUi: true, + agentWorking: true, + canStop: true, + turnIndicator: { thinking: false, activityText: null, stopping: true } + }) + const stop = root().find( + (node) => hostType(node) === 'Pressable' && node.props.accessibilityLabel === 'Stop the agent' + ) + expect(stop.props.disabled).toBe(false) + }) + + it('keeps Stop hidden during a structured dispatch until a provider turn can be cancelled', async () => { + const props = { structuredActivityUi: true, agentWorking: true, canStop: false } + await render(props) + const stops = () => root().findAll((node) => node.props.accessibilityLabel === 'Stop the agent') + expect(stops()).toHaveLength(0) + await update({ ...props, canStop: true }) + expect(stops()).toHaveLength(1) + await update({ agentWorking: true }) + expect(stops()).toHaveLength(1) + }) +}) diff --git a/mobile/src/session/MobileNativeChatView.tail-follow.test.ts b/mobile/src/session/MobileNativeChatView.tail-follow.test.ts new file mode 100644 index 00000000000..b5ce104c7c2 --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.tail-follow.test.ts @@ -0,0 +1,332 @@ +import { act } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import { + installChatViewHooks, + assistantTurn, + list, + pressSend, + render, + root, + scrollAwayFromTail, + scrollToEnd, + scrollToOffset, + update +} from './MobileNativeChatView.harness.test-fixture' + +describe('MobileNativeChatView: tail-follow', () => { + installChatViewHooks() + + it('lets content growth own streaming tail-follow without a delayed animated command', async () => { + vi.useFakeTimers() + try { + const folded = [assistantTurn('a1', 'Starting')] + await render({ folded }) + await act(async () => { + vi.runOnlyPendingTimers() + }) + scrollToEnd.mockClear() + + await update({ folded, streaming: 'Streaming output' }) + act(() => list().props.onContentSizeChange(320, 900)) + + expect(scrollToOffset).toHaveBeenCalledOnce() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 900 }) + await act(async () => { + vi.advanceTimersByTime(60) + }) + expect(scrollToOffset).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + + it('does not treat programmatic scroll metrics as user intent', async () => { + const folded = [assistantTurn('a1', 'Latest')] + await render({ folded }) + scrollToEnd.mockClear() + + act(() => { + list().props.onScroll({ + nativeEvent: { + contentOffset: { y: 200 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onContentSizeChange(320, 1_300) + }) + + expect(scrollToOffset).toHaveBeenCalledOnce() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_300 }) + expect( + root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') + ).toHaveLength(0) + }) + + it('keeps tail-follow paused across the drag-to-momentum handoff', async () => { + vi.useFakeTimers() + try { + const folded = [assistantTurn('a1', 'Latest')] + await render({ folded }) + scrollToEnd.mockClear() + + act(() => list().props.onScrollBeginDrag?.({})) + expect( + root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') + ).toHaveLength(0) + + act(() => { + list().props.onScroll({ + nativeEvent: { + contentOffset: { y: 700 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onScrollEndDrag?.({ + nativeEvent: { + contentOffset: { y: 700 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onContentSizeChange(320, 1_250) + list().props.onMomentumScrollBegin?.({}) + }) + await act(async () => { + vi.advanceTimersByTime(200) + }) + act(() => list().props.onContentSizeChange(320, 1_300)) + + expect(scrollToEnd).not.toHaveBeenCalled() + expect(scrollToOffset).not.toHaveBeenCalled() + + act(() => { + list().props.onMomentumScrollEnd?.({ + nativeEvent: { + contentOffset: { y: 800 }, + contentSize: { height: 1_300 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onContentSizeChange(320, 1_350) + }) + expect(scrollToEnd).toHaveBeenCalledOnce() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_350 }) + } finally { + vi.useRealTimers() + } + }) + + it('uses momentum-end metrics instead of a stale throttled scroll sample', async () => { + const folded = [assistantTurn('a1', 'Latest')] + await render({ folded }) + scrollToEnd.mockClear() + + act(() => { + list().props.onScrollBeginDrag?.({}) + list().props.onScroll({ + nativeEvent: { + contentOffset: { y: 700 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onScrollEndDrag?.({ + nativeEvent: { + contentOffset: { y: 700 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onMomentumScrollBegin?.({}) + list().props.onMomentumScrollEnd?.({ + nativeEvent: { + contentOffset: { y: 200 }, + contentSize: { height: 1_300 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onContentSizeChange(320, 1_350) + }) + + expect(scrollToEnd).not.toHaveBeenCalled() + expect(scrollToOffset).not.toHaveBeenCalled() + expect( + root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') + ).toHaveLength(1) + }) + + it('uses finger-release metrics when no momentum event follows', async () => { + vi.useFakeTimers() + try { + const folded = [assistantTurn('a1', 'Latest')] + await render({ folded }) + scrollToEnd.mockClear() + + act(() => { + list().props.onScrollBeginDrag?.({}) + list().props.onScroll({ + nativeEvent: { + contentOffset: { y: 200 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onScrollEndDrag?.({ + nativeEvent: { + contentOffset: { y: 620 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + }) + await act(async () => { + vi.advanceTimersByTime(200) + }) + act(() => list().props.onContentSizeChange(320, 1_250)) + + expect(scrollToEnd).toHaveBeenCalledOnce() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_250 }) + } finally { + vi.useRealTimers() + } + }) + + it('repins when content grows between tail release and drag settle', async () => { + vi.useFakeTimers() + try { + const folded = [assistantTurn('a1', 'Latest')] + await render({ folded }) + scrollToEnd.mockClear() + + act(() => { + list().props.onScrollBeginDrag?.({}) + list().props.onScrollEndDrag?.({ + nativeEvent: { + contentOffset: { y: 700 }, + contentSize: { height: 1_200 }, + layoutMeasurement: { height: 500 } + } + }) + list().props.onContentSizeChange(320, 1_250) + }) + + expect(scrollToEnd).not.toHaveBeenCalled() + expect(scrollToOffset).not.toHaveBeenCalled() + + await act(async () => { + vi.runOnlyPendingTimers() + }) + + expect(scrollToEnd).toHaveBeenCalledOnce() + expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false }) + expect( + root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') + ).toHaveLength(0) + } finally { + vi.useRealTimers() + } + }) + + it('uses measured content height when the first message fills an empty transcript', async () => { + await render() + + await pressSend() + expect(scrollToEnd).not.toHaveBeenCalled() + + await update({ folded: [assistantTurn('a1', 'First response')] }) + act(() => list().props.onContentSizeChange(320, 1_200)) + + expect(scrollToOffset).toHaveBeenCalledOnce() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_200 }) + }) + + it('routes an accepted send through the immediate nonanimated tail owner', async () => { + vi.useFakeTimers() + try { + const folded = [assistantTurn('a1', 'History')] + await render({ folded }) + await act(async () => { + vi.runOnlyPendingTimers() + }) + await scrollAwayFromTail() + scrollToEnd.mockClear() + + await pressSend() + + expect(scrollToEnd).toHaveBeenCalledOnce() + expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false }) + await act(async () => { + vi.advanceTimersByTime(60) + }) + expect(scrollToEnd).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + + it('routes the latest-message chevron through the tail owner and resumes following', async () => { + const folded = [assistantTurn('a1', 'History')] + await render({ folded }) + await scrollAwayFromTail() + scrollToEnd.mockClear() + + const chevron = root().find((node) => node.props.accessibilityLabel === 'Scroll to latest') + act(() => chevron.props.onPress()) + expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false }) + + act(() => list().props.onContentSizeChange(320, 1_300)) + expect(scrollToEnd).toHaveBeenCalledOnce() + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_300 }) + }) + + // The view stays mounted across chat tabs, so the reader's place in one must not carry over. + it('opens another chat at its newest message, following, keeping the Tools toggle', async () => { + await render({ folded: [assistantTurn('a1', 'Chat A')] }) + await scrollAwayFromTail() + const label = (text: string) => root().findAllByProps({ children: text }) + const jumpControls = () => root().findAllByProps({ accessibilityLabel: 'Scroll to latest' }) + act(() => label('Tools')[0].parent!.props.onPress()) + expect(jumpControls()).toHaveLength(1) + scrollToEnd.mockClear() + + await update({ folded: [assistantTurn('b1', 'Chat B')], sendSurfaceId: 'tab-b' }) + + expect(scrollToEnd).toHaveBeenCalledWith({ animated: false }) + expect(jumpControls()).toHaveLength(0) + act(() => list().props.onContentSizeChange(320, 900)) + expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 900 }) + expect(label('Collapse')).toHaveLength(1) + }) + + it('repins after a keyboard-driven viewport layout only while following', async () => { + vi.useFakeTimers() + try { + const folded = [assistantTurn('a1', 'Latest')] + await render({ folded }) + await act(async () => { + vi.runOnlyPendingTimers() + }) + scrollToEnd.mockClear() + + await update({ folded, keyboardInset: 320 }) + act(() => list().props.onLayout?.({ nativeEvent: { layout: { height: 400 } } })) + + expect(scrollToEnd).toHaveBeenCalledOnce() + expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false }) + await act(async () => { + vi.advanceTimersByTime(60) + }) + expect(scrollToEnd).toHaveBeenCalledOnce() + + await scrollAwayFromTail() + scrollToEnd.mockClear() + await update({ folded, keyboardInset: 0 }) + act(() => list().props.onLayout?.({ nativeEvent: { layout: { height: 700 } } })) + expect(scrollToEnd).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts deleted file mode 100644 index 2c235337bb8..00000000000 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ /dev/null @@ -1,939 +0,0 @@ -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' - -const scrollToEnd = vi.hoisted(() => vi.fn()) -const scrollToOffset = vi.hoisted(() => vi.fn()) -const platform = vi.hoisted(() => ({ OS: 'ios' })) -const pageScroller = vi.hoisted(() => ({ scrollHeight: 0 })) - -vi.mock('react-native', async () => { - const React = await import('react') - return { - ActivityIndicator: 'ActivityIndicator', - FlatList: React.forwardRef((props, ref) => { - const handle = { scrollToEnd, scrollToOffset, getScrollableNode: () => pageScroller } - React.useImperativeHandle(ref, () => handle, []) - return React.createElement('FlatList', props) - }), - Platform: platform, - 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('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) -vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) -vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) -vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) -vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) -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 - turnIndicator?: Parameters[0]['turnIndicator'] - agentWorking?: boolean - canStop?: boolean - ask?: Parameters[0]['ask'] - question?: Parameters[0]['question'] - permission?: Parameters[0]['permission'] - sendSurfaceId?: string - keyboardInset?: number - hasMore?: boolean - onLoadEarlier?: () => void - status?: Parameters[0]['status'] - error?: string - readFailedFinally?: boolean - actionablePromptIds?: readonly 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 - - beforeEach(() => { - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => - setTimeout(() => callback(0), 0) - ) - vi.stubGlobal('cancelAnimationFrame', (handle: ReturnType) => - clearTimeout(handle) - ) - }) - - afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - scrollToEnd.mockReset() - scrollToOffset.mockReset() - pageScroller.scrollHeight = 0 - vi.unstubAllGlobals() - }) - - 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)) - }) - } - - it("holds Stop and says Stopping while this phone's own Stop request is in flight", async () => { - await render({ - structuredActivityUi: true, - agentWorking: true, - canStop: true, - turnIndicator: { - thinking: false, - activityText: null, - stopping: true, - stopRequestInFlight: true - } - }) - const stop = renderer!.root.find( - (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Stopping…' - ) - expect(stop.props.disabled).toBe(true) - }) - - it.each([ - ['queue', 'Queue a message to run after the stop'], - ['send', 'Send a message to run after the stop'] - ] as const)( - 'tells the composer a message sent now runs after the stop (%s)', - async (afterStop, placeholder) => { - await render({ - structuredActivityUi: true, - agentWorking: true, - canStop: true, - turnIndicator: { thinking: false, activityText: null, stopping: true, afterStop } - }) - const composer = renderer!.root.find((node) => node.type === 'Composer') - expect(composer.props.placeholder).toBe(placeholder) - } - ) - - // A Stop the provider took and never answered ends only at a repeat Stop. - it('keeps Stop for the repeat that escalates while the host alone says Stopping', async () => { - await render({ - structuredActivityUi: true, - agentWorking: true, - canStop: true, - turnIndicator: { thinking: false, activityText: null, stopping: true } - }) - const stop = renderer!.root.find( - (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Stop the agent' - ) - expect(stop.props.disabled).toBe(false) - }) - - /** Ids of the rows the list is currently rendering. */ - it('keeps Stop hidden during a structured dispatch until a provider turn can be cancelled', async () => { - const props = { structuredActivityUi: true, agentWorking: true, canStop: false } - await render(props) - const stops = () => - renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Stop the agent') - expect(stops()).toHaveLength(0) - await update({ ...props, canStop: true }) - expect(stops()).toHaveLength(1) - await update({ agentWorking: true }) - expect(stops()).toHaveLength(1) - }) - - function listIds(): string[] { - return (list().props.data as { id: string }[]).map((row) => row.id) - } - - function list(): ReactTestInstance { - return renderer!.root.find((node) => node.type === 'FlatList') - } - - function renderedRow(id: string): ReturnType { - const listNode = list() - const data = listNode.props.data as NativeChatMessage[] - const index = data.findIndex((row) => row.id === id) - return listNode.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() - }) - } - - async function scrollAwayFromTail(): Promise { - await act(async () => { - list().props.onScrollBeginDrag?.({}) - list().props.onScroll({ - nativeEvent: { - contentOffset: { y: 200 }, - contentSize: { height: 1_200 }, - layoutMeasurement: { height: 500 } - } - }) - }) - } - - // A read no retry gets past takes the whole pane, even over a transcript already on screen: its - // words alone, and nothing that could only be refused again. - it('leaves only the words of a read that failed for good, over a loaded transcript', async () => { - const loaded = [assistantTurn('m1', 'earlier reply')] - const words = 'This chat was saved by a newer Orca. Update Orca to open it.' - const shown = (text: string) => - renderer!.root.findAll((node) => node.type === 'Text' && node.props.children === text) - const composers = () => renderer!.root.findAll((node) => node.type === 'Composer') - const lists = () => renderer!.root.findAll((node) => node.type === 'FlatList') - const failure = { - messages: loaded, - folded: loaded, - status: 'error' as const, - error: words, - canStop: true, - permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] }, - // A resend answered unknown says nothing beside the read's words. - sendErrorMessage: 'Message unconfirmed — check chat before retrying' - } - await render({ ...failure, readFailedFinally: true }) - expect(shown(words)).toHaveLength(1) - expect(composers()).toHaveLength(0) - expect(lists()).toHaveLength(0) - expect(banners()).toHaveLength(0) - expect(renderer!.root.findAllByProps({ accessibilityLabel: 'Stop the agent' })).toHaveLength(0) - - // A failure that can clear keeps the transcript and the composer. - await update({ ...failure, error: "Orca couldn't open this chat's history right now." }) - expect(listIds()).toEqual(['m1']) - expect(composers()).toHaveLength(1) - expect(banners()).toHaveLength(1) - }) - - 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('lets content growth own streaming tail-follow without a delayed animated command', async () => { - vi.useFakeTimers() - try { - const folded = [assistantTurn('a1', 'Starting')] - await render({ folded }) - await act(async () => { - vi.runOnlyPendingTimers() - }) - scrollToEnd.mockClear() - - await update({ folded, streaming: 'Streaming output' }) - act(() => list().props.onContentSizeChange(320, 900)) - - expect(scrollToOffset).toHaveBeenCalledOnce() - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 900 }) - await act(async () => { - vi.advanceTimersByTime(60) - }) - expect(scrollToOffset).toHaveBeenCalledOnce() - } finally { - vi.useRealTimers() - } - }) - - it('stops tail-follow before loading earlier history can resize the list', async () => { - const folded = [assistantTurn('a1', 'History')] - const onLoadEarlier = vi.fn() - await render({ folded, hasMore: true, onLoadEarlier }) - scrollToEnd.mockClear() - - act(() => { - list().props.onScrollBeginDrag?.({}) - list().props.onScroll({ - nativeEvent: { - contentOffset: { y: 40 }, - contentSize: { height: 1_200 }, - layoutMeasurement: { height: 500 } - } - }) - list().props.onContentSizeChange(320, 950) - }) - - expect(onLoadEarlier).toHaveBeenCalledOnce() - expect(scrollToEnd).not.toHaveBeenCalled() - expect(scrollToOffset).not.toHaveBeenCalled() - }) - - it('does not treat programmatic scroll metrics as user intent', async () => { - const folded = [assistantTurn('a1', 'Latest')] - await render({ folded }) - scrollToEnd.mockClear() - - act(() => { - list().props.onScroll({ - nativeEvent: { - contentOffset: { y: 200 }, - contentSize: { height: 1_200 }, - layoutMeasurement: { height: 500 } - } - }) - list().props.onContentSizeChange(320, 1_300) - }) - - expect(scrollToOffset).toHaveBeenCalledOnce() - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_300 }) - expect( - renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') - ).toHaveLength(0) - }) - - it('keeps tail-follow paused across the drag-to-momentum handoff', async () => { - vi.useFakeTimers() - try { - const folded = [assistantTurn('a1', 'Latest')] - await render({ folded }) - scrollToEnd.mockClear() - - act(() => list().props.onScrollBeginDrag?.({})) - expect( - renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') - ).toHaveLength(0) - - act(() => { - list().props.onScroll({ - nativeEvent: { - contentOffset: { y: 700 }, - contentSize: { height: 1_200 }, - layoutMeasurement: { height: 500 } - } - }) - list().props.onScrollEndDrag?.({ - nativeEvent: { - contentOffset: { y: 700 }, - contentSize: { height: 1_200 }, - layoutMeasurement: { height: 500 } - } - }) - list().props.onContentSizeChange(320, 1_250) - list().props.onMomentumScrollBegin?.({}) - }) - await act(async () => { - vi.advanceTimersByTime(200) - }) - act(() => list().props.onContentSizeChange(320, 1_300)) - - expect(scrollToEnd).not.toHaveBeenCalled() - expect(scrollToOffset).not.toHaveBeenCalled() - - act(() => { - list().props.onMomentumScrollEnd?.({ - nativeEvent: { - contentOffset: { y: 800 }, - contentSize: { height: 1_300 }, - layoutMeasurement: { height: 500 } - } - }) - list().props.onContentSizeChange(320, 1_350) - }) - expect(scrollToEnd).toHaveBeenCalledOnce() - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_350 }) - } finally { - vi.useRealTimers() - } - }) - - it('uses momentum-end metrics instead of a stale throttled scroll sample', async () => { - const folded = [assistantTurn('a1', 'Latest')] - await render({ folded }) - scrollToEnd.mockClear() - - act(() => { - list().props.onScrollBeginDrag?.({}) - list().props.onScroll({ - nativeEvent: { - contentOffset: { y: 700 }, - contentSize: { height: 1_200 }, - layoutMeasurement: { height: 500 } - } - }) - list().props.onScrollEndDrag?.({ - nativeEvent: { - contentOffset: { y: 700 }, - contentSize: { height: 1_200 }, - layoutMeasurement: { height: 500 } - } - }) - list().props.onMomentumScrollBegin?.({}) - list().props.onMomentumScrollEnd?.({ - nativeEvent: { - contentOffset: { y: 200 }, - contentSize: { height: 1_300 }, - layoutMeasurement: { height: 500 } - } - }) - list().props.onContentSizeChange(320, 1_350) - }) - - expect(scrollToEnd).not.toHaveBeenCalled() - expect(scrollToOffset).not.toHaveBeenCalled() - expect( - renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') - ).toHaveLength(1) - }) - - it('uses finger-release metrics when no momentum event follows', async () => { - vi.useFakeTimers() - try { - const folded = [assistantTurn('a1', 'Latest')] - await render({ folded }) - scrollToEnd.mockClear() - - act(() => { - list().props.onScrollBeginDrag?.({}) - list().props.onScroll({ - nativeEvent: { - contentOffset: { y: 200 }, - contentSize: { height: 1_200 }, - layoutMeasurement: { height: 500 } - } - }) - list().props.onScrollEndDrag?.({ - nativeEvent: { - contentOffset: { y: 620 }, - contentSize: { height: 1_200 }, - layoutMeasurement: { height: 500 } - } - }) - }) - await act(async () => { - vi.advanceTimersByTime(200) - }) - act(() => list().props.onContentSizeChange(320, 1_250)) - - expect(scrollToEnd).toHaveBeenCalledOnce() - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_250 }) - } finally { - vi.useRealTimers() - } - }) - - it('repins when content grows between tail release and drag settle', async () => { - vi.useFakeTimers() - try { - const folded = [assistantTurn('a1', 'Latest')] - await render({ folded }) - scrollToEnd.mockClear() - - act(() => { - list().props.onScrollBeginDrag?.({}) - list().props.onScrollEndDrag?.({ - nativeEvent: { - contentOffset: { y: 700 }, - contentSize: { height: 1_200 }, - layoutMeasurement: { height: 500 } - } - }) - list().props.onContentSizeChange(320, 1_250) - }) - - expect(scrollToEnd).not.toHaveBeenCalled() - expect(scrollToOffset).not.toHaveBeenCalled() - - await act(async () => { - vi.runOnlyPendingTimers() - }) - - expect(scrollToEnd).toHaveBeenCalledOnce() - expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false }) - expect( - renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') - ).toHaveLength(0) - } finally { - vi.useRealTimers() - } - }) - - it('uses measured content height when the first message fills an empty transcript', async () => { - await render() - - await pressSend() - expect(scrollToEnd).not.toHaveBeenCalled() - - await update({ folded: [assistantTurn('a1', 'First response')] }) - act(() => list().props.onContentSizeChange(320, 1_200)) - - expect(scrollToOffset).toHaveBeenCalledOnce() - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_200 }) - }) - - it('resumes following when the drag that paged in history ends at the tail', async () => { - vi.useFakeTimers() - try { - // A chat shorter than the screen: its top and its tail are the same place. - const shortChat = { - contentOffset: { y: 0 }, - contentSize: { height: 400 }, - layoutMeasurement: { height: 500 } - } - const folded = [assistantTurn('a1', 'Short history')] - const onLoadEarlier = vi.fn() - await render({ folded, hasMore: true, onLoadEarlier }) - - act(() => { - list().props.onScrollBeginDrag?.({}) - list().props.onScroll({ nativeEvent: shortChat }) - list().props.onScrollEndDrag?.({ nativeEvent: shortChat }) - }) - await act(async () => { - vi.advanceTimersByTime(200) - }) - act(() => list().props.onContentSizeChange(320, 950)) - - expect(onLoadEarlier).toHaveBeenCalledOnce() - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 950 }) - expect( - renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') - ).toHaveLength(0) - } finally { - vi.useRealTimers() - } - }) - - it('pages in history only from a scroll the reader made', async () => { - const onLoadEarlier = vi.fn() - await render({ folded: [assistantTurn('a1', 'Latest')], hasMore: true, onLoadEarlier }) - const nearTop = { - nativeEvent: { - contentOffset: { y: 40 }, - contentSize: { height: 520 }, - layoutMeasurement: { height: 500 } - } - } - - // The tail pin of a chat barely taller than the screen reports an offset near the top too. - act(() => list().props.onScroll(nearTop)) - expect(onLoadEarlier).not.toHaveBeenCalled() - - act(() => { - list().props.onScrollBeginDrag?.({}) - list().props.onScroll(nearTop) - }) - expect(onLoadEarlier).toHaveBeenCalledOnce() - }) - - it('anchors the first visible row, which the header above it is not', async () => { - const folded = [assistantTurn('a1', 'History')] - await render({ folded, hasMore: true }) - expect(list().props.maintainVisibleContentPosition).toEqual({ minIndexForVisible: 1 }) - - await update({ folded, hasMore: false }) - expect(list().props.maintainVisibleContentPosition).toEqual({ minIndexForVisible: 0 }) - }) - - describe('on the page, where the list reports no drag or momentum', () => { - beforeEach(() => { - platform.OS = 'web' - }) - - afterEach(() => { - platform.OS = 'ios' - }) - - function scrolledTo(y: number, contentHeight: number) { - return { - nativeEvent: { - contentOffset: { y }, - contentSize: { height: contentHeight }, - layoutMeasurement: { height: 500 } - } - } - } - - it('stops following when the reader scrolls up, and resumes at the end', async () => { - await render({ folded: [assistantTurn('a1', 'Latest')] }) - act(() => { - list().props.onContentSizeChange(320, 1_000) - list().props.onScroll(scrolledTo(500, 1_000)) - }) - scrollToOffset.mockClear() - - // No drag event arrives on the page: the move up is all there is to go on. - act(() => { - list().props.onScroll(scrolledTo(300, 1_000)) - list().props.onContentSizeChange(320, 1_200) - }) - expect(scrollToOffset).not.toHaveBeenCalled() - expect( - renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Scroll to latest') - ).toHaveLength(1) - - act(() => { - list().props.onScroll(scrolledTo(700, 1_200)) - list().props.onContentSizeChange(320, 1_400) - }) - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_400 }) - }) - - it('does not take its own pin, landing short of a grown end, for the reader', async () => { - await render({ folded: [assistantTurn('a1', 'Latest')] }) - act(() => { - list().props.onContentSizeChange(320, 1_000) - list().props.onScroll(scrolledTo(500, 1_000)) - }) - scrollToOffset.mockClear() - - // The pin reached the end it was sent to; the reply had grown 400 past it by then. - act(() => { - list().props.onScroll(scrolledTo(700, 1_600)) - list().props.onContentSizeChange(320, 1_600) - }) - - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_600 }) - }) - - it("opens another chat at its measured end, not the last chat's", async () => { - await render({ folded: [assistantTurn('a1', 'Chat A')] }) - act(() => list().props.onContentSizeChange(320, 1_000)) - pageScroller.scrollHeight = 2_500 - scrollToEnd.mockClear() - - await update({ folded: [assistantTurn('b1', 'Chat B')], sendSurfaceId: 'tab-b' }) - - expect(scrollToEnd).not.toHaveBeenCalled() - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 2_500 }) - }) - - it('pages in history when the reader scrolls up to the top, not when a pin passes it', async () => { - const onLoadEarlier = vi.fn() - await render({ folded: [assistantTurn('a1', 'Latest')], hasMore: true, onLoadEarlier }) - - act(() => list().props.onScroll(scrolledTo(40, 540))) - expect(onLoadEarlier).not.toHaveBeenCalled() - - act(() => list().props.onScroll(scrolledTo(10, 540))) - expect(onLoadEarlier).toHaveBeenCalledOnce() - }) - }) - - it('routes an accepted send through the immediate nonanimated tail owner', async () => { - vi.useFakeTimers() - try { - const folded = [assistantTurn('a1', 'History')] - await render({ folded }) - await act(async () => { - vi.runOnlyPendingTimers() - }) - await scrollAwayFromTail() - scrollToEnd.mockClear() - - await pressSend() - - expect(scrollToEnd).toHaveBeenCalledOnce() - expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false }) - await act(async () => { - vi.advanceTimersByTime(60) - }) - expect(scrollToEnd).toHaveBeenCalledOnce() - } finally { - vi.useRealTimers() - } - }) - - it('routes the latest-message chevron through the tail owner and resumes following', async () => { - const folded = [assistantTurn('a1', 'History')] - await render({ folded }) - await scrollAwayFromTail() - scrollToEnd.mockClear() - - const chevron = renderer!.root.find( - (node) => node.props.accessibilityLabel === 'Scroll to latest' - ) - act(() => chevron.props.onPress()) - expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false }) - - act(() => list().props.onContentSizeChange(320, 1_300)) - expect(scrollToEnd).toHaveBeenCalledOnce() - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_300 }) - }) - - // The view stays mounted across chat tabs, so the reader's place in one must not carry over. - it('opens another chat at its newest message, following, keeping the Tools toggle', async () => { - await render({ folded: [assistantTurn('a1', 'Chat A')] }) - await scrollAwayFromTail() - const label = (text: string) => renderer!.root.findAllByProps({ children: text }) - const jumpControls = () => - renderer!.root.findAllByProps({ accessibilityLabel: 'Scroll to latest' }) - act(() => label('Tools')[0].parent!.props.onPress()) - expect(jumpControls()).toHaveLength(1) - scrollToEnd.mockClear() - - await update({ folded: [assistantTurn('b1', 'Chat B')], sendSurfaceId: 'tab-b' }) - - expect(scrollToEnd).toHaveBeenCalledWith({ animated: false }) - expect(jumpControls()).toHaveLength(0) - act(() => list().props.onContentSizeChange(320, 900)) - expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 900 }) - expect(label('Collapse')).toHaveLength(1) - }) - - it('repins after a keyboard-driven viewport layout only while following', async () => { - vi.useFakeTimers() - try { - const folded = [assistantTurn('a1', 'Latest')] - await render({ folded }) - await act(async () => { - vi.runOnlyPendingTimers() - }) - scrollToEnd.mockClear() - - await update({ folded, keyboardInset: 320 }) - act(() => list().props.onLayout?.({ nativeEvent: { layout: { height: 400 } } })) - - expect(scrollToEnd).toHaveBeenCalledOnce() - expect(scrollToEnd).toHaveBeenLastCalledWith({ animated: false }) - await act(async () => { - vi.advanceTimersByTime(60) - }) - expect(scrollToEnd).toHaveBeenCalledOnce() - - await scrollAwayFromTail() - scrollToEnd.mockClear() - await update({ folded, keyboardInset: 0 }) - act(() => list().props.onLayout?.({ nativeEvent: { layout: { height: 700 } } })) - expect(scrollToEnd).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('tells a row whether the host still waits on the prompt it stands for', async () => { - const rows = [assistantTurn('asked', 'Tea or coffee?'), assistantTurn('open', 'Which branch?')] - await render({ messages: rows, folded: rows, actionablePromptIds: ['open'] }) - expect(renderedRow('asked').props.awaiting).toBe(false) - expect(renderedRow('open').props.awaiting).toBe(true) - }) - - 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() - } - }) - // Why: a terminal-backed send types into the agent's prompt and could answer it; drafting cannot. - describe('a prompt card owns Send in terminal-backed chat', () => { - const permission = { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] } - - it('blocks only Send while the card shows, and frees it when the card clears', async () => { - await render({ permission }) - expect(composer().props.sendDisabled).toBe(true) - expect(composer().props.disabled).toBe(false) - expect(composer().props.placeholder).toBe('Message, @files, /commands') - - await update({ permission: null }) - expect(composer().props.sendDisabled).toBe(false) - }) - - it('blocks Send for an ask and a heuristic question too', async () => { - await render({ - ask: { - questions: [{ question: 'Tabs?', multiSelect: false, options: [{ label: 'Tabs' }] }] - } - }) - expect(composer().props.sendDisabled).toBe(true) - await update({ - question: { question: 'Name?', options: [], multiSelect: false, optionTokens: [] } - }) - expect(composer().props.sendDisabled).toBe(true) - }) - - it('leaves a structured chat composer open: its host queues the send behind the prompt', async () => { - await render({ permission, structuredActivityUi: true }) - expect(composer().props.sendDisabled).toBe(false) - }) - }) -}) diff --git a/mobile/tests-typecheck-baseline.txt b/mobile/tests-typecheck-baseline.txt index b8dda1e45a7..295486ec2b2 100644 --- a/mobile/tests-typecheck-baseline.txt +++ b/mobile/tests-typecheck-baseline.txt @@ -47,7 +47,6 @@ src/session/MobileNativeChatQuestion.test.tsx src/session/MobileNativeChatSessionOptionPickers.test.ts src/session/MobileNativeChatSessionOptionRows.test.ts src/session/MobileNativeChatTurnStatus.test.ts -src/session/MobileNativeChatView.test.ts src/session/QuickCommandsList.test.ts src/session/QuickCommandsSheet.test.ts src/session/ai-vault-resume-launch.test.ts