Files
orca/mobile/src/session/MobileNativeChatComposer.test.ts
T
Brennan Benson 38a892c980 feat(mobile): native-chat model/session-option picker + shared slash catalog (STA-3332) (#12366)
* feat(mobile): native-chat model/session-option picker + shared slash catalog (STA-3332)

Piece A — shared slash catalog + send classification:
- Mobile composer now serves getVerifiedNativeChatCommands from the shared
  catalog (agent-aware, with description rows) instead of a hardcoded
  provider-agnostic list that advertised commands Claude does not have.
- classifyNativeChatSend moves to src/shared/native-chat-slash-commands.ts
  (renderer re-exports keep desktop import paths stable); mobile's send seam
  now gates optimistic echoes on it, so slash sends no longer create a
  'Queued' bubble that no transcript echo can ever retire, and the
  ack-lost hold only arms for chat sends.

Piece B — mobile model/session-option pickers:
- New per-tab session-option tracking (state/commands/labels modules) ported
  from the desktop live flow, reading the shared agent-session-option
  catalog for Claude AND Codex.
- Composer pill row (model + options) opening an inline choice card in the
  proven Ask-card pattern; applies use catalog modelApply semantics
  (/model <value> via the existing send path), Codex-style agent-picker
  entries dispatch the picker command and flip the tab to the terminal view.
- Current model seeds from the hook-reported provider model when derivable;
  typed /model-style commands update tracked state (recordOutgoingCommand
  parity); dispatched values render as sent-not-confirmed.

* fix(mobile): keep session option sends scoped

* fix(mobile): synchronize native chat refs after commit

* refactor: share native chat session option logic

* fix(mobile): keep the live tab's session-option record from eviction

`getScopedRecord` returned an existing record without re-inserting it, so the
per-tab record map evicted by insertion order rather than recency. A long-lived
active tab is the oldest key, so crossing the 32-scope cap silently dropped its
tracked model and reset the pill to "Model". Desktop's scope cache does
delete-then-set for exactly this reason.

Also moves the shared session-option tests to src/shared so the root suite runs
them (they only exercised src/shared logic the Electron renderer consumes, but
sat under mobile/ where only mobile's vitest project sees them), and restores
two "why" comments dropped while extracting the shared modules.

* fix(mobile): stop a stale session-start report reverting a model pick

Re-entering a chat tab re-delivers the same `agentStatus.model`, and the
reported-model effect re-applied it unconditionally — so picking a model, moving
to another tab, and coming back reverted the pill to the model the agent reported
at session start, which cannot have observed the `/model` sent after it. The
status stream reconnecting had the same effect.

A report is now only treated as evidence when the matched catalog id CHANGES for
that scope; a genuinely new report still supersedes a local pick. Mobile has no
screen read to confirm a switch against, so the repeat is all we can key off.

* fix(mobile): close four session-option picker defects found in review

D1 — a picker apply could interleave with a composer send. The composer already
blocks a text send while an apply is dispatching, but not the reverse: the host
spaces a send's body and its Enter ~500ms apart, so an apply tapped inside that
window was submitted as part of the user's prompt, and the pill then claimed a
model change that never ran as a command. The pickers render inside the composer,
so they now take its in-flight state directly — the same guard, mirrored.

D2 — an option was filed under the wrong model. `setTrackedSessionOption` resolves
the owning model when it commits, not when the command was built, and the report
effect mutates the same record off-queue. A report landing mid-dispatch therefore
recorded `/effort low` against the model it switched TO. Ports desktop's
supersession guard, which skips the commit when the baseline moved.

D3 — a command template's prefix also matches prose that starts with it, so
"/model is a weird word" tracked that prose as the current model, rendered it as
the pill label, and matched no catalog model, dropping every per-model option.
Parsed values are now canonicalized against the catalog; a typed value containing
whitespace is treated as a prompt rather than a command.

Perf — `/` on a Codex tab returned all 45 commands into a non-virtualized
ScrollView showing ~5, re-reconciled on every streaming tick above the transcript.
Capped at 12.

Also splits the row primitives out of MobileNativeChatSessionOptionPickers.tsx,
which the D1 guard pushed to 402 effective lines against a 400 cap.

* refactor: share the session-option display ordering

CATEGORY_ORDER and the non-model sort were byte-identical in
NativeChatSessionOptionPickers.tsx and mobile's labels module — pure logic with
no i18n in it, so there was no reason for two copies that can drift. Both now
call sortNativeChatSessionOptions from the shared snapshot module.

* refactor(mobile): align model picker layout

* style(mobile): round native chat composer

* fix(mobile): inset rounded chat composer
2026-08-04 19:34:17 -07:00

473 lines
16 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 ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { radii, spacing } from '../theme/mobile-theme'
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
vi.mock('react-native', async () => {
const React = await import('react')
return {
ActivityIndicator: 'ActivityIndicator',
Image: 'Image',
Keyboard: { dismiss: vi.fn() },
Pressable: 'Pressable',
ScrollView: ({ children, ...props }: { children?: unknown }) =>
React.createElement('ScrollView', props, children),
StyleSheet: {
create: (styles: unknown) => styles,
hairlineWidth: 1
},
Text: 'Text',
TextInput: 'TextInput',
View: 'View'
}
})
vi.mock('lucide-react-native', () => ({
ArrowUp: 'ArrowUp',
Check: 'Check',
ChevronDown: 'ChevronDown',
ChevronLeft: 'ChevronLeft',
ChevronRight: 'ChevronRight',
ImagePlus: 'ImagePlus',
Mic: 'Mic',
Square: 'Square',
X: 'X'
}))
vi.mock('../components/BottomDrawer', async () => {
const React = await import('react')
return {
BottomDrawer: ({ visible, children }: { visible: boolean; children?: unknown }) =>
visible ? React.createElement('BottomDrawer', { visible }, children) : null
}
})
function suppressRendererWarning(): () => void {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
return () => spy.mockRestore()
}
describe('MobileNativeChatComposer', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
async function render(
onSend: (text: string) => Promise<boolean>,
onChangeText: () => void,
isAttaching = false
) {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: ' hello ',
onChangeText,
onSend,
isAttaching
})
)
})
} finally {
restore()
}
}
function sendButton(): { props: { onPress: () => Promise<void> } } {
if (!renderer) {
throw new Error('Composer was not rendered')
}
return renderer.root.find(
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Send message'
) as { props: { onPress: () => Promise<void> } }
}
it('reports an accepted send without owning route-scoped draft cleanup', async () => {
const onChangeText = vi.fn()
const onSend = vi.fn().mockResolvedValue(true)
await render(onSend, onChangeText)
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith(' hello')
expect(onChangeText).not.toHaveBeenCalled()
})
it('stacks the input above the composer action row', async () => {
await render(vi.fn().mockResolvedValue(true), vi.fn())
const composer = renderer!.root.findByProps({ testID: 'native-chat-composer' })
const inset = renderer!.root.findByProps({ testID: 'native-chat-composer-inset' })
const actions = renderer!.root.findByProps({ testID: 'native-chat-composer-actions' })
expect(composer.findAllByType('TextInput')).toHaveLength(1)
expect(composer.children[1]).toBe(actions)
expect(inset.props.style).toMatchObject({
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.md
})
expect(composer.props.style).toMatchObject({
borderWidth: 1,
borderRadius: radii.card,
overflow: 'hidden'
})
})
it('preserves leading whitespace so prose is not turned into a slash command', async () => {
const onSend = vi.fn().mockResolvedValue(true)
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: ' /clear is prose ',
onChangeText: vi.fn(),
onSend
})
)
})
} finally {
restore()
}
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith(' /clear is prose')
})
it('locks the option pickers while a composer send is in flight', async () => {
// The reverse of the test below. The host spaces a send's body and its Enter
// ~500ms apart, so an apply tapped inside that window would be submitted as
// part of the user's prompt instead of running as its own command.
let releaseSend: ((accepted: boolean) => void) | undefined
const onSend = vi.fn(
() =>
new Promise<boolean>((resolve) => {
releaseSend = resolve
})
)
const controller = {
snapshot: [
{
id: 'model',
label: 'Model',
category: 'model' as const,
kind: {
type: 'select' as const,
choices: [
{ value: 'sonnet', label: 'Sonnet 5' },
{ value: 'opus', label: 'Opus 4.8' }
]
},
valueSource: 'unknown' as const,
settable: true
}
],
pendingId: null,
setOption: vi.fn(),
invokeAction: vi.fn(),
recordCommand: vi.fn()
}
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: 'run the tests',
onChangeText: vi.fn(),
onSend,
sessionOptions: { isWorking: false, controller }
})
)
})
const modelPill = (): { props: { accessibilityState: { disabled: boolean } } } =>
renderer!.root.find(
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Model, Model'
) as { props: { accessibilityState: { disabled: boolean } } }
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
// Start the send but don't await it — it stays in flight on purpose.
let pressed!: Promise<void>
await act(async () => {
pressed = sendButton().props.onPress()
await Promise.resolve()
})
expect(onSend).toHaveBeenCalled()
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: true })
await act(async () => {
releaseSend?.(true)
await pressed
})
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
} finally {
restore()
}
})
it('blocks composer submission while a session-option command is pending', async () => {
const onSend = vi.fn().mockResolvedValue(true)
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: 'hello',
onChangeText: vi.fn(),
onSend,
sessionOptions: {
isWorking: false,
controller: {
snapshot: [],
pendingId: 'model',
setOption: vi.fn(),
invokeAction: vi.fn(),
recordCommand: vi.fn()
}
}
})
)
})
} finally {
restore()
}
expect(sendButton().props).toMatchObject({ disabled: true })
await act(async () => sendButton().props.onPress())
expect(onSend).not.toHaveBeenCalled()
})
it('keeps the draft when the send is rejected', async () => {
const onChangeText = vi.fn()
const onSend = vi.fn().mockResolvedValue(false)
await render(onSend, onChangeText)
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith(' hello')
expect(onChangeText).not.toHaveBeenCalled()
})
it('disables send while an attachment path is still being injected', async () => {
const onSend = vi.fn().mockResolvedValue(true)
await render(onSend, vi.fn(), true)
expect(sendButton().props).toMatchObject({ disabled: true })
await act(async () => sendButton().props.onPress())
expect(onSend).not.toHaveBeenCalled()
})
it('keeps the text input editable while the send is locked', async () => {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: 'half-typed',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
disabled: true
})
)
})
} finally {
restore()
}
// Revoking `editable` on a focused field resigns first responder on iOS and
// yanks the keyboard mid-typing (#10681) — the lock may only gate sending.
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
props: { editable?: boolean }
}
expect(input.props.editable).not.toBe(false)
expect(sendButton().props).toMatchObject({ disabled: true })
})
it('renders a removable thumbnail for each pending image attachment', async () => {
const onRemoveAttachment = vi.fn()
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
attachments: [
{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' },
{ id: 'img-2', path: '/tmp/b.png', previewUri: 'file:///b.png' }
],
onRemoveAttachment
})
)
})
} finally {
restore()
}
const thumbs = renderer!.root.findAll((node) => node.type === 'Image') as Array<{
props: { source: { uri: string } }
}>
expect(thumbs.map((t) => t.props.source.uri)).toEqual(['file:///a.png', 'file:///b.png'])
const remove = renderer!.root.findAll(
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Remove image'
) as Array<{ props: { onPress: () => void } }>
remove[1].props.onPress()
expect(onRemoveAttachment).toHaveBeenCalledWith('img-2')
})
it('enables send with an attached image even when the text is empty', async () => {
const onSend = vi.fn().mockResolvedValue(true)
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '',
onChangeText: vi.fn(),
onSend,
attachments: [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }]
})
)
})
} finally {
restore()
}
expect(sendButton().props).toMatchObject({ disabled: false })
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith('')
})
it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '/c',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
agent: 'claude'
})
)
})
} finally {
restore()
}
const input = () =>
renderer!.root.find((node) => node.type === 'TextInput') as {
props: {
selection?: { start: number; end: number }
onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void
}
}
// Uncontrolled selection until a suggestion is applied.
expect(input().props.selection).toBeUndefined()
// Place the caret at the end so the slash trigger is active and suggestions render.
await act(async () =>
input().props.onSelectionChange({ nativeEvent: { selection: { end: 2 } } })
)
const firstSuggestion = renderer!.root.findAll(
(node) => node.type === 'Pressable' && !node.props.accessibilityLabel
)[0] as { props: { onPress: () => void } }
await act(async () => firstSuggestion.props.onPress())
// `/clear ` is 7 chars — the caret jumps just past the inserted command + space.
expect(input().props.selection).toEqual({ start: 7, end: 7 })
// The next native selection event releases control so manual placement still works.
await act(async () =>
input().props.onSelectionChange({ nativeEvent: { selection: { end: 7 } } })
)
expect(input().props.selection).toBeUndefined()
})
it('serves the active agent’s shared command catalog with descriptions', async () => {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '/',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
agent: 'codex'
})
)
})
} finally {
restore()
}
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
props: { onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void }
}
await act(async () => input.props.onSelectionChange({ nativeEvent: { selection: { end: 1 } } }))
const texts = renderer!.root
.findAll((node) => node.type === 'Text')
.map((node) => (node.props as { children?: unknown }).children)
// Codex-only commands from the shared catalog, with their description rows —
// and none of the old hardcoded provider-agnostic list's phantom entries.
expect(texts).toContain('/permissions')
expect(texts).toContain('Choose what Codex is allowed to do')
expect(texts).not.toContain('/cost')
})
it('wires the mic for hold vs toggle dictation like the terminal composer', async () => {
const onMicPress = vi.fn()
const onMicPressIn = vi.fn()
const onMicPressOut = vi.fn()
const mic = () =>
renderer!.root.find(
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Dictate'
) as { props: { onPress?: unknown; onPressIn?: unknown; onPressOut?: unknown } }
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
onMicPress,
dictationMode: 'hold',
onMicPressIn,
onMicPressOut
})
)
})
// Hold mode is walkie-talkie: press-in/out drive dictation, tap is inert.
expect(mic().props.onPress).toBeUndefined()
expect(mic().props.onPressIn).toBe(onMicPressIn)
expect(mic().props.onPressOut).toBe(onMicPressOut)
await act(async () => {
renderer!.update(
createElement(MobileNativeChatComposer, {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
onMicPress,
dictationMode: 'toggle',
onMicPressIn,
onMicPressOut
})
)
})
// Toggle mode: tap drives dictation, press-in/out inert.
expect(mic().props.onPress).toBe(onMicPress)
expect(mic().props.onPressIn).toBeUndefined()
expect(mic().props.onPressOut).toBeUndefined()
} finally {
restore()
}
})
})