mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
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.
388 lines
9.5 KiB
TypeScript
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
|
|
}
|
|
})
|