Files
orca/mobile/src/session/MobileNativeChatTurnStatus.test.ts
T
Brennan BensonandMerge Sim fab78c7669 fix(native-chat): show one live-turn indicator, and make Thinking mean reasoning (#19977)
* native-chat: render one indicator row for the live desktop turn

The turn-timing row and the spinner+activity line were two rows saying
"Working" at once. A settled turn keeps its own row; the live turn now has
only the spinner row, labelled provider activity -> Thinking -> Working for N
through the shared resolver. Reasoning is the turn's content, so it no longer
becomes the activity label, and "Thinking" now means the turn is reasoning
right now rather than that it has produced no output yet.

* mobile: give the live turn row a spinner and the shared indicator label

Mobile's per-turn row is already the only live indicator on the structured
lane, but it pulsed a bare word and never showed what the provider said it was
doing. It now renders a spinner beside the same resolved label desktop uses,
and reads reasoning from the journal instead of inferring it from missing
output. The bridge lane's four prompt/interrupt write seams move to one module
so the controller stays under its line cap.

* codex: mark streamed reasoning as reasoning too, and pin the provider markers

The settled reasoning item carried the marker but the streaming one did not,
so a live Codex turn - the only time the indicator is on screen - never read
as reasoning. Both paths now stamp it; a plan document keeps its own
presentation and must never read as reasoning.

* fix(native-chat): tighten live turn reasoning state

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-11 00:19:57 -07:00

122 lines
4.4 KiB
TypeScript

import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('react-native', async () => {
const React = await import('react')
const Text = ({ children, ...props }: { children?: unknown }): unknown =>
React.createElement('Text', props, children)
return {
ActivityIndicator: (props: Record<string, unknown>) =>
React.createElement('ActivityIndicator', props),
Pressable: ({ children, ...props }: { children?: unknown }) =>
React.createElement('Pressable', props, children),
Text,
View: ({ children, ...props }: { children?: unknown }) =>
React.createElement('View', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
}
})
vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' }))
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
describe('MobileNativeChatTurnStatus', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-04T00:00:00Z'))
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
})
function render(props: {
startedAt: number | null
thinking: boolean
workedSeconds?: number | null
activityText?: string | null
expanded?: boolean
onToggleExpanded?: () => void
}): ReactTestRenderer {
act(() => {
renderer = create(createElement(MobileNativeChatTurnStatus, props))
})
return renderer!
}
const labels = (node: ReactTestInstance): string[] =>
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
const spinners = (node: ReactTestInstance): ReactTestInstance[] =>
node.findAllByType('ActivityIndicator' as never)
it('reads "Thinking" beside one spinner while the turn reasons', () => {
const tree = render({ startedAt: Date.now(), thinking: true })
expect(labels(tree.root)).toEqual(['Thinking'])
expect(spinners(tree.root)).toHaveLength(1)
})
it('counts up on that same single row when the turn is not reasoning', () => {
const startedAt = Date.now()
const tree = render({ startedAt, thinking: false })
expect(labels(tree.root)).toEqual(['Working for 0s'])
act(() => {
vi.advanceTimersByTime(12_000)
})
expect(labels(tree.root)).toEqual(['Working for 12s'])
expect(spinners(tree.root)).toHaveLength(1)
})
it('lets provider activity text beat both fallbacks and hold the clock', () => {
const tree = render({
startedAt: Date.now(),
thinking: true,
activityText: 'Running pnpm test'
})
expect(labels(tree.root)).toEqual(['Running pnpm test'])
expect(spinners(tree.root)).toHaveLength(1)
// No label consumes the duration, so nothing schedules a tick for it.
expect(vi.getTimerCount()).toBe(0)
})
it('settles to a tappable "Worked for" row that toggles the turn', () => {
const onToggleExpanded = vi.fn()
const tree = render({
startedAt: Date.now(),
thinking: false,
workedSeconds: 184,
onToggleExpanded
})
expect(labels(tree.root)).toEqual(['Worked for 3m 4s'])
const button = tree.root.findByType('Pressable' as never)
expect(button.props.accessibilityLabel).toBe('Toggle turn details')
expect(button.props.accessibilityState).toEqual({ expanded: false })
act(() => button.props.onPress())
expect(onToggleExpanded).toHaveBeenCalledOnce()
})
it('stays a plain row when the settled turn has nothing to disclose', () => {
const tree = render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 })
expect(tree.root.findAllByType('Pressable' as never)).toHaveLength(0)
expect(labels(tree.root)).toEqual(['Worked for 5s'])
})
it('holds no interval, and no spinner, once the turn has settled', () => {
const tree = render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 })
expect(vi.getTimerCount()).toBe(0)
expect(spinners(tree.root)).toHaveLength(0)
})
it('announces the live row to assistive tech', () => {
const tree = render({ startedAt: Date.now(), thinking: true })
const row = tree.root.findByType('View' as never)
expect(row.props.accessibilityLiveRegion).toBe('polite')
expect(row.props.accessibilityLabel).toBe('Agent is responding')
})
})