import { getPromptControlledTraits, getTraitSelectChange, } from "@t3tools/client-runtime/model-traits"; import * as Dialog from "../../components/ui/Dialog"; import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; import { useSheetStackOptions } from "../layout/nested-stacks"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import { modelPreferencesMigrationPatch, resolveModelPreferences, } from "@t3tools/shared/serverSettings"; import type { EnvironmentId, ModelSelection, ProjectId, ProviderInstanceId, ProviderOptionDescriptor, ProviderOptionSelection, RuntimeMode, ServerSettings, ThreadId, } from "@t3tools/contracts"; import type { LegendListRenderItemProps } from "@legendapp/list/react-native"; import { RegistryContext, useAtomValue } from "@effect/atom-react"; import { AnimatedLegendList } from "@legendapp/list/reanimated"; import { getProviderOptionCurrentLabel, getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; import { useNavigation, useRoute, type RouteProp, type StaticScreenProps, } from "@react-navigation/native"; import { type NativeStackNavigationProp } from "@react-navigation/native-stack"; import * as Haptics from "expo-haptics"; import { AsyncResult } from "effect/reactivity"; import { createContext, use, useEffect, useEffectEvent, useRef, useState, type ReactNode, } from "react"; import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; import Animated, { FadeIn, FadeOut, LinearTransition, useReducedMotion, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; import { AppBar } from "../../components/ui/AppBar"; import { ProviderIcon } from "../../components/ProviderIcon"; import { ThemedSwitch } from "../../components/ui/ThemedSwitch"; import { cn } from "../../lib/cn"; import { useReduceMotionEnabled } from "../../lib/appState"; import { buildModelOptions, groupByProvider, type ModelOption, type ProviderGroup, } from "../../lib/modelOptions"; import { applyProviderOptionSelection } from "../../lib/providerOptions"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { rememberModelOptions, withRememberedModelOptions, } from "../../state/use-model-option-memory"; import { ChatGptSharingStatus } from "./ChatGptSharingStatus"; import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentQuery } from "../../state/query"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { useThreadReportedModelSelection } from "../../state/entities"; import { setComposerDraftText, updateComposerDraftSettings, useComposerDraft, useComposerDraftSettings, } from "../../state/use-composer-drafts"; import { useQuietThreadStructure } from "../../state/use-thread-detail"; import { activeComposerDraftKey, useQueuedRunEdit } from "../../state/queued-run-edit"; import { updateThreadDraftModelSelection } from "../../state/use-thread-composer-state"; import { useThreadSelection } from "../../state/use-thread-selection"; import { threadAllowsProviderSwitch } from "../../state/thread-provider-switching"; import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server"; import { mobilePreferencesAtom } from "../../state/preferences"; import { useAtomCommand } from "../../state/use-atom-command"; import { useNewTaskFlow } from "./new-task-flow-provider"; import { ScreenContent } from "../../components/ui/ScreenContent"; import { createProviderCatalogRefreshRunner, providerCatalogRefreshError, } from "./provider-catalog-refresh"; import { useSearchToolbarInset } from "../../components/ui/useBarInset"; import type { AppBarSearch } from "../../components/ui/AppBar"; import { ModelRow, ChoiceRow } from "./ThreadSettingsRows"; import { compatibleRuntimeModeForChoices, runtimeModeChoicesForSupportedModes, withManagedRuntimeModeLimits, type RuntimeModeChoice, selectableChoices, } from "./thread-settings-options"; import { canCommitPendingModel, favoritesFirst, modelFavoriteKey, modelMatchesCatalogQuery, pendingModelAfterPress, providerSectionIsCollapsed, } from "./thread-settings-sheet-state"; import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels"; const PRIMARY_PROVIDER_DRIVERS: ReadonlySet = new Set([ "claudeAgent", "codex", "antigravity", ]); /** Keep measured size changes stable; let catalog mutations return underflowing filtered lists 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 EMPTY_MODEL_FAVORITES: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string; }> = []; // Servers that predate `migrateModelPreferences` drop it and keep reporting null favorites. const attemptedMigrationGenerations = new Map(); function ProviderHeader(props: { readonly driver: string | undefined; readonly iconUrl: string | undefined; readonly label: string; readonly collapsible: boolean; readonly collapsed: boolean; readonly modelCount: number; readonly onToggle: () => void; }) { const content = ( <> {props.label} {props.collapsible ? ( <> {props.collapsed ? ( {props.modelCount} ) : null} ) : null} ); if (props.collapsible) { return ( {content} ); } return ( {content} ); } function DisclosureRow(props: { readonly label: string; readonly value: string | undefined; readonly onPress: () => void; readonly isLast?: boolean; }) { return ( {props.label} {props.value ? ( {props.value} ) : null} ); } function SwitchRow(props: { readonly label: string; readonly value: boolean; readonly onValueChange: (value: boolean) => void; readonly isLast?: boolean; }) { return ( {props.label} ); } type ThreadSettingsSubmenuPage = | { readonly kind: "descriptor"; readonly id: string } | { readonly kind: "runtime" }; type ThreadSettingsSessionProps = { readonly environmentId: EnvironmentId | null; readonly projectId: ProjectId | null; readonly providerInstanceId?: ProviderInstanceId; readonly lockedProviderInstanceId?: ProviderInstanceId; readonly providerGroups: ReadonlyArray; readonly selectedModel: ModelSelection | null; readonly prompt: string; readonly onPromptChange: (prompt: string) => void; readonly reportedModelSelection?: ModelSelection | null; readonly onSelectModel: (option: ModelOption) => void; readonly restoreSelection?: (selection: ModelSelection) => ModelSelection; readonly optionDescriptors: ReadonlyArray; readonly onUpdateOptionSelections: (selections: ReadonlyArray) => void; readonly runtimeMode: RuntimeMode; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; }; /** The composer draft the sheet edits: the thread's own, or a queued message being edited. */ export type ExistingThreadSettingsRouteParams = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly draftKey: string; }; type ThreadSettingsSessionValue = { readonly environmentId: EnvironmentId | null; readonly providerInstanceId?: ProviderInstanceId; readonly providerGroups: ReadonlyArray; readonly favoriteKeys: ReadonlySet; readonly favoritesLoaded: boolean; readonly toggleFavorite: (option: ModelOption) => void; readonly toggleHidden: (option: ModelOption) => void; readonly pendingHiddenKeys: ReadonlySet; readonly manageHidden: boolean; readonly setManageHidden: (value: boolean) => void; readonly runtimeMode: RuntimeMode; readonly runtimeModeChoices: ReadonlyArray; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; readonly displayedDescriptors: ReadonlyArray; readonly promptTraits: ReturnType; readonly displayedModelSelection: ModelSelection | null; readonly reportedModelSelection: ModelSelection | null; readonly providerExpansionOverrides: ReadonlySet; readonly hasLegacyModels: boolean; readonly pendingModel: ModelOption | null; readonly favoritesOnly: boolean; 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 setFavoritesOnly: (favoritesOnly: boolean) => 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(null); function ThreadSettingsSessionProvider( props: ThreadSettingsSessionProps & { readonly children: ReactNode }, ) { const registry = use(RegistryContext); const preferences = useAtomValue(mobilePreferencesAtom); const configs = useAtomValue(environmentServerConfigsAtom); const config = props.environmentId ? configs.get(props.environmentId) : undefined; const settings = config?.settings; const connection = useEnvironmentQuery( props.environmentId ? environmentCatalog.stateAtom(props.environmentId) : null, ).data; const connectionGeneration = connection?.phase === "connected" ? connection.generation : null; const [manageHidden, setManageHidden] = useState(false); const pendingHidden = useRef(new Set()); const pendingFavorites = useRef( new Map(), ); const [pendingHiddenKeys, setPendingHiddenKeys] = useState>(() => new Set()); const providerGroups = manageHidden && config ? groupByProvider( buildModelOptions( config, props.selectedModel, props.lockedProviderInstanceId, props.projectId, true, ), ) : props.providerGroups; const legacyFavorites = AsyncResult.isSuccess(preferences) ? (preferences.value.modelFavorites ?? EMPTY_MODEL_FAVORITES) : EMPTY_MODEL_FAVORITES; const favoritesLoaded = settings !== undefined && (settings.favorites !== null || AsyncResult.isSuccess(preferences)); const modelFavorites = resolveModelPreferences(settings ?? DEFAULT_SERVER_SETTINGS, { favorites: legacyFavorites, }).favorites; const saveSettings = useAtomCommand(serverEnvironment.updateSettings, "model favorites update"); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- writes the one-time legacy model favorites migration to server settings once per connection useEffect(() => { if ( !props.environmentId || !settings || !AsyncResult.isSuccess(preferences) || connectionGeneration === null || attemptedMigrationGenerations.get(props.environmentId) === connectionGeneration ) return; const patch = modelPreferencesMigrationPatch(settings, { favorites: legacyFavorites }); if (!patch) return; attemptedMigrationGenerations.set(props.environmentId, connectionGeneration); void saveSettings({ environmentId: props.environmentId, input: { patch } }); }, [ connectionGeneration, legacyFavorites, preferences, props.environmentId, saveSettings, settings, ]); const favoriteKeys = new Set( modelFavorites.map((favorite) => modelFavoriteKey(favorite.provider, favorite.model)), ); const toggleFavorite = async (option: ModelOption) => { const environmentId = props.environmentId; if (!favoritesLoaded || !environmentId) return; const currentSettings = registry.get(environmentServerConfigsAtom).get(environmentId)?.settings; if (!currentSettings) return; const favorites = resolveModelPreferences(currentSettings, { favorites: legacyFavorites, }).favorites; const key = JSON.stringify([ environmentId, option.selection.instanceId, option.selection.model, ]); const previous = pendingFavorites.current.get(key); const previousFavorite = previous && (previous.pending || previous.settings.favorites === currentSettings.favorites) ? previous.favorite : favorites.some( (favorite) => favorite.provider === option.selection.instanceId && favorite.model === option.selection.model, ); const pending = { favorite: !previousFavorite, settings: currentSettings, pending: true }; pendingFavorites.current.set(key, pending); void Haptics.selectionAsync(); const result = await saveSettings({ environmentId: environmentId, input: { patch: { setModelFavorites: [ { provider: option.selection.instanceId, model: option.selection.model, favorite: pending.favorite, }, ], ...(currentSettings.favorites === null ? { migrateModelPreferences: { favorites } } : {}), }, }, }); if ( AsyncResult.isSuccess(result) && registry.get(environmentServerConfigsAtom).get(environmentId)?.settings.favorites === currentSettings.favorites ) { pending.pending = false; } if (pending.pending && pendingFavorites.current.get(key) === pending) { if ( previous && !previous.pending && registry.get(environmentServerConfigsAtom).get(environmentId)?.settings.favorites === previous.settings.favorites ) { pendingFavorites.current.set(key, previous); } else { pendingFavorites.current.delete(key); } } }; const toggleHidden = (option: ModelOption) => { if ( !settings || !props.environmentId || !option.canHide || pendingHidden.current.has(option.key) ) return; pendingHidden.current.add(option.key); setPendingHiddenKeys(new Set(pendingHidden.current)); void Haptics.selectionAsync(); void saveSettings({ environmentId: props.environmentId, input: { patch: { setModelsHidden: [ { provider: option.selection.instanceId, model: option.selection.model, hidden: !option.isHidden, }, ], }, }, }).then(() => { pendingHidden.current.delete(option.key); setPendingHiddenKeys(new Set(pendingHidden.current)); }); }; const [showLegacyToggle, setShowLegacyToggle] = useState(false); const [favoritesOnly, setFavoritesOnly] = useState(false); const [providerFilter, setProviderFilter] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [providerExpansionOverrides, setProviderExpansionOverrides] = useState>( () => new Set(), ); const [pendingModel, setPendingModel] = useState(null); const [pendingPrompt, setPendingPrompt] = useState(null); const displayedPrompt = pendingModel ? (pendingPrompt ?? props.prompt) : props.prompt; const isApplied = (option: ModelOption) => option.selection.instanceId === props.selectedModel?.instanceId && option.selection.model === props.selectedModel.model; const isDisplayed = (option: ModelOption) => pendingModel ? option.key === pendingModel.key : isApplied(option); const displayedDescriptors = pendingModel ? pendingModel.capabilities ? getProviderOptionDescriptors({ caps: pendingModel.capabilities, selections: pendingModel.selection.options, }) : [] : props.optionDescriptors; const promptTraits = getPromptControlledTraits(displayedDescriptors, displayedPrompt); const displayedModel = pendingModel ?? props.providerGroups.flatMap((group) => group.models).find((option) => isApplied(option)) ?? null; const runtimeModeChoices = withManagedRuntimeModeLimits( runtimeModeChoicesForSupportedModes(displayedModel?.supportedRuntimeModes), [config?.managedPolicy], ); const compatibleRuntimeMode = compatibleRuntimeModeForChoices( props.runtimeMode, runtimeModeChoices, ); const hasLegacyModels = props.providerGroups.some((group) => group.models.some((model) => model.isLegacy), ); const commitPendingModel = () => { if (pendingModel) { if (!canCommitPendingModel(pendingModel, props.providerGroups)) { Dialog.alert( "Model unavailable", "Set up this provider on web or desktop, or select another model.", ); return false; } void Haptics.selectionAsync(); props.onSelectModel(pendingModel); if (pendingPrompt !== null && pendingPrompt !== props.prompt) { props.onPromptChange(pendingPrompt); } } return true; }; const applyOptionChange = (id: string, value: string | boolean) => { const descriptor = displayedDescriptors.find((entry) => entry.id === id); const change = descriptor?.type === "select" && typeof value === "string" ? getTraitSelectChange( { ...promptTraits, descriptors: displayedDescriptors }, descriptor, displayedPrompt, value, ) : null; if (descriptor?.type === "select") { if (!change) return; if (change.prompt !== displayedPrompt) { if (pendingModel) { setPendingPrompt(change.prompt); } else { props.onPromptChange(change.prompt); } } } const next = change ? (change.modelOptions ?? []) : applyProviderOptionSelection(displayedDescriptors, { id, value }); if (!next) { return; } if (pendingModel) { rememberModelOptions(pendingModel.selection.instanceId, pendingModel.selection.model, next); setPendingModel({ ...pendingModel, selection: { ...pendingModel.selection, options: next }, }); } else { props.onUpdateOptionSelections(next); } }; const toggleProvider = (providerKey: string) => { setProviderExpansionOverrides((current) => { const next = new Set(current); if (!next.delete(providerKey)) { next.add(providerKey); } return next; }); }; const pressModel = (option: ModelOption) => { void Haptics.selectionAsync(); if (isApplied(option) || option.key !== pendingModel?.key) { setPendingPrompt(null); } setPendingModel((current) => pendingModelAfterPress({ current, pressed: option, pressedIsApplied: isApplied(option), restoreSelection: props.restoreSelection, }), ); }; const value = { environmentId: props.environmentId, providerInstanceId: props.providerInstanceId, providerGroups, manageHidden, setManageHidden, toggleHidden, pendingHiddenKeys, runtimeMode: compatibleRuntimeMode, runtimeModeChoices, onUpdateRuntimeMode: props.onUpdateRuntimeMode, displayedDescriptors, promptTraits, displayedModelSelection: pendingModel?.selection ?? props.selectedModel, reportedModelSelection: pendingModel ? null : (props.reportedModelSelection ?? null), favoriteKeys, favoritesLoaded, providerExpansionOverrides, hasLegacyModels, pendingModel, favoritesOnly, providerFilter, searchQuery, showLegacy: showLegacyToggle, applyOptionChange, commitPendingModel, isApplied, isDisplayed, pressModel, setFavoritesOnly, setProviderFilter, setSearchQuery, setShowLegacy: setShowLegacyToggle, toggleProvider, toggleFavorite, } satisfies ThreadSettingsSessionValue; return ( {props.children} ); } function useThreadSettingsSession() { const value = use(ThreadSettingsSessionContext); if (!value) { throw new Error("useThreadSettingsSession must be used inside ThreadSettingsSessionProvider."); } return value; } type ThreadSettingsProviderCatalog = { readonly key: string; readonly driver: string | undefined; readonly iconUrl: string | undefined; readonly label: string; readonly collapsible: boolean; readonly collapsed: boolean; readonly modelCount: number; readonly models: ReadonlyArray; }; type ThreadSettingsCatalogItem = | { readonly kind: "provider"; readonly key: string; readonly provider: ThreadSettingsProviderCatalog; } | { readonly kind: "model"; readonly key: string; readonly option: ModelOption; readonly isFirst: boolean; readonly isLast: boolean; } | { readonly kind: "notice"; readonly key: string; readonly text: string; } | { readonly kind: "empty"; readonly key: "empty"; } | { readonly kind: "options"; readonly key: "options"; }; function ThreadSettingsModelListRow(props: { readonly option: ModelOption; readonly isFirst: boolean; readonly isLast: boolean; }) { const session = useThreadSettingsSession(); const onPress = () => session.pressModel(props.option); return ( session.toggleFavorite(props.option)} onToggleHidden={ session.manageHidden && props.option.canHide ? () => session.toggleHidden(props.option) : undefined } hiddenUpdatePending={session.pendingHiddenKeys.has(props.option.key)} option={props.option} selected={session.isDisplayed(props.option)} /> ); } function ThreadSettingsProviderListHeader(props: { readonly provider: ThreadSettingsProviderCatalog; }) { const session = useThreadSettingsSession(); const onToggle = () => session.toggleProvider(props.provider.key); return ( ); } function buildThreadSettingsCatalogItems( session: ThreadSettingsSessionValue, ): ReadonlyArray { return session.providerGroups.flatMap((group) => { if (session.providerFilter !== null && group.providerKey !== session.providerFilter) { return []; } const driver = group.models[0]?.providerDriver ?? group.providerKey; const catalogModels = session.manageHidden || session.showLegacy || session.favoritesOnly ? group.models : group.models.filter( (model) => !model.isLegacy || session.isDisplayed(model) || session.favoriteKeys.has(model.key), ); const visibleModels = favoritesFirst( catalogModels.filter( (model) => (!session.favoritesOnly || session.favoriteKeys.has(model.key)) && modelMatchesCatalogQuery({ model, providerLabel: group.providerLabel, query: session.searchQuery, }), ), session.favoriteKeys, ); const updateRequiredNotice = group.updateRequired && !session.favoritesOnly ? formatProviderUpdateRequiredNotice(group.updateRequired, session.searchQuery) : null; if (visibleModels.length === 0 && !updateRequiredNotice) { return []; } const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver); const containsAppliedSelection = group.models.some(session.isApplied); const isNarrowed = session.favoritesOnly || session.providerFilter !== null || session.searchQuery.trim().length > 0; const collapsible = !isNarrowed; const collapsed = providerSectionIsCollapsed({ defaultExpanded: isPrimary || containsAppliedSelection, hasExpansionOverride: session.providerExpansionOverrides.has(group.providerKey), isNarrowed, }); const provider: ThreadSettingsProviderCatalog = { key: group.providerKey, driver, iconUrl: group.models[0]?.providerIconUrl, label: group.providerLabel, collapsible, collapsed, modelCount: visibleModels.length, models: collapsed ? [] : visibleModels, }; return [ { kind: "provider" as const, key: `provider:${group.providerKey}`, provider, }, ...provider.models.map((option, index) => ({ kind: "model" as const, key: `model:${option.key}`, option, isFirst: index === 0, isLast: index === provider.models.length - 1, })), ...(!collapsed && updateRequiredNotice ? [ { kind: "notice" as const, key: `notice:${group.providerKey}`, text: updateRequiredNotice, }, ] : []), ]; }); } function ThreadSettingsOptionsItem(props: { readonly animationsReady: boolean; readonly bottomInset: number; readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void; }) { const insets = useSafeAreaInsets(); const session = useThreadSettingsSession(); const configs = useAtomValue(environmentServerConfigsAtom); const selectedProvider = session.environmentId ? (configs .get(session.environmentId) ?.providers.find((provider) => provider.instanceId === session.providerInstanceId) ?? null) : null; return ( Options {session.displayedDescriptors.map((descriptor) => { if (descriptor.type === "select") { return ( props.onOpenSubmenu({ kind: "descriptor", id: descriptor.id })} /> ); } return ( session.applyOptionChange(descriptor.id, value)} /> ); })} choice.mode === session.runtimeMode) ?.label } onPress={() => props.onOpenSubmenu({ kind: "runtime" })} /> Catalog ); } function ThreadSettingsMainContent(props: { readonly bottomInset: number; readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void; }) { const session = useThreadSettingsSession(); const refreshProvidersCommand = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); const refreshProviderCatalog = createProviderCatalogRefreshRunner(refreshProvidersCommand); const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); const refreshProviders = () => { if (!session.environmentId || isRefreshingProviders) return; setIsRefreshingProviders(true); void refreshProviderCatalog(session.environmentId).then((result) => { setIsRefreshingProviders(false); const error = providerCatalogRefreshError(result); if (error) Dialog.alert("Could not refresh models", error); }); }; const [listLoaded, setListLoaded] = useState(false); // Android can drop the first frame of a reduced-motion fade, leaving rows invisible. const reducedMotion = useReducedMotion(); const reduceMotionEnabled = useReduceMotionEnabled(); const animationsReady = listLoaded && !reducedMotion && !reduceMotionEnabled; const catalogItems = buildThreadSettingsCatalogItems(session); const hasActiveCatalogFilter = session.favoritesOnly || session.providerFilter !== null || session.searchQuery.trim().length > 0; const listItems = [ ...(catalogItems.length === 0 ? ([{ kind: "empty", key: "empty" }] as const) : catalogItems), { kind: "options", key: "options" }, ] satisfies ReadonlyArray; const renderCatalogItem = (itemProps: LegendListRenderItemProps) => { const item = itemProps.item; let content: ReactNode; if (item.kind === "provider") { content = ; } else if (item.kind === "model") { content = ( ); } else if (item.kind === "notice") { content = {item.text}; } else if (item.kind === "empty") { content = ( {session.favoritesOnly && session.searchQuery.trim().length === 0 ? "No favorite models" : hasActiveCatalogFilter ? "No matching models" : "No available models"} ); } else { content = ( ); } return ( {content} ); }; return ( // oxlint-disable-next-line t3code/require-registered-list-scroll -- Sheet content; sheets sit outside workspace columns and keep their own header handling. item.kind} itemLayoutAnimation={THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION} keyExtractor={(item) => item.key} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" maintainVisibleContentPosition={THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION} recycleItems refreshControl={ session.environmentId ? ( ) : undefined } onLoad={() => setListLoaded(true)} renderItem={renderCatalogItem} showsVerticalScrollIndicator={false} /> ); } function ThreadSettingsChoiceContent(props: { readonly submenu: ThreadSettingsSubmenuPage; readonly onSelected: () => void; }) { const insets = useSafeAreaInsets(); const session = useThreadSettingsSession(); const descriptorId = props.submenu.kind === "descriptor" ? props.submenu.id : null; const activeDescriptor = descriptorId !== null ? session.displayedDescriptors.find( (descriptor) => descriptor.type === "select" && descriptor.id === descriptorId, ) : undefined; const submenuContent = props.submenu.kind === "runtime" ? { rows: session.runtimeModeChoices.map((choice) => ({ id: choice.mode, label: choice.label, description: choice.disabledReason ?? choice.description, disabled: choice.disabledReason !== undefined, selected: choice.mode === session.runtimeMode, onPress: () => { void Haptics.selectionAsync(); session.onUpdateRuntimeMode(choice.mode); props.onSelected(); }, })), } : activeDescriptor?.type === "select" ? { rows: selectableChoices(activeDescriptor).map((choice) => ({ id: choice.id, label: choice.label, description: choice.description, disabled: false, selected: choice.id === (session.promptTraits.ultrathinkPromptControlled && activeDescriptor.id === session.promptTraits.primarySelectDescriptor?.id ? "ultrathink" : getProviderOptionCurrentValue( activeDescriptor, session.displayedModelSelection, session.reportedModelSelection, )), onPress: () => { void Haptics.selectionAsync(); if ( session.promptTraits.ultrathinkInBodyText && activeDescriptor.id === session.promptTraits.primarySelectDescriptor?.id ) { Dialog.alert( "Reasoning controlled by prompt", 'Your prompt contains "ultrathink" in the text. Remove it to change this option.', ); return; } session.applyOptionChange(activeDescriptor.id, choice.id); props.onSelected(); }, })), } : null; if (!submenuContent) { return ; } return ( {submenuContent.rows.map((row, index) => ( ))} ); } type ThreadSettingsPickerStackParams = { ThreadSettingsModels: undefined; ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string }; }; type ThreadSettingsPickerPresentation = { readonly onClose: () => void; }; const ThreadSettingsPickerStack = createNativeStackNavigator(); const ThreadSettingsPickerPresentationContext = createContext(null); function useThreadSettingsPickerPresentation() { const value = use(ThreadSettingsPickerPresentationContext); if (!value) { throw new Error( "useThreadSettingsPickerPresentation must be used inside ThreadSettingsPickerNavigator.", ); } return value; } function ThreadSettingsModelsScreen() { const session = useThreadSettingsSession(); const presentation = useThreadSettingsPickerPresentation(); const navigation = useNavigation>(); const hasCustomCatalogFilter = session.favoritesOnly || session.providerFilter !== null || session.showLegacy; const commitAndClose = useEffectEvent(() => { if (!session.commitPendingModel()) return; presentation.onClose(); }); const search: AppBarSearch = { value: session.searchQuery, onChangeText: session.setSearchQuery, placeholder: "Find a model", clearAccessibilityLabel: "Clear model search", mode: "inline", compactToolbar: true, }; const searchToolbarInset = useSearchToolbarInset(search); const providerFilters = [ { id: "all-providers", title: "All providers", value: null }, ...session.providerGroups.map((group) => ({ id: `provider:${group.providerKey}`, title: group.providerLabel, value: group.providerKey, })), ]; return ( <> session.setFavoritesOnly(!session.favoritesOnly), }, { id: "provider", title: "Provider", items: providerFilters.map((filter) => ({ id: filter.id, title: filter.title, selected: session.providerFilter === filter.value, onPress: () => session.setProviderFilter(filter.value), })), }, ...(session.hasLegacyModels ? [ { id: "show-legacy", title: "Show legacy models", selected: session.showLegacy, onPress: () => session.setShowLegacy(!session.showLegacy), }, ] : []), ], }, ]} optionsVersion={[ session.favoritesOnly, session.providerFilter, session.providerGroups.map((group) => group.providerKey), session.showLegacy, session.hasLegacyModels, ]} confirm={{ label: session.pendingModel ? "Save" : "Done", onPress: commitAndClose, }} hideBottomBorder /> { const title = submenu.kind === "runtime" ? "Runtime" : (session.displayedDescriptors.find( (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, )?.label ?? "Option"); navigation.navigate("ThreadSettingsChoice", { ...submenu, title }); }} /> ); } function ThreadSettingsChoiceScreen() { const navigation = useNavigation>(); const route = useRoute>(); return ( <> navigation.goBack()} hideBottomBorder /> navigation.goBack()} /> ); } function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) { const screenOptions = useSheetStackOptions(); const presentation = { onClose: props.onClose, }; return ( ({ title: route.params.title })} /> ); } export function ThreadSettingsPickerScreen( props: ThreadSettingsSessionProps & { readonly onClose: () => void }, ) { return ( ); } export function ExistingThreadSettingsRouteScreen({ route, }: StaticScreenProps) { const navigation = useNavigation>>(); // Reads the same environmentId/threadId params as the Thread route beneath it. const { selectedThread, selectedThreadDetailRef, selectedEnvironmentRuntime } = useThreadSelection(); // Streamed text would otherwise re-render the whole model picker on every delta. const structure = useQuietThreadStructure(selectedThreadDetailRef); const reportedModelSelection = useThreadReportedModelSelection(selectedThreadDetailRef); const threadKey = scopedThreadKey(route.params.environmentId, route.params.threadId); const draftSettings = useComposerDraftSettings(threadKey); const queuedRunEdit = useQueuedRunEdit(threadKey); // A queued edit can end while the sheet is open; the prompt then belongs to the thread's draft. const draftKey = activeComposerDraftKey(threadKey, route.params.draftKey, queuedRunEdit); const prompt = useComposerDraft(draftKey).text; if (!selectedThread) { return ; } const serverConfig = selectedEnvironmentRuntime?.serverConfig ?? null; const modelSelection = draftSettings.modelSelection ?? selectedThread.modelSelection; const lockedProviderInstanceId = threadAllowsProviderSwitch({ thread: selectedThread, projection: structure?.projection, }) ? undefined : modelSelection.instanceId; const modelOptions = buildModelOptions( serverConfig, modelSelection, lockedProviderInstanceId, selectedThread.projectId, ); const currentModelOption = modelOptions.find( (option) => option.selection.instanceId === modelSelection.instanceId && option.selection.model === modelSelection.model, ) ?? null; const updateModelSelection = (selection: ModelSelection) => updateThreadDraftModelSelection(threadKey, selection, serverConfig?.providers ?? []); return ( setComposerDraftText(draftKey, text)} reportedModelSelection={reportedModelSelection} onSelectModel={(option) => updateModelSelection(withRememberedModelOptions(option.selection))} restoreSelection={withRememberedModelOptions} optionDescriptors={resolveProviderOptionDescriptors({ capabilities: currentModelOption?.capabilities, selections: modelSelection.options, })} onUpdateOptionSelections={(options) => { rememberModelOptions(modelSelection.instanceId, modelSelection.model, options ?? []); updateModelSelection({ ...modelSelection, options }); }} runtimeMode={draftSettings.runtimeMode ?? selectedThread.runtimeMode} onUpdateRuntimeMode={(runtimeMode) => updateComposerDraftSettings(threadKey, { runtimeMode })} onClose={() => navigation.goBack()} /> ); } /** RNS owns nested sheet dismissal; Reasoning and Runtime remain ordinary pushes. */ export function NewTaskThreadSettingsRouteScreen() { const flow = useNewTaskFlow(); const navigation = useNavigation>>(); const optionDescriptors = resolveProviderOptionDescriptors({ capabilities: flow.selectedModelOption?.capabilities, selections: flow.selectedModel?.options, }); return ( flow.setSelectedModelKey(option.key, option.selection.options)} restoreSelection={withRememberedModelOptions} optionDescriptors={optionDescriptors} onUpdateOptionSelections={flow.setSelectedModelOptions} runtimeMode={flow.runtimeMode} onUpdateRuntimeMode={flow.setRuntimeMode} onClose={() => navigation.goBack()} /> ); }