mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
* feat(mobile): port the restructured native-chat turn status and live tool progress Mobile chat had a single static "Agent is working" row and no live tool activity, while the desktop restructure (#17597, #18705) replaced that with a per-turn status row and a running-tool label. This brings mobile to parity and puts the derivation in one place instead of two. Shared (new, pure, RN-safe — desktop uses them as i18n fallbacks, mobile directly, matching the native-chat-empty-state pattern): - `native-chat-turn-status.ts`: duration formatting, label selection, the turn-timing state machine, and the active/settled split. - `native-chat-tool-activity.ts`: command-tool classification, the running-tool label descriptor, and running-call selection. Desktop now consumes both; `NativeChatWorkingStatus`, `NativeChatToolRun` and `use-native-chat-turn-status` keep their existing behavior and strings. Mobile gains the "Thinking" / "Working for 12s" / "Worked for 3m 4s" row with a caret that discloses the turn's tool activity, the pulsing "Running npm test" row with terminal-vs-wrench glyphs, and desktop's rule that a completed turn's tool run hides behind the turn caret. The bridge lane is untouched and keeps its three-dot indicator. Headings, quotes, code, lists and table cells are now selectable. Files at their max-lines cap were split rather than bumped: the tool-run subtree, the prompt card, the session-lane wiring, and the turn-disclosure state each move to their own module. * perf(mobile): stop the turn-status rows from re-rendering the whole transcript A streaming turn re-renders the chat list many times a second. The disclosure wiring handed every row a fresh status object and a fresh toggle closure on each of those renders, so `MobileNativeChatMessage`'s memo never held and every visible row re-rendered per tick — including settled turns that had not changed. Memoize the status selection on the timing map, and keep one stable toggle handler per turn (pruned when a turn leaves the transcript) attached only to the settled rows that can actually disclose anything. Now only the live turn's row changes identity while the agent works. * fix(mobile): keep the turn clock running when the optimistic echo is replaced An accepted send renders as `pending-N` until the transcript echo lands under its real message id. That flips the active turn key mid-turn, and the timing reducer treated the new key as a new turn — so a turn that had reached "Working for 8s" visibly restarted at "Working for 0s". The reducer now carries the start over when the previous key names a turn that has since left the transcript, which is exactly the echo-replacement case. A genuinely new turn (the previous key still in the transcript) and a turn that had already settled both keep their own clock; both are pinned by tests. Desktop does not pass the new key and is unaffected. * fix(mobile): keep the Tools toggle working on settled turns Hiding a settled turn's tool run behind the turn caret (desktop parity) also made the composer's global Tools control a no-op on every completed turn: the run it wanted to expand was not rendered at all. Let that toggle override the hiding, so it still reveals every run at once the way it did before. * fix(mobile): re-key the turn timing instead of only carrying its start The previous fix carried the start forward only while the turn was still working. When the transcript echo landed after the turn had already settled, the new key inherited nothing, the settled timing was pruned with the old key, and the turn's "Worked for N" row disappeared entirely. Move the timing onto the new key instead, which covers both orderings: an in-flight turn keeps counting from its original start (and later settles against it), and an already-settled turn keeps its duration. Both orderings are pinned. * test(mobile): pin the structured turn-status wiring at the view level Emulator QA could not reach the structured lane (mobile's Create Tab -> Codex falls back to a terminal tab when agentSession.createSupport says unsupported), so the view's own lane wiring had no coverage — the one seam between the shared turn-timing reducer and the rendered rows. Assert what the view hands each row: the live user turn gets a status object and the three-dot indicator is gone on the structured lane; the bridge lane keeps the indicator and gets no status; a finished turn settles to a numeric duration with a toggle; and an assistant row never carries a status row of its own. * fix(mobile): isolate structured chat turn state * fix(mobile): let the capability RPC actually store what a phone advertises `runtime.clientCapabilities.update` records the advertised set by assigning `authenticatedSocket.clientCapabilities`, but the socket handed to the dispatcher defined that property with a getter only. In strict mode the assignment throws `TypeError: Cannot set property clientCapabilities ... which has only a getter`, so the RPC answered `runtime_error` and the set was never stored. The consequence is not subtle: `supportsStructuredAgentSessions` requires the capability, so `projectSessionTabAgentStatus` removed every `agent-session` tab from a phone that had advertised it correctly. A paired phone saw ZERO tabs on a worktree whose only tab was a structured Codex chat — structured native chat was unreachable on mobile over this transport, not just missing its new turn UI. Give the socket a setter that writes through to the channel, which already owns the set for the connection's lifetime, so later requests on the same socket see it. Found while trying to capture emulator screenshots of the turn-status port: two full QA runs reported the new UI "missing" because the phone could only ever get a bridge/PTY tab. * fix(mobile): carry the turn key instead of caching a handler in a ref Builds on the scope-isolation fix: that kept (and extended) a ref that is written during render — once to memoize a per-turn handler, once to prune dead turns, once to reset on a scope change. React Doctor's "Ref mutated during render" is what CI's `check:react-doctor:changed` was failing on (x2), and on mobile it is a real hazard rather than a style note: react-freeze discards renders, and a discarded render would leave the cache mutated. Pass the settled turn's key down the row instead and let it call one stable handler with it. That preserves both properties the cache was bought for — per scope isolation, and identity stability so a streaming transcript does not defeat the row's memo — with no ref writes and no pruning to get wrong. The scope-keyed expanded set and the 128-turn cap are untouched; their tests move to the new contract and one now pins handler identity across a re-render. Note for future changes here: `check:code-quality:changed` does NOT cover this. CI additionally runs the standalone react-doctor CLI, which has rules the oxlint plugin config does not enable. * fix: ship native chat status translations * test(native-chat): pin the shared copy against the English catalog The shared constants are desktop's i18n fallback and mobile's actually-rendered string. If one changes without the other, desktop keeps rendering en.json while mobile renders the constant — and nothing fails, because a fallback is only used when the key is missing. That silent divergence is the exact thing the shared module exists to prevent, and it is now reachable precisely because these strings are runtime-required rather than statically extracted. Assert every key in both shared copy objects matches en.json byte for byte, plus the interpolation placeholders the catalog interpolates on. --------- Co-authored-by: Merge Sim <sim@local>
357 lines
12 KiB
TypeScript
357 lines
12 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
|
|
agentWorking?: 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. */
|
|
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 workingIndicators(): ReactTestInstance[] {
|
|
return renderer!.root.findAll((node) => node.type === 'WorkingIndicator')
|
|
}
|
|
|
|
it('gives the live user turn a status row and drops the three-dot indicator', async () => {
|
|
const folded = [userTurn('u1', 'go')]
|
|
await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true })
|
|
const props = rowProps('u1')
|
|
expect(props.structuredActivityUi).toBe(true)
|
|
expect(props.turnStatus).toMatchObject({ thinking: true, workedSeconds: null })
|
|
expect(props.activeTurnIsWorking).toBe(true)
|
|
expect(workingIndicators()).toHaveLength(0)
|
|
})
|
|
|
|
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(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).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)
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
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(rowProps('u1').turnStatus).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(rowProps('u2').turnStatus).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)
|
|
})
|
|
})
|
|
})
|