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