1import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator";
2import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
3import {
4 modelPreferencesMigrationPatch,
5 resolveModelPreferences,
6} from "@t3tools/shared/serverSettings";
7import type {
8 EnvironmentId,
9 ModelSelection,
10 ProjectId,
11 ProviderInstanceId,
12 ProviderOptionDescriptor,
13 ProviderOptionSelection,
14 RuntimeMode,
15 ServerSettings,
16} from "@t3tools/contracts";
17import type { LegendListRenderItemProps } from "@legendapp/list/react-native";
18import { RegistryContext, useAtomValue } from "@effect/atom-react";
19import { AnimatedLegendList } from "@legendapp/list/reanimated";
20import {
21 getProviderOptionCurrentLabel,
22 getProviderOptionCurrentValue,
23 getProviderOptionDescriptors,
24} from "@t3tools/shared/model";
25import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native";
26import { type NativeStackNavigationProp } from "@react-navigation/native-stack";
27import * as Haptics from "expo-haptics";
28import { AsyncResult } from "effect/reactivity";
29import {
30 createContext,
31 use,
32 useCallback,
33 useEffect,
34 useMemo,
35 useRef,
36 useState,
37 type ReactNode,
38} from "react";
39import {
40 Alert,
41 Platform,
42 Pressable,
43 RefreshControl,
44 ScrollView,
45 TextInput,
46 View,
47} from "react-native";
48import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
49import { useSafeAreaInsets } from "react-native-safe-area-context";
50
51import { SymbolView } from "../../components/AppSymbol";
52import { AppText as Text } from "../../components/AppText";
53import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
54import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu";
55import { MaterialButton } from "../../components/MaterialButton";
56import { MaterialIconButton } from "../../components/MaterialIconButton";
57import { ProviderIcon } from "../../components/ProviderIcon";
58import { ThemedSwitch } from "../../components/ThemedSwitch";
59import { cn } from "../../lib/cn";
60import {
61 buildModelOptions,
62 groupByProvider,
63 type ModelOption,
64 type ProviderGroup,
65} from "../../lib/modelOptions";
66import { applyProviderOptionSelection } from "../../lib/providerOptions";
67import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
68import { useUniwindTheme } from "../../lib/useUniwindTheme";
69import { rememberModelOptions } from "../../state/use-model-option-memory";
70import {
71 NativeHeaderToolbar,
72 NativeStackScreenOptions,
73 nativeHeaderScrollEdgeEffects,
74} from "../../native/StackHeader";
75import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
76import { ChatGptSharingStatus } from "./ChatGptSharingStatus";
77import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server";
78import { mobilePreferencesAtom } from "../../state/preferences";
79import { useAtomCommand } from "../../state/use-atom-command";
80import { useNewTaskFlow } from "./new-task-flow-provider";
81import { MaterialScreenContent } from "../../components/MaterialScreenContent";
82import {
83 createProviderCatalogRefreshRunner,
84 providerCatalogRefreshError,
85} from "./provider-catalog-refresh";
86import {
87 createNativeMailSearchToolbarItem,
88 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
89} from "../layout/native-mail-search-toolbar";
90import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar";
91import { ModelRow, ChoiceRow } from "./ThreadSettingsRows";
92import {
93 compatibleRuntimeModeForChoices,
94 runtimeModeChoicesForSupportedModes,
95 selectableChoices,
96} from "./thread-settings-options";
97import {
98 canCommitPendingModel,
99 favoritesFirst,
100 modelFavoriteKey,
101 modelMatchesCatalogQuery,
102 pendingModelAfterPress,
103 providerSectionIsCollapsed,
104} from "./thread-settings-sheet-state";
105import { 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 */
112const 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 */
121const THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION = {
122 data: false,
123 size: true,
124} as const;
125const THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION = LinearTransition.duration(180);
126const THREAD_SETTINGS_CATALOG_ENTER_TRANSITION = FadeIn.duration(140);
127const THREAD_SETTINGS_CATALOG_EXIT_TRANSITION = FadeOut.duration(120);
128const THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION = LinearTransition.duration(180);
129const THREAD_SETTINGS_OPTION_ENTER_TRANSITION = FadeIn.duration(140);
130const THREAD_SETTINGS_OPTION_EXIT_TRANSITION = FadeOut.duration(100);
131const THREAD_SETTINGS_HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(
132 Platform.OS,
133 Platform.Version,
134);
135const EMPTY_MODEL_FAVORITES: ReadonlyArray<{
136 readonly provider: ProviderInstanceId;
137 readonly model: string;
138}> = [];
139const FAVORITES_PROVIDER_FILTER = "@favorites";
140/** Provider catalog header with its harness logo and disclosure state. */
141function 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. */
195function 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
227function 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
250type ThreadSettingsSubmenuPage =
251 | { readonly kind: "descriptor"; readonly id: string }
252 | { readonly kind: "runtime" };
253
254type 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
269export type ExistingThreadSettingsRouteSession = ThreadSettingsSessionProps & {
270 readonly ownerId: string;
271};
272
273type ExistingThreadSettingsRouteContextValue = {
274 readonly session: ExistingThreadSettingsRouteSession | null;
275 readonly present: (session: ExistingThreadSettingsRouteSession) => void;
276 readonly clear: (ownerId: string) => void;
277};
278
279const ExistingThreadSettingsRouteContext =
280 createContext<ExistingThreadSettingsRouteContextValue | null>(null);
281
282/** Bridges the active thread's settings state into the root native sheet route. */
283export 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
300export 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
310type 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
344const ThreadSettingsSessionContext = createContext<ThreadSettingsSessionValue | null>(null);
345
346/** Owns the staged model and option state for one picker presentation. */
347function 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
689function 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
697type 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
708type 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
735function 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
766function 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
788function 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
894function 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. */
992function 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. */
1158function 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
1246type ThreadSettingsPickerStackParams = {
1247 ThreadSettingsModels: undefined;
1248 ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string };
1249};
1250
1251type ThreadSettingsPickerPresentation = {
1252 readonly onClose: () => void;
1253};
1254
1255const ThreadSettingsPickerStack = createNativeStackNavigator<ThreadSettingsPickerStackParams>();
1256const ThreadSettingsPickerPresentationContext =
1257 createContext<ThreadSettingsPickerPresentation | null>(null);
1258
1259function 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
1269function 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
1490function 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
1514function 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. */
1564export 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. */
1575export 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 */
1602export 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}