mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 00:02:34 +00:00
* 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>
213 lines
7.5 KiB
TypeScript
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
|
|
}
|
|
})
|