mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +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
245 lines
7.6 KiB
TypeScript
245 lines
7.6 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 type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options'
|
|
import { MobileNativeChatSessionOptionPickers } from './MobileNativeChatSessionOptionPickers'
|
|
import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options'
|
|
|
|
vi.mock('react-native', () => ({
|
|
ActivityIndicator: 'ActivityIndicator',
|
|
Keyboard: { dismiss: vi.fn() },
|
|
Pressable: 'Pressable',
|
|
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
|
Text: 'Text',
|
|
View: 'View'
|
|
}))
|
|
vi.mock('lucide-react-native', () => ({
|
|
Check: 'Check',
|
|
ChevronDown: 'ChevronDown',
|
|
ChevronLeft: 'ChevronLeft',
|
|
ChevronRight: 'ChevronRight',
|
|
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
|
|
}
|
|
})
|
|
|
|
const MODEL_DESCRIPTOR: SessionOptionDescriptor = {
|
|
id: 'model',
|
|
label: 'Model',
|
|
category: 'model',
|
|
kind: {
|
|
type: 'select',
|
|
currentValue: 'sonnet',
|
|
choices: [
|
|
{ value: 'sonnet', label: 'Sonnet 5' },
|
|
{ value: 'opus', label: 'Opus 4.8', description: 'Most capable' }
|
|
]
|
|
},
|
|
valueSource: 'reported',
|
|
settable: true
|
|
}
|
|
|
|
const EFFORT_DESCRIPTOR: SessionOptionDescriptor = {
|
|
id: 'effort',
|
|
label: 'Effort',
|
|
category: 'thought_level',
|
|
kind: {
|
|
type: 'select',
|
|
currentValue: 'high',
|
|
choices: [
|
|
{ value: 'low', label: 'Low' },
|
|
{ value: 'high', label: 'High' }
|
|
]
|
|
},
|
|
valueSource: 'dispatched',
|
|
settable: true
|
|
}
|
|
|
|
const FAST_MODE_DESCRIPTOR: SessionOptionDescriptor = {
|
|
id: 'fastMode',
|
|
label: 'Fast mode',
|
|
category: 'mode',
|
|
kind: { type: 'boolean', currentValue: false },
|
|
valueSource: 'reported',
|
|
settable: true
|
|
}
|
|
|
|
describe('MobileNativeChatSessionOptionPickers', () => {
|
|
let renderer: ReactTestRenderer | null = null
|
|
const setOption = vi.fn<MobileNativeChatSessionOptionsController['setOption']>()
|
|
const invokeAction = vi.fn<MobileNativeChatSessionOptionsController['invokeAction']>()
|
|
|
|
const mount = (snapshot: SessionOptionDescriptor[], isWorking = false): void => {
|
|
const controller: MobileNativeChatSessionOptionsController = {
|
|
snapshot,
|
|
pendingId: null,
|
|
setOption,
|
|
invokeAction,
|
|
recordCommand: vi.fn()
|
|
}
|
|
act(() => {
|
|
renderer = create(
|
|
createElement(MobileNativeChatSessionOptionPickers, { controller, isWorking })
|
|
)
|
|
})
|
|
}
|
|
|
|
const pill = (
|
|
name: string
|
|
): {
|
|
props: {
|
|
onPress: () => void
|
|
accessibilityLabel?: string
|
|
disabled?: boolean
|
|
accessibilityRole?: string
|
|
accessibilityState?: { disabled?: boolean }
|
|
}
|
|
} =>
|
|
renderer!.root.find(
|
|
(node) =>
|
|
node.type === 'Pressable' &&
|
|
typeof node.props.accessibilityLabel === 'string' &&
|
|
node.props.accessibilityLabel.startsWith(name)
|
|
) as {
|
|
props: {
|
|
onPress: () => void
|
|
accessibilityLabel?: string
|
|
disabled?: boolean
|
|
accessibilityRole?: string
|
|
accessibilityState?: { disabled?: boolean }
|
|
}
|
|
}
|
|
|
|
const rowByText = (
|
|
text: string
|
|
): {
|
|
props: {
|
|
onPress: () => void
|
|
accessibilityRole?: string
|
|
accessibilityLabel?: string
|
|
accessibilityState?: { checked?: boolean; disabled?: boolean }
|
|
}
|
|
} => {
|
|
const label = renderer!.root
|
|
.findAll((node) => node.type === 'Text')
|
|
.find((node) => (node.props as { children?: unknown }).children === text)
|
|
if (!label) {
|
|
throw new Error(`No row labeled ${text}`)
|
|
}
|
|
let parent = label.parent
|
|
while (parent && parent.type !== 'Pressable') {
|
|
parent = parent.parent
|
|
}
|
|
if (!parent) {
|
|
throw new Error(`No pressable row for ${text}`)
|
|
}
|
|
return parent as unknown as {
|
|
props: {
|
|
onPress: () => void
|
|
accessibilityRole?: string
|
|
accessibilityLabel?: string
|
|
accessibilityState?: { checked?: boolean; disabled?: boolean }
|
|
}
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
|
setOption.mockReset()
|
|
setOption.mockResolvedValue(true)
|
|
invokeAction.mockReset()
|
|
invokeAction.mockResolvedValue(true)
|
|
})
|
|
afterEach(() => {
|
|
act(() => {
|
|
renderer?.unmount()
|
|
})
|
|
renderer = null
|
|
})
|
|
|
|
it('renders nothing without a model descriptor', () => {
|
|
mount([])
|
|
expect(renderer!.toJSON()).toBeNull()
|
|
})
|
|
|
|
it('shows the current model and effort in one pill', () => {
|
|
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
|
|
expect(pill('Model').props).toMatchObject({
|
|
accessibilityLabel: 'Model, Sonnet 5 High',
|
|
disabled: false
|
|
})
|
|
const labels = renderer!.root
|
|
.findAll((node) => node.type === 'Text')
|
|
.map((node) => (node.props as { children?: unknown }).children)
|
|
expect(labels).toContain('Sonnet 5 High')
|
|
})
|
|
|
|
it('opens the model sheet and applies a picked model', async () => {
|
|
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
|
|
await act(async () => pill('Model').props.onPress())
|
|
expect(renderer!.root.findByType('BottomDrawer').props.visible).toBe(true)
|
|
await act(async () => rowByText('Opus 4.8').props.onPress())
|
|
expect(setOption).toHaveBeenCalledWith('model', 'opus')
|
|
})
|
|
|
|
it('opens an option picker from the model sheet summary', async () => {
|
|
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
|
|
await act(async () => pill('Model').props.onPress())
|
|
await act(async () => rowByText('Effort').props.onPress())
|
|
await act(async () => rowByText('Low').props.onPress())
|
|
expect(setOption).toHaveBeenCalledWith('effort', 'low')
|
|
})
|
|
|
|
it('shows absolute boolean values in option summaries', async () => {
|
|
mount([MODEL_DESCRIPTOR, FAST_MODE_DESCRIPTOR])
|
|
await act(async () => pill('Model').props.onPress())
|
|
expect(rowByText('Off').props.accessibilityLabel).toBe('Fast mode, Off')
|
|
})
|
|
|
|
it('announces choice selection and disabled state', async () => {
|
|
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
|
|
expect(pill('Model').props).toMatchObject({
|
|
accessibilityRole: 'button',
|
|
accessibilityState: { disabled: false }
|
|
})
|
|
await act(async () => pill('Model').props.onPress())
|
|
expect(rowByText('Sonnet 5').props).toMatchObject({
|
|
accessibilityRole: 'radio',
|
|
accessibilityState: { checked: true, disabled: false }
|
|
})
|
|
expect(rowByText('Opus 4.8').props.accessibilityState?.checked).toBe(false)
|
|
})
|
|
|
|
it('closes without dispatch when re-picking the tracked value', async () => {
|
|
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
|
|
await act(async () => pill('Model').props.onPress())
|
|
await act(async () => rowByText('Sonnet 5').props.onPress())
|
|
expect(setOption).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('renders agent-picker descriptors as a single action row', async () => {
|
|
mount([
|
|
{
|
|
...MODEL_DESCRIPTOR,
|
|
kind: { type: 'select', choices: [] },
|
|
valueSource: 'unknown',
|
|
action: { type: 'agent-picker' }
|
|
}
|
|
])
|
|
await act(async () => pill('Model').props.onPress())
|
|
expect(rowByText('Choose in agent picker…').props.accessibilityRole).toBe('button')
|
|
await act(async () => rowByText('Choose in agent picker…').props.onPress())
|
|
expect(invokeAction).toHaveBeenCalledWith('model')
|
|
})
|
|
|
|
it('locks the pills while the agent is working', () => {
|
|
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR], true)
|
|
expect(pill('Model').props).toMatchObject({ disabled: true })
|
|
})
|
|
})
|