| 1 | import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; |
| 2 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; |
| 3 | import { |
| 4 | modelPreferencesMigrationPatch, |
| 5 | resolveModelPreferences, |
| 6 | } from "@t3tools/shared/serverSettings"; |
| 7 | import type { |
| 8 | EnvironmentId, |
| 9 | ModelSelection, |
| 10 | ProjectId, |
| 11 | ProviderInstanceId, |
| 12 | ProviderOptionDescriptor, |
| 13 | ProviderOptionSelection, |
| 14 | RuntimeMode, |
| 15 | ServerSettings, |
| 16 | } from "@t3tools/contracts"; |
| 17 | import type { LegendListRenderItemProps } from "@legendapp/list/react-native"; |
| 18 | import { RegistryContext, useAtomValue } from "@effect/atom-react"; |
| 19 | import { AnimatedLegendList } from "@legendapp/list/reanimated"; |
| 20 | import { |
| 21 | getProviderOptionCurrentLabel, |
| 22 | getProviderOptionCurrentValue, |
| 23 | getProviderOptionDescriptors, |
| 24 | } from "@t3tools/shared/model"; |
| 25 | import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native"; |
| 26 | import { type NativeStackNavigationProp } from "@react-navigation/native-stack"; |
| 27 | import * as Haptics from "expo-haptics"; |
| 28 | import { AsyncResult } from "effect/reactivity"; |
| 29 | import { |
| 30 | createContext, |
| 31 | use, |
| 32 | useCallback, |
| 33 | useEffect, |
| 34 | useMemo, |
| 35 | useRef, |
| 36 | useState, |
| 37 | type ReactNode, |
| 38 | } from "react"; |
| 39 | import { |
| 40 | Alert, |
| 41 | Platform, |
| 42 | Pressable, |
| 43 | RefreshControl, |
| 44 | ScrollView, |
| 45 | TextInput, |
| 46 | View, |
| 47 | } from "react-native"; |
| 48 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; |
| 49 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 50 | |
| 51 | import { SymbolView } from "../../components/AppSymbol"; |
| 52 | import { AppText as Text } from "../../components/AppText"; |
| 53 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 54 | import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu"; |
| 55 | import { MaterialButton } from "../../components/MaterialButton"; |
| 56 | import { MaterialIconButton } from "../../components/MaterialIconButton"; |
| 57 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 58 | import { ThemedSwitch } from "../../components/ThemedSwitch"; |
| 59 | import { cn } from "../../lib/cn"; |
| 60 | import { |
| 61 | buildModelOptions, |
| 62 | groupByProvider, |
| 63 | type ModelOption, |
| 64 | type ProviderGroup, |
| 65 | } from "../../lib/modelOptions"; |
| 66 | import { applyProviderOptionSelection } from "../../lib/providerOptions"; |
| 67 | import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; |
| 68 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 69 | import { rememberModelOptions } from "../../state/use-model-option-memory"; |
| 70 | import { |
| 71 | NativeHeaderToolbar, |
| 72 | NativeStackScreenOptions, |
| 73 | nativeHeaderScrollEdgeEffects, |
| 74 | } from "../../native/StackHeader"; |
| 75 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 76 | import { ChatGptSharingStatus } from "./ChatGptSharingStatus"; |
| 77 | import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server"; |
| 78 | import { mobilePreferencesAtom } from "../../state/preferences"; |
| 79 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 80 | import { useNewTaskFlow } from "./new-task-flow-provider"; |
| 81 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; |
| 82 | import { |
| 83 | createProviderCatalogRefreshRunner, |
| 84 | providerCatalogRefreshError, |
| 85 | } from "./provider-catalog-refresh"; |
| 86 | import { |
| 87 | createNativeMailSearchToolbarItem, |
| 88 | NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, |
| 89 | } from "../layout/native-mail-search-toolbar"; |
| 90 | import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; |
| 91 | import { ModelRow, ChoiceRow } from "./ThreadSettingsRows"; |
| 92 | import { |
| 93 | compatibleRuntimeModeForChoices, |
| 94 | runtimeModeChoicesForSupportedModes, |
| 95 | selectableChoices, |
| 96 | } from "./thread-settings-options"; |
| 97 | import { |
| 98 | canCommitPendingModel, |
| 99 | favoritesFirst, |
| 100 | modelFavoriteKey, |
| 101 | modelMatchesCatalogQuery, |
| 102 | pendingModelAfterPress, |
| 103 | providerSectionIsCollapsed, |
| 104 | } from "./thread-settings-sheet-state"; |
| 105 | import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels"; |
| 106 | |
| 107 | /** |
| 108 | * Everyday harnesses start expanded; every other provider (OpenRouter catalogs |
| 109 | * and friends) starts folded so a 300-model catalog cannot bury the list. All |
| 110 | * provider headers remain user-collapsible. |
| 111 | */ |
| 112 | const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set([ |
| 113 | "claudeAgent", |
| 114 | "codex", |
| 115 | "antigravity", |
| 116 | ]); |
| 117 | /** |
| 118 | * Keep measured row changes stable, but let catalog mutations use the list's |
| 119 | * native bounds so a filtered catalog that underflows returns to the top. |
| 120 | */ |
| 121 | const THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION = { |
| 122 | data: false, |
| 123 | size: true, |
| 124 | } as const; |
| 125 | const THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION = LinearTransition.duration(180); |
| 126 | const THREAD_SETTINGS_CATALOG_ENTER_TRANSITION = FadeIn.duration(140); |
| 127 | const THREAD_SETTINGS_CATALOG_EXIT_TRANSITION = FadeOut.duration(120); |
| 128 | const THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION = LinearTransition.duration(180); |
| 129 | const THREAD_SETTINGS_OPTION_ENTER_TRANSITION = FadeIn.duration(140); |
| 130 | const THREAD_SETTINGS_OPTION_EXIT_TRANSITION = FadeOut.duration(100); |
| 131 | const THREAD_SETTINGS_HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects( |
| 132 | Platform.OS, |
| 133 | Platform.Version, |
| 134 | ); |
| 135 | const EMPTY_MODEL_FAVORITES: ReadonlyArray<{ |
| 136 | readonly provider: ProviderInstanceId; |
| 137 | readonly model: string; |
| 138 | }> = []; |
| 139 | const FAVORITES_PROVIDER_FILTER = "@favorites"; |
| 140 | /** Provider catalog header with its harness logo and disclosure state. */ |
| 141 | function ProviderHeader(props: { |
| 142 | readonly driver: string | undefined; |
| 143 | readonly iconUrl: string | undefined; |
| 144 | readonly label: string; |
| 145 | readonly collapsible: boolean; |
| 146 | readonly collapsed: boolean; |
| 147 | readonly modelCount: number; |
| 148 | readonly onToggle: () => void; |
| 149 | }) { |
| 150 | const content = ( |
| 151 | <> |
| 152 | <ProviderIcon iconUrl={props.iconUrl} provider={props.driver} size={15} /> |
| 153 | <Text className="text-sm font-t3-medium text-foreground-muted">{props.label}</Text> |
| 154 | {props.collapsible ? ( |
| 155 | <> |
| 156 | <View className="flex-1" /> |
| 157 | {props.collapsed ? ( |
| 158 | <Text className="text-2xs font-t3-medium text-foreground-muted"> |
| 159 | {props.modelCount} |
| 160 | </Text> |
| 161 | ) : null} |
| 162 | <SymbolView |
| 163 | name={props.collapsed ? "chevron.down" : "chevron.up"} |
| 164 | size={12} |
| 165 | tintColorClassName="accent-icon-subtle" |
| 166 | type="monochrome" |
| 167 | /> |
| 168 | </> |
| 169 | ) : null} |
| 170 | </> |
| 171 | ); |
| 172 | |
| 173 | if (props.collapsible) { |
| 174 | return ( |
| 175 | <Pressable |
| 176 | accessibilityLabel={`${props.label}, ${props.modelCount} models`} |
| 177 | accessibilityRole="button" |
| 178 | accessibilityState={{ expanded: !props.collapsed }} |
| 179 | className="mx-4 mt-1 min-h-11 flex-row items-center gap-2 rounded-xl px-1 pt-2 active:opacity-60 android:min-h-12" |
| 180 | onPress={props.onToggle} |
| 181 | > |
| 182 | {content} |
| 183 | </Pressable> |
| 184 | ); |
| 185 | } |
| 186 | |
| 187 | return ( |
| 188 | <View accessibilityRole="header" className="mx-4 min-h-9 flex-row items-center gap-2 px-1 pt-1"> |
| 189 | {content} |
| 190 | </View> |
| 191 | ); |
| 192 | } |
| 193 | |
| 194 | /** Compact row that opens a single-choice submenu panel. */ |
| 195 | function DisclosureRow(props: { |
| 196 | readonly label: string; |
| 197 | readonly value: string | undefined; |
| 198 | readonly onPress: () => void; |
| 199 | readonly isLast?: boolean; |
| 200 | }) { |
| 201 | return ( |
| 202 | <Pressable |
| 203 | accessibilityRole="button" |
| 204 | onPress={props.onPress} |
| 205 | className={cn( |
| 206 | "min-h-11 flex-row items-center gap-2 bg-grouped-card px-4 py-2 active:bg-subtle android:min-h-14", |
| 207 | !props.isLast && "border-b border-border-subtle", |
| 208 | )} |
| 209 | > |
| 210 | <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text> |
| 211 | <View className="flex-1" /> |
| 212 | {props.value ? ( |
| 213 | <Text className="text-sm text-foreground-muted" numberOfLines={1}> |
| 214 | {props.value} |
| 215 | </Text> |
| 216 | ) : null} |
| 217 | <SymbolView |
| 218 | name="chevron.right" |
| 219 | size={12} |
| 220 | tintColorClassName="accent-icon-subtle" |
| 221 | type="monochrome" |
| 222 | /> |
| 223 | </Pressable> |
| 224 | ); |
| 225 | } |
| 226 | |
| 227 | function SwitchRow(props: { |
| 228 | readonly label: string; |
| 229 | readonly value: boolean; |
| 230 | readonly onValueChange: (value: boolean) => void; |
| 231 | readonly isLast?: boolean; |
| 232 | }) { |
| 233 | return ( |
| 234 | <View |
| 235 | className={cn( |
| 236 | "min-h-11 flex-row items-center justify-between bg-grouped-card px-4 py-1", |
| 237 | !props.isLast && "border-b border-border-subtle", |
| 238 | )} |
| 239 | > |
| 240 | <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text> |
| 241 | <ThemedSwitch |
| 242 | accessibilityLabel={props.label} |
| 243 | onValueChange={props.onValueChange} |
| 244 | value={props.value} |
| 245 | /> |
| 246 | </View> |
| 247 | ); |
| 248 | } |
| 249 | |
| 250 | type ThreadSettingsSubmenuPage = |
| 251 | | { readonly kind: "descriptor"; readonly id: string } |
| 252 | | { readonly kind: "runtime" }; |
| 253 | |
| 254 | type ThreadSettingsSessionProps = { |
| 255 | readonly environmentId: EnvironmentId | null; |
| 256 | readonly projectId: ProjectId | null; |
| 257 | readonly providerInstanceId?: ProviderInstanceId; |
| 258 | readonly lockedProviderInstanceId?: ProviderInstanceId; |
| 259 | readonly providerGroups: ReadonlyArray<ProviderGroup>; |
| 260 | readonly selectedModel: ModelSelection | null; |
| 261 | readonly reportedModelSelection?: ModelSelection | null; |
| 262 | readonly onSelectModel: (option: ModelOption) => void; |
| 263 | readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>; |
| 264 | readonly onUpdateOptionSelections: (selections: ReadonlyArray<ProviderOptionSelection>) => void; |
| 265 | readonly runtimeMode: RuntimeMode; |
| 266 | readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; |
| 267 | }; |
| 268 | |
| 269 | export type ExistingThreadSettingsRouteSession = ThreadSettingsSessionProps & { |
| 270 | readonly ownerId: string; |
| 271 | }; |
| 272 | |
| 273 | type ExistingThreadSettingsRouteContextValue = { |
| 274 | readonly session: ExistingThreadSettingsRouteSession | null; |
| 275 | readonly present: (session: ExistingThreadSettingsRouteSession) => void; |
| 276 | readonly clear: (ownerId: string) => void; |
| 277 | }; |
| 278 | |
| 279 | const ExistingThreadSettingsRouteContext = |
| 280 | createContext<ExistingThreadSettingsRouteContextValue | null>(null); |
| 281 | |
| 282 | /** Bridges the active thread's settings state into the root native sheet route. */ |
| 283 | export function ExistingThreadSettingsRouteProvider(props: { readonly children: ReactNode }) { |
| 284 | const [session, setSession] = useState<ExistingThreadSettingsRouteSession | null>(null); |
| 285 | const present = useCallback((nextSession: ExistingThreadSettingsRouteSession) => { |
| 286 | setSession(nextSession); |
| 287 | }, []); |
| 288 | const clear = useCallback((ownerId: string) => { |
| 289 | setSession((current) => (current?.ownerId === ownerId ? null : current)); |
| 290 | }, []); |
| 291 | const value = useMemo(() => ({ session, present, clear }), [clear, present, session]); |
| 292 | |
| 293 | return ( |
| 294 | <ExistingThreadSettingsRouteContext.Provider value={value}> |
| 295 | {props.children} |
| 296 | </ExistingThreadSettingsRouteContext.Provider> |
| 297 | ); |
| 298 | } |
| 299 | |
| 300 | export function useExistingThreadSettingsRoutePresentation() { |
| 301 | const value = use(ExistingThreadSettingsRouteContext); |
| 302 | if (!value) { |
| 303 | throw new Error( |
| 304 | "useExistingThreadSettingsRoutePresentation must be used inside ExistingThreadSettingsRouteProvider.", |
| 305 | ); |
| 306 | } |
| 307 | return value; |
| 308 | } |
| 309 | |
| 310 | type ThreadSettingsSessionValue = { |
| 311 | readonly environmentId: EnvironmentId | null; |
| 312 | readonly providerInstanceId?: ProviderInstanceId; |
| 313 | readonly providerGroups: ReadonlyArray<ProviderGroup>; |
| 314 | readonly favoriteKeys: ReadonlySet<string>; |
| 315 | readonly favoritesLoaded: boolean; |
| 316 | readonly toggleFavorite: (option: ModelOption) => void; |
| 317 | readonly toggleHidden: (option: ModelOption) => void; |
| 318 | readonly pendingHiddenKeys: ReadonlySet<string>; |
| 319 | readonly manageHidden: boolean; |
| 320 | readonly setManageHidden: (value: boolean) => void; |
| 321 | readonly runtimeMode: RuntimeMode; |
| 322 | readonly runtimeModeChoices: ReturnType<typeof runtimeModeChoicesForSupportedModes>; |
| 323 | readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; |
| 324 | readonly displayedDescriptors: ReadonlyArray<ProviderOptionDescriptor>; |
| 325 | readonly displayedModelSelection: ModelSelection | null; |
| 326 | readonly reportedModelSelection: ModelSelection | null; |
| 327 | readonly providerExpansionOverrides: ReadonlySet<string>; |
| 328 | readonly hasLegacyModels: boolean; |
| 329 | readonly pendingModel: ModelOption | null; |
| 330 | readonly providerFilter: string | null; |
| 331 | readonly searchQuery: string; |
| 332 | readonly showLegacy: boolean; |
| 333 | readonly applyOptionChange: (id: string, value: string | boolean) => void; |
| 334 | readonly commitPendingModel: () => boolean; |
| 335 | readonly isApplied: (option: ModelOption) => boolean; |
| 336 | readonly isDisplayed: (option: ModelOption) => boolean; |
| 337 | readonly pressModel: (option: ModelOption) => void; |
| 338 | readonly setProviderFilter: (providerKey: string | null) => void; |
| 339 | readonly setSearchQuery: (query: string) => void; |
| 340 | readonly setShowLegacy: (showLegacy: boolean) => void; |
| 341 | readonly toggleProvider: (providerKey: string) => void; |
| 342 | }; |
| 343 | |
| 344 | const ThreadSettingsSessionContext = createContext<ThreadSettingsSessionValue | null>(null); |
| 345 | |
| 346 | /** Owns the staged model and option state for one picker presentation. */ |
| 347 | function ThreadSettingsSessionProvider( |
| 348 | props: ThreadSettingsSessionProps & { readonly children: ReactNode }, |
| 349 | ) { |
| 350 | const registry = use(RegistryContext); |
| 351 | const preferences = useAtomValue(mobilePreferencesAtom); |
| 352 | const configs = useAtomValue(environmentServerConfigsAtom); |
| 353 | const config = props.environmentId ? configs.get(props.environmentId) : undefined; |
| 354 | const settings = config?.settings; |
| 355 | const [manageHidden, setManageHidden] = useState(false); |
| 356 | const pendingHidden = useRef(new Set<string>()); |
| 357 | const pendingFavorites = useRef( |
| 358 | new Map<string, { favorite: boolean; settings: ServerSettings; pending: boolean }>(), |
| 359 | ); |
| 360 | const [pendingHiddenKeys, setPendingHiddenKeys] = useState<ReadonlySet<string>>(() => new Set()); |
| 361 | const providerGroups = useMemo( |
| 362 | () => |
| 363 | manageHidden && config |
| 364 | ? groupByProvider( |
| 365 | buildModelOptions( |
| 366 | config, |
| 367 | props.selectedModel, |
| 368 | props.lockedProviderInstanceId, |
| 369 | props.projectId, |
| 370 | true, |
| 371 | ), |
| 372 | ) |
| 373 | : props.providerGroups, |
| 374 | [ |
| 375 | config, |
| 376 | manageHidden, |
| 377 | props.providerGroups, |
| 378 | props.selectedModel, |
| 379 | props.lockedProviderInstanceId, |
| 380 | props.projectId, |
| 381 | ], |
| 382 | ); |
| 383 | const legacyFavorites = AsyncResult.isSuccess(preferences) |
| 384 | ? (preferences.value.modelFavorites ?? EMPTY_MODEL_FAVORITES) |
| 385 | : EMPTY_MODEL_FAVORITES; |
| 386 | const favoritesLoaded = |
| 387 | settings !== undefined && (settings.favorites !== null || AsyncResult.isSuccess(preferences)); |
| 388 | const modelFavorites = resolveModelPreferences(settings ?? DEFAULT_SERVER_SETTINGS, { |
| 389 | favorites: legacyFavorites, |
| 390 | }).favorites; |
| 391 | const saveSettings = useAtomCommand(serverEnvironment.updateSettings, "model favorites update"); |
| 392 | useEffect(() => { |
| 393 | if (!props.environmentId || !settings || !AsyncResult.isSuccess(preferences)) return; |
| 394 | const patch = modelPreferencesMigrationPatch(settings, { favorites: legacyFavorites }); |
| 395 | if (patch) void saveSettings({ environmentId: props.environmentId, input: { patch } }); |
| 396 | }, [legacyFavorites, preferences, props.environmentId, saveSettings, settings]); |
| 397 | const favoriteKeys = useMemo( |
| 398 | () => |
| 399 | new Set( |
| 400 | modelFavorites.map((favorite) => modelFavoriteKey(favorite.provider, favorite.model)), |
| 401 | ), |
| 402 | [modelFavorites], |
| 403 | ); |
| 404 | const toggleFavorite = useCallback( |
| 405 | async (option: ModelOption) => { |
| 406 | if (!favoritesLoaded || !props.environmentId) return; |
| 407 | const currentSettings = registry |
| 408 | .get(environmentServerConfigsAtom) |
| 409 | .get(props.environmentId)?.settings; |
| 410 | if (!currentSettings) return; |
| 411 | const favorites = resolveModelPreferences(currentSettings, { |
| 412 | favorites: legacyFavorites, |
| 413 | }).favorites; |
| 414 | const key = JSON.stringify([ |
| 415 | props.environmentId, |
| 416 | option.selection.instanceId, |
| 417 | option.selection.model, |
| 418 | ]); |
| 419 | const previous = pendingFavorites.current.get(key); |
| 420 | const previousFavorite = |
| 421 | previous && (previous.pending || previous.settings.favorites === currentSettings.favorites) |
| 422 | ? previous.favorite |
| 423 | : favorites.some( |
| 424 | (favorite) => |
| 425 | favorite.provider === option.selection.instanceId && |
| 426 | favorite.model === option.selection.model, |
| 427 | ); |
| 428 | const pending = { favorite: !previousFavorite, settings: currentSettings, pending: true }; |
| 429 | pendingFavorites.current.set(key, pending); |
| 430 | void Haptics.selectionAsync(); |
| 431 | try { |
| 432 | const result = await saveSettings({ |
| 433 | environmentId: props.environmentId, |
| 434 | input: { |
| 435 | patch: { |
| 436 | setModelFavorites: [ |
| 437 | { |
| 438 | provider: option.selection.instanceId, |
| 439 | model: option.selection.model, |
| 440 | favorite: pending.favorite, |
| 441 | }, |
| 442 | ], |
| 443 | ...(currentSettings.favorites === null |
| 444 | ? { migrateModelPreferences: { favorites } } |
| 445 | : {}), |
| 446 | }, |
| 447 | }, |
| 448 | }); |
| 449 | if ( |
| 450 | AsyncResult.isSuccess(result) && |
| 451 | registry.get(environmentServerConfigsAtom).get(props.environmentId)?.settings |
| 452 | .favorites === currentSettings.favorites |
| 453 | ) { |
| 454 | pending.pending = false; |
| 455 | } |
| 456 | } finally { |
| 457 | if (pending.pending && pendingFavorites.current.get(key) === pending) { |
| 458 | if ( |
| 459 | previous && |
| 460 | !previous.pending && |
| 461 | registry.get(environmentServerConfigsAtom).get(props.environmentId)?.settings |
| 462 | .favorites === previous.settings.favorites |
| 463 | ) { |
| 464 | pendingFavorites.current.set(key, previous); |
| 465 | } else { |
| 466 | pendingFavorites.current.delete(key); |
| 467 | } |
| 468 | } |
| 469 | } |
| 470 | }, |
| 471 | [favoritesLoaded, legacyFavorites, props.environmentId, registry, saveSettings], |
| 472 | ); |
| 473 | const toggleHidden = useCallback( |
| 474 | (option: ModelOption) => { |
| 475 | if ( |
| 476 | !settings || |
| 477 | !props.environmentId || |
| 478 | !option.canHide || |
| 479 | pendingHidden.current.has(option.key) |
| 480 | ) |
| 481 | return; |
| 482 | pendingHidden.current.add(option.key); |
| 483 | setPendingHiddenKeys(new Set(pendingHidden.current)); |
| 484 | void Haptics.selectionAsync(); |
| 485 | void saveSettings({ |
| 486 | environmentId: props.environmentId, |
| 487 | input: { |
| 488 | patch: { |
| 489 | setModelsHidden: [ |
| 490 | { |
| 491 | provider: option.selection.instanceId, |
| 492 | model: option.selection.model, |
| 493 | hidden: !option.isHidden, |
| 494 | }, |
| 495 | ], |
| 496 | }, |
| 497 | }, |
| 498 | }).then(() => { |
| 499 | pendingHidden.current.delete(option.key); |
| 500 | setPendingHiddenKeys(new Set(pendingHidden.current)); |
| 501 | }); |
| 502 | }, |
| 503 | [props.environmentId, saveSettings, settings], |
| 504 | ); |
| 505 | const [showLegacyToggle, setShowLegacyToggle] = useState(false); |
| 506 | const [providerFilter, setProviderFilter] = useState<string | null>(null); |
| 507 | const [searchQuery, setSearchQuery] = useState(""); |
| 508 | const [providerExpansionOverrides, setProviderExpansionOverrides] = useState<ReadonlySet<string>>( |
| 509 | () => new Set(), |
| 510 | ); |
| 511 | const [pendingModel, setPendingModel] = useState<ModelOption | null>(null); |
| 512 | |
| 513 | const isApplied = useCallback( |
| 514 | (option: ModelOption) => |
| 515 | option.selection.instanceId === props.selectedModel?.instanceId && |
| 516 | option.selection.model === props.selectedModel.model, |
| 517 | [props.selectedModel], |
| 518 | ); |
| 519 | // The list highlights the staged pick; Save turns it into the applied one. |
| 520 | const isDisplayed = useCallback( |
| 521 | (option: ModelOption) => (pendingModel ? option.key === pendingModel.key : isApplied(option)), |
| 522 | [isApplied, pendingModel], |
| 523 | ); |
| 524 | |
| 525 | // While a model is staged, the settings rows describe and edit the staged |
| 526 | // model's options (kept on its pending selection); Save applies model and |
| 527 | // options together. Otherwise they edit the applied selection directly. |
| 528 | const displayedDescriptors = useMemo( |
| 529 | () => |
| 530 | pendingModel |
| 531 | ? pendingModel.capabilities |
| 532 | ? getProviderOptionDescriptors({ |
| 533 | caps: pendingModel.capabilities, |
| 534 | selections: pendingModel.selection.options, |
| 535 | }) |
| 536 | : [] |
| 537 | : props.optionDescriptors, |
| 538 | [pendingModel, props.optionDescriptors], |
| 539 | ); |
| 540 | const displayedModel = useMemo( |
| 541 | () => |
| 542 | pendingModel ?? |
| 543 | props.providerGroups.flatMap((group) => group.models).find((option) => isApplied(option)) ?? |
| 544 | null, |
| 545 | [isApplied, pendingModel, props.providerGroups], |
| 546 | ); |
| 547 | const runtimeModeChoices = runtimeModeChoicesForSupportedModes( |
| 548 | displayedModel?.supportedRuntimeModes, |
| 549 | ); |
| 550 | const compatibleRuntimeMode = compatibleRuntimeModeForChoices( |
| 551 | props.runtimeMode, |
| 552 | runtimeModeChoices, |
| 553 | ); |
| 554 | |
| 555 | const hasLegacyModels = useMemo( |
| 556 | () => props.providerGroups.some((group) => group.models.some((model) => model.isLegacy)), |
| 557 | [props.providerGroups], |
| 558 | ); |
| 559 | const commitPendingModel = useCallback(() => { |
| 560 | if (pendingModel) { |
| 561 | if (!canCommitPendingModel(pendingModel, props.providerGroups)) { |
| 562 | Alert.alert( |
| 563 | "Model unavailable", |
| 564 | "Set up this provider on web or desktop, or select another model.", |
| 565 | ); |
| 566 | return false; |
| 567 | } |
| 568 | void Haptics.selectionAsync(); |
| 569 | props.onSelectModel(pendingModel); |
| 570 | } |
| 571 | return true; |
| 572 | }, [pendingModel, props.onSelectModel, props.providerGroups]); |
| 573 | |
| 574 | const applyOptionChange = useCallback( |
| 575 | (id: string, value: string | boolean) => { |
| 576 | const next = applyProviderOptionSelection(displayedDescriptors, { id, value }); |
| 577 | if (!next) { |
| 578 | return; |
| 579 | } |
| 580 | if (pendingModel) { |
| 581 | rememberModelOptions(pendingModel.selection.instanceId, pendingModel.selection.model, next); |
| 582 | setPendingModel({ |
| 583 | ...pendingModel, |
| 584 | selection: { ...pendingModel.selection, options: next }, |
| 585 | }); |
| 586 | } else { |
| 587 | props.onUpdateOptionSelections(next); |
| 588 | } |
| 589 | }, |
| 590 | [displayedDescriptors, pendingModel, props.onUpdateOptionSelections], |
| 591 | ); |
| 592 | |
| 593 | const toggleProvider = useCallback((providerKey: string) => { |
| 594 | setProviderExpansionOverrides((current) => { |
| 595 | const next = new Set(current); |
| 596 | if (!next.delete(providerKey)) { |
| 597 | next.add(providerKey); |
| 598 | } |
| 599 | return next; |
| 600 | }); |
| 601 | }, []); |
| 602 | |
| 603 | const pressModel = useCallback( |
| 604 | (option: ModelOption) => { |
| 605 | void Haptics.selectionAsync(); |
| 606 | setPendingModel((current) => |
| 607 | pendingModelAfterPress({ |
| 608 | current, |
| 609 | pressed: option, |
| 610 | pressedIsApplied: isApplied(option), |
| 611 | }), |
| 612 | ); |
| 613 | }, |
| 614 | [isApplied], |
| 615 | ); |
| 616 | |
| 617 | const value = useMemo<ThreadSettingsSessionValue>( |
| 618 | () => ({ |
| 619 | environmentId: props.environmentId, |
| 620 | providerInstanceId: props.providerInstanceId, |
| 621 | providerGroups, |
| 622 | manageHidden, |
| 623 | setManageHidden, |
| 624 | toggleHidden, |
| 625 | pendingHiddenKeys, |
| 626 | runtimeMode: compatibleRuntimeMode, |
| 627 | runtimeModeChoices, |
| 628 | onUpdateRuntimeMode: props.onUpdateRuntimeMode, |
| 629 | displayedDescriptors, |
| 630 | displayedModelSelection: pendingModel?.selection ?? props.selectedModel, |
| 631 | reportedModelSelection: pendingModel ? null : (props.reportedModelSelection ?? null), |
| 632 | favoriteKeys, |
| 633 | favoritesLoaded, |
| 634 | providerExpansionOverrides, |
| 635 | hasLegacyModels, |
| 636 | pendingModel, |
| 637 | providerFilter, |
| 638 | searchQuery, |
| 639 | showLegacy: showLegacyToggle, |
| 640 | applyOptionChange, |
| 641 | commitPendingModel, |
| 642 | isApplied, |
| 643 | isDisplayed, |
| 644 | pressModel, |
| 645 | setProviderFilter, |
| 646 | setSearchQuery, |
| 647 | setShowLegacy: setShowLegacyToggle, |
| 648 | toggleProvider, |
| 649 | toggleFavorite, |
| 650 | }), |
| 651 | [ |
| 652 | applyOptionChange, |
| 653 | commitPendingModel, |
| 654 | compatibleRuntimeMode, |
| 655 | displayedDescriptors, |
| 656 | favoriteKeys, |
| 657 | favoritesLoaded, |
| 658 | providerExpansionOverrides, |
| 659 | hasLegacyModels, |
| 660 | isApplied, |
| 661 | isDisplayed, |
| 662 | props.environmentId, |
| 663 | props.selectedModel, |
| 664 | props.reportedModelSelection, |
| 665 | props.providerInstanceId, |
| 666 | pendingModel, |
| 667 | pressModel, |
| 668 | providerFilter, |
| 669 | props.onUpdateRuntimeMode, |
| 670 | providerGroups, |
| 671 | manageHidden, |
| 672 | toggleHidden, |
| 673 | pendingHiddenKeys, |
| 674 | runtimeModeChoices, |
| 675 | searchQuery, |
| 676 | showLegacyToggle, |
| 677 | toggleProvider, |
| 678 | toggleFavorite, |
| 679 | ], |
| 680 | ); |
| 681 | |
| 682 | return ( |
| 683 | <ThreadSettingsSessionContext.Provider value={value}> |
| 684 | {props.children} |
| 685 | </ThreadSettingsSessionContext.Provider> |
| 686 | ); |
| 687 | } |
| 688 | |
| 689 | function useThreadSettingsSession() { |
| 690 | const value = use(ThreadSettingsSessionContext); |
| 691 | if (!value) { |
| 692 | throw new Error("useThreadSettingsSession must be used inside ThreadSettingsSessionProvider."); |
| 693 | } |
| 694 | return value; |
| 695 | } |
| 696 | |
| 697 | type ThreadSettingsProviderCatalog = { |
| 698 | readonly key: string; |
| 699 | readonly driver: string | undefined; |
| 700 | readonly iconUrl: string | undefined; |
| 701 | readonly label: string; |
| 702 | readonly collapsible: boolean; |
| 703 | readonly collapsed: boolean; |
| 704 | readonly modelCount: number; |
| 705 | readonly models: ReadonlyArray<ModelOption>; |
| 706 | }; |
| 707 | |
| 708 | type ThreadSettingsCatalogItem = |
| 709 | | { |
| 710 | readonly kind: "provider"; |
| 711 | readonly key: string; |
| 712 | readonly provider: ThreadSettingsProviderCatalog; |
| 713 | } |
| 714 | | { |
| 715 | readonly kind: "model"; |
| 716 | readonly key: string; |
| 717 | readonly option: ModelOption; |
| 718 | readonly isFirst: boolean; |
| 719 | readonly isLast: boolean; |
| 720 | } |
| 721 | | { |
| 722 | readonly kind: "notice"; |
| 723 | readonly key: string; |
| 724 | readonly text: string; |
| 725 | } |
| 726 | | { |
| 727 | readonly kind: "empty"; |
| 728 | readonly key: "empty"; |
| 729 | } |
| 730 | | { |
| 731 | readonly kind: "options"; |
| 732 | readonly key: "options"; |
| 733 | }; |
| 734 | |
| 735 | function ThreadSettingsModelListRow(props: { |
| 736 | readonly option: ModelOption; |
| 737 | readonly isFirst: boolean; |
| 738 | readonly isLast: boolean; |
| 739 | }) { |
| 740 | const session = useThreadSettingsSession(); |
| 741 | const onPress = useCallback( |
| 742 | () => session.pressModel(props.option), |
| 743 | [props.option, session.pressModel], |
| 744 | ); |
| 745 | |
| 746 | return ( |
| 747 | <ModelRow |
| 748 | isFirst={props.isFirst} |
| 749 | isLast={props.isLast} |
| 750 | onPress={onPress} |
| 751 | isFavorite={session.favoriteKeys.has(props.option.key)} |
| 752 | favoritesLoaded={session.favoritesLoaded} |
| 753 | onToggleFavorite={() => session.toggleFavorite(props.option)} |
| 754 | onToggleHidden={ |
| 755 | session.manageHidden && props.option.canHide |
| 756 | ? () => session.toggleHidden(props.option) |
| 757 | : undefined |
| 758 | } |
| 759 | hiddenUpdatePending={session.pendingHiddenKeys.has(props.option.key)} |
| 760 | option={props.option} |
| 761 | selected={session.isDisplayed(props.option)} |
| 762 | /> |
| 763 | ); |
| 764 | } |
| 765 | |
| 766 | function ThreadSettingsProviderListHeader(props: { |
| 767 | readonly provider: ThreadSettingsProviderCatalog; |
| 768 | }) { |
| 769 | const session = useThreadSettingsSession(); |
| 770 | const onToggle = useCallback( |
| 771 | () => session.toggleProvider(props.provider.key), |
| 772 | [props.provider.key, session.toggleProvider], |
| 773 | ); |
| 774 | |
| 775 | return ( |
| 776 | <ProviderHeader |
| 777 | collapsible={props.provider.collapsible} |
| 778 | collapsed={props.provider.collapsed} |
| 779 | driver={props.provider.driver} |
| 780 | iconUrl={props.provider.iconUrl} |
| 781 | label={props.provider.label} |
| 782 | modelCount={props.provider.modelCount} |
| 783 | onToggle={onToggle} |
| 784 | /> |
| 785 | ); |
| 786 | } |
| 787 | |
| 788 | function useThreadSettingsCatalogItems( |
| 789 | session: ThreadSettingsSessionValue, |
| 790 | ): ReadonlyArray<ThreadSettingsCatalogItem> { |
| 791 | return useMemo( |
| 792 | () => |
| 793 | session.providerGroups.flatMap((group) => { |
| 794 | if ( |
| 795 | session.providerFilter !== null && |
| 796 | session.providerFilter !== FAVORITES_PROVIDER_FILTER && |
| 797 | group.providerKey !== session.providerFilter |
| 798 | ) { |
| 799 | return []; |
| 800 | } |
| 801 | const driver = group.models[0]?.providerDriver ?? group.providerKey; |
| 802 | const catalogModels = |
| 803 | session.manageHidden || |
| 804 | session.showLegacy || |
| 805 | session.providerFilter === FAVORITES_PROVIDER_FILTER |
| 806 | ? group.models |
| 807 | : group.models.filter( |
| 808 | (model) => |
| 809 | !model.isLegacy || |
| 810 | session.isDisplayed(model) || |
| 811 | session.favoriteKeys.has(model.key), |
| 812 | ); |
| 813 | const visibleModels = favoritesFirst( |
| 814 | catalogModels.filter( |
| 815 | (model) => |
| 816 | (session.providerFilter !== FAVORITES_PROVIDER_FILTER || |
| 817 | session.favoriteKeys.has(model.key)) && |
| 818 | modelMatchesCatalogQuery({ |
| 819 | model, |
| 820 | providerLabel: group.providerLabel, |
| 821 | query: session.searchQuery, |
| 822 | }), |
| 823 | ), |
| 824 | session.favoriteKeys, |
| 825 | ); |
| 826 | // Favorites list only selectable models, so it never explains gated ones. |
| 827 | const updateRequiredNotice = |
| 828 | group.updateRequired && session.providerFilter !== FAVORITES_PROVIDER_FILTER |
| 829 | ? formatProviderUpdateRequiredNotice(group.updateRequired, session.searchQuery) |
| 830 | : null; |
| 831 | if (visibleModels.length === 0 && !updateRequiredNotice) { |
| 832 | return []; |
| 833 | } |
| 834 | const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver); |
| 835 | // Staging a model must not change disclosure state. The applied model |
| 836 | // stays stable for the lifetime of this picker (Save closes it), so it |
| 837 | // is safe to use as the initial selected-provider default. |
| 838 | const containsAppliedSelection = group.models.some(session.isApplied); |
| 839 | const isNarrowed = session.providerFilter !== null || session.searchQuery.trim().length > 0; |
| 840 | const collapsible = !isNarrowed; |
| 841 | const collapsed = providerSectionIsCollapsed({ |
| 842 | defaultExpanded: isPrimary || containsAppliedSelection, |
| 843 | hasExpansionOverride: session.providerExpansionOverrides.has(group.providerKey), |
| 844 | isNarrowed, |
| 845 | }); |
| 846 | const provider: ThreadSettingsProviderCatalog = { |
| 847 | key: group.providerKey, |
| 848 | driver, |
| 849 | iconUrl: group.models[0]?.providerIconUrl, |
| 850 | label: group.providerLabel, |
| 851 | collapsible, |
| 852 | collapsed, |
| 853 | modelCount: visibleModels.length, |
| 854 | models: collapsed ? [] : visibleModels, |
| 855 | }; |
| 856 | return [ |
| 857 | { |
| 858 | kind: "provider" as const, |
| 859 | key: `provider:${group.providerKey}`, |
| 860 | provider, |
| 861 | }, |
| 862 | ...provider.models.map((option, index) => ({ |
| 863 | kind: "model" as const, |
| 864 | key: `model:${option.key}`, |
| 865 | option, |
| 866 | isFirst: index === 0, |
| 867 | isLast: index === provider.models.length - 1, |
| 868 | })), |
| 869 | ...(!collapsed && updateRequiredNotice |
| 870 | ? [ |
| 871 | { |
| 872 | kind: "notice" as const, |
| 873 | key: `notice:${group.providerKey}`, |
| 874 | text: updateRequiredNotice, |
| 875 | }, |
| 876 | ] |
| 877 | : []), |
| 878 | ]; |
| 879 | }), |
| 880 | [ |
| 881 | session.isApplied, |
| 882 | session.isDisplayed, |
| 883 | session.favoriteKeys, |
| 884 | session.providerExpansionOverrides, |
| 885 | session.providerFilter, |
| 886 | session.providerGroups, |
| 887 | session.searchQuery, |
| 888 | session.showLegacy, |
| 889 | session.manageHidden, |
| 890 | ], |
| 891 | ); |
| 892 | } |
| 893 | |
| 894 | function ThreadSettingsOptionsItem(props: { |
| 895 | readonly animationsReady: boolean; |
| 896 | readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void; |
| 897 | }) { |
| 898 | const insets = useSafeAreaInsets(); |
| 899 | const session = useThreadSettingsSession(); |
| 900 | const configs = useAtomValue(environmentServerConfigsAtom); |
| 901 | const selectedProvider = session.environmentId |
| 902 | ? (configs |
| 903 | .get(session.environmentId) |
| 904 | ?.providers.find((provider) => provider.instanceId === session.providerInstanceId) ?? null) |
| 905 | : null; |
| 906 | const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); |
| 907 | const bottomToolbarInset = usesNativeMailSearchToolbar |
| 908 | ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET |
| 909 | : 0; |
| 910 | |
| 911 | return ( |
| 912 | <View style={{ paddingBottom: insets.bottom + bottomToolbarInset + 12 }}> |
| 913 | <ChatGptSharingStatus provider={selectedProvider} /> |
| 914 | <Text className="px-5 pb-2 pt-2 text-sm font-t3-medium text-foreground-muted">Options</Text> |
| 915 | <Animated.View |
| 916 | className="mx-4 overflow-hidden rounded-2xl bg-grouped-card" |
| 917 | layout={THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION} |
| 918 | > |
| 919 | {session.displayedDescriptors.map((descriptor) => { |
| 920 | if (descriptor.type === "select") { |
| 921 | return ( |
| 922 | <Animated.View |
| 923 | key={descriptor.id} |
| 924 | entering={ |
| 925 | props.animationsReady ? THREAD_SETTINGS_OPTION_ENTER_TRANSITION : undefined |
| 926 | } |
| 927 | exiting={props.animationsReady ? THREAD_SETTINGS_OPTION_EXIT_TRANSITION : undefined} |
| 928 | layout={THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION} |
| 929 | > |
| 930 | <DisclosureRow |
| 931 | label={descriptor.label} |
| 932 | value={getProviderOptionCurrentLabel( |
| 933 | descriptor, |
| 934 | session.displayedModelSelection, |
| 935 | session.reportedModelSelection, |
| 936 | )} |
| 937 | onPress={() => props.onOpenSubmenu({ kind: "descriptor", id: descriptor.id })} |
| 938 | /> |
| 939 | </Animated.View> |
| 940 | ); |
| 941 | } |
| 942 | return ( |
| 943 | <Animated.View |
| 944 | key={descriptor.id} |
| 945 | entering={props.animationsReady ? THREAD_SETTINGS_OPTION_ENTER_TRANSITION : undefined} |
| 946 | exiting={props.animationsReady ? THREAD_SETTINGS_OPTION_EXIT_TRANSITION : undefined} |
| 947 | layout={THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION} |
| 948 | > |
| 949 | <SwitchRow |
| 950 | label={descriptor.label} |
| 951 | value={descriptor.currentValue ?? false} |
| 952 | onValueChange={(value) => session.applyOptionChange(descriptor.id, value)} |
| 953 | /> |
| 954 | </Animated.View> |
| 955 | ); |
| 956 | })} |
| 957 | <Animated.View layout={THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION}> |
| 958 | <DisclosureRow |
| 959 | isLast |
| 960 | label="Runtime" |
| 961 | value={ |
| 962 | session.runtimeModeChoices.find((choice) => choice.mode === session.runtimeMode) |
| 963 | ?.label |
| 964 | } |
| 965 | onPress={() => props.onOpenSubmenu({ kind: "runtime" })} |
| 966 | /> |
| 967 | </Animated.View> |
| 968 | </Animated.View> |
| 969 | |
| 970 | <Text className="px-5 pb-2 pt-7 text-sm font-t3-medium text-foreground-muted">Catalog</Text> |
| 971 | <View className="mx-4 overflow-hidden rounded-2xl bg-grouped-card"> |
| 972 | <SwitchRow |
| 973 | label="Manage hidden models" |
| 974 | value={session.manageHidden} |
| 975 | onValueChange={session.setManageHidden} |
| 976 | isLast={Platform.OS === "ios" || !session.hasLegacyModels} |
| 977 | /> |
| 978 | {Platform.OS !== "ios" && session.hasLegacyModels ? ( |
| 979 | <SwitchRow |
| 980 | isLast |
| 981 | label="Legacy models" |
| 982 | onValueChange={session.setShowLegacy} |
| 983 | value={session.showLegacy} |
| 984 | /> |
| 985 | ) : null} |
| 986 | </View> |
| 987 | </View> |
| 988 | ); |
| 989 | } |
| 990 | |
| 991 | /** One native scroll owner for the model catalog and its related settings. */ |
| 992 | function ThreadSettingsMainContent(props: { |
| 993 | readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void; |
| 994 | }) { |
| 995 | const session = useThreadSettingsSession(); |
| 996 | const refreshProvidersCommand = useAtomCommand(serverEnvironment.refreshProviders, { |
| 997 | reportFailure: false, |
| 998 | }); |
| 999 | const refreshProviderCatalog = useMemo( |
| 1000 | () => createProviderCatalogRefreshRunner(refreshProvidersCommand), |
| 1001 | [refreshProvidersCommand], |
| 1002 | ); |
| 1003 | const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); |
| 1004 | const refreshProviders = useCallback(() => { |
| 1005 | if (!session.environmentId || isRefreshingProviders) return; |
| 1006 | setIsRefreshingProviders(true); |
| 1007 | void refreshProviderCatalog(session.environmentId).then((result) => { |
| 1008 | setIsRefreshingProviders(false); |
| 1009 | const error = providerCatalogRefreshError(result); |
| 1010 | if (error) Alert.alert("Could not refresh models", error); |
| 1011 | }); |
| 1012 | }, [isRefreshingProviders, refreshProviderCatalog, session.environmentId]); |
| 1013 | const catalogItems = useThreadSettingsCatalogItems(session); |
| 1014 | const [animationsReady, setAnimationsReady] = useState(false); |
| 1015 | const hasActiveCatalogFilter = |
| 1016 | session.providerFilter !== null || session.searchQuery.trim().length > 0; |
| 1017 | const listItems = useMemo<ReadonlyArray<ThreadSettingsCatalogItem>>( |
| 1018 | () => [ |
| 1019 | ...(catalogItems.length === 0 ? ([{ kind: "empty", key: "empty" }] as const) : catalogItems), |
| 1020 | { kind: "options", key: "options" }, |
| 1021 | ], |
| 1022 | [catalogItems], |
| 1023 | ); |
| 1024 | const renderCatalogItem = useCallback( |
| 1025 | (itemProps: LegendListRenderItemProps<ThreadSettingsCatalogItem>) => { |
| 1026 | const item = itemProps.item; |
| 1027 | let content: ReactNode; |
| 1028 | |
| 1029 | if (item.kind === "provider") { |
| 1030 | content = <ThreadSettingsProviderListHeader provider={item.provider} />; |
| 1031 | } else if (item.kind === "model") { |
| 1032 | content = ( |
| 1033 | <ThreadSettingsModelListRow |
| 1034 | isFirst={item.isFirst} |
| 1035 | isLast={item.isLast} |
| 1036 | option={item.option} |
| 1037 | /> |
| 1038 | ); |
| 1039 | } else if (item.kind === "notice") { |
| 1040 | content = <Text className="mx-8 mt-2 text-xs text-foreground-muted">{item.text}</Text>; |
| 1041 | } else if (item.kind === "empty") { |
| 1042 | content = ( |
| 1043 | <View className="items-center px-8 py-14"> |
| 1044 | <Text className="text-center text-sm text-foreground-muted"> |
| 1045 | {session.providerFilter === FAVORITES_PROVIDER_FILTER && |
| 1046 | session.searchQuery.trim().length === 0 |
| 1047 | ? "No favorite models" |
| 1048 | : hasActiveCatalogFilter |
| 1049 | ? "No matching models" |
| 1050 | : "No available models"} |
| 1051 | </Text> |
| 1052 | </View> |
| 1053 | ); |
| 1054 | } else { |
| 1055 | content = ( |
| 1056 | <ThreadSettingsOptionsItem |
| 1057 | animationsReady={animationsReady} |
| 1058 | onOpenSubmenu={props.onOpenSubmenu} |
| 1059 | /> |
| 1060 | ); |
| 1061 | } |
| 1062 | |
| 1063 | return ( |
| 1064 | <Animated.View |
| 1065 | key={item.key} |
| 1066 | entering={animationsReady ? THREAD_SETTINGS_CATALOG_ENTER_TRANSITION : undefined} |
| 1067 | exiting={animationsReady ? THREAD_SETTINGS_CATALOG_EXIT_TRANSITION : undefined} |
| 1068 | > |
| 1069 | {content} |
| 1070 | </Animated.View> |
| 1071 | ); |
| 1072 | }, |
| 1073 | [ |
| 1074 | animationsReady, |
| 1075 | hasActiveCatalogFilter, |
| 1076 | props.onOpenSubmenu, |
| 1077 | session.providerFilter, |
| 1078 | session.searchQuery, |
| 1079 | ], |
| 1080 | ); |
| 1081 | |
| 1082 | return ( |
| 1083 | <AnimatedLegendList |
| 1084 | alwaysBounceVertical |
| 1085 | automaticallyAdjustsScrollIndicatorInsets |
| 1086 | className="flex-1 bg-sheet" |
| 1087 | style={ |
| 1088 | Platform.OS === "android" |
| 1089 | ? { width: "100%", maxWidth: 720, alignSelf: "center" } |
| 1090 | : undefined |
| 1091 | } |
| 1092 | contentContainerStyle={{ paddingTop: 4 }} |
| 1093 | contentInsetAdjustmentBehavior="automatic" |
| 1094 | data={listItems} |
| 1095 | estimatedItemSize={Platform.OS === "android" ? 56 : 48} |
| 1096 | extraData={animationsReady} |
| 1097 | getItemType={(item) => item.kind} |
| 1098 | itemLayoutAnimation={THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION} |
| 1099 | keyExtractor={(item) => item.key} |
| 1100 | keyboardDismissMode="on-drag" |
| 1101 | keyboardShouldPersistTaps="handled" |
| 1102 | maintainVisibleContentPosition={THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION} |
| 1103 | ListHeaderComponent={ |
| 1104 | <> |
| 1105 | {Platform.OS === "android" ? ( |
| 1106 | <View className="px-4 pb-2 pt-3"> |
| 1107 | <View |
| 1108 | className="flex-row items-center rounded-full bg-input px-2" |
| 1109 | style={{ minHeight: 56 }} |
| 1110 | > |
| 1111 | <View pointerEvents="none" className="px-2"> |
| 1112 | <SymbolView |
| 1113 | name="magnifyingglass" |
| 1114 | size={24} |
| 1115 | tintColorClassName="accent-icon-subtle" |
| 1116 | /> |
| 1117 | </View> |
| 1118 | <TextInput |
| 1119 | accessibilityLabel="Find a model" |
| 1120 | autoCapitalize="none" |
| 1121 | autoCorrect={false} |
| 1122 | className="min-w-0 flex-1 px-2 py-0 text-base text-foreground" |
| 1123 | style={{ minHeight: 56, includeFontPadding: false, textAlignVertical: "center" }} |
| 1124 | onChangeText={session.setSearchQuery} |
| 1125 | placeholder="Find a model" |
| 1126 | placeholderTextColorClassName="accent-placeholder" |
| 1127 | selectionColorClassName="accent-focus/32" |
| 1128 | cursorColorClassName="accent-focus" |
| 1129 | selectionHandleColorClassName="accent-focus" |
| 1130 | value={session.searchQuery} |
| 1131 | /> |
| 1132 | {session.searchQuery.length > 0 ? ( |
| 1133 | <MaterialIconButton |
| 1134 | accessibilityLabel="Clear model search" |
| 1135 | icon="xmark" |
| 1136 | onPress={() => session.setSearchQuery("")} |
| 1137 | /> |
| 1138 | ) : null} |
| 1139 | </View> |
| 1140 | </View> |
| 1141 | ) : null} |
| 1142 | </> |
| 1143 | } |
| 1144 | recycleItems |
| 1145 | refreshControl={ |
| 1146 | session.environmentId ? ( |
| 1147 | <RefreshControl refreshing={isRefreshingProviders} onRefresh={refreshProviders} /> |
| 1148 | ) : undefined |
| 1149 | } |
| 1150 | onLoad={() => setAnimationsReady(true)} |
| 1151 | renderItem={renderCatalogItem} |
| 1152 | showsVerticalScrollIndicator={false} |
| 1153 | /> |
| 1154 | ); |
| 1155 | } |
| 1156 | |
| 1157 | /** Compact choice page pushed by the picker navigator. */ |
| 1158 | function ThreadSettingsChoiceContent(props: { |
| 1159 | readonly submenu: ThreadSettingsSubmenuPage; |
| 1160 | readonly onSelected: () => void; |
| 1161 | }) { |
| 1162 | const insets = useSafeAreaInsets(); |
| 1163 | const session = useThreadSettingsSession(); |
| 1164 | const descriptorId = props.submenu.kind === "descriptor" ? props.submenu.id : null; |
| 1165 | |
| 1166 | const activeDescriptor = |
| 1167 | descriptorId !== null |
| 1168 | ? session.displayedDescriptors.find( |
| 1169 | (descriptor) => descriptor.type === "select" && descriptor.id === descriptorId, |
| 1170 | ) |
| 1171 | : undefined; |
| 1172 | |
| 1173 | const submenuContent = |
| 1174 | props.submenu.kind === "runtime" |
| 1175 | ? { |
| 1176 | rows: session.runtimeModeChoices.map((choice) => ({ |
| 1177 | id: choice.mode, |
| 1178 | label: choice.label, |
| 1179 | description: choice.description, |
| 1180 | selected: choice.mode === session.runtimeMode, |
| 1181 | onPress: () => { |
| 1182 | void Haptics.selectionAsync(); |
| 1183 | session.onUpdateRuntimeMode(choice.mode); |
| 1184 | props.onSelected(); |
| 1185 | }, |
| 1186 | })), |
| 1187 | } |
| 1188 | : activeDescriptor?.type === "select" |
| 1189 | ? { |
| 1190 | rows: selectableChoices(activeDescriptor).map((choice) => ({ |
| 1191 | id: choice.id, |
| 1192 | label: choice.label, |
| 1193 | description: undefined, |
| 1194 | selected: |
| 1195 | choice.id === |
| 1196 | getProviderOptionCurrentValue( |
| 1197 | activeDescriptor, |
| 1198 | session.displayedModelSelection, |
| 1199 | session.reportedModelSelection, |
| 1200 | ), |
| 1201 | onPress: () => { |
| 1202 | void Haptics.selectionAsync(); |
| 1203 | session.applyOptionChange(activeDescriptor.id, choice.id); |
| 1204 | props.onSelected(); |
| 1205 | }, |
| 1206 | })), |
| 1207 | } |
| 1208 | : null; |
| 1209 | |
| 1210 | if (!submenuContent) { |
| 1211 | return <View className="flex-1 bg-sheet" />; |
| 1212 | } |
| 1213 | |
| 1214 | return ( |
| 1215 | <ScrollView |
| 1216 | className="flex-1 bg-sheet" |
| 1217 | style={ |
| 1218 | Platform.OS === "android" |
| 1219 | ? { width: "100%", maxWidth: 720, alignSelf: "center" } |
| 1220 | : undefined |
| 1221 | } |
| 1222 | contentContainerStyle={{ |
| 1223 | paddingBottom: insets.bottom + 12, |
| 1224 | paddingHorizontal: 16, |
| 1225 | paddingTop: 16, |
| 1226 | }} |
| 1227 | contentInsetAdjustmentBehavior="automatic" |
| 1228 | showsVerticalScrollIndicator={false} |
| 1229 | > |
| 1230 | <View className="overflow-hidden rounded-2xl bg-grouped-card"> |
| 1231 | {submenuContent.rows.map((row, index) => ( |
| 1232 | <ChoiceRow |
| 1233 | key={row.id} |
| 1234 | description={row.description} |
| 1235 | isLast={index === submenuContent.rows.length - 1} |
| 1236 | label={row.label} |
| 1237 | selected={row.selected} |
| 1238 | onPress={row.onPress} |
| 1239 | /> |
| 1240 | ))} |
| 1241 | </View> |
| 1242 | </ScrollView> |
| 1243 | ); |
| 1244 | } |
| 1245 | |
| 1246 | type ThreadSettingsPickerStackParams = { |
| 1247 | ThreadSettingsModels: undefined; |
| 1248 | ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string }; |
| 1249 | }; |
| 1250 | |
| 1251 | type ThreadSettingsPickerPresentation = { |
| 1252 | readonly onClose: () => void; |
| 1253 | }; |
| 1254 | |
| 1255 | const ThreadSettingsPickerStack = createNativeStackNavigator<ThreadSettingsPickerStackParams>(); |
| 1256 | const ThreadSettingsPickerPresentationContext = |
| 1257 | createContext<ThreadSettingsPickerPresentation | null>(null); |
| 1258 | |
| 1259 | function useThreadSettingsPickerPresentation() { |
| 1260 | const value = use(ThreadSettingsPickerPresentationContext); |
| 1261 | if (!value) { |
| 1262 | throw new Error( |
| 1263 | "useThreadSettingsPickerPresentation must be used inside ThreadSettingsPickerNavigator.", |
| 1264 | ); |
| 1265 | } |
| 1266 | return value; |
| 1267 | } |
| 1268 | |
| 1269 | function ThreadSettingsModelsScreen() { |
| 1270 | const session = useThreadSettingsSession(); |
| 1271 | const presentation = useThreadSettingsPickerPresentation(); |
| 1272 | const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>(); |
| 1273 | const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); |
| 1274 | const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy; |
| 1275 | const commitAndClose = useCallback(() => { |
| 1276 | if (!session.commitPendingModel()) return; |
| 1277 | presentation.onClose(); |
| 1278 | }, [presentation, session]); |
| 1279 | const providerFilters = useMemo( |
| 1280 | () => [ |
| 1281 | { id: "all-providers", title: "All providers", value: null }, |
| 1282 | { id: "favorites", title: "Favorites", value: FAVORITES_PROVIDER_FILTER }, |
| 1283 | ...session.providerGroups.map((group) => ({ |
| 1284 | id: `provider:${group.providerKey}`, |
| 1285 | title: group.providerLabel, |
| 1286 | value: group.providerKey, |
| 1287 | })), |
| 1288 | ], |
| 1289 | [session.providerGroups], |
| 1290 | ); |
| 1291 | const filterMenu = useMemo( |
| 1292 | () => ({ |
| 1293 | title: "Model filters", |
| 1294 | items: [ |
| 1295 | { |
| 1296 | type: "submenu" as const, |
| 1297 | title: "Provider", |
| 1298 | items: providerFilters.map((filter) => ({ |
| 1299 | type: "action" as const, |
| 1300 | title: filter.title, |
| 1301 | state: session.providerFilter === filter.value ? ("on" as const) : ("off" as const), |
| 1302 | onPress: () => session.setProviderFilter(filter.value), |
| 1303 | })), |
| 1304 | }, |
| 1305 | ...(session.hasLegacyModels |
| 1306 | ? [ |
| 1307 | { |
| 1308 | type: "action" as const, |
| 1309 | title: "Show legacy models", |
| 1310 | state: session.showLegacy ? ("on" as const) : ("off" as const), |
| 1311 | onPress: () => session.setShowLegacy(!session.showLegacy), |
| 1312 | }, |
| 1313 | ] |
| 1314 | : []), |
| 1315 | ], |
| 1316 | }), |
| 1317 | [providerFilters, session], |
| 1318 | ); |
| 1319 | |
| 1320 | return ( |
| 1321 | <> |
| 1322 | {Platform.OS === "android" ? ( |
| 1323 | <AndroidScreenHeader |
| 1324 | trailing={ |
| 1325 | <View className="flex-row items-center"> |
| 1326 | <AndroidAnchoredMenu |
| 1327 | title="Model filters" |
| 1328 | actions={[ |
| 1329 | { |
| 1330 | title: "Provider", |
| 1331 | subactions: providerFilters.map((filter) => ({ |
| 1332 | id: filter.id, |
| 1333 | title: filter.title, |
| 1334 | state: session.providerFilter === filter.value ? "on" : "off", |
| 1335 | })), |
| 1336 | }, |
| 1337 | ...(session.hasLegacyModels |
| 1338 | ? [ |
| 1339 | { |
| 1340 | id: "show-legacy", |
| 1341 | title: "Show legacy models", |
| 1342 | state: session.showLegacy ? ("on" as const) : ("off" as const), |
| 1343 | }, |
| 1344 | ] |
| 1345 | : []), |
| 1346 | ]} |
| 1347 | onPressAction={({ nativeEvent }) => { |
| 1348 | if (nativeEvent.event === "show-legacy") { |
| 1349 | session.setShowLegacy(!session.showLegacy); |
| 1350 | } else { |
| 1351 | const filter = providerFilters.find((item) => item.id === nativeEvent.event); |
| 1352 | if (filter) session.setProviderFilter(filter.value); |
| 1353 | } |
| 1354 | }} |
| 1355 | > |
| 1356 | {(open) => ( |
| 1357 | <MaterialIconButton |
| 1358 | accessibilityLabel="Filter models" |
| 1359 | icon="line.3.horizontal.decrease" |
| 1360 | selected={hasCustomCatalogFilter} |
| 1361 | variant={hasCustomCatalogFilter ? "tonal" : "standard"} |
| 1362 | onPress={open} |
| 1363 | /> |
| 1364 | )} |
| 1365 | </AndroidAnchoredMenu> |
| 1366 | {session.pendingModel ? ( |
| 1367 | <MaterialButton label="Save" tone="text" onPress={commitAndClose} /> |
| 1368 | ) : null} |
| 1369 | </View> |
| 1370 | } |
| 1371 | onBack={presentation.onClose} |
| 1372 | title="Thread settings" |
| 1373 | hideBottomBorder |
| 1374 | /> |
| 1375 | ) : null} |
| 1376 | <NativeStackScreenOptions |
| 1377 | optionsVersion={[ |
| 1378 | session.providerFilter, |
| 1379 | session.providerGroups.map((group) => group.providerKey), |
| 1380 | session.showLegacy, |
| 1381 | ]} |
| 1382 | options={{ |
| 1383 | unstable_headerToolbarItems: usesNativeMailSearchToolbar |
| 1384 | ? () => [ |
| 1385 | createNativeMailSearchToolbarItem({ |
| 1386 | filterButtonId: "thread-settings-model-filter", |
| 1387 | filterMenu, |
| 1388 | filterSystemImageName: hasCustomCatalogFilter |
| 1389 | ? "line.3.horizontal.decrease.circle.fill" |
| 1390 | : "line.3.horizontal.decrease", |
| 1391 | onSearchTextChange: session.setSearchQuery, |
| 1392 | placeholder: "Find a model", |
| 1393 | searchTextChangeId: "thread-settings-model-search-text", |
| 1394 | showsSearchDismissButton: true, |
| 1395 | }), |
| 1396 | ] |
| 1397 | : undefined, |
| 1398 | headerShown: Platform.OS !== "android", |
| 1399 | headerSearchBarOptions: |
| 1400 | Platform.OS === "ios" && !usesNativeMailSearchToolbar |
| 1401 | ? { |
| 1402 | autoCapitalize: "none", |
| 1403 | hideNavigationBar: false, |
| 1404 | obscureBackground: false, |
| 1405 | onCancelButtonPress: () => session.setSearchQuery(""), |
| 1406 | onChangeText: (event) => session.setSearchQuery(event.nativeEvent.text), |
| 1407 | placeholder: "Find a model", |
| 1408 | } |
| 1409 | : undefined, |
| 1410 | }} |
| 1411 | /> |
| 1412 | <MaterialScreenContent> |
| 1413 | <ThreadSettingsMainContent |
| 1414 | onOpenSubmenu={(submenu) => { |
| 1415 | const title = |
| 1416 | submenu.kind === "runtime" |
| 1417 | ? "Runtime" |
| 1418 | : (session.displayedDescriptors.find( |
| 1419 | (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, |
| 1420 | )?.label ?? "Option"); |
| 1421 | navigation.navigate("ThreadSettingsChoice", { ...submenu, title }); |
| 1422 | }} |
| 1423 | /> |
| 1424 | </MaterialScreenContent> |
| 1425 | <NativeHeaderToolbar placement="left"> |
| 1426 | <NativeHeaderToolbar.Button |
| 1427 | accessibilityLabel="Cancel thread settings" |
| 1428 | label="Cancel" |
| 1429 | onPress={presentation.onClose} |
| 1430 | /> |
| 1431 | </NativeHeaderToolbar> |
| 1432 | <NativeHeaderToolbar placement="right"> |
| 1433 | <NativeHeaderToolbar.Button |
| 1434 | accessibilityLabel={session.pendingModel ? "Save thread settings" : "Done"} |
| 1435 | label={session.pendingModel ? "Save" : "Done"} |
| 1436 | onPress={commitAndClose} |
| 1437 | /> |
| 1438 | </NativeHeaderToolbar> |
| 1439 | {Platform.OS === "ios" && !usesNativeMailSearchToolbar ? ( |
| 1440 | <NativeHeaderToolbar placement="bottom"> |
| 1441 | <NativeHeaderToolbar.Menu |
| 1442 | accessibilityLabel="Filter models" |
| 1443 | icon={ |
| 1444 | hasCustomCatalogFilter |
| 1445 | ? "line.3.horizontal.decrease.circle.fill" |
| 1446 | : "line.3.horizontal.decrease.circle" |
| 1447 | } |
| 1448 | separateBackground |
| 1449 | title="Model filters" |
| 1450 | > |
| 1451 | <NativeHeaderToolbar.Menu title="Provider"> |
| 1452 | <NativeHeaderToolbar.Label>Provider</NativeHeaderToolbar.Label> |
| 1453 | <NativeHeaderToolbar.MenuAction |
| 1454 | isOn={session.providerFilter === null} |
| 1455 | onPress={() => session.setProviderFilter(null)} |
| 1456 | > |
| 1457 | All providers |
| 1458 | </NativeHeaderToolbar.MenuAction> |
| 1459 | <NativeHeaderToolbar.MenuAction |
| 1460 | isOn={session.providerFilter === FAVORITES_PROVIDER_FILTER} |
| 1461 | onPress={() => session.setProviderFilter(FAVORITES_PROVIDER_FILTER)} |
| 1462 | > |
| 1463 | Favorites |
| 1464 | </NativeHeaderToolbar.MenuAction> |
| 1465 | {session.providerGroups.map((group) => ( |
| 1466 | <NativeHeaderToolbar.MenuAction |
| 1467 | key={group.providerKey} |
| 1468 | isOn={session.providerFilter === group.providerKey} |
| 1469 | onPress={() => session.setProviderFilter(group.providerKey)} |
| 1470 | > |
| 1471 | {group.providerLabel} |
| 1472 | </NativeHeaderToolbar.MenuAction> |
| 1473 | ))} |
| 1474 | </NativeHeaderToolbar.Menu> |
| 1475 | {session.hasLegacyModels ? ( |
| 1476 | <NativeHeaderToolbar.MenuAction |
| 1477 | isOn={session.showLegacy} |
| 1478 | onPress={() => session.setShowLegacy(!session.showLegacy)} |
| 1479 | > |
| 1480 | Show legacy models |
| 1481 | </NativeHeaderToolbar.MenuAction> |
| 1482 | ) : null} |
| 1483 | </NativeHeaderToolbar.Menu> |
| 1484 | </NativeHeaderToolbar> |
| 1485 | ) : null} |
| 1486 | </> |
| 1487 | ); |
| 1488 | } |
| 1489 | |
| 1490 | function ThreadSettingsChoiceScreen() { |
| 1491 | const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>(); |
| 1492 | const route = useRoute<RouteProp<ThreadSettingsPickerStackParams, "ThreadSettingsChoice">>(); |
| 1493 | |
| 1494 | return ( |
| 1495 | <> |
| 1496 | <NativeStackScreenOptions options={{ headerShown: Platform.OS !== "android" }} /> |
| 1497 | {Platform.OS === "android" ? ( |
| 1498 | <AndroidScreenHeader |
| 1499 | title={route.params.title} |
| 1500 | onBack={() => navigation.goBack()} |
| 1501 | hideBottomBorder |
| 1502 | /> |
| 1503 | ) : null} |
| 1504 | <MaterialScreenContent> |
| 1505 | <ThreadSettingsChoiceContent |
| 1506 | submenu={route.params} |
| 1507 | onSelected={() => navigation.goBack()} |
| 1508 | /> |
| 1509 | </MaterialScreenContent> |
| 1510 | </> |
| 1511 | ); |
| 1512 | } |
| 1513 | |
| 1514 | function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) { |
| 1515 | const theme = useUniwindTheme(); |
| 1516 | const solidSheetBackground = theme["--color-sheet-solid"]; |
| 1517 | const foreground = theme["--color-foreground"]; |
| 1518 | const presentation = useMemo( |
| 1519 | () => ({ |
| 1520 | onClose: props.onClose, |
| 1521 | }), |
| 1522 | [props.onClose], |
| 1523 | ); |
| 1524 | |
| 1525 | return ( |
| 1526 | <ThreadSettingsPickerPresentationContext.Provider value={presentation}> |
| 1527 | <ThreadSettingsPickerStack.Navigator |
| 1528 | initialRouteName="ThreadSettingsModels" |
| 1529 | screenOptions={{ |
| 1530 | animation: Platform.OS === "android" ? "default" : "slide_from_right", |
| 1531 | contentStyle: { backgroundColor: solidSheetBackground }, |
| 1532 | gestureEnabled: true, |
| 1533 | headerBackButtonDisplayMode: "minimal", |
| 1534 | headerBackTitle: "", |
| 1535 | headerShown: Platform.OS !== "android", |
| 1536 | headerShadowVisible: false, |
| 1537 | headerStyle: { |
| 1538 | backgroundColor: NATIVE_LIQUID_GLASS_SUPPORTED ? "transparent" : solidSheetBackground, |
| 1539 | }, |
| 1540 | headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, |
| 1541 | headerTintColor: foreground, |
| 1542 | headerTitleStyle: { fontSize: 17, fontWeight: "700" }, |
| 1543 | scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED |
| 1544 | ? THREAD_SETTINGS_HEADER_SCROLL_EDGE_EFFECTS |
| 1545 | : undefined, |
| 1546 | }} |
| 1547 | > |
| 1548 | <ThreadSettingsPickerStack.Screen |
| 1549 | name="ThreadSettingsModels" |
| 1550 | component={ThreadSettingsModelsScreen} |
| 1551 | options={{ headerBackVisible: false, title: "Thread settings" }} |
| 1552 | /> |
| 1553 | <ThreadSettingsPickerStack.Screen |
| 1554 | name="ThreadSettingsChoice" |
| 1555 | component={ThreadSettingsChoiceScreen} |
| 1556 | options={({ route }) => ({ title: route.params.title })} |
| 1557 | /> |
| 1558 | </ThreadSettingsPickerStack.Navigator> |
| 1559 | </ThreadSettingsPickerPresentationContext.Provider> |
| 1560 | ); |
| 1561 | } |
| 1562 | |
| 1563 | /** Shared model catalog and option screens, bound to the caller's draft. */ |
| 1564 | export function ThreadSettingsPickerScreen( |
| 1565 | props: ThreadSettingsSessionProps & { readonly onClose: () => void }, |
| 1566 | ) { |
| 1567 | return ( |
| 1568 | <ThreadSettingsSessionProvider {...props}> |
| 1569 | <ThreadSettingsPickerNavigator onClose={props.onClose} /> |
| 1570 | </ThreadSettingsSessionProvider> |
| 1571 | ); |
| 1572 | } |
| 1573 | |
| 1574 | /** Existing-thread model picker hosted by the root RNS form-sheet route. */ |
| 1575 | export function ExistingThreadSettingsRouteScreen() { |
| 1576 | const navigation = useNavigation<NativeStackNavigationProp<Record<string, object | undefined>>>(); |
| 1577 | const presentation = useExistingThreadSettingsRoutePresentation(); |
| 1578 | const session = presentation.session; |
| 1579 | |
| 1580 | useEffect(() => { |
| 1581 | if (session) { |
| 1582 | return; |
| 1583 | } |
| 1584 | |
| 1585 | navigation.goBack(); |
| 1586 | }, [navigation, session]); |
| 1587 | |
| 1588 | if (!session) { |
| 1589 | return <View className="flex-1 bg-sheet" />; |
| 1590 | } |
| 1591 | |
| 1592 | const { ownerId: _ownerId, ...settings } = session; |
| 1593 | |
| 1594 | return <ThreadSettingsPickerScreen {...settings} onClose={() => navigation.goBack()} />; |
| 1595 | } |
| 1596 | |
| 1597 | /** |
| 1598 | * Native stack hosted by the New Task navigator's form-sheet route. Keeping |
| 1599 | * the sheet presentation in RNS gives UIKit ownership of nested dismissal, |
| 1600 | * while Reasoning and Runtime remain regular pushes inside this navigator. |
| 1601 | */ |
| 1602 | export function NewTaskThreadSettingsRouteScreen() { |
| 1603 | const flow = useNewTaskFlow(); |
| 1604 | const navigation = useNavigation<NativeStackNavigationProp<Record<string, object | undefined>>>(); |
| 1605 | const optionDescriptors = useMemo( |
| 1606 | () => |
| 1607 | resolveProviderOptionDescriptors({ |
| 1608 | capabilities: flow.selectedModelOption?.capabilities, |
| 1609 | selections: flow.selectedModel?.options, |
| 1610 | }), |
| 1611 | [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], |
| 1612 | ); |
| 1613 | |
| 1614 | return ( |
| 1615 | <ThreadSettingsPickerScreen |
| 1616 | environmentId={flow.selectedEnvironmentId} |
| 1617 | projectId={flow.selectedProject?.id ?? null} |
| 1618 | {...(flow.selectedModel ? { providerInstanceId: flow.selectedModel.instanceId } : {})} |
| 1619 | providerGroups={flow.providerGroups} |
| 1620 | selectedModel={flow.selectedModel} |
| 1621 | onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)} |
| 1622 | optionDescriptors={optionDescriptors} |
| 1623 | onUpdateOptionSelections={flow.setSelectedModelOptions} |
| 1624 | runtimeMode={flow.runtimeMode} |
| 1625 | onUpdateRuntimeMode={flow.setRuntimeMode} |
| 1626 | onClose={() => navigation.goBack()} |
| 1627 | /> |
| 1628 | ); |
| 1629 | } |