mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +00:00
* fix(mobile): show skill commands you ran in the chat Claude records a skill run (/simplify, /review-pr 123) as a command envelope, which the noise filter hides. Desktop surfaces it as the typed `/name args` before folding; the phone only normalised image markers, so the turn vanished and the reply answered nothing. Both apps now prepare a terminal agent's transcript through one shared prepareNativeChatMessages: skill envelopes, reporting-command output and image marker turns. * fix(native-chat): keep a streamed sentence from coming back after a tool or thought row Both chats hide the live preview once the transcript shows it, but each only compared the preview with the last row. When a tool call or thought landed below the sentence, the last row had no words and the earlier sentence came back at the bottom as a live message. latestNativeChatReplyText reads back past those rows to the newest assistant words in the turn; desktop's preview check and the phone's streaming gate both use it. * fix(mobile): draw errors, warnings and status notices as notices, not agent replies The phone drew every system row through the agent's markdown, so an API retry warning, a sign-in failure, "Context compacted" or "Stopped manually" read as the agent's own words. Only the clear boundary and two host notices were special-cased. Desktop's notice rules move to shared native-chat-notice: which block of a system row is its notice, and the look it gets (divider, command output, host status, plan, aside, or a toned line). Desktop's notice row switches on it with no change in what it draws; the phone's new notice row draws the same looks, with words in faint type when a row has no notice. * fix(native-chat): show a new reply that follows a tool call on desktop The earlier fix read back past a tool call or thought to the newest assistant words, then kept desktop's length rule. Commentary written before the tool is often longer than the reply after it, so a new, shorter reply stayed hidden until the transcript caught up. The last-row rule is back as it was. Reading back now only hides the preview when the earlier words already contain it, which is the sentence still on screen while the tool runs. The phone's gate reads only rows that landed after the current reply began, so it was not affected. * fix(mobile): open the actions sheet from a long press on a plan's link A plan card's Markdown was drawn without the row's long-press handler, so on Android holding a link or file path inside a plan no longer opened Copy message / Select text, as it did before plans were drawn as notices. The notice row now passes the handler to the plan's Markdown.
641 lines
24 KiB
TypeScript
641 lines
24 KiB
TypeScript
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,
|
||
AgentJournalToolCallItem
|
||
} from '../../../src/shared/agent-session-journal-types'
|
||
import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary'
|
||
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 { colors } from '../theme/mobile-theme'
|
||
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 }
|
||
}
|
||
})
|
||
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
|
||
vi.mock('lucide-react-native', () => ({
|
||
AlertCircle: 'AlertCircle',
|
||
AlertTriangle: 'AlertTriangle',
|
||
ArrowUp: 'ArrowUp',
|
||
Brain: 'Brain',
|
||
ChevronDown: 'ChevronDown',
|
||
Copy: 'Copy',
|
||
SquareChevronRight: 'SquareChevronRight',
|
||
SquareTerminal: 'SquareTerminal',
|
||
Wrench: 'Wrench',
|
||
ChevronRight: 'ChevronRight'
|
||
}))
|
||
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
|
||
turnExpanded?: boolean
|
||
turnStatus?: {
|
||
startedAt: number | null
|
||
thinking: boolean
|
||
workedSeconds: number | null
|
||
} | null
|
||
onToggleTurn?: () => void
|
||
reasoningIsLive?: boolean
|
||
unsentNotice?: string
|
||
} = {}
|
||
): ReactTestRenderer {
|
||
act(() => {
|
||
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
|
||
})
|
||
return renderer!
|
||
}
|
||
|
||
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)
|
||
})
|
||
|
||
// 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)
|
||
expect(Object.assign({}, ...text.props.style)).toMatchObject({
|
||
color: colors.textMuted,
|
||
fontSize: 25.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', () => {
|
||
expect(textIn(render(notSent, { unsentNotice: 'Claude is not signed in.' }).root)).toEqual([
|
||
'fix it',
|
||
'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.props.accessibilityLabel).toBe('a photo')
|
||
})
|
||
|
||
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('makes user message text selectable', () => {
|
||
const tree = render(userMessage([{ type: 'text', text: 'Prompt I typed' }]))
|
||
const text = tree.root
|
||
.findAllByType('Text' as never)
|
||
.find((node) => String(node.children.join('')) === 'Prompt I typed')
|
||
expect(text?.props.selectable).toBe(true)
|
||
})
|
||
|
||
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)
|
||
})
|
||
|
||
it('labels a tool row with the target path instead of raw input JSON', () => {
|
||
const tree = render(
|
||
toolMessage([{ type: 'tool-call', name: 'Read', input: { file_path: 'src/index.ts' } }]),
|
||
{ toolsExpanded: true }
|
||
)
|
||
const texts = textIn(tree.root)
|
||
expect(texts).toContain('src/index.ts')
|
||
expect(texts.some((text) => text.includes('"file_path":"src/index.ts"'))).toBe(false)
|
||
})
|
||
|
||
it('bounds expanded diff-less tool input before native text layout', () => {
|
||
const tree = render(
|
||
toolMessage([
|
||
{ type: 'tool-call', name: 'CustomTool', input: { payload: 'x'.repeat(100_000) } }
|
||
]),
|
||
{ toolsExpanded: true }
|
||
)
|
||
const detail = textIn(tree.root).find((text) => text.startsWith('{\n'))
|
||
expect(detail).toHaveLength(MAX_TOOL_DETAIL_LENGTH + 1)
|
||
expect(detail?.endsWith('…')).toBe(true)
|
||
})
|
||
|
||
it('expands formatted detail for a collapsed JSON-string tool input', () => {
|
||
const tree = render(
|
||
toolMessage([
|
||
{
|
||
type: 'tool-call',
|
||
name: 'CustomTool',
|
||
input: '{"cmd":"git status","description":"Inspect changes"}'
|
||
}
|
||
])
|
||
)
|
||
const pressableWith = (label: string): ReactTestInstance =>
|
||
tree.root.findAllByType('Pressable' as never).find((node) => textIn(node).includes(label))!
|
||
|
||
act(() => pressableWith('1×').props.onPress())
|
||
// The row label is the command, and the detail stays closed until tapped.
|
||
expect(textIn(tree.root)).toContain('git status')
|
||
expect(textIn(tree.root).some((text) => text.startsWith('{\n'))).toBe(false)
|
||
|
||
act(() => pressableWith('CustomTool').props.onPress())
|
||
expect(textIn(tree.root)).toContain(
|
||
'{\n "cmd": "git status",\n "description": "Inspect changes"\n}'
|
||
)
|
||
})
|
||
|
||
it('does not echo the row label as detail when a row has nothing to expand', () => {
|
||
// The Tools toggle opens every row at once, bypassing the tap guard — a row
|
||
// whose formatted input is its own label would echo itself in a panel that
|
||
// no tap can dismiss.
|
||
const tree = render(toolMessage([{ type: 'tool-call', name: 'ListTodos', input: '{}' }]), {
|
||
toolsExpanded: true
|
||
})
|
||
expect(textIn(tree.root).filter((text) => text === '{}')).toHaveLength(1)
|
||
// The chevron has to agree with the panel, or the row claims to be open over
|
||
// nothing and the tap that would close it is guarded off. Only the run header
|
||
// is open here; the row itself stays collapsed.
|
||
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
|
||
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
|
||
})
|
||
|
||
it('does not expand a plain input that already fits in the row label', () => {
|
||
const input = 'x'.repeat(60)
|
||
const tree = render(toolMessage([{ type: 'tool-call', name: 'CustomTool', input }]), {
|
||
toolsExpanded: true
|
||
})
|
||
expect(textIn(tree.root).filter((text) => text === input)).toHaveLength(1)
|
||
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
|
||
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
|
||
})
|
||
|
||
describe('output a call left when it ended early', () => {
|
||
function projectedCall(ending: Pick<AgentJournalToolCallItem, 'endedAs'>): NativeChatMessage {
|
||
const body: AgentJournalToolCallItem = {
|
||
kind: 'tool-call',
|
||
name: 'shell',
|
||
input: { command: 'sleep 20' },
|
||
state: 'failed',
|
||
...ending,
|
||
output: { head: 'partial', byteLength: 7, digest: 'd', truncated: false }
|
||
}
|
||
const projected = projectStructuredItemToNativeChat({
|
||
itemId: 'call',
|
||
sequence: 1,
|
||
revision: 1,
|
||
observedAt: 100,
|
||
body
|
||
})
|
||
if (!projected) {
|
||
throw new Error('the call projects no message')
|
||
}
|
||
return { ...projected, id: 'a1', source: 'transcript' }
|
||
}
|
||
const outputStyle = (message: NativeChatMessage): unknown => {
|
||
const tree = render(message, { toolsExpanded: true })
|
||
const output = tree.root
|
||
.findAllByType('Text' as never)
|
||
.find((node) => node.children.join('') === 'partial')
|
||
// The tint is on the result box: the nearest View around the output text.
|
||
let box = output?.parent ?? null
|
||
while (box && String(box.type) !== 'View') {
|
||
box = box.parent
|
||
}
|
||
return box?.props.style
|
||
}
|
||
|
||
it('shows the output a stop cut short without the error tint', () => {
|
||
expect(outputStyle(projectedCall({ endedAs: 'interrupted' }))).toEqual([
|
||
expect.any(Object),
|
||
false
|
||
])
|
||
})
|
||
|
||
it('keeps the error tint on a call nothing proved was cut short', () => {
|
||
expect(outputStyle(projectedCall({ endedAs: 'unverifiable' }))).toEqual([
|
||
expect.any(Object),
|
||
expect.objectContaining({ backgroundColor: expect.any(String) })
|
||
])
|
||
})
|
||
})
|
||
|
||
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('shows the live tool label with a terminal glyph while a command runs', () => {
|
||
const tree = render(toolMessage([runningCall]), {
|
||
structuredActivityUi: true,
|
||
activeTurnIsWorking: true
|
||
})
|
||
expect(textIn(tree.root)).toContain('Running npm test')
|
||
expect(tree.root.findAllByType('SquareTerminal' as never)).toHaveLength(1)
|
||
expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(0)
|
||
})
|
||
|
||
it('uses the wrench glyph for a non-command tool', () => {
|
||
const tree = render(
|
||
toolMessage([
|
||
{ type: 'tool-call', name: 'Read', input: { file_path: 'a/b.ts' }, state: 'running' }
|
||
]),
|
||
{ structuredActivityUi: true, activeTurnIsWorking: true }
|
||
)
|
||
expect(textIn(tree.root)).toContain('Running Read a/b.ts')
|
||
expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(1)
|
||
})
|
||
|
||
it('falls back to the collapsed count row once the run settles', () => {
|
||
const tree = render(toolMessage([settledCall]), {
|
||
structuredActivityUi: true,
|
||
activeTurnIsWorking: true
|
||
})
|
||
expect(textIn(tree.root)).not.toContain('Running Read a/b.ts')
|
||
expect(textIn(tree.root)).toContain('1×')
|
||
})
|
||
|
||
it("hides a completed turn's activity until the turn caret discloses it", () => {
|
||
const collapsed = render(toolMessage([settledCall]), {
|
||
structuredActivityUi: true,
|
||
activeTurnIsWorking: false
|
||
})
|
||
expect(textIn(collapsed.root)).not.toContain('1×')
|
||
act(() => collapsed.unmount())
|
||
|
||
const disclosed = render(toolMessage([settledCall]), {
|
||
structuredActivityUi: true,
|
||
activeTurnIsWorking: false,
|
||
turnExpanded: true
|
||
})
|
||
expect(textIn(disclosed.root)).toContain('1×')
|
||
})
|
||
|
||
it('lets the global Tools toggle reveal a hidden settled run', () => {
|
||
// Otherwise the composer's Tools control is a no-op on every settled turn.
|
||
const tree = render(toolMessage([settledCall]), {
|
||
structuredActivityUi: true,
|
||
activeTurnIsWorking: false,
|
||
toolsExpanded: true
|
||
})
|
||
expect(textIn(tree.root)).toContain('1\u00d7')
|
||
})
|
||
|
||
it('keeps the bridge lane on its always-visible tool run', () => {
|
||
const tree = render(toolMessage([settledCall]), { activeTurnIsWorking: false })
|
||
expect(textIn(tree.root)).toContain('1×')
|
||
expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(0)
|
||
})
|
||
|
||
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(textIn(tree.root)).toEqual(['go'])
|
||
})
|
||
})
|
||
|
||
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')
|
||
})
|
||
})
|
||
})
|