import type { EnvironmentId, ModelSelection, ProviderInstanceId, ProviderOptionDescriptor, ProviderOptionSelection, RuntimeMode,} from "@t3tools/contracts";import type { LegendListRenderItemProps } from "@legendapp/list/react-native";import { AnimatedLegendList } from "@legendapp/list/reanimated";import { HeaderHeightContext } from "@react-navigation/elements";import { getProviderOptionCurrentLabel, getProviderOptionCurrentValue, getProviderOptionDescriptors,} from "@t3tools/shared/model";import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native";import { createNativeStackNavigator, type NativeStackNavigationProp,} from "@react-navigation/native-stack";import * as Haptics from "expo-haptics";import { createContext, use, useCallback, useEffect, useMemo, useState, type ReactNode,} from "react";import { Alert, Platform, Pressable, ScrollView, TextInput, View } from "react-native";import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/AppSymbol";import { AppText as Text } from "../../components/AppText";import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";import { ProviderIcon } from "../../components/ProviderIcon";import { ThemedSwitch } from "../../components/ThemedSwitch";import { cn } from "../../lib/cn";import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";import { applyProviderOptionSelection } from "../../lib/providerOptions";import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";import { useUniwindTheme } from "../../lib/useUniwindTheme";import { NativeHeaderToolbar, NativeStackScreenOptions, nativeHeaderScrollEdgeEffects,} from "../../native/StackHeader";import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";import { serverEnvironment } from "../../state/server";import { useAtomCommand } from "../../state/use-atom-command";import { useNewTaskFlow } from "./new-task-flow-provider";import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";import { createProviderCatalogRefreshRunner, providerCatalogRefreshError,} from "./provider-catalog-refresh";import { createNativeMailSearchToolbarItem, NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,} from "../layout/native-mail-search-toolbar";import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options";import { canCommitPendingModel, modelMatchesCatalogQuery, pendingModelAfterPress, providerSectionIsCollapsed,} from "./thread-settings-sheet-state"; /** * Everyday harnesses start expanded; every other provider (OpenRouter catalogs * and friends) starts folded so a 300-model catalog cannot bury the list. All * provider headers remain user-collapsible. */const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set([ "claudeAgent", "codex", "antigravity",]);/** * Keep measured row changes stable, but let catalog mutations use the list's * native bounds so a filtered catalog that underflows returns to the top. */const THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION = { data: false, size: true,} as const;const THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION = LinearTransition.duration(180);const THREAD_SETTINGS_CATALOG_ENTER_TRANSITION = FadeIn.duration(140);const THREAD_SETTINGS_CATALOG_EXIT_TRANSITION = FadeOut.duration(120);const THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION = LinearTransition.duration(180);const THREAD_SETTINGS_OPTION_ENTER_TRANSITION = FadeIn.duration(140);const THREAD_SETTINGS_OPTION_EXIT_TRANSITION = FadeOut.duration(100);const THREAD_SETTINGS_HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects( Platform.OS, Platform.Version,);function ModelRow(props: { readonly option: ModelOption; readonly selected: boolean; readonly onPress: () => void; readonly isFirst: boolean; readonly isLast: boolean;}) { const { materialYouStyleLayoutActive } = useAppearancePreferences(); const selectedMaterialRow = materialYouStyleLayoutActive && props.selected; return ( <Pressable accessibilityLabel={[props.option.label, props.option.subtitle].filter(Boolean).join(", ")} accessibilityRole="radio" accessibilityState={{ checked: props.selected, disabled: props.option.isUnavailable === true, }} disabled={props.option.isUnavailable} onPress={props.onPress} className={cn( "mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle", selectedMaterialRow && "bg-thread-selected", props.isFirst && "rounded-t-2xl", props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle", )} > <View className="min-w-0 flex-1"> <View className="flex-row items-center gap-2"> <Text className="min-w-0 shrink text-base font-t3-medium 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} {props.option.isUnavailable ? ( <Text className="text-xs text-foreground">Unavailable</Text> ) : null} </View> {props.option.subtitle ? ( <Text className="text-xs text-foreground-muted" numberOfLines={1}> {props.option.subtitle} </Text> ) : null} </View> {props.selected ? ( <SymbolView name="checkmark" size={16} tintColorClassName={"accent-icon"} type="monochrome" weight="semibold" /> ) : null} </Pressable> );} /** Provider catalog header with its harness logo and disclosure state. */function ProviderHeader(props: { readonly driver: string | undefined; readonly label: string; readonly collapsible: boolean; readonly collapsed: boolean; readonly modelCount: number; readonly onToggle: () => void;}) { const content = ( <> <ProviderIcon provider={props.driver} size={15} /> <Text className="text-sm font-t3-medium 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={12} tintColorClassName={"accent-icon-subtle"} type="monochrome" /> </> ) : null} </> ); if (props.collapsible) { return ( <Pressable accessibilityLabel={`${props.label}, ${props.modelCount} models`} accessibilityRole="button" accessibilityState={{ expanded: !props.collapsed }} className="mx-4 mt-1 min-h-11 flex-row items-center gap-2 rounded-xl px-1 pt-2 active:opacity-60" onPress={props.onToggle} > {content} </Pressable> ); } return ( <View accessibilityRole="header" className="mx-4 min-h-9 flex-row items-center gap-2 px-1 pt-1"> {content} </View> );} /** Compact row that opens a single-choice submenu panel. */function DisclosureRow(props: { readonly label: string; readonly value: string | undefined; readonly onPress: () => void; readonly isLast?: boolean;}) { return ( <Pressable accessibilityRole="button" onPress={props.onPress} className={cn( "min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle", !props.isLast && "border-b border-border-subtle", )} > <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} tintColorClassName={"accent-icon-subtle"} type="monochrome" /> </Pressable> );} /** Single option inside a submenu panel. */function ChoiceRow(props: { readonly label: string; readonly description?: string; readonly selected: boolean; readonly onPress: () => void; readonly isLast: boolean;}) { return ( <Pressable accessibilityLabel={props.description ? `${props.label}. ${props.description}` : props.label} accessibilityRole="radio" accessibilityState={{ checked: props.selected }} onPress={props.onPress} className={cn( "min-h-14 flex-row items-center gap-3 bg-card px-4 py-3 active:bg-subtle", !props.isLast && "border-b border-border-subtle", )} > <View className="min-w-0 flex-1 gap-0.5"> <Text className="text-base font-t3-medium text-foreground">{props.label}</Text> {props.description ? ( <Text className="text-sm leading-5 text-foreground-muted">{props.description}</Text> ) : null} </View> {props.selected ? ( <SymbolView name="checkmark" size={16} tintColorClassName={"accent-icon"} type="monochrome" weight="semibold" /> ) : null} </Pressable> );} function SwitchRow(props: { readonly label: string; readonly value: boolean; readonly onValueChange: (value: boolean) => void; readonly isLast?: boolean;}) { return ( <View className={cn( "min-h-11 flex-row items-center justify-between bg-card px-4 py-1", !props.isLast && "border-b border-border-subtle", )} > <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text> <ThemedSwitch accessibilityLabel={props.label} onValueChange={props.onValueChange} value={props.value} /> </View> );} type ThreadSettingsSubmenuPage = | { readonly kind: "descriptor"; readonly id: string } | { readonly kind: "runtime" }; type ThreadSettingsSessionProps = { readonly environmentId: EnvironmentId | null; readonly providerInstanceId?: ProviderInstanceId; 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;}; export type ExistingThreadSettingsRouteSession = ThreadSettingsSessionProps & { readonly ownerId: string;}; type ExistingThreadSettingsRouteContextValue = { readonly session: ExistingThreadSettingsRouteSession | null; readonly present: (session: ExistingThreadSettingsRouteSession) => void; readonly clear: (ownerId: string) => void;}; const ExistingThreadSettingsRouteContext = createContext<ExistingThreadSettingsRouteContextValue | null>(null); /** Bridges the active thread's settings state into the root native sheet route. */export function ExistingThreadSettingsRouteProvider(props: { readonly children: ReactNode }) { const [session, setSession] = useState<ExistingThreadSettingsRouteSession | null>(null); const present = useCallback((nextSession: ExistingThreadSettingsRouteSession) => { setSession(nextSession); }, []); const clear = useCallback((ownerId: string) => { setSession((current) => (current?.ownerId === ownerId ? null : current)); }, []); const value = useMemo(() => ({ session, present, clear }), [clear, present, session]); return ( <ExistingThreadSettingsRouteContext.Provider value={value}> {props.children} </ExistingThreadSettingsRouteContext.Provider> );} export function useExistingThreadSettingsRoutePresentation() { const value = use(ExistingThreadSettingsRouteContext); if (!value) { throw new Error( "useExistingThreadSettingsRoutePresentation must be used inside ExistingThreadSettingsRouteProvider.", ); } return value;} type ThreadSettingsSessionValue = { readonly environmentId: EnvironmentId | null; readonly providerInstanceId?: ProviderInstanceId; readonly providerGroups: ReadonlyArray<ProviderGroup>; readonly runtimeMode: RuntimeMode; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; readonly displayedDescriptors: ReadonlyArray<ProviderOptionDescriptor>; readonly providerExpansionOverrides: ReadonlySet<string>; readonly hasLegacyModels: boolean; readonly pendingModel: ModelOption | null; readonly providerFilter: string | null; readonly searchQuery: string; readonly showLegacy: boolean; readonly applyOptionChange: (id: string, value: string | boolean) => void; readonly commitPendingModel: () => boolean; readonly isApplied: (option: ModelOption) => boolean; readonly isDisplayed: (option: ModelOption) => boolean; readonly pressModel: (option: ModelOption) => void; readonly setProviderFilter: (providerKey: string | null) => void; readonly setSearchQuery: (query: string) => void; readonly setShowLegacy: (showLegacy: boolean) => void; readonly toggleProvider: (providerKey: string) => void;}; const ThreadSettingsSessionContext = createContext<ThreadSettingsSessionValue | null>(null); /** Owns the staged model and option state for one picker presentation. */function ThreadSettingsSessionProvider( props: ThreadSettingsSessionProps & { readonly children: ReactNode },) { const [showLegacyToggle, setShowLegacyToggle] = useState(false); const [providerFilter, setProviderFilter] = useState<string | null>(null); const [searchQuery, setSearchQuery] = useState(""); const [providerExpansionOverrides, setProviderExpansionOverrides] = useState<ReadonlySet<string>>( () => new Set(), ); const [pendingModel, setPendingModel] = useState<ModelOption | null>(null); const isApplied = useCallback( (option: ModelOption) => option.selection.instanceId === props.selectedModel?.instanceId && option.selection.model === props.selectedModel.model, [props.selectedModel], ); // The list highlights the staged pick; Save turns it into the applied one. const isDisplayed = useCallback( (option: ModelOption) => (pendingModel ? option.key === pendingModel.key : isApplied(option)), [isApplied, pendingModel], ); // 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 = useMemo( () => pendingModel ? pendingModel.capabilities ? getProviderOptionDescriptors({ caps: pendingModel.capabilities, selections: pendingModel.selection.options, }) : [] : props.optionDescriptors, [pendingModel, props.optionDescriptors], ); const hasLegacyModels = useMemo( () => props.providerGroups.some((group) => group.models.some((model) => model.isLegacy)), [props.providerGroups], ); const commitPendingModel = useCallback(() => { if (pendingModel) { if (!canCommitPendingModel(pendingModel, props.providerGroups)) { Alert.alert( "Model unavailable", "Set up this provider on web or desktop, or select another model.", ); return false; } void Haptics.selectionAsync(); props.onSelectModel(pendingModel); } return true; }, [pendingModel, props.onSelectModel, props.providerGroups]); const applyOptionChange = useCallback( (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); } }, [displayedDescriptors, pendingModel, props.onUpdateOptionSelections], ); const toggleProvider = useCallback((providerKey: string) => { setProviderExpansionOverrides((current) => { const next = new Set(current); if (!next.delete(providerKey)) { next.add(providerKey); } return next; }); }, []); const pressModel = useCallback( (option: ModelOption) => { void Haptics.selectionAsync(); setPendingModel((current) => pendingModelAfterPress({ current, pressed: option, pressedIsApplied: isApplied(option), }), ); }, [isApplied], ); const value = useMemo<ThreadSettingsSessionValue>( () => ({Lines 1–500 of 1315. Load more
import type { EnvironmentId, ModelSelection, ProviderInstanceId, ProviderOptionDescriptor, ProviderOptionSelection, RuntimeMode, } from "@t3tools/contracts"; import type { LegendListRenderItemProps } from "@legendapp/list/react-native"; import { AnimatedLegendList } from "@legendapp/list/reanimated"; import { HeaderHeightContext } from "@react-navigation/elements"; import { getProviderOptionCurrentLabel, getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native"; import { createNativeStackNavigator, type NativeStackNavigationProp, } from "@react-navigation/native-stack"; import * as Haptics from "expo-haptics"; import { createContext, use, useCallback, useEffect, useMemo, useState, type ReactNode, } from "react"; import { Alert, Platform, Pressable, ScrollView, TextInput, View } from "react-native"; import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ProviderIcon } from "../../components/ProviderIcon"; import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { applyProviderOptionSelection } from "../../lib/providerOptions"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { NativeHeaderToolbar, NativeStackScreenOptions, nativeHeaderScrollEdgeEffects, } from "../../native/StackHeader"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { useNewTaskFlow } from "./new-task-flow-provider"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { createProviderCatalogRefreshRunner, providerCatalogRefreshError, } from "./provider-catalog-refresh"; import { createNativeMailSearchToolbarItem, NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options"; import { canCommitPendingModel, modelMatchesCatalogQuery, pendingModelAfterPress, providerSectionIsCollapsed, } from "./thread-settings-sheet-state"; /** * Everyday harnesses start expanded; every other provider (OpenRouter catalogs * and friends) starts folded so a 300-model catalog cannot bury the list. All * provider headers remain user-collapsible. */ const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set([ "claudeAgent", "codex", "antigravity", ]); /** * Keep measured row changes stable, but let catalog mutations use the list's * native bounds so a filtered catalog that underflows returns to the top. */ const THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION = { data: false, size: true, } as const; const THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION = LinearTransition.duration(180); const THREAD_SETTINGS_CATALOG_ENTER_TRANSITION = FadeIn.duration(140); const THREAD_SETTINGS_CATALOG_EXIT_TRANSITION = FadeOut.duration(120); const THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION = LinearTransition.duration(180); const THREAD_SETTINGS_OPTION_ENTER_TRANSITION = FadeIn.duration(140); const THREAD_SETTINGS_OPTION_EXIT_TRANSITION = FadeOut.duration(100); const THREAD_SETTINGS_HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects( Platform.OS, Platform.Version, ); function ModelRow(props: { readonly option: ModelOption; readonly selected: boolean; readonly onPress: () => void; readonly isFirst: boolean; readonly isLast: boolean; }) { const { materialYouStyleLayoutActive } = useAppearancePreferences(); const selectedMaterialRow = materialYouStyleLayoutActive && props.selected; return ( <Pressable accessibilityLabel={[props.option.label, props.option.subtitle].filter(Boolean).join(", ")} accessibilityRole="radio" accessibilityState={{ checked: props.selected, disabled: props.option.isUnavailable === true, }} disabled={props.option.isUnavailable} onPress={props.onPress} className={cn( "mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle", selectedMaterialRow && "bg-thread-selected", props.isFirst && "rounded-t-2xl", props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle", )} > <View className="min-w-0 flex-1"> <View className="flex-row items-center gap-2"> <Text className="min-w-0 shrink text-base font-t3-medium 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} {props.option.isUnavailable ? ( <Text className="text-xs text-foreground">Unavailable</Text> ) : null} </View> {props.option.subtitle ? ( <Text className="text-xs text-foreground-muted" numberOfLines={1}> {props.option.subtitle} </Text> ) : null} </View> {props.selected ? ( <SymbolView name="checkmark" size={16} tintColorClassName={"accent-icon"} type="monochrome" weight="semibold" /> ) : null} </Pressable> ); } /** Provider catalog header with its harness logo and disclosure state. */ function ProviderHeader(props: { readonly driver: string | undefined; readonly label: string; readonly collapsible: boolean; readonly collapsed: boolean; readonly modelCount: number; readonly onToggle: () => void; }) { const content = ( <> <ProviderIcon provider={props.driver} size={15} /> <Text className="text-sm font-t3-medium 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={12} tintColorClassName={"accent-icon-subtle"} type="monochrome" /> </> ) : null} </> ); if (props.collapsible) { return ( <Pressable accessibilityLabel={`${props.label}, ${props.modelCount} models`} accessibilityRole="button" accessibilityState={{ expanded: !props.collapsed }} className="mx-4 mt-1 min-h-11 flex-row items-center gap-2 rounded-xl px-1 pt-2 active:opacity-60" onPress={props.onToggle} > {content} </Pressable> ); } return ( <View accessibilityRole="header" className="mx-4 min-h-9 flex-row items-center gap-2 px-1 pt-1"> {content} </View> ); } /** Compact row that opens a single-choice submenu panel. */ function DisclosureRow(props: { readonly label: string; readonly value: string | undefined; readonly onPress: () => void; readonly isLast?: boolean; }) { return ( <Pressable accessibilityRole="button" onPress={props.onPress} className={cn( "min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle", !props.isLast && "border-b border-border-subtle", )} > <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} tintColorClassName={"accent-icon-subtle"} type="monochrome" /> </Pressable> ); } /** Single option inside a submenu panel. */ function ChoiceRow(props: { readonly label: string; readonly description?: string; readonly selected: boolean; readonly onPress: () => void; readonly isLast: boolean; }) { return ( <Pressable accessibilityLabel={props.description ? `${props.label}. ${props.description}` : props.label} accessibilityRole="radio" accessibilityState={{ checked: props.selected }} onPress={props.onPress} className={cn( "min-h-14 flex-row items-center gap-3 bg-card px-4 py-3 active:bg-subtle", !props.isLast && "border-b border-border-subtle", )} > <View className="min-w-0 flex-1 gap-0.5"> <Text className="text-base font-t3-medium text-foreground">{props.label}</Text> {props.description ? ( <Text className="text-sm leading-5 text-foreground-muted">{props.description}</Text> ) : null} </View> {props.selected ? ( <SymbolView name="checkmark" size={16} tintColorClassName={"accent-icon"} type="monochrome" weight="semibold" /> ) : null} </Pressable> ); } function SwitchRow(props: { readonly label: string; readonly value: boolean; readonly onValueChange: (value: boolean) => void; readonly isLast?: boolean; }) { return ( <View className={cn( "min-h-11 flex-row items-center justify-between bg-card px-4 py-1", !props.isLast && "border-b border-border-subtle", )} > <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text> <ThemedSwitch accessibilityLabel={props.label} onValueChange={props.onValueChange} value={props.value} /> </View> ); } type ThreadSettingsSubmenuPage = | { readonly kind: "descriptor"; readonly id: string } | { readonly kind: "runtime" }; type ThreadSettingsSessionProps = { readonly environmentId: EnvironmentId | null; readonly providerInstanceId?: ProviderInstanceId; 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; }; export type ExistingThreadSettingsRouteSession = ThreadSettingsSessionProps & { readonly ownerId: string; }; type ExistingThreadSettingsRouteContextValue = { readonly session: ExistingThreadSettingsRouteSession | null; readonly present: (session: ExistingThreadSettingsRouteSession) => void; readonly clear: (ownerId: string) => void; }; const ExistingThreadSettingsRouteContext = createContext<ExistingThreadSettingsRouteContextValue | null>(null); /** Bridges the active thread's settings state into the root native sheet route. */ export function ExistingThreadSettingsRouteProvider(props: { readonly children: ReactNode }) { const [session, setSession] = useState<ExistingThreadSettingsRouteSession | null>(null); const present = useCallback((nextSession: ExistingThreadSettingsRouteSession) => { setSession(nextSession); }, []); const clear = useCallback((ownerId: string) => { setSession((current) => (current?.ownerId === ownerId ? null : current)); }, []); const value = useMemo(() => ({ session, present, clear }), [clear, present, session]); return ( <ExistingThreadSettingsRouteContext.Provider value={value}> {props.children} </ExistingThreadSettingsRouteContext.Provider> ); } export function useExistingThreadSettingsRoutePresentation() { const value = use(ExistingThreadSettingsRouteContext); if (!value) { throw new Error( "useExistingThreadSettingsRoutePresentation must be used inside ExistingThreadSettingsRouteProvider.", ); } return value; } type ThreadSettingsSessionValue = { readonly environmentId: EnvironmentId | null; readonly providerInstanceId?: ProviderInstanceId; readonly providerGroups: ReadonlyArray<ProviderGroup>; readonly runtimeMode: RuntimeMode; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; readonly displayedDescriptors: ReadonlyArray<ProviderOptionDescriptor>; readonly providerExpansionOverrides: ReadonlySet<string>; readonly hasLegacyModels: boolean; readonly pendingModel: ModelOption | null; readonly providerFilter: string | null; readonly searchQuery: string; readonly showLegacy: boolean; readonly applyOptionChange: (id: string, value: string | boolean) => void; readonly commitPendingModel: () => boolean; readonly isApplied: (option: ModelOption) => boolean; readonly isDisplayed: (option: ModelOption) => boolean; readonly pressModel: (option: ModelOption) => void; readonly setProviderFilter: (providerKey: string | null) => void; readonly setSearchQuery: (query: string) => void; readonly setShowLegacy: (showLegacy: boolean) => void; readonly toggleProvider: (providerKey: string) => void; }; const ThreadSettingsSessionContext = createContext<ThreadSettingsSessionValue | null>(null); /** Owns the staged model and option state for one picker presentation. */ function ThreadSettingsSessionProvider( props: ThreadSettingsSessionProps & { readonly children: ReactNode }, ) { const [showLegacyToggle, setShowLegacyToggle] = useState(false); const [providerFilter, setProviderFilter] = useState<string | null>(null); const [searchQuery, setSearchQuery] = useState(""); const [providerExpansionOverrides, setProviderExpansionOverrides] = useState<ReadonlySet<string>>( () => new Set(), ); const [pendingModel, setPendingModel] = useState<ModelOption | null>(null); const isApplied = useCallback( (option: ModelOption) => option.selection.instanceId === props.selectedModel?.instanceId && option.selection.model === props.selectedModel.model, [props.selectedModel], ); // The list highlights the staged pick; Save turns it into the applied one. const isDisplayed = useCallback( (option: ModelOption) => (pendingModel ? option.key === pendingModel.key : isApplied(option)), [isApplied, pendingModel], ); // 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 = useMemo( () => pendingModel ? pendingModel.capabilities ? getProviderOptionDescriptors({ caps: pendingModel.capabilities, selections: pendingModel.selection.options, }) : [] : props.optionDescriptors, [pendingModel, props.optionDescriptors], ); const hasLegacyModels = useMemo( () => props.providerGroups.some((group) => group.models.some((model) => model.isLegacy)), [props.providerGroups], ); const commitPendingModel = useCallback(() => { if (pendingModel) { if (!canCommitPendingModel(pendingModel, props.providerGroups)) { Alert.alert( "Model unavailable", "Set up this provider on web or desktop, or select another model.", ); return false; } void Haptics.selectionAsync(); props.onSelectModel(pendingModel); } return true; }, [pendingModel, props.onSelectModel, props.providerGroups]); const applyOptionChange = useCallback( (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); } }, [displayedDescriptors, pendingModel, props.onUpdateOptionSelections], ); const toggleProvider = useCallback((providerKey: string) => { setProviderExpansionOverrides((current) => { const next = new Set(current); if (!next.delete(providerKey)) { next.add(providerKey); } return next; }); }, []); const pressModel = useCallback( (option: ModelOption) => { void Haptics.selectionAsync(); setPendingModel((current) => pendingModelAfterPress({ current, pressed: option, pressedIsApplied: isApplied(option), }), ); }, [isApplied], ); const value = useMemo<ThreadSettingsSessionValue>( () => ({