Files
orca/mobile/src/session/MobileNativeChatSessionOptionRows.tsx
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

346 lines
8.3 KiB
TypeScript

// The pill and choice-row primitives the session-option card is built from, kept
// beside it so the card file stays about layout and apply wiring.
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { Check, ChevronDown, ChevronRight } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type {
SessionOptionDescriptor,
SessionOptionValue
} from '../../../src/shared/native-chat-session-options'
/** Muted one-liner above a group — dispatch state, or why a row is locked. */
export function SessionOptionCaption({ children }: { children: string }): React.JSX.Element {
return <Text style={styles.caption}>{children}</Text>
}
export function Pill({
label,
accessibleName,
disabled,
onPress
}: {
label: string
accessibleName: string
disabled: boolean
onPress: () => void
}): React.JSX.Element {
return (
<Pressable
accessibilityLabel={accessibleName}
accessibilityRole="button"
accessibilityState={{ disabled }}
style={({ pressed }) => [styles.pill, pressed && !disabled && styles.pressed]}
onPress={onPress}
disabled={disabled}
hitSlop={6}
>
<Text
style={[styles.pillText, disabled && styles.pillTextDisabled]}
numberOfLines={1}
ellipsizeMode="tail"
>
{label}
</Text>
<ChevronDown size={12} color={disabled ? colors.textMuted : colors.textSecondary} />
</Pressable>
)
}
function ChoiceRow({
label,
description,
selected,
disabled,
grouped,
divided,
onPress
}: {
label: string
description?: string
selected: boolean
disabled: boolean
grouped: boolean
divided: boolean
onPress: () => void
}): React.JSX.Element {
return (
<Pressable
accessibilityRole="radio"
accessibilityState={{ checked: selected, disabled }}
style={[
styles.row,
selected && styles.rowSelected,
grouped && styles.rowGrouped,
divided && styles.rowDivided,
disabled && styles.rowDisabled
]}
onPress={onPress}
disabled={disabled}
>
<View style={[styles.radio, selected && styles.radioOn]}>
{selected ? <Check size={12} color={colors.bgBase} strokeWidth={3} /> : null}
</View>
<View style={styles.rowBody}>
<Text style={styles.rowLabel}>{label}</Text>
{description ? (
<Text style={styles.rowDescription} numberOfLines={2}>
{description}
</Text>
) : null}
</View>
</Pressable>
)
}
function ActionRow({
label,
disabled,
grouped,
onPress
}: {
label: string
disabled: boolean
grouped: boolean
onPress: () => void
}): React.JSX.Element {
return (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled }}
style={[styles.row, grouped && styles.rowGrouped, disabled && styles.rowDisabled]}
onPress={onPress}
disabled={disabled}
>
<View style={styles.rowBody}>
<Text style={styles.rowLabel}>{label}</Text>
</View>
</Pressable>
)
}
export function SessionOptionSummaryRow({
label,
value,
disabled,
divided,
onPress
}: {
label: string
value: string
disabled: boolean
divided: boolean
onPress: () => void
}): React.JSX.Element {
return (
<Pressable
accessibilityLabel={`${label}, ${value}`}
accessibilityRole="button"
accessibilityState={{ disabled }}
style={({ pressed }) => [
styles.summaryRow,
divided && styles.rowDivided,
pressed && !disabled && styles.pressed,
disabled && styles.rowDisabled
]}
onPress={onPress}
disabled={disabled}
>
<Text style={styles.summaryLabel}>{label}</Text>
<Text style={styles.summaryValue} numberOfLines={1}>
{value}
</Text>
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.2} />
</Pressable>
)
}
export function DescriptorRows({
descriptor,
disabled,
grouped = false,
onSetOption,
onInvokeAction
}: {
descriptor: SessionOptionDescriptor
disabled: boolean
grouped?: boolean
onSetOption: (value: SessionOptionValue) => void
onInvokeAction: () => void
}): React.JSX.Element {
const locked = disabled || !descriptor.settable
// Why: flip-only without a baseline is an action — never claim On/Off.
if (descriptor.action?.type === 'toggle-command') {
return (
<ActionRow
label={`Toggle ${descriptor.label.toLowerCase()}`}
disabled={locked}
grouped={grouped}
onPress={onInvokeAction}
/>
)
}
// Why: agent-picker opens the TUI; it is not a set of radio choices.
if (descriptor.action?.type === 'agent-picker') {
return (
<ActionRow
label="Choose in agent picker…"
disabled={locked}
grouped={grouped}
onPress={onInvokeAction}
/>
)
}
// Unknown booleans leave both radios unselected instead of inventing truth.
if (descriptor.kind.type === 'boolean') {
const current = descriptor.kind.currentValue
return (
<>
{current === undefined ? (
<SessionOptionCaption>Current value unknown — pick On or Off</SessionOptionCaption>
) : null}
<ChoiceRow
label="On"
selected={current === true}
disabled={locked}
grouped={grouped}
divided={grouped}
onPress={() => onSetOption(true)}
/>
<ChoiceRow
label="Off"
selected={current === false}
disabled={locked}
grouped={grouped}
divided={false}
onPress={() => onSetOption(false)}
/>
</>
)
}
const { currentValue, choices } = descriptor.kind
return (
<>
{choices.map((choice, index) => (
<ChoiceRow
key={choice.value}
label={choice.label}
description={choice.description}
selected={choice.value === currentValue}
disabled={locked}
grouped={grouped}
divided={grouped && index < choices.length - 1}
onPress={() => onSetOption(choice.value)}
/>
))}
</>
)
}
const styles = StyleSheet.create({
pill: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
maxWidth: 180,
minHeight: 28,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radii.button,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgRaised
},
pillText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '600',
flexShrink: 1
},
pillTextDisabled: {
color: colors.textMuted
},
pressed: {
opacity: 0.7
},
caption: {
color: colors.textMuted,
fontSize: typography.metaSize,
paddingHorizontal: spacing.md,
paddingBottom: spacing.xs
},
row: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.sm,
minHeight: 44,
alignItems: 'center',
borderRadius: radii.card,
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
marginBottom: spacing.xs
},
rowSelected: {
borderColor: colors.statusGreen
},
rowGrouped: {
marginBottom: 0,
borderWidth: 0,
borderRadius: 0,
backgroundColor: 'transparent'
},
rowDivided: {
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
rowDisabled: {
opacity: 0.5
},
radio: {
width: 18,
height: 18,
borderRadius: 9,
borderWidth: 1.5,
borderColor: colors.textMuted,
alignItems: 'center',
justifyContent: 'center'
},
radioOn: {
backgroundColor: colors.statusGreen,
borderColor: colors.statusGreen
},
rowBody: {
flex: 1,
gap: 2
},
rowLabel: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
rowDescription: {
color: colors.textSecondary,
fontSize: typography.metaSize
},
summaryRow: {
minHeight: 48,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm
},
summaryLabel: {
flex: 1,
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
summaryValue: {
maxWidth: 160,
color: colors.textSecondary,
fontSize: typography.bodySize
}
})