1import {
2 getPromptControlledTraits,
3 getTraitSelectChange,
4} from "@t3tools/client-runtime/model-traits";
5import * as Dialog from "../../components/ui/Dialog";
6import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator";
7import { useSheetStackOptions } from "../layout/nested-stacks";
8import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId } from "@t3tools/contracts";
9import {
10 modelPreferencesMigrationPatch,
11 resolveModelPreferences,
12} from "@t3tools/shared/serverSettings";
13import type {
14 EnvironmentId,
15 ModelSelection,
16 ProjectId,
17 ProviderOptionDescriptor,
18 ProviderOptionSelection,
19 RuntimeMode,
20 ServerSettings,
21 ThreadId,
22} from "@t3tools/contracts";
23import type { LegendListRenderItemProps } from "@legendapp/list/react-native";
24import { RegistryContext, useAtomMount, useAtomValue } from "@effect/atom-react";
25import { AnimatedLegendList } from "@legendapp/list/reanimated";
26import {
27 getProviderOptionCurrentLabel,
28 getProviderOptionCurrentValue,
29} from "@t3tools/shared/model";
30import {
31 useNavigation,
32 useRoute,
33 type RouteProp,
34 type StaticScreenProps,
35} from "@react-navigation/native";
36import { type NativeStackNavigationProp } from "@react-navigation/native-stack";
37import * as Haptics from "expo-haptics";
38import { AsyncResult } from "effect/reactivity";
39import {
40 createContext,
41 use,
42 useEffect,
43 useEffectEvent,
44 useRef,
45 useState,
46 type ReactNode,
47} from "react";
48import { Pressable, ScrollView, View } from "react-native";
49import { READABLE_WIDTH_STYLE, ROW_MIN_HEIGHT } from "../../components/ui/listMetrics";
50import { RefreshControl } from "../../components/ui/RefreshControl";
51import Animated, {
52 FadeIn,
53 FadeOut,
54 LinearTransition,
55 useReducedMotion,
56} from "react-native-reanimated";
57import { useSafeAreaInsets } from "react-native-safe-area-context";
58
59import { SymbolView } from "../../components/ui/AppSymbol";
60import { AppText as Text } from "../../components/ui/AppText";
61import { AppBar } from "../../components/ui/AppBar";
62import { ProviderIcon } from "../../components/ProviderIcon";
63import { ThemedSwitch } from "../../components/ui/ThemedSwitch";
64import { cn } from "../../lib/cn";
65import { useReduceMotionEnabled } from "../../lib/appState";
66import {
67 buildModelOptions,
68 groupByProvider,
69 serverConfigForCwd,
70 type ModelOption,
71 type ProviderGroup,
72} from "../../lib/modelOptions";
73import { applyProviderOptionSelection } from "../../lib/providerOptions";
74import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
75import {
76 rememberModelOptions,
77 withRememberedModelOptions,
78} from "../../state/use-model-option-memory";
79import { ChatGptSharingStatus } from "./ChatGptSharingStatus";
80import { environmentCatalog } from "../../connection/catalog";
81import { useEnvironmentQuery } from "../../state/query";
82import { scopedThreadKey } from "../../lib/scopedEntities";
83import { useThreadReportedModelSelection } from "../../state/entities";
84import {
85 setComposerDraftText,
86 updateComposerDraftSettings,
87 useComposerDraft,
88 useComposerDraftSettings,
89} from "../../state/use-composer-drafts";
90import { useQuietThreadStructure } from "../../state/use-thread-detail";
91import { activeComposerDraftKey, useQueuedRunEdit } from "../../state/queued-run-edit";
92import { selectThreadModel } from "../../state/thread-model-selection";
93import { useThreadSelection } from "../../state/use-thread-selection";
94import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
95import { providerWorkspaceSnapshotRefresh } from "../../state/provider-workspace-snapshot-refresh";
96import { threadAllowsProviderSwitch } from "../../state/thread-provider-switching";
97import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server";
98import { mobilePreferencesAtom } from "../../state/preferences";
99import { useAtomCommand } from "../../state/use-atom-command";
100import { useNewTaskFlow } from "./new-task-flow-provider";
101import { ScreenContent } from "../../components/ui/ScreenContent";
102import {
103 createProviderCatalogRefreshRunner,
104 providerCatalogRefreshError,
105} from "./provider-catalog-refresh";
106import { useSearchToolbarInset } from "../../components/ui/useBarInset";
107import type { AppBarSearch } from "../../components/ui/AppBar";
108import { ModelRow, ChoiceRow } from "./ThreadSettingsRows";
109import {
110 compatibleRuntimeModeForChoices,
111 runtimeModeChoicesForSupportedModes,
112 withManagedRuntimeModeLimits,
113 type RuntimeModeChoice,
114 selectableChoices,
115} from "./thread-settings-options";
116import {
117 canCommitPendingModel,
118 favoritesFirst,
119 modelFavoriteKey,
120 modelMatchesCatalogQuery,
121 pendingModelAfterPress,
122 providerSectionIsCollapsed,
123} from "./thread-settings-sheet-state";
124import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels";
125
126const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set([
127 "claudeAgent",
128 "codex",
129 "antigravity",
130]);
131/** Keep measured size changes stable; let catalog mutations return underflowing filtered lists to the top. */
132const THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION = {
133 data: false,
134 size: true,
135} as const;
136const THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION = LinearTransition.duration(180);
137const THREAD_SETTINGS_CATALOG_ENTER_TRANSITION = FadeIn.duration(140);
138const THREAD_SETTINGS_CATALOG_EXIT_TRANSITION = FadeOut.duration(120);
139const THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION = LinearTransition.duration(180);
140const THREAD_SETTINGS_OPTION_ENTER_TRANSITION = FadeIn.duration(140);
141const THREAD_SETTINGS_OPTION_EXIT_TRANSITION = FadeOut.duration(100);
142const EMPTY_MODEL_FAVORITES: ReadonlyArray<{
143 readonly provider: ProviderInstanceId;
144 readonly model: string;
145}> = [];
146const FAVORITES_PROVIDER_FILTER = "@favorites";
147// Servers that predate `migrateModelPreferences` drop it and keep reporting null favorites.
148const attemptedMigrationGenerations = new Map<EnvironmentId, number>();
149function ProviderHeader(props: {
150 readonly driver: string | undefined;
151 readonly iconUrl: string | undefined;
152 readonly label: string;
153 readonly collapsible: boolean;
154 readonly collapsed: boolean;
155 readonly modelCount: number;
156 readonly onToggle: () => void;
157}) {
158 const content = (
159 <>
160 <ProviderIcon iconUrl={props.iconUrl} provider={props.driver} size={15} />
161 <Text className="text-sm font-t3-medium text-foreground-muted">{props.label}</Text>
162 {props.collapsible ? (
163 <>
164 <View className="flex-1" />
165 {props.collapsed ? (
166 <Text className="text-2xs font-t3-medium text-foreground-muted">
167 {props.modelCount}
168 </Text>
169 ) : null}
170 <SymbolView
171 name={props.collapsed ? "chevron.down" : "chevron.up"}
172 size={12}
173 tintColorClassName="accent-icon-subtle"
174 type="monochrome"
175 />
176 </>
177 ) : null}
178 </>
179 );
180
181 if (props.collapsible) {
182 return (
183 <Pressable
184 accessibilityLabel={`${props.label}, ${props.modelCount} models`}
185 accessibilityRole="button"
186 accessibilityState={{ expanded: !props.collapsed }}
187 className="mx-4 mt-1 flex-row items-center gap-2 rounded-xl px-1 pt-2 active:opacity-60"
188 style={{ minHeight: ROW_MIN_HEIGHT.control }}
189 onPress={props.onToggle}
190 >
191 {content}
192 </Pressable>
193 );
194 }
195
196 return (
197 <View accessibilityRole="header" className="mx-4 min-h-9 flex-row items-center gap-2 px-1 pt-1">
198 {content}
199 </View>
200 );
201}
202
203function DisclosureRow(props: {
204 readonly label: string;
205 readonly value: string | undefined;
206 readonly onPress: () => void;
207 readonly isLast?: boolean;
208}) {
209 return (
210 <Pressable
211 accessibilityRole="button"
212 onPress={props.onPress}
213 className={cn(
214 "flex-row items-center gap-2 bg-grouped-card px-4 py-2 active:bg-subtle",
215 !props.isLast && "border-b border-border-subtle",
216 )}
217 style={{ minHeight: ROW_MIN_HEIGHT.item }}
218 >
219 <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text>
220 <View className="flex-1" />
221 {props.value ? (
222 <Text className="text-sm text-foreground-muted" numberOfLines={1}>
223 {props.value}
224 </Text>
225 ) : null}
226 <SymbolView
227 name="chevron.right"
228 size={12}
229 tintColorClassName="accent-icon-subtle"
230 type="monochrome"
231 />
232 </Pressable>
233 );
234}
235
236function SwitchRow(props: {
237 readonly label: string;
238 readonly value: boolean;
239 readonly onValueChange: (value: boolean) => void;
240 readonly isLast?: boolean;
241}) {
242 return (
243 <View
244 className={cn(
245 "min-h-11 flex-row items-center justify-between bg-grouped-card px-4 py-1",
246 !props.isLast && "border-b border-border-subtle",
247 )}
248 >
249 <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text>
250 <ThemedSwitch
251 accessibilityLabel={props.label}
252 onValueChange={props.onValueChange}
253 value={props.value}
254 />
255 </View>
256 );
257}
258
259type ThreadSettingsSubmenuPage =
260 | { readonly kind: "descriptor"; readonly id: string }
261 | { readonly kind: "runtime" };
262
263type ThreadSettingsSessionProps = {
264 readonly environmentId: EnvironmentId | null;
265 readonly projectId: ProjectId | null;
266 readonly cwd: string | null;
267 readonly providerInstanceId?: ProviderInstanceId;
268 readonly lockedProviderInstanceId?: ProviderInstanceId;
269 readonly providerGroups: ReadonlyArray<ProviderGroup>;
270 readonly selectedModel: ModelSelection | null;
271 readonly prompt: string;
272 readonly onPromptChange: (prompt: string) => void;
273 readonly reportedModelSelection?: ModelSelection | null;
274 readonly onSelectModel: (option: ModelOption) => void;
275 readonly restoreSelection?: (selection: ModelSelection) => ModelSelection;
276 readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>;
277 readonly onUpdateOptionSelections: (selections: ReadonlyArray<ProviderOptionSelection>) => void;
278 readonly runtimeMode: RuntimeMode;
279 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
280};
281
282/** The composer draft the sheet edits: the thread's own, or a queued message being edited. */
283export type ExistingThreadSettingsRouteParams = {
284 readonly environmentId: EnvironmentId;
285 readonly threadId: ThreadId;
286 readonly draftKey: string;
287};
288
289type ThreadSettingsSessionValue = {
290 readonly environmentId: EnvironmentId | null;
291 readonly providerInstanceId?: ProviderInstanceId;
292 readonly providerGroups: ReadonlyArray<ProviderGroup>;
293 readonly favoriteKeys: ReadonlySet<string>;
294 readonly favoritesLoaded: boolean;
295 readonly toggleFavorite: (option: ModelOption) => void;
296 readonly toggleHidden: (option: ModelOption) => void;
297 readonly pendingHiddenKeys: ReadonlySet<string>;
298 readonly manageHidden: boolean;
299 readonly setManageHidden: (value: boolean) => void;
300 readonly runtimeMode: RuntimeMode;
301 readonly runtimeModeChoices: ReadonlyArray<RuntimeModeChoice>;
302 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
303 readonly displayedDescriptors: ReadonlyArray<ProviderOptionDescriptor>;
304 readonly promptTraits: ReturnType<typeof getPromptControlledTraits>;
305 readonly displayedModelSelection: ModelSelection | null;
306 readonly reportedModelSelection: ModelSelection | null;
307 readonly providerExpansionOverrides: ReadonlySet<string>;
308 readonly hasLegacyModels: boolean;
309 readonly pendingModel: ModelOption | null;
310 readonly providerFilter: string | null;
311 readonly searchQuery: string;
312 readonly showLegacy: boolean;
313 readonly applyOptionChange: (id: string, value: string | boolean) => void;
314 readonly commitPendingModel: () => boolean;
315 readonly isApplied: (option: ModelOption) => boolean;
316 readonly isDisplayed: (option: ModelOption) => boolean;
317 readonly pressModel: (option: ModelOption) => void;
318 readonly setProviderFilter: (providerKey: string | null) => void;
319 readonly setSearchQuery: (query: string) => void;
320 readonly setShowLegacy: (showLegacy: boolean) => void;
321 readonly toggleProvider: (providerKey: string) => void;
322 /** The provider whose catalog for this folder the picker keeps fresh. */
323 readonly workspaceScan: { readonly instanceId: ProviderInstanceId; readonly cwd: string } | null;
324};
325
326const ThreadSettingsSessionContext = createContext<ThreadSettingsSessionValue | null>(null);
327
328function ThreadSettingsSessionProvider(
329 props: ThreadSettingsSessionProps & { readonly children: ReactNode },
330) {
331 const registry = use(RegistryContext);
332 const preferences = useAtomValue(mobilePreferencesAtom);
333 const configs = useAtomValue(environmentServerConfigsAtom);
334 const config = props.environmentId ? configs.get(props.environmentId) : undefined;
335 const settings = config?.settings;
336 const connection = useEnvironmentQuery(
337 props.environmentId ? environmentCatalog.stateAtom(props.environmentId) : null,
338 ).data;
339 const connectionGeneration = connection?.phase === "connected" ? connection.generation : null;
340 const [manageHidden, setManageHidden] = useState(false);
341 const pendingHidden = useRef(new Set<string>());
342 const pendingFavorites = useRef(
343 new Map<string, { favorite: boolean; settings: ServerSettings; pending: boolean }>(),
344 );
345 const [pendingHiddenKeys, setPendingHiddenKeys] = useState<ReadonlySet<string>>(() => new Set());
346 const providerGroups =
347 manageHidden && config
348 ? groupByProvider(
349 buildModelOptions(
350 serverConfigForCwd(config, props.cwd),
351 props.selectedModel,
352 props.lockedProviderInstanceId,
353 props.projectId,
354 true,
355 ),
356 )
357 : props.providerGroups;
358 const legacyFavorites = AsyncResult.isSuccess(preferences)
359 ? (preferences.value.modelFavorites ?? EMPTY_MODEL_FAVORITES)
360 : EMPTY_MODEL_FAVORITES;
361 const favoritesLoaded =
362 settings !== undefined && (settings.favorites !== null || AsyncResult.isSuccess(preferences));
363 const modelFavorites = resolveModelPreferences(settings ?? DEFAULT_SERVER_SETTINGS, {
364 favorites: legacyFavorites,
365 }).favorites;
366 const saveSettings = useAtomCommand(serverEnvironment.updateSettings, "model favorites update");
367 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- writes the one-time legacy model favorites migration to server settings once per connection
368 useEffect(() => {
369 if (
370 !props.environmentId ||
371 !settings ||
372 !AsyncResult.isSuccess(preferences) ||
373 connectionGeneration === null ||
374 attemptedMigrationGenerations.get(props.environmentId) === connectionGeneration
375 )
376 return;
377 const patch = modelPreferencesMigrationPatch(settings, { favorites: legacyFavorites });
378 if (!patch) return;
379 attemptedMigrationGenerations.set(props.environmentId, connectionGeneration);
380 void saveSettings({ environmentId: props.environmentId, input: { patch } });
381 }, [
382 connectionGeneration,
383 legacyFavorites,
384 preferences,
385 props.environmentId,
386 saveSettings,
387 settings,
388 ]);
389 const favoriteKeys = new Set(
390 modelFavorites.map((favorite) => modelFavoriteKey(favorite.provider, favorite.model)),
391 );
392 const toggleFavorite = async (option: ModelOption) => {
393 const environmentId = props.environmentId;
394 if (!favoritesLoaded || !environmentId) return;
395 const currentSettings = registry.get(environmentServerConfigsAtom).get(environmentId)?.settings;
396 if (!currentSettings) return;
397 const favorites = resolveModelPreferences(currentSettings, {
398 favorites: legacyFavorites,
399 }).favorites;
400 const key = JSON.stringify([
401 environmentId,
402 option.selection.instanceId,
403 option.selection.model,
404 ]);
405 const previous = pendingFavorites.current.get(key);
406 const previousFavorite =
407 previous && (previous.pending || previous.settings.favorites === currentSettings.favorites)
408 ? previous.favorite
409 : favorites.some(
410 (favorite) =>
411 favorite.provider === option.selection.instanceId &&
412 favorite.model === option.selection.model,
413 );
414 const pending = { favorite: !previousFavorite, settings: currentSettings, pending: true };
415 pendingFavorites.current.set(key, pending);
416 void Haptics.selectionAsync();
417 const result = await saveSettings({
418 environmentId: environmentId,
419 input: {
420 patch: {
421 setModelFavorites: [
422 {
423 provider: option.selection.instanceId,
424 model: option.selection.model,
425 favorite: pending.favorite,
426 },
427 ],
428 ...(currentSettings.favorites === null ? { migrateModelPreferences: { favorites } } : {}),
429 },
430 },
431 });
432 if (
433 AsyncResult.isSuccess(result) &&
434 registry.get(environmentServerConfigsAtom).get(environmentId)?.settings.favorites ===
435 currentSettings.favorites
436 ) {
437 pending.pending = false;
438 }
439 if (pending.pending && pendingFavorites.current.get(key) === pending) {
440 if (
441 previous &&
442 !previous.pending &&
443 registry.get(environmentServerConfigsAtom).get(environmentId)?.settings.favorites ===
444 previous.settings.favorites
445 ) {
446 pendingFavorites.current.set(key, previous);
447 } else {
448 pendingFavorites.current.delete(key);
449 }
450 }
451 };
452 const toggleHidden = (option: ModelOption) => {
453 if (
454 !settings ||
455 !props.environmentId ||
456 !option.canHide ||
457 pendingHidden.current.has(option.key)
458 )
459 return;
460 pendingHidden.current.add(option.key);
461 setPendingHiddenKeys(new Set(pendingHidden.current));
462 void Haptics.selectionAsync();
463 void saveSettings({
464 environmentId: props.environmentId,
465 input: {
466 patch: {
467 setModelsHidden: [
468 {
469 provider: option.selection.instanceId,
470 model: option.selection.model,
471 hidden: !option.isHidden,
472 },
473 ],
474 },
475 },
476 }).then(() => {
477 pendingHidden.current.delete(option.key);
478 setPendingHiddenKeys(new Set(pendingHidden.current));
479 });
480 };
481 const [showLegacyToggle, setShowLegacyToggle] = useState(false);
482 const [providerFilter, setProviderFilter] = useState<string | null>(null);
483 const [searchQuery, setSearchQuery] = useState("");
484 const [providerExpansionOverrides, setProviderExpansionOverrides] = useState<ReadonlySet<string>>(
485 () => new Set(),
486 );
487 const [pendingModel, setPendingModel] = useState<ModelOption | null>(null);
488 const [pendingPrompt, setPendingPrompt] = useState<string | null>(null);
489 const displayedPrompt = pendingModel ? (pendingPrompt ?? props.prompt) : props.prompt;
490
491 const isApplied = (option: ModelOption) =>
492 option.selection.instanceId === props.selectedModel?.instanceId &&
493 option.selection.model === props.selectedModel.model;
494 const isDisplayed = (option: ModelOption) =>
495 pendingModel ? option.key === pendingModel.key : isApplied(option);
496
497 const displayedDescriptors = pendingModel
498 ? resolveProviderOptionDescriptors({
499 capabilities: pendingModel.capabilities,
500 selections: pendingModel.selection.options,
501 })
502 : props.optionDescriptors;
503 const promptTraits = getPromptControlledTraits(displayedDescriptors, displayedPrompt);
504 const displayedModel =
505 pendingModel ??
506 props.providerGroups.flatMap((group) => group.models).find((option) => isApplied(option)) ??
507 null;
508 const runtimeModeChoices = withManagedRuntimeModeLimits(
509 runtimeModeChoicesForSupportedModes(displayedModel?.supportedRuntimeModes),
510 [config?.managedPolicy],
511 );
512 const compatibleRuntimeMode = compatibleRuntimeModeForChoices(
513 props.runtimeMode,
514 runtimeModeChoices,
515 );
516
517 const hasLegacyModels = props.providerGroups.some((group) =>
518 group.models.some((model) => model.isLegacy),
519 );
520 const commitPendingModel = () => {
521 if (pendingModel) {
522 if (!canCommitPendingModel(pendingModel, props.providerGroups)) {
523 Dialog.alert(
524 "Model unavailable",
525 "Set up this provider on web or desktop, or select another model.",
526 );
527 return false;
528 }
529 void Haptics.selectionAsync();
530 props.onSelectModel(pendingModel);
531 if (pendingPrompt !== null && pendingPrompt !== props.prompt) {
532 props.onPromptChange(pendingPrompt);
533 }
534 }
535 return true;
536 };
537
538 const applyOptionChange = (id: string, value: string | boolean) => {
539 const descriptor = displayedDescriptors.find((entry) => entry.id === id);
540 const change =
541 descriptor?.type === "select" && typeof value === "string"
542 ? getTraitSelectChange(
543 { ...promptTraits, descriptors: displayedDescriptors },
544 descriptor,
545 displayedPrompt,
546 value,
547 )
548 : null;
549 if (descriptor?.type === "select") {
550 if (!change) return;
551 if (change.prompt !== displayedPrompt) {
552 if (pendingModel) {
553 setPendingPrompt(change.prompt);
554 } else {
555 props.onPromptChange(change.prompt);
556 }
557 }
558 }
559 const next = change
560 ? (change.modelOptions ?? [])
561 : applyProviderOptionSelection(displayedDescriptors, { id, value });
562 if (!next) {
563 return;
564 }
565 if (pendingModel) {
566 rememberModelOptions(pendingModel.selection.instanceId, pendingModel.selection.model, next);
567 setPendingModel({
568 ...pendingModel,
569 selection: { ...pendingModel.selection, options: next },
570 });
571 } else {
572 props.onUpdateOptionSelections(next);
573 }
574 };
575
576 const toggleProvider = (providerKey: string) => {
577 setProviderExpansionOverrides((current) => {
578 const next = new Set(current);
579 if (!next.delete(providerKey)) {
580 next.add(providerKey);
581 }
582 return next;
583 });
584 };
585
586 const pressModel = (option: ModelOption) => {
587 void Haptics.selectionAsync();
588 if (isApplied(option) || option.key !== pendingModel?.key) {
589 setPendingPrompt(null);
590 }
591 setPendingModel((current) =>
592 pendingModelAfterPress({
593 current,
594 pressed: option,
595 pressedIsApplied: isApplied(option),
596 restoreSelection: props.restoreSelection,
597 }),
598 );
599 };
600
601 const environmentId = props.environmentId;
602 // Like the web picker's open tab: scanning every provider would spawn a probe per provider.
603 const scannedInstanceId =
604 providerFilter !== null && providerFilter !== FAVORITES_PROVIDER_FILTER
605 ? ProviderInstanceId.make(providerFilter)
606 : props.providerInstanceId;
607 const workspaceScan =
608 props.cwd && scannedInstanceId ? { instanceId: scannedInstanceId, cwd: props.cwd } : null;
609
610 const value = {
611 environmentId,
612 providerInstanceId: props.providerInstanceId,
613 providerGroups,
614 manageHidden,
615 setManageHidden,
616 toggleHidden,
617 pendingHiddenKeys,
618 runtimeMode: compatibleRuntimeMode,
619 runtimeModeChoices,
620 onUpdateRuntimeMode: props.onUpdateRuntimeMode,
621 displayedDescriptors,
622 promptTraits,
623 displayedModelSelection: pendingModel?.selection ?? props.selectedModel,
624 reportedModelSelection: pendingModel ? null : (props.reportedModelSelection ?? null),
625 favoriteKeys,
626 favoritesLoaded,
627 providerExpansionOverrides,
628 hasLegacyModels,
629 pendingModel,
630 providerFilter,
631 searchQuery,
632 showLegacy: showLegacyToggle,
633 applyOptionChange,
634 commitPendingModel,
635 isApplied,
636 isDisplayed,
637 pressModel,
638 setProviderFilter,
639 setSearchQuery,
640 setShowLegacy: setShowLegacyToggle,
641 toggleProvider,
642 toggleFavorite,
643 workspaceScan,
644 } satisfies ThreadSettingsSessionValue;
645
646 return (
647 <ThreadSettingsSessionContext.Provider value={value}>
648 {environmentId && workspaceScan ? (
649 <ProviderWorkspaceSnapshotScan
650 key={workspaceScan.instanceId}
651 environmentId={environmentId}
652 instanceId={workspaceScan.instanceId}
653 cwd={workspaceScan.cwd}
654 />
655 ) : null}
656 {props.children}
657 </ThreadSettingsSessionContext.Provider>
658 );
659}
660
661/** A project's own provider config can add models only its workspace scan lists. */
662function ProviderWorkspaceSnapshotScan(props: {
663 readonly environmentId: EnvironmentId;
664 readonly instanceId: ProviderInstanceId;
665 readonly cwd: string;
666}) {
667 useAtomMount(providerWorkspaceSnapshotRefresh(props));
668 return null;
669}
670
671function useThreadSettingsSession() {
672 const value = use(ThreadSettingsSessionContext);
673 if (!value) {
674 throw new Error("useThreadSettingsSession must be used inside ThreadSettingsSessionProvider.");
675 }
676 return value;
677}
678
679type ThreadSettingsProviderCatalog = {
680 readonly key: string;
681 readonly driver: string | undefined;
682 readonly iconUrl: string | undefined;
683 readonly label: string;
684 readonly collapsible: boolean;
685 readonly collapsed: boolean;
686 readonly modelCount: number;
687 readonly models: ReadonlyArray<ModelOption>;
688};
689
690type ThreadSettingsCatalogItem =
691 | {
692 readonly kind: "provider";
693 readonly key: string;
694 readonly provider: ThreadSettingsProviderCatalog;
695 }
696 | {
697 readonly kind: "model";
698 readonly key: string;
699 readonly option: ModelOption;
700 readonly isFirst: boolean;
701 readonly isLast: boolean;
702 }
703 | {
704 readonly kind: "notice";
705 readonly key: string;
706 readonly text: string;
707 }
708 | {
709 readonly kind: "empty";
710 readonly key: "empty";
711 }
712 | {
713 readonly kind: "options";
714 readonly key: "options";
715 };
716
717function ThreadSettingsModelListRow(props: {
718 readonly option: ModelOption;
719 readonly isFirst: boolean;
720 readonly isLast: boolean;
721}) {
722 const session = useThreadSettingsSession();
723 const onPress = () => session.pressModel(props.option);
724
725 return (
726 <ModelRow
727 isFirst={props.isFirst}
728 isLast={props.isLast}
729 onPress={onPress}
730 isFavorite={session.favoriteKeys.has(props.option.key)}
731 favoritesLoaded={session.favoritesLoaded}
732 onToggleFavorite={() => session.toggleFavorite(props.option)}
733 onToggleHidden={
734 session.manageHidden && props.option.canHide
735 ? () => session.toggleHidden(props.option)
736 : undefined
737 }
738 hiddenUpdatePending={session.pendingHiddenKeys.has(props.option.key)}
739 option={props.option}
740 selected={session.isDisplayed(props.option)}
741 />
742 );
743}
744
745function ThreadSettingsProviderListHeader(props: {
746 readonly provider: ThreadSettingsProviderCatalog;
747}) {
748 const session = useThreadSettingsSession();
749 const onToggle = () => session.toggleProvider(props.provider.key);
750
751 return (
752 <ProviderHeader
753 collapsible={props.provider.collapsible}
754 collapsed={props.provider.collapsed}
755 driver={props.provider.driver}
756 iconUrl={props.provider.iconUrl}
757 label={props.provider.label}
758 modelCount={props.provider.modelCount}
759 onToggle={onToggle}
760 />
761 );
762}
763
764function buildThreadSettingsCatalogItems(
765 session: ThreadSettingsSessionValue,
766): ReadonlyArray<ThreadSettingsCatalogItem> {
767 return session.providerGroups.flatMap((group) => {
768 if (
769 session.providerFilter !== null &&
770 session.providerFilter !== FAVORITES_PROVIDER_FILTER &&
771 group.providerKey !== session.providerFilter
772 ) {
773 return [];
774 }
775 const driver = group.models[0]?.providerDriver ?? group.providerKey;
776 const catalogModels =
777 session.manageHidden ||
778 session.showLegacy ||
779 session.providerFilter === FAVORITES_PROVIDER_FILTER
780 ? group.models
781 : group.models.filter(
782 (model) =>
783 !model.isLegacy || session.isDisplayed(model) || session.favoriteKeys.has(model.key),
784 );
785 const visibleModels = favoritesFirst(
786 catalogModels.filter(
787 (model) =>
788 (session.providerFilter !== FAVORITES_PROVIDER_FILTER ||
789 session.favoriteKeys.has(model.key)) &&
790 modelMatchesCatalogQuery({
791 model,
792 providerLabel: group.providerLabel,
793 query: session.searchQuery,
794 }),
795 ),
796 session.favoriteKeys,
797 );
798 const updateRequiredNotice =
799 group.updateRequired && session.providerFilter !== FAVORITES_PROVIDER_FILTER
800 ? formatProviderUpdateRequiredNotice(group.updateRequired, session.searchQuery)
801 : null;
802 if (visibleModels.length === 0 && !updateRequiredNotice) {
803 return [];
804 }
805 const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver);
806 const containsAppliedSelection = group.models.some(session.isApplied);
807 const isNarrowed = session.providerFilter !== null || session.searchQuery.trim().length > 0;
808 const collapsible = !isNarrowed;
809 const collapsed = providerSectionIsCollapsed({
810 defaultExpanded: isPrimary || containsAppliedSelection,
811 hasExpansionOverride: session.providerExpansionOverrides.has(group.providerKey),
812 isNarrowed,
813 });
814 const provider: ThreadSettingsProviderCatalog = {
815 key: group.providerKey,
816 driver,
817 iconUrl: group.models[0]?.providerIconUrl,
818 label: group.providerLabel,
819 collapsible,
820 collapsed,
821 modelCount: visibleModels.length,
822 models: collapsed ? [] : visibleModels,
823 };
824 return [
825 {
826 kind: "provider" as const,
827 key: `provider:${group.providerKey}`,
828 provider,
829 },
830 ...provider.models.map((option, index) => ({
831 kind: "model" as const,
832 key: `model:${option.key}`,
833 option,
834 isFirst: index === 0,
835 isLast: index === provider.models.length - 1,
836 })),
837 ...(!collapsed && updateRequiredNotice
838 ? [
839 {
840 kind: "notice" as const,
841 key: `notice:${group.providerKey}`,
842 text: updateRequiredNotice,
843 },
844 ]
845 : []),
846 ];
847 });
848}
849
850function ThreadSettingsOptionsItem(props: {
851 readonly animationsReady: boolean;
852 readonly bottomInset: number;
853 readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void;
854}) {
855 const insets = useSafeAreaInsets();
856 const session = useThreadSettingsSession();
857 const configs = useAtomValue(environmentServerConfigsAtom);
858 const selectedProvider = session.environmentId
859 ? (configs
860 .get(session.environmentId)
861 ?.providers.find((provider) => provider.instanceId === session.providerInstanceId) ?? null)
862 : null;
863
864 return (
865 <View style={{ paddingBottom: insets.bottom + props.bottomInset + 12 }}>
866 <ChatGptSharingStatus provider={selectedProvider} />
867 <Text className="px-5 pb-2 pt-2 text-sm font-t3-medium text-foreground-muted">Options</Text>
868 <Animated.View
869 className="mx-4 overflow-hidden rounded-2xl bg-grouped-card"
870 layout={THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION}
871 >
872 {session.displayedDescriptors.map((descriptor) => {
873 if (descriptor.type === "select") {
874 return (
875 <Animated.View
876 key={descriptor.id}
877 entering={
878 props.animationsReady ? THREAD_SETTINGS_OPTION_ENTER_TRANSITION : undefined
879 }
880 exiting={props.animationsReady ? THREAD_SETTINGS_OPTION_EXIT_TRANSITION : undefined}
881 layout={THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION}
882 >
883 <DisclosureRow
884 label={descriptor.label}
885 value={
886 session.promptTraits.ultrathinkPromptControlled &&
887 descriptor.id === session.promptTraits.primarySelectDescriptor?.id
888 ? "Ultrathink"
889 : getProviderOptionCurrentLabel(
890 descriptor,
891 session.displayedModelSelection,
892 session.reportedModelSelection,
893 )
894 }
895 onPress={() => props.onOpenSubmenu({ kind: "descriptor", id: descriptor.id })}
896 />
897 </Animated.View>
898 );
899 }
900 return (
901 <Animated.View
902 key={descriptor.id}
903 entering={props.animationsReady ? THREAD_SETTINGS_OPTION_ENTER_TRANSITION : undefined}
904 exiting={props.animationsReady ? THREAD_SETTINGS_OPTION_EXIT_TRANSITION : undefined}
905 layout={THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION}
906 >
907 <SwitchRow
908 label={descriptor.label}
909 value={descriptor.currentValue ?? false}
910 onValueChange={(value) => session.applyOptionChange(descriptor.id, value)}
911 />
912 </Animated.View>
913 );
914 })}
915 <Animated.View layout={THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION}>
916 <DisclosureRow
917 isLast
918 label="Runtime"
919 value={
920 session.runtimeModeChoices.find((choice) => choice.mode === session.runtimeMode)
921 ?.label
922 }
923 onPress={() => props.onOpenSubmenu({ kind: "runtime" })}
924 />
925 </Animated.View>
926 </Animated.View>
927
928 <Text className="px-5 pb-2 pt-7 text-sm font-t3-medium text-foreground-muted">Catalog</Text>
929 <View className="mx-4 overflow-hidden rounded-2xl bg-grouped-card">
930 <SwitchRow
931 label="Manage hidden models"
932 value={session.manageHidden}
933 onValueChange={session.setManageHidden}
934 isLast
935 />
936 </View>
937 </View>
938 );
939}
940
941function ThreadSettingsMainContent(props: {
942 readonly bottomInset: number;
943 readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void;
944}) {
945 const session = useThreadSettingsSession();
946 const refreshProvidersCommand = useAtomCommand(serverEnvironment.refreshProviders, {
947 reportFailure: false,
948 });
949 const refreshProviderCatalog = createProviderCatalogRefreshRunner(refreshProvidersCommand);
950 const registry = use(RegistryContext);
951 const refreshModelCatalog = useEffectEvent(() => {
952 const environmentId = session.environmentId;
953 if (!environmentId) return;
954 // An older server drops the field and probes every provider.
955 const config = registry.get(environmentServerConfigsAtom).get(environmentId);
956 if (config?.environment.capabilities.modelCatalogRefresh !== true) return;
957 void refreshProvidersCommand({ environmentId, input: { modelCatalogOnly: true } });
958 });
959 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- asks the server for newly released models when the model list opens
960 useEffect(() => {
961 refreshModelCatalog();
962 }, []);
963 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
964 const refreshProviders = () => {
965 if (!session.environmentId || isRefreshingProviders) return;
966 setIsRefreshingProviders(true);
967 const environmentId = session.environmentId;
968 const workspaceScan = session.workspaceScan;
969 void refreshProviderCatalog(environmentId)
970 .then(async (result) => {
971 // A project's own models are read per folder, so the machine refresh alone keeps them stale.
972 // A failed folder read keeps its previous models and retries, so only the machine result alerts.
973 if (result._tag === "Success" && workspaceScan) {
974 await refreshProvidersCommand({
975 environmentId,
976 input: { ...workspaceScan, fresh: true },
977 });
978 }
979 return result;
980 })
981 .then((result) => {
982 setIsRefreshingProviders(false);
983 const error = providerCatalogRefreshError(result);
984 if (error) Dialog.alert("Could not refresh models", error);
985 });
986 };
987 const [listLoaded, setListLoaded] = useState(false);
988 // Android can drop the first frame of a reduced-motion fade, leaving rows invisible.
989 const reducedMotion = useReducedMotion();
990 const reduceMotionEnabled = useReduceMotionEnabled();
991 const animationsReady = listLoaded && !reducedMotion && !reduceMotionEnabled;
992 const catalogItems = buildThreadSettingsCatalogItems(session);
993 const hasActiveCatalogFilter =
994 session.providerFilter !== null || session.searchQuery.trim().length > 0;
995 const listItems = [
996 ...(catalogItems.length === 0 ? ([{ kind: "empty", key: "empty" }] as const) : catalogItems),
997 { kind: "options", key: "options" },
998 ] satisfies ReadonlyArray<ThreadSettingsCatalogItem>;
999 const renderCatalogItem = (itemProps: LegendListRenderItemProps<ThreadSettingsCatalogItem>) => {
1000 const item = itemProps.item;
1001 let content: ReactNode;
1002
1003 if (item.kind === "provider") {
1004 content = <ThreadSettingsProviderListHeader provider={item.provider} />;
1005 } else if (item.kind === "model") {
1006 content = (
1007 <ThreadSettingsModelListRow
1008 isFirst={item.isFirst}
1009 isLast={item.isLast}
1010 option={item.option}
1011 />
1012 );
1013 } else if (item.kind === "notice") {
1014 content = <Text className="mx-8 mt-2 text-xs text-foreground-muted">{item.text}</Text>;
1015 } else if (item.kind === "empty") {
1016 content = (
1017 <View className="items-center px-8 py-14">
1018 <Text className="text-center text-sm text-foreground-muted">
1019 {session.providerFilter === FAVORITES_PROVIDER_FILTER &&
1020 session.searchQuery.trim().length === 0
1021 ? "No favorite models"
1022 : hasActiveCatalogFilter
1023 ? "No matching models"
1024 : "No available models"}
1025 </Text>
1026 </View>
1027 );
1028 } else {
1029 content = (
1030 <ThreadSettingsOptionsItem
1031 animationsReady={animationsReady}
1032 bottomInset={props.bottomInset}
1033 onOpenSubmenu={props.onOpenSubmenu}
1034 />
1035 );
1036 }
1037
1038 return (
1039 <Animated.View
1040 key={item.key}
1041 entering={animationsReady ? THREAD_SETTINGS_CATALOG_ENTER_TRANSITION : undefined}
1042 exiting={animationsReady ? THREAD_SETTINGS_CATALOG_EXIT_TRANSITION : undefined}
1043 >
1044 {content}
1045 </Animated.View>
1046 );
1047 };
1048
1049 return (
1050 // oxlint-disable-next-line t3code/require-registered-list-scroll -- Sheet content; sheets sit outside workspace columns and keep their own header handling.
1051 <AnimatedLegendList
1052 alwaysBounceVertical
1053 automaticallyAdjustsScrollIndicatorInsets
1054 className="flex-1 bg-sheet"
1055 style={READABLE_WIDTH_STYLE}
1056 contentContainerStyle={{ paddingTop: 4 }}
1057 contentInsetAdjustmentBehavior="automatic"
1058 data={listItems}
1059 estimatedItemSize={ROW_MIN_HEIGHT.item}
1060 extraData={animationsReady}
1061 getItemType={(item) => item.kind}
1062 itemLayoutAnimation={THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION}
1063 keyExtractor={(item) => item.key}
1064 keyboardDismissMode="on-drag"
1065 keyboardShouldPersistTaps="handled"
1066 maintainVisibleContentPosition={THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION}
1067 recycleItems
1068 refreshControl={
1069 session.environmentId ? (
1070 <RefreshControl refreshing={isRefreshingProviders} onRefresh={refreshProviders} />
1071 ) : undefined
1072 }
1073 onLoad={() => setListLoaded(true)}
1074 renderItem={renderCatalogItem}
1075 showsVerticalScrollIndicator={false}
1076 />
1077 );
1078}
1079
1080function ThreadSettingsChoiceContent(props: {
1081 readonly submenu: ThreadSettingsSubmenuPage;
1082 readonly onSelected: () => void;
1083}) {
1084 const insets = useSafeAreaInsets();
1085 const session = useThreadSettingsSession();
1086 const descriptorId = props.submenu.kind === "descriptor" ? props.submenu.id : null;
1087
1088 const activeDescriptor =
1089 descriptorId !== null
1090 ? session.displayedDescriptors.find(
1091 (descriptor) => descriptor.type === "select" && descriptor.id === descriptorId,
1092 )
1093 : undefined;
1094
1095 const submenuContent =
1096 props.submenu.kind === "runtime"
1097 ? {
1098 rows: session.runtimeModeChoices.map((choice) => ({
1099 id: choice.mode,
1100 label: choice.label,
1101 description: choice.disabledReason ?? choice.description,
1102 disabled: choice.disabledReason !== undefined,
1103 selected: choice.mode === session.runtimeMode,
1104 onPress: () => {
1105 void Haptics.selectionAsync();
1106 session.onUpdateRuntimeMode(choice.mode);
1107 props.onSelected();
1108 },
1109 })),
1110 }
1111 : activeDescriptor?.type === "select"
1112 ? {
1113 rows: selectableChoices(activeDescriptor).map((choice) => ({
1114 id: choice.id,
1115 label: choice.label,
1116 description: choice.description,
1117 disabled: false,
1118 selected:
1119 choice.id ===
1120 (session.promptTraits.ultrathinkPromptControlled &&
1121 activeDescriptor.id === session.promptTraits.primarySelectDescriptor?.id
1122 ? "ultrathink"
1123 : getProviderOptionCurrentValue(
1124 activeDescriptor,
1125 session.displayedModelSelection,
1126 session.reportedModelSelection,
1127 )),
1128 onPress: () => {
1129 void Haptics.selectionAsync();
1130 if (
1131 session.promptTraits.ultrathinkInBodyText &&
1132 activeDescriptor.id === session.promptTraits.primarySelectDescriptor?.id
1133 ) {
1134 Dialog.alert(
1135 "Reasoning controlled by prompt",
1136 'Your prompt contains "ultrathink" in the text. Remove it to change this option.',
1137 );
1138 return;
1139 }
1140 session.applyOptionChange(activeDescriptor.id, choice.id);
1141 props.onSelected();
1142 },
1143 })),
1144 }
1145 : null;
1146
1147 if (!submenuContent) {
1148 return <View className="flex-1 bg-sheet" />;
1149 }
1150
1151 return (
1152 <ScrollView
1153 className="flex-1 bg-sheet"
1154 style={READABLE_WIDTH_STYLE}
1155 contentContainerStyle={{
1156 paddingBottom: insets.bottom + 12,
1157 paddingHorizontal: 16,
1158 paddingTop: 16,
1159 }}
1160 contentInsetAdjustmentBehavior="automatic"
1161 showsVerticalScrollIndicator={false}
1162 >
1163 <View className="overflow-hidden rounded-2xl bg-grouped-card">
1164 {submenuContent.rows.map((row, index) => (
1165 <ChoiceRow
1166 key={row.id}
1167 description={row.description}
1168 isLast={index === submenuContent.rows.length - 1}
1169 label={row.label}
1170 selected={row.selected}
1171 disabled={row.disabled}
1172 onPress={row.onPress}
1173 />
1174 ))}
1175 </View>
1176 </ScrollView>
1177 );
1178}
1179
1180type ThreadSettingsPickerStackParams = {
1181 ThreadSettingsModels: undefined;
1182 ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string };
1183};
1184
1185type ThreadSettingsPickerPresentation = {
1186 readonly onClose: () => void;
1187};
1188
1189const ThreadSettingsPickerStack = createNativeStackNavigator<ThreadSettingsPickerStackParams>();
1190const ThreadSettingsPickerPresentationContext =
1191 createContext<ThreadSettingsPickerPresentation | null>(null);
1192
1193function useThreadSettingsPickerPresentation() {
1194 const value = use(ThreadSettingsPickerPresentationContext);
1195 if (!value) {
1196 throw new Error(
1197 "useThreadSettingsPickerPresentation must be used inside ThreadSettingsPickerNavigator.",
1198 );
1199 }
1200 return value;
1201}
1202
1203function ThreadSettingsModelsScreen() {
1204 const session = useThreadSettingsSession();
1205 const presentation = useThreadSettingsPickerPresentation();
1206 const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>();
1207 const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy;
1208 const commitAndClose = useEffectEvent(() => {
1209 if (!session.commitPendingModel()) return;
1210 presentation.onClose();
1211 });
1212 const search: AppBarSearch = {
1213 value: session.searchQuery,
1214 onChangeText: session.setSearchQuery,
1215 placeholder: "Find a model",
1216 clearAccessibilityLabel: "Clear model search",
1217 mode: "inline",
1218 compactToolbar: true,
1219 };
1220 const searchToolbarInset = useSearchToolbarInset(search);
1221 const providerFilters = [
1222 { id: "all-providers", title: "All providers", value: null },
1223 { id: "favorites", title: "Favorites", value: FAVORITES_PROVIDER_FILTER },
1224 ...session.providerGroups.map((group) => ({
1225 id: `provider:${group.providerKey}`,
1226 title: group.providerLabel,
1227 value: group.providerKey,
1228 })),
1229 ];
1230
1231 return (
1232 <>
1233 <AppBar
1234 title="Thread settings"
1235 onClose={presentation.onClose}
1236 closeAccessibilityLabel="Cancel thread settings"
1237 search={search}
1238 menus={[
1239 {
1240 title: "Model filters",
1241 icon: hasCustomCatalogFilter
1242 ? "line.3.horizontal.decrease.circle.fill"
1243 : "line.3.horizontal.decrease",
1244 items: [
1245 {
1246 id: "provider",
1247 title: "Provider",
1248 items: providerFilters.map((filter) => ({
1249 id: filter.id,
1250 title: filter.title,
1251 selected: session.providerFilter === filter.value,
1252 onPress: () => session.setProviderFilter(filter.value),
1253 })),
1254 },
1255 ...(session.hasLegacyModels
1256 ? [
1257 {
1258 id: "show-legacy",
1259 title: "Show legacy models",
1260 selected: session.showLegacy,
1261 onPress: () => session.setShowLegacy(!session.showLegacy),
1262 },
1263 ]
1264 : []),
1265 ],
1266 },
1267 ]}
1268 optionsVersion={[
1269 session.providerFilter,
1270 session.providerGroups.map((group) => group.providerKey),
1271 session.showLegacy,
1272 session.hasLegacyModels,
1273 ]}
1274 confirm={{
1275 label: session.pendingModel ? "Save" : "Done",
1276 onPress: commitAndClose,
1277 }}
1278 hideBottomBorder
1279 />
1280 <ScreenContent>
1281 <ThreadSettingsMainContent
1282 bottomInset={searchToolbarInset}
1283 onOpenSubmenu={(submenu) => {
1284 const title =
1285 submenu.kind === "runtime"
1286 ? "Runtime"
1287 : (session.displayedDescriptors.find(
1288 (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id,
1289 )?.label ?? "Option");
1290 navigation.navigate("ThreadSettingsChoice", { ...submenu, title });
1291 }}
1292 />
1293 </ScreenContent>
1294 </>
1295 );
1296}
1297
1298function ThreadSettingsChoiceScreen() {
1299 const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>();
1300 const route = useRoute<RouteProp<ThreadSettingsPickerStackParams, "ThreadSettingsChoice">>();
1301
1302 return (
1303 <>
1304 <AppBar title={route.params.title} onBack={() => navigation.goBack()} hideBottomBorder />
1305 <ScreenContent>
1306 <ThreadSettingsChoiceContent
1307 submenu={route.params}
1308 onSelected={() => navigation.goBack()}
1309 />
1310 </ScreenContent>
1311 </>
1312 );
1313}
1314
1315function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) {
1316 const screenOptions = useSheetStackOptions();
1317 const presentation = {
1318 onClose: props.onClose,
1319 };
1320
1321 return (
1322 <ThreadSettingsPickerPresentationContext.Provider value={presentation}>
1323 <ThreadSettingsPickerStack.Navigator
1324 initialRouteName="ThreadSettingsModels"
1325 screenOptions={screenOptions}
1326 >
1327 <ThreadSettingsPickerStack.Screen
1328 name="ThreadSettingsModels"
1329 component={ThreadSettingsModelsScreen}
1330 options={{ headerBackVisible: false, title: "Thread settings" }}
1331 />
1332 <ThreadSettingsPickerStack.Screen
1333 name="ThreadSettingsChoice"
1334 component={ThreadSettingsChoiceScreen}
1335 options={({ route }) => ({ title: route.params.title })}
1336 />
1337 </ThreadSettingsPickerStack.Navigator>
1338 </ThreadSettingsPickerPresentationContext.Provider>
1339 );
1340}
1341
1342export function ThreadSettingsPickerScreen(
1343 props: ThreadSettingsSessionProps & { readonly onClose: () => void },
1344) {
1345 return (
1346 <ThreadSettingsSessionProvider {...props}>
1347 <ThreadSettingsPickerNavigator onClose={props.onClose} />
1348 </ThreadSettingsSessionProvider>
1349 );
1350}
1351
1352export function ExistingThreadSettingsRouteScreen({
1353 route,
1354}: StaticScreenProps<ExistingThreadSettingsRouteParams>) {
1355 const navigation = useNavigation<NativeStackNavigationProp<Record<string, object | undefined>>>();
1356 // Reads the same environmentId/threadId params as the Thread route beneath it.
1357 const { selectedThread, selectedThreadDetailRef, selectedEnvironmentRuntime } =
1358 useThreadSelection();
1359 const { selectedThreadCwd } = useSelectedThreadWorktree();
1360 // Streamed text would otherwise re-render the whole model picker on every delta.
1361 const structure = useQuietThreadStructure(selectedThreadDetailRef);
1362 const reportedModelSelection = useThreadReportedModelSelection(selectedThreadDetailRef);
1363 const threadKey = scopedThreadKey(route.params.environmentId, route.params.threadId);
1364 const draftSettings = useComposerDraftSettings(threadKey);
1365 const queuedRunEdit = useQueuedRunEdit(threadKey);
1366 // A queued edit can end while the sheet is open; the prompt then belongs to the thread's draft.
1367 const draftKey = activeComposerDraftKey(threadKey, route.params.draftKey, queuedRunEdit);
1368 const prompt = useComposerDraft(draftKey).text;
1369
1370 if (!selectedThread) {
1371 return <View className="flex-1 bg-sheet" />;
1372 }
1373
1374 const serverConfig = serverConfigForCwd(
1375 selectedEnvironmentRuntime?.serverConfig ?? null,
1376 selectedThreadCwd,
1377 );
1378 const modelSelection = draftSettings.modelSelection ?? selectedThread.modelSelection;
1379 const lockedProviderInstanceId = threadAllowsProviderSwitch({
1380 thread: selectedThread,
1381 projection: structure?.projection,
1382 })
1383 ? undefined
1384 : modelSelection.instanceId;
1385 const modelOptions = buildModelOptions(
1386 serverConfig,
1387 modelSelection,
1388 lockedProviderInstanceId,
1389 selectedThread.projectId,
1390 );
1391 const currentModelOption =
1392 modelOptions.find(
1393 (option) =>
1394 option.selection.instanceId === modelSelection.instanceId &&
1395 option.selection.model === modelSelection.model,
1396 ) ?? null;
1397 const updateModelSelection = (selection: ModelSelection) =>
1398 selectThreadModel(route.params, selection);
1399
1400 return (
1401 <ThreadSettingsPickerScreen
1402 environmentId={selectedThread.environmentId}
1403 projectId={selectedThread.projectId}
1404 cwd={selectedThreadCwd}
1405 providerInstanceId={modelSelection.instanceId}
1406 {...(lockedProviderInstanceId ? { lockedProviderInstanceId } : {})}
1407 providerGroups={groupByProvider(modelOptions)}
1408 selectedModel={modelSelection}
1409 prompt={prompt}
1410 onPromptChange={(text) => setComposerDraftText(draftKey, text)}
1411 reportedModelSelection={reportedModelSelection}
1412 onSelectModel={(option) => updateModelSelection(withRememberedModelOptions(option.selection))}
1413 restoreSelection={withRememberedModelOptions}
1414 optionDescriptors={resolveProviderOptionDescriptors({
1415 capabilities: currentModelOption?.capabilities,
1416 selections: modelSelection.options,
1417 })}
1418 onUpdateOptionSelections={(options) => {
1419 rememberModelOptions(modelSelection.instanceId, modelSelection.model, options ?? []);
1420 updateModelSelection({ ...modelSelection, options });
1421 }}
1422 runtimeMode={draftSettings.runtimeMode ?? selectedThread.runtimeMode}
1423 onUpdateRuntimeMode={(runtimeMode) => updateComposerDraftSettings(threadKey, { runtimeMode })}
1424 onClose={() => navigation.goBack()}
1425 />
1426 );
1427}
1428
1429/** RNS owns nested sheet dismissal; Reasoning and Runtime remain ordinary pushes. */
1430export function NewTaskThreadSettingsRouteScreen() {
1431 const flow = useNewTaskFlow();
1432 const navigation = useNavigation<NativeStackNavigationProp<Record<string, object | undefined>>>();
1433 const optionDescriptors = resolveProviderOptionDescriptors({
1434 capabilities: flow.selectedModelOption?.capabilities,
1435 selections: flow.selectedModel?.options,
1436 });
1437
1438 return (
1439 <ThreadSettingsPickerScreen
1440 environmentId={flow.selectedEnvironmentId}
1441 projectId={flow.selectedProject?.id ?? null}
1442 cwd={flow.workspaceCwd}
1443 {...(flow.selectedModel ? { providerInstanceId: flow.selectedModel.instanceId } : {})}
1444 providerGroups={flow.providerGroups}
1445 selectedModel={flow.selectedModel}
1446 prompt={flow.prompt}
1447 onPromptChange={flow.setPrompt}
1448 onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)}
1449 restoreSelection={withRememberedModelOptions}
1450 optionDescriptors={optionDescriptors}
1451 onUpdateOptionSelections={flow.setSelectedModelOptions}
1452 runtimeMode={flow.runtimeMode}
1453 onUpdateRuntimeMode={flow.setRuntimeMode}
1454 onClose={() => navigation.goBack()}
1455 />
1456 );
1457}