mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 00:02:19 +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
346 lines
8.3 KiB
TypeScript
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
|
|
}
|
|
})
|