Files
orca/mobile/src/session/mobile-native-chat-autocomplete.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

94 lines
2.9 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
applyAutocomplete,
detectAutocompleteTrigger,
rankSlashCommandSuggestions,
rankSuggestions
} from './mobile-native-chat-autocomplete'
describe('detectAutocompleteTrigger', () => {
it('detects a slash command only at the start', () => {
expect(detectAutocompleteTrigger('/rev', 4)).toEqual({
kind: 'slash',
query: 'rev',
start: 0,
end: 4
})
expect(detectAutocompleteTrigger('hi /rev', 7)).toBeNull()
})
it('detects an @ mention after whitespace or at start', () => {
expect(detectAutocompleteTrigger('@src', 4)).toMatchObject({ kind: 'file', query: 'src' })
expect(detectAutocompleteTrigger('look at @comp', 13)).toMatchObject({
kind: 'file',
query: 'comp'
})
})
it('does not trigger @ mid-word (email-like)', () => {
expect(detectAutocompleteTrigger('me@host', 7)).toBeNull()
})
it('closes the token once a space is typed', () => {
expect(detectAutocompleteTrigger('@src ', 5)).toBeNull()
})
it('returns empty query right after the trigger char', () => {
expect(detectAutocompleteTrigger('@', 1)).toMatchObject({ kind: 'file', query: '' })
})
})
describe('applyAutocomplete', () => {
it('replaces the trigger span and leaves a trailing space + cursor', () => {
const trigger = detectAutocompleteTrigger('look at @comp', 13)!
const { text, cursor } = applyAutocomplete('look at @comp', trigger, '@src/App.tsx')
expect(text).toBe('look at @src/App.tsx ')
expect(cursor).toBe(text.length)
})
})
describe('rankSuggestions', () => {
it('prefers prefix matches on the basename', () => {
const out = rankSuggestions(['src/app/Main.tsx', 'src/AppBar.tsx', 'lib/zapp.ts'], 'app')
expect(out[0]).toBe('src/AppBar.tsx')
expect(out).toContain('lib/zapp.ts')
})
it('returns the head of the list for an empty query', () => {
expect(rankSuggestions(['a', 'b', 'c'], '', 2)).toEqual(['a', 'b'])
})
})
describe('rankSlashCommandSuggestions', () => {
const commands = [
{ name: 'clear', description: 'Clear conversation history' },
{ name: 'compact', description: 'Summarize and compact' },
{ name: 'mcp', description: 'List MCP tools' }
]
it('shows the whole catalog for a bare slash', () => {
expect(rankSlashCommandSuggestions(commands, '').map((c) => c.name)).toEqual([
'clear',
'compact',
'mcp'
])
})
it('ranks prefix matches ahead of substring matches', () => {
expect(rankSlashCommandSuggestions(commands, 'c').map((c) => c.name)).toEqual([
'clear',
'compact',
'mcp'
])
expect(rankSlashCommandSuggestions(commands, 'm').map((c) => c.name)).toEqual([
'mcp',
'compact'
])
})
it('is case-insensitive and drops non-matches', () => {
expect(rankSlashCommandSuggestions(commands, 'CLE').map((c) => c.name)).toEqual(['clear'])
expect(rankSlashCommandSuggestions(commands, 'zzz')).toEqual([])
})
})