Files
orca/mobile/src/session/MobileNativeChatMessage.test.ts
T

782 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { AgentJournalStatusItem } from '../../../src/shared/agent-session-journal-types'
import { projectStructuredItemToNativeChat } from '../../../src/shared/structured-agent-session-projection'
import {
NATIVE_CHAT_INTERRUPTED_STATUS_TEXT,
type NativeChatMessage
} from '../../../src/shared/native-chat-types'
import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows'
import {
agentSessionFailureFact,
readProviderRetry
} from '../../../src/shared/agent-session-failure'
import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words'
import { structuredQuestionTranscript } from '../../../src/shared/structured-agent-question-projection'
import type { NativeChatResolvedPrompt } from '../../../src/shared/native-chat-resolution-receipt'
import { colors, typography } from '../theme/mobile-theme'
import { ANSWERED_PROMPT_ITEMS } from './mobile-native-chat-answered-prompts.test-fixture'
import { MobileNativeChatRowOverlaysContext } from './mobile-native-chat-row-overlays'
import { styles } from './mobile-native-chat-message-styles'
vi.mock('react-native', async () => {
const React = await import('react')
const Text = ({ children, ...props }: { children?: unknown }): unknown =>
React.createElement('Text', props, children)
return {
ActivityIndicator: 'ActivityIndicator',
Animated: {
Text,
Value: class {
setValue(): void {}
},
loop: (animation: unknown) => animation,
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
timing: () => ({ start: vi.fn(), stop: vi.fn() })
},
Image: 'Image',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: ({ children, ...props }: { children?: unknown }) =>
React.createElement('ScrollView', props, children),
Text,
View: ({ children, ...props }: { children?: unknown }) =>
React.createElement('View', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
useWindowDimensions: () => ({ width: 400, height: 800 })
}
})
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
vi.mock('lucide-react-native', () => ({
AlertCircle: 'AlertCircle',
AlertTriangle: 'AlertTriangle',
ArrowUp: 'ArrowUp',
Bot: 'Bot',
Brain: 'Brain',
Check: 'Check',
ChevronDown: 'ChevronDown',
ChevronRight: 'ChevronRight',
Copy: 'Copy',
Eye: 'Eye',
FileMinus2: 'FileMinus2',
FilePen: 'FilePen',
FilePlus2: 'FilePlus2',
Folder: 'Folder',
Globe: 'Globe',
ListChecks: 'ListChecks',
MessageSquareMore: 'MessageSquareMore',
Pencil: 'Pencil',
Plug: 'Plug',
Search: 'Search',
SquareChevronRight: 'SquareChevronRight',
SquareTerminal: 'SquareTerminal',
Wrench: 'Wrench'
}))
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
}))
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
function userMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
return { id: 'u1', role: 'user', blocks, timestamp: null, source: 'transcript' }
}
function toolMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
return { id: 'a1', role: 'assistant', blocks, timestamp: null, source: 'transcript' }
}
describe('MobileNativeChatMessage', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function render(
message: NativeChatMessage,
props: {
fontScale?: number
toolsExpanded?: boolean
structuredActivityUi?: boolean
activeTurnIsWorking?: boolean
trailingRun?: boolean
foldedAway?: boolean
turnExpanded?: boolean
turnStatus?: {
startedAt: number | null
thinking: boolean
workedSeconds: number | null
} | null
onToggleTurn?: () => void
reasoningIsLive?: boolean
unsentNotice?: string
receipt?: NativeChatResolvedPrompt
userMessageOpen?: boolean
onToggleUserMessage?: (messageId: string) => void
awaiting?: boolean
} = {}
): ReactTestRenderer {
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
})
return renderer!
}
const markdownIn = (node: ReactTestInstance): string[] =>
node
.findAll((child) => String(child.type) === 'MobileMarkdown')
.map((markdown) => markdown.props.content)
const textIn = (node: ReactTestInstance): string[] =>
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
it('renders the shared clear boundary as one muted divider with prior history retained', () => {
const message = projectStructuredItemToNativeChat({
itemId: 'clear',
revision: 1,
sequence: 5,
observedAt: 5,
body: {
kind: 'status',
text: 'Context cleared',
presentation: 'context-cleared',
contextClear: { operationId: 'clear', afterFence: 3, clearedAt: 5 }
}
})!
const tree = render(message)
expect(textIn(tree.root)).toEqual(['Context cleared'])
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0)
expect(
tree.root.findAll(
(node) =>
String(node.type) === 'View' && node.props.accessibilityLabel === 'Context cleared'
)
).toHaveLength(1)
})
const words = (node: ReactTestInstance): string[] =>
node.children.flatMap((child) => (typeof child === 'string' ? [child] : words(child)))
it('draws an answered approval and question as their receipts', () => {
const { messages, receipts } = structuredQuestionTranscript(ANSWERED_PROMPT_ITEMS)
const drawn = messages.flatMap((message) => {
const receipt = receipts.get(message.id)
return receipt ? [words(render(message, { receipt }).root).join('')] : []
})
// Each ends with when it was answered, in the device's own clock.
expect(drawn).toEqual([
expect.stringMatching(
/^Bash\{[\s\S]*echo receipt-check[\s\S]*\}AllowResolved · Answered on desktop-renderer\d{1,2}:\d{2}(\s[AP]M)?$/
),
expect.stringMatching(
/^Asked: Do you prefer tea or coffee\?CoffeeResolved · Answered on desktop-renderer\d{1,2}:\d{2}(\s[AP]M)?$/
)
])
})
it("keeps an answered prompt's receipt when its settled turn folds away, as desktop does", () => {
const { messages, receipts } = structuredQuestionTranscript(ANSWERED_PROMPT_ITEMS)
const question = messages.find((message) => receipts.get(message.id)?.kind === 'question')!
const tree = render(question, { receipt: receipts.get(question.id), foldedAway: true })
expect(words(tree.root).join('')).toContain('Asked: Do you prefer tea or coffee?')
})
it('zooms a receipt with the chat, as its words were before it became a receipt', () => {
const { messages, receipts } = structuredQuestionTranscript(ANSWERED_PROMPT_ITEMS)
const question = messages.find((message) => receipts.get(message.id)?.kind === 'question')!
const tree = render(question, { receipt: receipts.get(question.id), fontScale: 1.5 })
const sizeOf = (first: string) =>
Object.assign(
{},
...[
tree.root.find((node) => String(node.type) === 'Text' && node.children[0] === first).props
.style
].flat()
).fontSize
expect(sizeOf('Asked:')).toBe(typography.bodySize * 1.5)
expect(sizeOf('Coffee')).toBe(typography.metaSize * 1.5)
expect(tree.root.find((node) => String(node.type) === 'MessageSquareMore').props.size).toBe(
15 * 1.5
)
const time = tree.root.find(
(node) => String(node.type) === 'Text' && node.props.accessibilityLabel
)
expect(Object.assign({}, ...[time.props.style].flat()).fontSize).toBe(typography.metaSize * 1.5)
})
it("lists a group's questions once nobody can answer them here, as desktop does", () => {
const group = (state: 'pending' | 'cancelled'): NativeChatResolvedPrompt => ({
kind: 'question',
question: '2 grouped questions from Claude',
options: [],
questions: [
{ id: 'q1', question: 'Which targets?', multiSelect: true, options: [] },
{ id: 'q2', question: 'Proceed?', multiSelect: false, options: [] }
],
resolution: { state, selectedOptionId: null, resolvedBy: null, resolvedAt: null }
})
const row: NativeChatMessage = {
id: 'grouped',
role: 'system',
blocks: [{ type: 'text', text: '2 grouped questions from Claude' }],
timestamp: 1,
source: 'transcript'
}
expect(words(render(row, { receipt: group('cancelled') }).root).join('')).toBe(
'Asked: 2 questions1. Which targets?2. Proceed?Cancelled'
)
expect(words(render(row, { receipt: group('pending'), awaiting: false }).root).join('')).toBe(
'Asked: 2 questions1. Which targets?2. Proceed?'
)
// While the host waits, the answer card shows them.
expect(words(render(row, { receipt: group('pending') }).root).join('')).toBe(
'Awaiting user input: 2 questions'
)
})
it('draws a pending question the host no longer waits on as asked, as desktop does', () => {
// The same journal, with the agent ended before the question was answered.
const { messages, receipts } = structuredQuestionTranscript(
ANSWERED_PROMPT_ITEMS.map((item) =>
item.body.kind === 'question'
? {
...item,
body: {
...item.body,
resolution: {
state: 'pending',
selectedOptionId: null,
resolvedBy: null,
resolvedAt: null
}
}
}
: item
)
)
const row = messages.find((message) => receipts.get(message.id)?.kind === 'question')!
const receipt = receipts.get(row.id)
expect(words(render(row, { receipt }).root).join('')).toBe(
'Awaiting user input: Do you prefer tea or coffee?'
)
expect(words(render(row, { receipt, awaiting: false }).root).join('')).toBe(
'Asked: Do you prefer tea or coffee?'
)
})
it('shows when your message and the reply were written, beside copy, zoomed with the chat, as desktop does', () => {
const at = new Date(2026, 9, 15, 15, 42).getTime()
const timeIn = (message: NativeChatMessage) =>
render(message, { fontScale: 1.5 })
.root.findAll((node) => String(node.type) === 'Text' && node.props.accessibilityLabel)
.map((node) => [
node.children.join(''),
Object.assign({}, ...[node.props.style].flat()).fontSize
])
const shown = [[expect.stringMatching(/^(3:42\sPM|15:42)$/), typography.metaSize * 1.5]]
expect(timeIn({ ...userMessage([{ type: 'text', text: 'ship it' }]), timestamp: at })).toEqual(
shown
)
expect(timeIn({ ...toolMessage([{ type: 'text', text: 'Shipped.' }]), timestamp: at })).toEqual(
shown
)
expect(timeIn(userMessage([{ type: 'text', text: 'ship it' }]))).toEqual([])
})
it('draws a question call as the question awaited, then as asked, never as a raw tool line', () => {
// A Claude terminal chat's AskUserQuestion call and its answer, as the host decoded them.
const ask = {
type: 'tool-call' as const,
name: 'AskUserQuestion',
input: {
questions: [
{
question: 'Do you prefer red or blue?',
header: 'Color',
multiSelect: false,
options: [
{ label: 'Red', description: 'You prefer red.' },
{ label: 'Blue', description: 'You prefer blue.' }
]
}
]
},
callId: 'toolu_01D2nEEE5zhVRVmAvtmLBz5T'
}
const call = toolMessage([ask])
const answer = {
type: 'tool-result' as const,
output:
'Your questions have been answered: "Do you prefer red or blue?"="Blue". You can now continue with these answers in mind.',
callId: 'toolu_01D2nEEE5zhVRVmAvtmLBz5T'
}
expect(words(render(call).root).join('')).toBe(
'Awaiting user input: Do you prefer red or blue?'
)
expect(words(render({ ...call, blocks: [...call.blocks, answer] }).root).join('')).toBe(
'Asked: Do you prefer red or blue?'
)
// A native chat asks desktop's selector.
const live = { structuredActivityUi: true, activeTurnIsWorking: true }
expect(words(render(toolMessage([{ ...ask, state: 'running' }]), live).root).join('')).toBe(
'Awaiting user input: Do you prefer red or blue?'
)
expect(words(render(toolMessage([{ ...ask, state: 'completed' }]), live).root).join('')).toBe(
'Asked: Do you prefer red or blue?'
)
})
// Each row as its host writes it: Claude's API retry and sign-in rows use these shared words.
const statusRow = (body: AgentJournalStatusItem): NativeChatMessage =>
projectStructuredItemToNativeChat({
itemId: 'status',
revision: 1,
sequence: 2,
observedAt: 2,
body
})!
it.each([
[
'an API retry as a warning',
statusRow({
kind: 'status',
tone: 'warning',
...agentSessionFailureWords(
agentSessionFailureFact('providerRetrying', {
retry: readProviderRetry({
error: 'overloaded',
status: 529,
attempt: 2,
maxRetries: 10
})
}),
{ surface: 'row', agentName: 'Claude' }
)
}),
colors.statusAmber
],
[
'a sign-in failure as an error',
statusRow({
kind: 'status',
tone: 'error',
...agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), {
agentName: 'Claude',
provider: 'claude',
surface: 'row'
})
}),
colors.statusRed
],
[
'an interruption as a faint aside',
{
id: 'interrupted',
role: 'system',
blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }],
timestamp: 1,
source: 'transcript'
} satisfies NativeChatMessage,
colors.textMuted
]
])('draws %s, never as the agent speaking', (_, message, color) => {
const tree = render(message)
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0)
const text = tree.root.find(
(node) => String(node.type) === 'Text' && node.props.selectable === true
)
expect(text.props.children).toBe(message.blocks.find((block) => block.type === 'text')?.text)
expect(Object.assign({}, ...text.props.style)).toMatchObject({ color })
})
it('draws a compaction as a divider, not as a reply', () => {
const tree = render(
statusRow({ kind: 'status', text: 'Context compacted', presentation: 'compaction' })
)
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0)
expect(
tree.root.findAll(
(node) =>
String(node.type) === 'View' && node.props.accessibilityLabel === 'Context compacted'
)
).toHaveLength(1)
})
it.each(['system', 'user'] as const)(
'renders a %s host notice as selectable muted text rather than a markdown answer',
(role) => {
const tree = render(
{
id: 'notice',
role,
timestamp: 1,
blocks: [
{ type: 'text', text: 'provider fallback', presentation: 'history-item-too-large' }
]
},
{ fontScale: 1.5 }
)
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0)
const text = tree.root.find(
(node) => String(node.type) === 'Text' && node.props.selectable === true
)
expect(text.props.children).toBe(AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'])
expect(text.props.selectable).toBe(true)
// Zoomed 1.5× with its leading zoomed too, so wrapped lines never overlap.
expect(Object.assign({}, ...text.props.style)).toMatchObject({
color: colors.textMuted,
fontSize: 25.5,
lineHeight: 34.5
})
}
)
it("names another agent's message and sets it apart from the person's bubble", () => {
const agentMessage: NativeChatMessage = {
...userMessage([{ type: 'text', text: 'You have 1 orchestration message.' }]),
from: {
kind: 'agent',
senders: [
{
party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null },
name: 'Coder'
}
],
orchestration: null
}
}
const tree = render(agentMessage)
expect(textIn(tree.root)[0]).toBe('Message from Coder')
// Left-aligned agent prose, not the person's inverted bubble.
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(1)
const rows = tree.root.findAll(
(node) => String(node.type) === 'View' && Array.isArray(node.props.style)
)
expect(rows.some((row) => row.props.style.includes(styles.rowUser))).toBe(false)
})
describe('a message the host recorded and did not deliver', () => {
const notSent = { ...userMessage([{ type: 'text', text: 'fix it' }]), unsent: true as const }
const lineIn = (tree: ReactTestRenderer) =>
tree.root.findAll(
(node) => String(node.type) === 'Text' && node.props.style === styles.unsentNotice
)
it('says why under the bubble, and draws no line without a reason, as the desktop', () => {
const tree = render(notSent, { unsentNotice: 'Claude is not signed in.' })
expect(markdownIn(tree.root)).toEqual(['fix it'])
expect(textIn(tree.root)).toEqual(['Claude is not signed in.'])
expect(lineIn(render(notSent))).toHaveLength(0)
})
it("draws the line under another agent's message too", () => {
const fromAgent: NativeChatMessage = {
...notSent,
from: {
kind: 'agent',
senders: [
{
party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null },
name: 'Coder'
}
],
orchestration: null
}
}
expect(
lineIn(render(fromAgent, { unsentNotice: 'Your message was not sent.' }))
).toHaveLength(1)
})
})
it('preserves an ordinary assistant answer without interpreting its text as a host notice', () => {
const tree = render(toolMessage([{ type: 'text', text: 'provider fallback' }]))
expect(tree.root.find((node) => String(node.type) === 'MobileMarkdown').props.content).toBe(
'provider fallback'
)
})
it('renders a loadable preview URI as an image thumbnail', () => {
const tree = render(userMessage([{ type: 'image-ref', url: 'file:///a.jpg', alt: 'a photo' }]))
const image = tree.root.findByType('Image' as never)
expect(image.props.source).toEqual({ uri: 'file:///a.jpg' })
expect(image.parent?.props).toMatchObject({
accessibilityRole: 'imagebutton',
accessibilityLabel: 'a photo'
})
})
it('opens an attachment full size when tapped', () => {
const openImage = vi.fn()
act(() => {
renderer = create(
createElement(
MobileNativeChatRowOverlaysContext.Provider,
{ value: { openImage, openActions: vi.fn() } },
createElement(MobileNativeChatMessage, {
message: userMessage([{ type: 'image-ref', url: 'file:///a.jpg', alt: 'a photo' }])
})
)
)
})
act(() => renderer!.root.findByProps({ accessibilityRole: 'imagebutton' }).props.onPress())
expect(openImage).toHaveBeenCalledWith({ uri: 'file:///a.jpg', label: 'a photo' })
})
it('draws a picture-only message with nothing else in its bubble, so no gap sits under it', () => {
render(userMessage([{ type: 'image-ref', url: 'file:///a.jpg', alt: 'a photo' }]))
const bubble = renderer!.root.find(
(node) =>
String(node.type) === 'View' && [node.props.style].flat().includes(styles.userBubble)
)
expect(bubble.findAll((node) => node !== bubble && String(node.type) === 'View')).toHaveLength(
0
)
})
it('folds a long message of yours until opened, and the transcript keeps it open', () => {
const long = Array.from({ length: 12 }, (_, line) => `line ${line + 1}`).join('\n')
const message = userMessage([{ type: 'text', text: long }])
const onToggleUserMessage = vi.fn()
const clips = (tree: ReactTestRenderer) =>
tree.root.findAll(
(node) => String(node.type) === 'View' && node.props.style?.overflow === 'hidden'
)
const folded = render(message, { onToggleUserMessage })
expect(clips(folded)).toHaveLength(1)
// Native layout fits the cut text to the clip, so it measures at the fold: it stays folded and
// offers the rest, rather than unfolding to measure taller and folding again.
act(() =>
clips(folded)[0]!
.find((node) => node.props.onLayout !== undefined)
.props.onLayout({ nativeEvent: { layout: { height: 190 } } })
)
expect(clips(folded)).toHaveLength(1)
act(() =>
folded.root
.find(
(node) => String(node.type) === 'Pressable' && textIn(node).includes('Show full message')
)
.props.onPress()
)
expect(onToggleUserMessage).toHaveBeenCalledWith('u1')
// The list recycles rows; the transcript's state draws it open again.
expect(clips(render(message, { userMessageOpen: true, onToggleUserMessage }))).toHaveLength(0)
})
it('prefers the url over the path when both are present', () => {
const tree = render(
userMessage([{ type: 'image-ref', url: 'file:///local.jpg', path: '/tmp/host.png' }])
)
expect(tree.root.findByType('Image' as never).props.source).toEqual({
uri: 'file:///local.jpg'
})
})
it('falls back to a text placeholder for a bare host path', () => {
// A host temp path (e.g. on an SSH host) is not loadable on the device.
const tree = render(userMessage([{ type: 'image-ref', path: '/tmp/host.png' }]))
expect(tree.root.findAllByType('Image' as never)).toHaveLength(0)
const texts = tree.root
.findAllByType('Text' as never)
.map((node) => String(node.children.join('')))
expect(texts.some((text) => text.includes('/tmp/host.png'))).toBe(true)
})
it('draws your message as selectable Markdown in the bubble’s dark-on-light colours', () => {
const tree = render(userMessage([{ type: 'text', text: '**Fix** `src/a.ts`' }]))
const markdown = tree.root.findByType('MobileMarkdown' as never)
expect(markdown.props).toMatchObject({
content: '**Fix** `src/a.ts`',
tone: 'onBright',
rangeSelectable: true
})
expect(markdown.props.renderVisual).toBeUndefined()
})
it('routes assistant prose through selectable Markdown', () => {
const tree = render(toolMessage([{ type: 'text', text: 'Agent reply prose' }]))
const markdown = tree.root.findByType('MobileMarkdown' as never)
expect(markdown.props.content).toBe('Agent reply prose')
expect(markdown.props.rangeSelectable).toBe(true)
})
describe('structured activity UI', () => {
const runningCall = {
type: 'tool-call' as const,
name: 'Bash',
input: { command: 'npm test' },
state: 'running' as const
}
const settledCall = {
type: 'tool-call' as const,
name: 'Read',
input: { file_path: 'a/b.ts' },
state: 'completed' as const
}
it('keeps one live header while calls start and finish, naming the latest call beside it', () => {
const tree = render(toolMessage([runningCall]), {
structuredActivityUi: true,
activeTurnIsWorking: true
})
expect(textIn(tree.root)).toEqual(expect.arrayContaining(['Running 1 command', 'npm test']))
expect(tree.root.findAllByType('SquareTerminal' as never)).toHaveLength(1)
// A finished call, and a Codex diff that carries no state, keep the same header.
act(() =>
tree.update(
createElement(MobileNativeChatMessage, {
message: toolMessage([
{ ...runningCall, state: 'completed' },
{ type: 'tool-call', name: 'Diff', input: { path: 'src/a.ts' } }
]),
structuredActivityUi: true,
activeTurnIsWorking: true
})
)
)
expect(textIn(tree.root)).toEqual(
expect.arrayContaining(['Running 1 command and editing 1 file', 'Diff src/a.ts'])
)
expect(textIn(tree.root).some((text) => text.startsWith('Running Diff'))).toBe(false)
})
it('settles to the past tense once the agent moves past the run', () => {
const tree = render(toolMessage([settledCall, runningCall]), {
structuredActivityUi: true,
activeTurnIsWorking: true,
trailingRun: false
})
expect(textIn(tree.root)).toContain('Read 1 file and ran 1 command')
})
it('draws nothing for a row its settled turn folded away, unless the Tools toggle is on', () => {
const folded = render(toolMessage([settledCall]), {
structuredActivityUi: true,
activeTurnIsWorking: false,
foldedAway: true
})
expect(textIn(folded.root)).toEqual([])
act(() => folded.unmount())
// Otherwise the composer's Tools control is a no-op on every settled turn.
const revealed = render(toolMessage([settledCall]), {
structuredActivityUi: true,
activeTurnIsWorking: false,
foldedAway: true,
toolsExpanded: true
})
expect(textIn(revealed.root)).toContain('Read 1 file')
})
it("keeps a settled turn's answer row whole, its own run included", () => {
const tree = render(toolMessage([{ type: 'text', text: 'Done.' }, settledCall]), {
structuredActivityUi: true,
activeTurnIsWorking: false
})
expect(textIn(tree.root)).toContain('Read 1 file')
})
it('renders the settled turn status row under a user message', () => {
const tree = render(userMessage([{ type: 'text', text: 'go' }]), {
structuredActivityUi: true,
turnStatus: { startedAt: Date.now() - 3_000, thinking: false, workedSeconds: 3 }
})
expect(textIn(tree.root)).toContain('Worked for 3s')
})
it('does not render a turn status row without one', () => {
const tree = render(userMessage([{ type: 'text', text: 'go' }]), {
structuredActivityUi: true
})
expect(markdownIn(tree.root)).toEqual(['go'])
expect(textIn(tree.root)).toEqual([])
})
})
describe('a reasoning row', () => {
const reasoning = (fields: Partial<NativeChatMessage> = {}): NativeChatMessage => ({
id: 'r1',
role: 'reasoning',
blocks: [{ type: 'text', text: 'Weighing two approaches' }],
timestamp: 1_000,
source: 'transcript',
state: 'completed',
completedAt: 4_000,
...fields
})
const toggleOf = (tree: ReactTestRenderer): ReactTestInstance =>
tree.root.find(
(node) => String(node.type) === 'Pressable' && node.props.accessibilityRole === 'button'
)
const markdownIn = (tree: ReactTestRenderer): ReactTestInstance[] =>
tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')
it('starts collapsed to its headline, with its text unmounted', () => {
const tree = render(reasoning())
expect(textIn(tree.root)).toContain('Thought for 3s')
expect(toggleOf(tree).props.accessibilityState).toEqual({ expanded: false })
// Said with what it is, as desktop's screen-reader prefix does, on a 32 + 2 × 6 pt target.
expect(toggleOf(tree).props.accessibilityLabel).toBe('Reasoning: Thought for 3s')
expect(toggleOf(tree).props.hitSlop).toBe(6)
expect(markdownIn(tree)).toHaveLength(0)
})
it('leads its headline with the brain, as desktop does', () => {
const [first] = toggleOf(render(reasoning())).children
expect(typeof first === 'string' ? first : first?.type).toBe('Brain')
})
it('mounts its text once opened', () => {
const tree = render(reasoning())
act(() => toggleOf(tree).props.onPress())
expect(toggleOf(tree).props.accessibilityState).toEqual({ expanded: true })
expect(markdownIn(tree).map((node) => node.props.content)).toEqual([
'Weighing two approaches'
])
})
it('draws nothing while the live line discloses it, or when blank', () => {
expect(
render(reasoning({ state: 'running' }), {
activeTurnIsWorking: true,
reasoningIsLive: true
}).toJSON()
).toBeNull()
expect(render(reasoning({ blocks: [{ type: 'text', text: ' \n ' }] })).toJSON()).toBeNull()
})
// The turn's bar is not the block's: hiding the block must not hide the bar it sits on.
it("still draws its turn's bar while the live line discloses it", () => {
const tree = render(reasoning({ state: 'running' }), {
activeTurnIsWorking: true,
reasoningIsLive: true,
turnStatus: { startedAt: 1_000, thinking: true, workedSeconds: null }
})
expect(tree.root.findAll((node) => String(node.type) === 'Pressable')).toHaveLength(0)
expect(textIn(tree.root).some((text) => text.startsWith('Working for'))).toBe(true)
})
// Only the block the line discloses hides: a subagent's or a stale open block draws, unended.
it('draws any other open block in its working turn as Reasoning', () => {
const child = render(reasoning({ state: 'running', agentId: 'sub-1' }), {
activeTurnIsWorking: true
})
expect(textIn(child.root)).toContain('Reasoning')
expect(toggleOf(child).props.accessibilityLabel).toBe('Reasoning')
})
it('says only what the host saw', () => {
expect(textIn(render(reasoning({ state: 'running' })).root)).toContain('Thought')
const unknown = render(reasoning({ state: undefined, completedAt: undefined }))
expect(textIn(unknown.root)).toContain('Reasoning')
// No "Reasoning: Reasoning".
expect(toggleOf(unknown).props.accessibilityLabel).toBe('Reasoning')
expect(textIn(render(reasoning({ completedAt: 1_300 })).root)).toContain('Thought for 1s')
})
})
})