Files
orca/mobile/src/session/MobileNativeChatSessionOptionRows.tsx
T
Brennan Benson 2233fd3669 feat(native-chat): each Claude/Codex chat gets its own permission mode (#26116)
Each native Claude and Codex chat now has its own permission mode (Ask for approval, Accept edits for Claude, Approve for me, Full access), shown and changed from a button right after "+" in the composer. A change takes effect before the next message: the agent restarts quietly and resumes the same conversation, never interrupting a reply or background work. New chats start from Settings → Chat → Permissions, which is seeded once from Agent Permissions. The mode is stored as an ordinary chat option, and the restart reuses the existing "replace the agent before the next message" step.
2026-10-12 15:36:45 -07:00

388 lines
9.5 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, Switch, Text, View } from 'react-native'
import { Check, ChevronDown, ChevronRight } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import {
sessionOptionValueMarker,
type SessionOptionDescriptor,
type SessionOptionValueMarker,
type 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,
labelColor
}: {
label: string
accessibleName: string
disabled: boolean
onPress: () => void
labelColor?: string
}): 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,
labelColor ? { color: labelColor } : null,
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,
labelColor
}: {
label: string
description?: string
selected: boolean
disabled: boolean
grouped: boolean
divided: boolean
onPress: () => void
labelColor?: string
}): 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, labelColor ? { color: labelColor } : null]}>{label}</Text>
{description ? (
<Text style={styles.rowDescription} numberOfLines={2}>
{description}
</Text>
) : null}
</View>
</Pressable>
)
}
function ToggleRow({
label,
checked,
marker,
disabled,
grouped,
onToggle
}: {
label: string
checked: boolean
/** Where the rendered value came from, or null once something picked it. */
marker: SessionOptionValueMarker | null
disabled: boolean
grouped: boolean
onToggle: (next: boolean) => void
}): React.JSX.Element {
return (
<View style={[styles.row, grouped && styles.rowGrouped, disabled && styles.rowDisabled]}>
<View style={styles.rowBody}>
<Text style={styles.rowLabel}>{label}</Text>
</View>
{marker ? (
<Text style={styles.rowMarker}>{marker === 'default' ? 'Default' : 'Not reported'}</Text>
) : null}
<Switch
accessibilityLabel={label}
value={checked}
onValueChange={onToggle}
disabled={disabled}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
)
}
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,
choiceLabelColor
}: {
descriptor: SessionOptionDescriptor
disabled: boolean
grouped?: boolean
onSetOption: (value: SessionOptionValue) => void
onInvokeAction: () => void
/** A choice whose name reads in a colour of its own, as Full access does. */
choiceLabelColor?: (value: string) => string | undefined
}): 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}
/>
)
}
// One switch, not an On/Off pair: the option is binary. The value always
// renders; the marker is what keeps an unpicked one from reading as confirmed,
// since the switch itself cannot say "nobody said".
if (descriptor.kind.type === 'boolean') {
return (
<ToggleRow
label={descriptor.label}
checked={descriptor.kind.currentValue}
marker={sessionOptionValueMarker(descriptor)}
disabled={locked}
grouped={grouped}
onToggle={(next) => onSetOption(next)}
/>
)
}
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)}
labelColor={choiceLabelColor?.(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
},
rowMarker: {
color: colors.textMuted,
fontSize: typography.metaSize
},
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
}
})