mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 00:03:55 +00:00
* 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
473 lines
16 KiB
TypeScript
473 lines
16 KiB
TypeScript
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()
|
||
}
|
||
})
|
||
})
|