Files
orca/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx
T
Brennan BensonandMerge Sim f1d8545024 feat(chat): support structured /clear and /compact commands (#19164)
* feat(chat): support structured clear and compact commands

* fix(chat): authorize mobile commands and bound clear-chain projection

* fix(chat): localize conversation command send errors

* fix(chat): retain clear pane identity with reopened history

* test: account for combined structured session RPC additions

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-06 21:17:00 -07:00

213 lines
7.5 KiB
TypeScript

import { useState } from 'react'
import { ActivityIndicator, Keyboard, Pressable, StyleSheet, Text, View } from 'react-native'
import { ChevronLeft, X } from 'lucide-react-native'
import { BottomDrawer } from '../components/BottomDrawer'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import {
sessionOptionDispatchUnconfirmed,
type SessionOptionDescriptor,
type SessionOptionValue
} from '../../../src/shared/native-chat-session-options'
import {
mobileModelPillLabel,
mobileOptionsPillLabel,
mobileSessionOptionSummaryValue,
mobileSessionOptionDisabledReason
} from './mobile-native-chat-session-option-labels'
import {
DescriptorRows,
Pill,
SessionOptionCaption,
SessionOptionSummaryRow
} from './MobileNativeChatSessionOptionRows'
import { sortNativeChatSessionOptions } from '../../../src/shared/native-chat-session-option-snapshot'
import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options'
export type MobileNativeChatSessionOptionPickersProps = {
controller: MobileNativeChatSessionOptionsController
/** Pickers lock while the agent works — a mid-turn `/model` interleaves with
* the agent's own output (desktop parity). */
isWorking: boolean
/** A composer send owns the TUI input line until it settles. The host spaces a
* send's body and its Enter ~500ms apart, so an apply dispatched inside that
* window would be submitted as part of the user's prompt. The composer blocks
* the reverse direction on `pendingId`; this is the same guard mirrored. */
sendInFlight?: boolean
}
/** Combined model/session-option trigger and its mobile bottom drawer. */
export function MobileNativeChatSessionOptionPickers({
controller,
isWorking,
sendInFlight = false
}: MobileNativeChatSessionOptionPickersProps): React.JSX.Element | null {
const [openDescriptorId, setOpenDescriptorId] = useState<string | null>(null)
const [lastRequest, setLastRequest] = useState(controller.optionPickerRequest)
if (controller.optionPickerRequest && lastRequest !== controller.optionPickerRequest) {
setLastRequest(controller.optionPickerRequest)
setOpenDescriptorId(controller.optionPickerRequest.id)
}
const { snapshot, pendingId } = controller
const model = snapshot.find((descriptor) => descriptor.category === 'model')
const options = sortNativeChatSessionOptions(snapshot)
if (!model) {
return null
}
const disabled = isWorking || pendingId !== null || sendInFlight
const activeDescriptor = snapshot.find((descriptor) => descriptor.id === openDescriptorId)
const modelView = activeDescriptor?.id === model.id
const modelLabel = mobileModelPillLabel(model)
const optionsLabel = options.length > 0 ? mobileOptionsPillLabel(options) : null
const pillLabel = optionsLabel ? `${modelLabel} ${optionsLabel}` : modelLabel
const reason = mobileSessionOptionDisabledReason(activeDescriptor?.disabledReason)
const closePicker = (): void => setOpenDescriptorId(null)
const openPicker = (): void => {
Keyboard.dismiss()
setOpenDescriptorId(model.id)
}
const applyOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => {
// Re-picking the tracked value is a no-op — never re-dispatch it.
if (
descriptor.valueSource !== 'unknown' &&
descriptor.kind.type === 'select' &&
descriptor.kind.currentValue === value
) {
closePicker()
return
}
void controller.setOption(descriptor.id, value).then((applied) => {
if (applied) {
closePicker()
}
})
}
const invokeAction = (descriptor: SessionOptionDescriptor): void => {
void controller.invokeAction(descriptor.id).then((invoked) => {
if (invoked) {
closePicker()
}
})
}
return (
<View>
<Pill
label={pillLabel}
accessibleName={`Model, ${pillLabel}`}
disabled={disabled}
onPress={openPicker}
/>
<BottomDrawer visible={activeDescriptor !== undefined} onClose={closePicker}>
{activeDescriptor ? (
<View style={styles.sheet}>
<View style={styles.sheetHeader}>
<Pressable
accessibilityLabel={modelView ? 'Close picker' : 'Back to models'}
accessibilityRole="button"
style={({ pressed }) => [styles.sheetNav, pressed && styles.pressed]}
onPress={modelView ? closePicker : () => setOpenDescriptorId(model.id)}
hitSlop={8}
>
{modelView ? (
<X size={18} color={colors.textSecondary} strokeWidth={2.2} />
) : (
<ChevronLeft size={18} color={colors.textSecondary} strokeWidth={2.2} />
)}
</Pressable>
<Text style={styles.sheetTitle}>
{modelView ? 'Select model' : `Select ${activeDescriptor.label.toLowerCase()}`}
</Text>
<View style={styles.sheetHeaderSide}>
{pendingId !== null ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : null}
</View>
</View>
{sessionOptionDispatchUnconfirmed(activeDescriptor) ? (
<SessionOptionCaption>Sent to the agent — not confirmed</SessionOptionCaption>
) : null}
{reason ? <SessionOptionCaption>{reason}</SessionOptionCaption> : null}
<View style={styles.choiceGroup}>
<DescriptorRows
descriptor={activeDescriptor}
disabled={disabled}
grouped
onSetOption={(value) => applyOption(activeDescriptor, value)}
onInvokeAction={() => invokeAction(activeDescriptor)}
/>
</View>
{modelView && options.length > 0 ? (
<View style={styles.optionGroup}>
{options.map((descriptor, index) => (
<SessionOptionSummaryRow
key={descriptor.id}
label={descriptor.label}
value={mobileSessionOptionSummaryValue(descriptor)}
disabled={disabled}
divided={index < options.length - 1}
onPress={() => setOpenDescriptorId(descriptor.id)}
/>
))}
</View>
) : null}
</View>
) : null}
</BottomDrawer>
</View>
)
}
const styles = StyleSheet.create({
sheet: {
paddingBottom: spacing.xs
},
sheetHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingBottom: spacing.lg
},
sheetTitle: {
flex: 1,
color: colors.textPrimary,
fontSize: typography.titleSize,
fontWeight: '700',
textAlign: 'center'
},
sheetNav: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
},
sheetHeaderSide: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center'
},
choiceGroup: {
overflow: 'hidden',
borderRadius: radii.card,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgRaised
},
optionGroup: {
overflow: 'hidden',
marginTop: spacing.md,
borderRadius: radii.card,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgRaised
},
pressed: {
opacity: 0.7
}
})