import type { ModelSelection, ProviderInteractionMode, ProviderOptionDescriptor, ProviderOptionSelection, RuntimeMode,} from "@t3tools/contracts";import { getProviderOptionCurrentLabel, getProviderOptionCurrentValue, getProviderOptionDescriptors,} from "@t3tools/shared/model";import * as Haptics from "expo-haptics";import { useCallback, useEffect, useRef, useState } from "react";import { Modal, Platform, Pressable, ScrollView, Switch, useWindowDimensions, View,} from "react-native";import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/AppSymbol";import { AppText as Text } from "../../components/AppText";import { ProviderIcon } from "../../components/ProviderIcon";import { cn } from "../../lib/cn";import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions";import { useThemeColor } from "../../lib/useThemeColor";import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-menu";import { pendingModelAfterPress } from "./thread-settings-sheet-state";import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation"; /** * The everyday harnesses stay expanded; every other provider (OpenRouter * catalogs and friends) folds behind its header so a 300-model catalog can't * bury the list. */const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set(["claudeAgent", "codex"]); /** * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill, * covering model, provider options, runtime mode, and plan mode in one label. */export function threadSettingsSummaryLabel(input: { readonly modelLabel: string; readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode;}): string { const runtime = RUNTIME_MODE_CHOICES.find((choice) => choice.mode === input.runtimeMode); return [ input.modelLabel, ...providerOptionValueLabels(input.optionDescriptors), ...(runtime ? [runtime.shortLabel] : []), ...(input.interactionMode === "plan" ? ["Plan"] : []), ].join(" · ");} function ModelRow(props: { readonly option: ModelOption; readonly selected: boolean; readonly onPress: () => void;}) { const primaryFg = useThemeColor("--color-primary-foreground"); return ( <Pressable accessibilityRole="button" accessibilityState={{ selected: props.selected }} onPress={props.onPress} // Selected rows get the same primary treatment as the submenu rows. // Subtle backgrounds (bg-subtle-strong) get overridden by the OS // selection chrome on iOS 26, so use the explicit high-contrast style // everywhere instead. className={cn( "mx-2.5 flex-row items-center gap-2 rounded-xl px-3 py-3.5 active:opacity-70", props.selected ? "bg-primary" : "bg-transparent", )} > <Text className={cn( "shrink text-sm font-t3-medium", props.selected ? "text-primary-foreground" : "text-foreground", )} numberOfLines={1} > {props.option.label} </Text> {props.option.isDefault ? ( <View className="rounded-md bg-subtle-strong px-1.5 py-0.5"> <Text className="text-3xs font-t3-bold text-foreground-muted">Default</Text> </View> ) : null} {props.option.isLegacy ? ( <View className="rounded-md bg-subtle px-1.5 py-0.5"> <Text className="text-3xs font-t3-bold text-foreground-muted">Legacy</Text> </View> ) : null} <View className="flex-1" /> {props.selected ? ( <SymbolView name="checkmark" size={14} tintColor={primaryFg} type="monochrome" /> ) : null} </Pressable> );} /** * Provider section header with the harness logo. Secondary providers render * as a tappable fold (count + chevron while collapsed); primary providers * and the group holding the current selection are static headers. */function ProviderHeader(props: { readonly driver: string | undefined; readonly label: string; readonly collapsible: boolean; readonly collapsed: boolean; readonly modelCount: number; readonly onToggle: () => void;}) { const iconSubtle = useThemeColor("--color-icon-subtle"); return ( <Pressable accessibilityRole={props.collapsible ? "button" : "header"} accessibilityState={props.collapsible ? { expanded: !props.collapsed } : undefined} accessibilityLabel={ props.collapsible ? `${props.label}, ${props.modelCount} models` : props.label } disabled={!props.collapsible} onPress={props.onToggle} className={cn( "mx-2.5 flex-row items-center gap-2 rounded-xl px-3", props.collapsible ? "py-3.5 active:opacity-70" : "pb-2 pt-4", )} > <ProviderIcon provider={props.driver} size={15} /> <Text className="text-2xs font-t3-bold uppercase tracking-widest text-foreground-muted"> {props.label} </Text> {props.collapsible ? ( <> <View className="flex-1" /> {props.collapsed ? ( <Text className="text-2xs font-t3-medium text-foreground-muted"> {props.modelCount} </Text> ) : null} <SymbolView name={props.collapsed ? "chevron.down" : "chevron.up"} size={11} tintColor={iconSubtle} type="monochrome" /> </> ) : null} </Pressable> );} /** Compact row that opens a single-choice submenu panel. */function DisclosureRow(props: { readonly label: string; readonly value: string | undefined; readonly disabled?: boolean; readonly onPress: () => void;}) { const iconSubtle = useThemeColor("--color-icon-subtle"); return ( <Pressable accessibilityRole="button" disabled={props.disabled} onPress={props.onPress} className={cn( "flex-row items-center gap-2 px-5 py-3 active:opacity-70", props.disabled && "opacity-40", )} > <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text> <View className="flex-1" /> {props.value ? ( <Text className="text-sm text-foreground-muted" numberOfLines={1}> {props.value} </Text> ) : null} <SymbolView name="chevron.right" size={12} tintColor={iconSubtle} type="monochrome" /> </Pressable> );} /** Single option inside a submenu panel. */function ChoiceRow(props: { readonly label: string; readonly selected: boolean; readonly onPress: () => void;}) { const primaryFg = useThemeColor("--color-primary-foreground"); return ( <Pressable accessibilityRole="button" accessibilityState={{ selected: props.selected }} onPress={props.onPress} className={cn( "mx-2.5 flex-row items-center rounded-xl px-3 py-3.5 active:opacity-70", props.selected ? "bg-primary" : "bg-transparent", )} > <Text className={cn( "shrink text-sm font-t3-medium", props.selected ? "text-primary-foreground" : "text-foreground", )} > {props.label} </Text> <View className="flex-1" /> {props.selected ? ( <SymbolView name="checkmark" size={14} tintColor={primaryFg} type="monochrome" /> ) : null} </Pressable> );} function SwitchRow(props: { readonly label: string; readonly value: boolean; readonly disabled?: boolean; readonly onValueChange: (value: boolean) => void;}) { const activeTrack = String(useThemeColor("--color-switch-active")); const track = String(useThemeColor("--color-secondary-border")); return ( <View className={cn( "flex-row items-center justify-between px-5 py-2.5", props.disabled && "opacity-40", )} > <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text> <Switch disabled={props.disabled} ios_backgroundColor={track} onValueChange={props.onValueChange} trackColor={{ false: track, true: activeTrack }} value={props.value} /> </View> );} type SubmenuPage = | { readonly kind: "descriptor"; readonly id: string } | { readonly kind: "runtime" }; /** * Unified thread settings: the sheet is the provider-grouped model list * (primary harnesses expanded, other providers folded, legacy behind the * top-right pill) with a Save button, plus compact disclosure rows whose * single-choice submenus stack in a small panel over the sheet so it never * changes size. Model changes stage until Save — while staged, the settings * rows edit the staged model's options and Save applies everything together. * * Callers control which harnesses are offered via providerGroups: an * existing thread must pass only its own provider's group, since a session * can't switch harness mid-thread. * * Rendered through an RN Modal (not the root OverlayPortal) so it also * presents above natively-presented form sheets like the new-task draft. * Callers must dismiss the keyboard when opening — the iOS keyboard window * would otherwise cover the lower half of the sheet. */export function ThreadSettingsSheet(props: { readonly visible: boolean; /** * "save" = the Save/Done button (the user is finished configuring); * "dismiss" = backdrop, grabber, or system back. Hosts only restore the * keyboard for "save" so a stray tap outside a control never pops it. */ readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void; readonly onDismissed: () => void; readonly providerGroups: ReadonlyArray<ProviderGroup>; readonly selectedModel: ModelSelection | null; readonly onSelectModel: (option: ModelOption) => void; readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>; readonly onUpdateOptionSelections: (selections: ReadonlyArray<ProviderOptionSelection>) => void; readonly runtimeMode: RuntimeMode; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;}) { const insets = useSafeAreaInsets(); const { height: windowHeight } = useWindowDimensions(); const [showLegacyToggle, setShowLegacyToggle] = useState(false); const [expandedProviders, setExpandedProviders] = useState<ReadonlySet<string>>(() => new Set()); const [pendingModel, setPendingModel] = useState<ModelOption | null>(null); const [submenu, setSubmenu] = useState<SubmenuPage | null>(null); const wasPresentedRef = useRef(false); const notifyDismissed = useCallback(() => { if (!wasPresentedRef.current) { return; } wasPresentedRef.current = false; props.onDismissed(); }, [props.onDismissed]); // Every open starts fresh: no staged model, no submenu, legacy hidden, // secondary providers folded. The sheet stays mounted between opens, so // state would otherwise stick around. useEffect(() => { if (props.visible) { wasPresentedRef.current = true; setShowLegacyToggle(false); setExpandedProviders(new Set()); setPendingModel(null); setSubmenu(null); } else if (Platform.OS === "android" && wasPresentedRef.current) { // React Native only emits Modal.onDismiss on iOS. Android uses no exit // animation below, so the post-commit effect is its dismissal boundary. notifyDismissed(); } }, [notifyDismissed, props.visible]); const isApplied = (option: ModelOption) => option.selection.instanceId === props.selectedModel?.instanceId && option.selection.model === props.selectedModel.model; // The list highlights the staged pick; Save turns it into the applied one. const isDisplayed = (option: ModelOption) => pendingModel ? option.key === pendingModel.key : isApplied(option); // While a model is staged, the settings rows describe and edit the staged // model's options (kept on its pending selection); Save applies model and // options together. Otherwise they edit the applied selection directly. const displayedDescriptors = pendingModel ? pendingModel.capabilities ? getProviderOptionDescriptors({ caps: pendingModel.capabilities, selections: pendingModel.selection.options, }) : [] : props.optionDescriptors; const hasLegacyModels = props.providerGroups.some((group) => group.models.some((model) => model.isLegacy), ); // Legacy stays hidden unless the pill is toggled this open; a highlighted // legacy model is exempted from the filter instead of forcing the whole // legacy list visible. const showLegacy = showLegacyToggle; // Stable settings rows: the union of descriptors across the primary // harnesses' current models (plus whatever the displayed model advertises) // always renders, with unsupported rows disabled instead of vanishing when // the selection changes. Keyed by label, not id — Claude and Codex use // different ids for the same "Reasoning" concept. const descriptorTemplate = (() => { const seen = new Map<string, { type: "select" | "boolean" }>(); for (const group of props.providerGroups) { const driver = group.models[0]?.providerDriver; if (driver === undefined || !PRIMARY_PROVIDER_DRIVERS.has(driver)) { continue; } for (const model of group.models) { if (model.isLegacy) { continue; } for (const descriptor of model.capabilities?.optionDescriptors ?? []) { if (!seen.has(descriptor.label)) { seen.set(descriptor.label, { type: descriptor.type }); } } } } for (const descriptor of displayedDescriptors) { if (!seen.has(descriptor.label)) { seen.set(descriptor.label, { type: descriptor.type }); } } return [...seen.entries()].map(([label, entry]) => ({ label, ...entry })); })(); const handleSave = () => { if (pendingModel) { void Haptics.selectionAsync(); props.onSelectModel(pendingModel); } props.onClose("save"); }; const handleOptionChange = (id: string, value: string | boolean) => { const next = applyProviderOptionSelection(displayedDescriptors, { id, value }); if (!next) { return; } if (pendingModel) { setPendingModel({ ...pendingModel, selection: { ...pendingModel.selection, options: next }, }); } else { props.onUpdateOptionSelections(next); } }; const toggleProvider = (providerKey: string) => { setExpandedProviders((current) => { const next = new Set(current); if (!next.delete(providerKey)) { next.add(providerKey); } return next; }); }; const activeDescriptor = submenu?.kind === "descriptor" ? displayedDescriptors.find( (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, ) : undefined; const submenuContent = submenu?.kind === "runtime" ? { title: "Runtime", rows: RUNTIME_MODE_CHOICES.map((choice) => ({ id: choice.mode, label: choice.label, selected: choice.mode === props.runtimeMode, onPress: () => { void Haptics.selectionAsync(); props.onUpdateRuntimeMode(choice.mode); setSubmenu(null); }, })), } : activeDescriptor?.type === "select" ? { title: activeDescriptor.label, rows: selectableChoices(activeDescriptor).map((choice) => ({ id: choice.id, label: choice.label, selected: choice.id === getProviderOptionCurrentValue(activeDescriptor), onPress: () => { void Haptics.selectionAsync(); handleOptionChange(activeDescriptor.id, choice.id); setSubmenu(null); }, })), } : null; return ( <Modal transparent statusBarTranslucent navigationBarTranslucent animationType={Platform.OS === "ios" ? "fade" : "none"} visible={props.visible} onDismiss={notifyDismissed} onRequestClose={submenuContent ? () => setSubmenu(null) : () => props.onClose("dismiss")} > <View className="flex-1 justify-end"> <Pressable accessibilityLabel="Close thread settings" className="absolute inset-0 bg-backdrop" onPress={() => props.onClose("dismiss")} /> <View className="overflow-hidden rounded-t-[24px] border border-b-0 border-border bg-sheet" style={{ maxHeight: windowHeight * 0.85 }} > {/* The grabber doubles as the accessible close control: the dim backdrop above a tall sheet is a sliver, and VoiceOver can't reach it at all. */} <Pressable accessibilityLabel="Close thread settings" accessibilityRole="button" onPress={() => props.onClose("dismiss")} className="items-center pb-1 pt-2.5" > <View className="h-1 w-9 rounded-full bg-subtle-strong" /> </Pressable> {hasLegacyModels ? ( <View className="flex-row justify-end px-4 pb-1.5"> <Pressable accessibilityRole="button" accessibilityState={{ selected: showLegacy }} hitSlop={8} onPress={() => { void Haptics.selectionAsync(); setShowLegacyToggle(!showLegacy); }} className="rounded-full border border-border bg-subtle px-3 py-1.5 active:opacity-70" > <Text className="text-2xs font-t3-medium text-foreground-muted"> {showLegacy ? "Hide legacy models" : "Show legacy models"} </Text> </Pressable> </View> ) : null} {/* Only the model list scrolls. Provider catalogs can run toLines 1–500 of 652. Load more
import type { ModelSelection, ProviderInteractionMode, ProviderOptionDescriptor, ProviderOptionSelection, RuntimeMode, } from "@t3tools/contracts"; import { getProviderOptionCurrentLabel, getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; import * as Haptics from "expo-haptics"; import { useCallback, useEffect, useRef, useState } from "react"; import { Modal, Platform, Pressable, ScrollView, Switch, useWindowDimensions, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { ProviderIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions"; import { useThemeColor } from "../../lib/useThemeColor"; import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-menu"; import { pendingModelAfterPress } from "./thread-settings-sheet-state"; import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation"; /** * The everyday harnesses stay expanded; every other provider (OpenRouter * catalogs and friends) folds behind its header so a 300-model catalog can't * bury the list. */ const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set(["claudeAgent", "codex"]); /** * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill, * covering model, provider options, runtime mode, and plan mode in one label. */ export function threadSettingsSummaryLabel(input: { readonly modelLabel: string; readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; }): string { const runtime = RUNTIME_MODE_CHOICES.find((choice) => choice.mode === input.runtimeMode); return [ input.modelLabel, ...providerOptionValueLabels(input.optionDescriptors), ...(runtime ? [runtime.shortLabel] : []), ...(input.interactionMode === "plan" ? ["Plan"] : []), ].join(" · "); } function ModelRow(props: { readonly option: ModelOption; readonly selected: boolean; readonly onPress: () => void; }) { const primaryFg = useThemeColor("--color-primary-foreground"); return ( <Pressable accessibilityRole="button" accessibilityState={{ selected: props.selected }} onPress={props.onPress} // Selected rows get the same primary treatment as the submenu rows. // Subtle backgrounds (bg-subtle-strong) get overridden by the OS // selection chrome on iOS 26, so use the explicit high-contrast style // everywhere instead. className={cn( "mx-2.5 flex-row items-center gap-2 rounded-xl px-3 py-3.5 active:opacity-70", props.selected ? "bg-primary" : "bg-transparent", )} > <Text className={cn( "shrink text-sm font-t3-medium", props.selected ? "text-primary-foreground" : "text-foreground", )} numberOfLines={1} > {props.option.label} </Text> {props.option.isDefault ? ( <View className="rounded-md bg-subtle-strong px-1.5 py-0.5"> <Text className="text-3xs font-t3-bold text-foreground-muted">Default</Text> </View> ) : null} {props.option.isLegacy ? ( <View className="rounded-md bg-subtle px-1.5 py-0.5"> <Text className="text-3xs font-t3-bold text-foreground-muted">Legacy</Text> </View> ) : null} <View className="flex-1" /> {props.selected ? ( <SymbolView name="checkmark" size={14} tintColor={primaryFg} type="monochrome" /> ) : null} </Pressable> ); } /** * Provider section header with the harness logo. Secondary providers render * as a tappable fold (count + chevron while collapsed); primary providers * and the group holding the current selection are static headers. */ function ProviderHeader(props: { readonly driver: string | undefined; readonly label: string; readonly collapsible: boolean; readonly collapsed: boolean; readonly modelCount: number; readonly onToggle: () => void; }) { const iconSubtle = useThemeColor("--color-icon-subtle"); return ( <Pressable accessibilityRole={props.collapsible ? "button" : "header"} accessibilityState={props.collapsible ? { expanded: !props.collapsed } : undefined} accessibilityLabel={ props.collapsible ? `${props.label}, ${props.modelCount} models` : props.label } disabled={!props.collapsible} onPress={props.onToggle} className={cn( "mx-2.5 flex-row items-center gap-2 rounded-xl px-3", props.collapsible ? "py-3.5 active:opacity-70" : "pb-2 pt-4", )} > <ProviderIcon provider={props.driver} size={15} /> <Text className="text-2xs font-t3-bold uppercase tracking-widest text-foreground-muted"> {props.label} </Text> {props.collapsible ? ( <> <View className="flex-1" /> {props.collapsed ? ( <Text className="text-2xs font-t3-medium text-foreground-muted"> {props.modelCount} </Text> ) : null} <SymbolView name={props.collapsed ? "chevron.down" : "chevron.up"} size={11} tintColor={iconSubtle} type="monochrome" /> </> ) : null} </Pressable> ); } /** Compact row that opens a single-choice submenu panel. */ function DisclosureRow(props: { readonly label: string; readonly value: string | undefined; readonly disabled?: boolean; readonly onPress: () => void; }) { const iconSubtle = useThemeColor("--color-icon-subtle"); return ( <Pressable accessibilityRole="button" disabled={props.disabled} onPress={props.onPress} className={cn( "flex-row items-center gap-2 px-5 py-3 active:opacity-70", props.disabled && "opacity-40", )} > <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text> <View className="flex-1" /> {props.value ? ( <Text className="text-sm text-foreground-muted" numberOfLines={1}> {props.value} </Text> ) : null} <SymbolView name="chevron.right" size={12} tintColor={iconSubtle} type="monochrome" /> </Pressable> ); } /** Single option inside a submenu panel. */ function ChoiceRow(props: { readonly label: string; readonly selected: boolean; readonly onPress: () => void; }) { const primaryFg = useThemeColor("--color-primary-foreground"); return ( <Pressable accessibilityRole="button" accessibilityState={{ selected: props.selected }} onPress={props.onPress} className={cn( "mx-2.5 flex-row items-center rounded-xl px-3 py-3.5 active:opacity-70", props.selected ? "bg-primary" : "bg-transparent", )} > <Text className={cn( "shrink text-sm font-t3-medium", props.selected ? "text-primary-foreground" : "text-foreground", )} > {props.label} </Text> <View className="flex-1" /> {props.selected ? ( <SymbolView name="checkmark" size={14} tintColor={primaryFg} type="monochrome" /> ) : null} </Pressable> ); } function SwitchRow(props: { readonly label: string; readonly value: boolean; readonly disabled?: boolean; readonly onValueChange: (value: boolean) => void; }) { const activeTrack = String(useThemeColor("--color-switch-active")); const track = String(useThemeColor("--color-secondary-border")); return ( <View className={cn( "flex-row items-center justify-between px-5 py-2.5", props.disabled && "opacity-40", )} > <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text> <Switch disabled={props.disabled} ios_backgroundColor={track} onValueChange={props.onValueChange} trackColor={{ false: track, true: activeTrack }} value={props.value} /> </View> ); } type SubmenuPage = | { readonly kind: "descriptor"; readonly id: string } | { readonly kind: "runtime" }; /** * Unified thread settings: the sheet is the provider-grouped model list * (primary harnesses expanded, other providers folded, legacy behind the * top-right pill) with a Save button, plus compact disclosure rows whose * single-choice submenus stack in a small panel over the sheet so it never * changes size. Model changes stage until Save — while staged, the settings * rows edit the staged model's options and Save applies everything together. * * Callers control which harnesses are offered via providerGroups: an * existing thread must pass only its own provider's group, since a session * can't switch harness mid-thread. * * Rendered through an RN Modal (not the root OverlayPortal) so it also * presents above natively-presented form sheets like the new-task draft. * Callers must dismiss the keyboard when opening — the iOS keyboard window * would otherwise cover the lower half of the sheet. */ export function ThreadSettingsSheet(props: { readonly visible: boolean; /** * "save" = the Save/Done button (the user is finished configuring); * "dismiss" = backdrop, grabber, or system back. Hosts only restore the * keyboard for "save" so a stray tap outside a control never pops it. */ readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void; readonly onDismissed: () => void; readonly providerGroups: ReadonlyArray<ProviderGroup>; readonly selectedModel: ModelSelection | null; readonly onSelectModel: (option: ModelOption) => void; readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>; readonly onUpdateOptionSelections: (selections: ReadonlyArray<ProviderOptionSelection>) => void; readonly runtimeMode: RuntimeMode; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; }) { const insets = useSafeAreaInsets(); const { height: windowHeight } = useWindowDimensions(); const [showLegacyToggle, setShowLegacyToggle] = useState(false); const [expandedProviders, setExpandedProviders] = useState<ReadonlySet<string>>(() => new Set()); const [pendingModel, setPendingModel] = useState<ModelOption | null>(null); const [submenu, setSubmenu] = useState<SubmenuPage | null>(null); const wasPresentedRef = useRef(false); const notifyDismissed = useCallback(() => { if (!wasPresentedRef.current) { return; } wasPresentedRef.current = false; props.onDismissed(); }, [props.onDismissed]); // Every open starts fresh: no staged model, no submenu, legacy hidden, // secondary providers folded. The sheet stays mounted between opens, so // state would otherwise stick around. useEffect(() => { if (props.visible) { wasPresentedRef.current = true; setShowLegacyToggle(false); setExpandedProviders(new Set()); setPendingModel(null); setSubmenu(null); } else if (Platform.OS === "android" && wasPresentedRef.current) { // React Native only emits Modal.onDismiss on iOS. Android uses no exit // animation below, so the post-commit effect is its dismissal boundary. notifyDismissed(); } }, [notifyDismissed, props.visible]); const isApplied = (option: ModelOption) => option.selection.instanceId === props.selectedModel?.instanceId && option.selection.model === props.selectedModel.model; // The list highlights the staged pick; Save turns it into the applied one. const isDisplayed = (option: ModelOption) => pendingModel ? option.key === pendingModel.key : isApplied(option); // While a model is staged, the settings rows describe and edit the staged // model's options (kept on its pending selection); Save applies model and // options together. Otherwise they edit the applied selection directly. const displayedDescriptors = pendingModel ? pendingModel.capabilities ? getProviderOptionDescriptors({ caps: pendingModel.capabilities, selections: pendingModel.selection.options, }) : [] : props.optionDescriptors; const hasLegacyModels = props.providerGroups.some((group) => group.models.some((model) => model.isLegacy), ); // Legacy stays hidden unless the pill is toggled this open; a highlighted // legacy model is exempted from the filter instead of forcing the whole // legacy list visible. const showLegacy = showLegacyToggle; // Stable settings rows: the union of descriptors across the primary // harnesses' current models (plus whatever the displayed model advertises) // always renders, with unsupported rows disabled instead of vanishing when // the selection changes. Keyed by label, not id — Claude and Codex use // different ids for the same "Reasoning" concept. const descriptorTemplate = (() => { const seen = new Map<string, { type: "select" | "boolean" }>(); for (const group of props.providerGroups) { const driver = group.models[0]?.providerDriver; if (driver === undefined || !PRIMARY_PROVIDER_DRIVERS.has(driver)) { continue; } for (const model of group.models) { if (model.isLegacy) { continue; } for (const descriptor of model.capabilities?.optionDescriptors ?? []) { if (!seen.has(descriptor.label)) { seen.set(descriptor.label, { type: descriptor.type }); } } } } for (const descriptor of displayedDescriptors) { if (!seen.has(descriptor.label)) { seen.set(descriptor.label, { type: descriptor.type }); } } return [...seen.entries()].map(([label, entry]) => ({ label, ...entry })); })(); const handleSave = () => { if (pendingModel) { void Haptics.selectionAsync(); props.onSelectModel(pendingModel); } props.onClose("save"); }; const handleOptionChange = (id: string, value: string | boolean) => { const next = applyProviderOptionSelection(displayedDescriptors, { id, value }); if (!next) { return; } if (pendingModel) { setPendingModel({ ...pendingModel, selection: { ...pendingModel.selection, options: next }, }); } else { props.onUpdateOptionSelections(next); } }; const toggleProvider = (providerKey: string) => { setExpandedProviders((current) => { const next = new Set(current); if (!next.delete(providerKey)) { next.add(providerKey); } return next; }); }; const activeDescriptor = submenu?.kind === "descriptor" ? displayedDescriptors.find( (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, ) : undefined; const submenuContent = submenu?.kind === "runtime" ? { title: "Runtime", rows: RUNTIME_MODE_CHOICES.map((choice) => ({ id: choice.mode, label: choice.label, selected: choice.mode === props.runtimeMode, onPress: () => { void Haptics.selectionAsync(); props.onUpdateRuntimeMode(choice.mode); setSubmenu(null); }, })), } : activeDescriptor?.type === "select" ? { title: activeDescriptor.label, rows: selectableChoices(activeDescriptor).map((choice) => ({ id: choice.id, label: choice.label, selected: choice.id === getProviderOptionCurrentValue(activeDescriptor), onPress: () => { void Haptics.selectionAsync(); handleOptionChange(activeDescriptor.id, choice.id); setSubmenu(null); }, })), } : null; return ( <Modal transparent statusBarTranslucent navigationBarTranslucent animationType={Platform.OS === "ios" ? "fade" : "none"} visible={props.visible} onDismiss={notifyDismissed} onRequestClose={submenuContent ? () => setSubmenu(null) : () => props.onClose("dismiss")} > <View className="flex-1 justify-end"> <Pressable accessibilityLabel="Close thread settings" className="absolute inset-0 bg-backdrop" onPress={() => props.onClose("dismiss")} /> <View className="overflow-hidden rounded-t-[24px] border border-b-0 border-border bg-sheet" style={{ maxHeight: windowHeight * 0.85 }} > {/* The grabber doubles as the accessible close control: the dim backdrop above a tall sheet is a sliver, and VoiceOver can't reach it at all. */} <Pressable accessibilityLabel="Close thread settings" accessibilityRole="button" onPress={() => props.onClose("dismiss")} className="items-center pb-1 pt-2.5" > <View className="h-1 w-9 rounded-full bg-subtle-strong" /> </Pressable> {hasLegacyModels ? ( <View className="flex-row justify-end px-4 pb-1.5"> <Pressable accessibilityRole="button" accessibilityState={{ selected: showLegacy }} hitSlop={8} onPress={() => { void Haptics.selectionAsync(); setShowLegacyToggle(!showLegacy); }} className="rounded-full border border-border bg-subtle px-3 py-1.5 active:opacity-70" > <Text className="text-2xs font-t3-medium text-foreground-muted"> {showLegacy ? "Hide legacy models" : "Show legacy models"} </Text> </Pressable> </View> ) : null} {/* Only the model list scrolls. Provider catalogs can run to