authorgravatar for jakeleventhal@me.comJake Leventhal <jakeleventhal@me.com> 2026-10-11 03:04:56-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 00:04:56-07:00
logf8458952710cc70894bdeb5853d700524a1c7c49
treefae44412ae416bac0229d78c34821e5a0a6c0e49
parentbb9b9aae57ce5614c9c26d540ca51c805cdbf09b
signature Signed by PGP key B5690EEEBB952194

fix: sync model favorites and visibility across clients (#16816)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

40 files changed, 1460 insertions(+), 244 deletions(-)

apps/mobile/src/components/AppSymbol.tsx+2
......@@ -53,6 +53,7 @@ import IconDotsVertical from "@tabler/icons-react-native/IconDotsVertical";
5353import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal";
5454import IconEdit from "@tabler/icons-react-native/IconEdit";
5555import IconExternalLink from "@tabler/icons-react-native/IconExternalLink";
56import IconEyeOff from "@tabler/icons-react-native/IconEyeOff";
5657import IconEye from "@tabler/icons-react-native/IconEye";
5758import IconFileText from "@tabler/icons-react-native/IconFileText";
5859import IconFilter from "@tabler/icons-react-native/IconFilter";
......@@ -177,6 +178,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
177178 "exclamationmark.triangle": IconAlertTriangle,
178179 "exclamationmark.circle": IconAlertCircle,
179180 eye: IconEye,
181 "eye.slash": IconEyeOff,
180182 folder: IconFolder,
181183 "folder.badge.plus": IconFolderPlus,
182184 "folder.fill": IconFolder,
apps/mobile/src/features/threads/ThreadComposer.tsx+2
......@@ -675,6 +675,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
675675 ownerId: settingsOwnerId,
676676 environmentId: props.environmentId,
677677 providerInstanceId: currentModelSelection.instanceId,
678 ...(lockedProviderInstanceId ? { lockedProviderInstanceId } : {}),
678679 providerGroups: threadProviderGroups,
679680 selectedModel: currentModelSelection,
680681 reportedModelSelection: props.reportedModelSelection,
......@@ -700,6 +701,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
700701 props.onUpdateRuntimeMode,
701702 providerOptionDescriptors,
702703 settingsOwnerId,
704 lockedProviderInstanceId,
703705 threadProviderGroups,
704706 ],
705707 );
apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx+24-2
......@@ -12,6 +12,8 @@ export type ModelRowProps = {
1212 readonly isFavorite: boolean;
1313 readonly favoritesLoaded: boolean;
1414 readonly onToggleFavorite: () => void;
15 readonly onToggleHidden?: () => void;
16 readonly hiddenUpdatePending?: boolean;
1517 readonly isFirst: boolean;
1618 readonly isLast: boolean;
1719};
......@@ -52,10 +54,10 @@ export function ModelRowContent(
5254 accessibilityRole="radio"
5355 accessibilityState={{
5456 checked: props.selected,
55 disabled: props.option.isUnavailable === true,
57 disabled: props.option.isUnavailable === true || props.option.isHidden === true,
5658 }}
5759 className="min-h-11 min-w-0 flex-1 flex-row items-center gap-2 active:opacity-70"
58 disabled={props.option.isUnavailable}
60 disabled={props.option.isUnavailable || props.option.isHidden}
5961 onPress={props.onPress}
6062 >
6163 {props.leadingSelection}
......@@ -77,6 +79,9 @@ export function ModelRowContent(
7779 <Text className="text-3xs font-t3-bold text-foreground-muted">Legacy</Text>
7880 </View>
7981 ) : null}
82 {props.option.isHidden ? (
83 <Text className="text-xs text-foreground-muted">Hidden</Text>
84 ) : null}
8085 {props.option.isUnavailable ? (
8186 <Text className="text-xs text-foreground">Unavailable</Text>
8287 ) : null}
......@@ -92,6 +97,23 @@ export function ModelRowContent(
9297 </View>
9398 {props.trailingSelection}
9499 </Pressable>
100 {props.onToggleHidden ? (
101 <Pressable
102 accessibilityLabel={`${props.option.isHidden ? "Show" : "Hide"} model: ${props.option.providerLabel}, ${props.option.label}`}
103 accessibilityRole="button"
104 accessibilityState={{ disabled: props.hiddenUpdatePending === true }}
105 className="min-h-11 min-w-11 items-center justify-center"
106 disabled={props.hiddenUpdatePending}
107 onPress={props.onToggleHidden}
108 >
109 <SymbolView
110 name={props.option.isHidden ? "eye.slash" : "eye"}
111 size={18}
112 tintColorClassName={props.option.isHidden ? "accent-icon-subtle" : "accent-icon"}
113 type="monochrome"
114 />
115 </Pressable>
116 ) : null}
95117 <Pressable
96118 accessibilityLabel={`${props.isFavorite ? "Remove from" : "Add to"} favorites: ${
97119 props.option.providerLabel
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+188-34
......@@ -1,4 +1,9 @@
11import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator";
2import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
3import {
4 modelPreferencesMigrationPatch,
5 resolveModelPreferences,
6} from "@t3tools/shared/serverSettings";
27import type {
38 EnvironmentId,
49 ModelSelection,
......@@ -6,9 +11,10 @@ import type {
611 ProviderOptionDescriptor,
712 ProviderOptionSelection,
813 RuntimeMode,
14 ServerSettings,
915} from "@t3tools/contracts";
1016import type { LegendListRenderItemProps } from "@legendapp/list/react-native";
11import { useAtomSet, useAtomValue } from "@effect/atom-react";
17import { RegistryContext, useAtomValue } from "@effect/atom-react";
1218import { AnimatedLegendList } from "@legendapp/list/reanimated";
1319import {
1420 getProviderOptionCurrentLabel,
......@@ -25,6 +31,7 @@ import {
2531 useCallback,
2632 useEffect,
2733 useMemo,
34 useRef,
2835 useState,
2936 type ReactNode,
3037} from "react";
......@@ -49,7 +56,12 @@ import { MaterialIconButton } from "../../components/MaterialIconButton";
4956import { ProviderIcon } from "../../components/ProviderIcon";
5057import { ThemedSwitch } from "../../components/ThemedSwitch";
5158import { cn } from "../../lib/cn";
52import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
59import {
60 buildModelOptions,
61 groupByProvider,
62 type ModelOption,
63 type ProviderGroup,
64} from "../../lib/modelOptions";
5365import { applyProviderOptionSelection } from "../../lib/providerOptions";
5466import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
5567import { useUniwindTheme } from "../../lib/useUniwindTheme";
......@@ -62,7 +74,7 @@ import {
6274import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
6375import { ChatGptSharingStatus } from "./ChatGptSharingStatus";
6476import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server";
65import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
77import { mobilePreferencesAtom } from "../../state/preferences";
6678import { useAtomCommand } from "../../state/use-atom-command";
6779import { useNewTaskFlow } from "./new-task-flow-provider";
6880import { MaterialScreenContent } from "../../components/MaterialScreenContent";
......@@ -88,7 +100,6 @@ import {
88100 modelMatchesCatalogQuery,
89101 pendingModelAfterPress,
90102 providerSectionIsCollapsed,
91 toggleModelFavorite,
92103} from "./thread-settings-sheet-state";
93104import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels";
94105
......@@ -242,6 +253,7 @@ type ThreadSettingsSubmenuPage =
242253type ThreadSettingsSessionProps = {
243254 readonly environmentId: EnvironmentId | null;
244255 readonly providerInstanceId?: ProviderInstanceId;
256 readonly lockedProviderInstanceId?: ProviderInstanceId;
245257 readonly providerGroups: ReadonlyArray<ProviderGroup>;
246258 readonly selectedModel: ModelSelection | null;
247259 readonly reportedModelSelection?: ModelSelection | null;
......@@ -300,6 +312,10 @@ type ThreadSettingsSessionValue = {
300312 readonly favoriteKeys: ReadonlySet<string>;
301313 readonly favoritesLoaded: boolean;
302314 readonly toggleFavorite: (option: ModelOption) => void;
315 readonly toggleHidden: (option: ModelOption) => void;
316 readonly pendingHiddenKeys: ReadonlySet<string>;
317 readonly manageHidden: boolean;
318 readonly setManageHidden: (value: boolean) => void;
303319 readonly runtimeMode: RuntimeMode;
304320 readonly runtimeModeChoices: ReturnType<typeof runtimeModeChoicesForSupportedModes>;
305321 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
......@@ -329,12 +345,46 @@ const ThreadSettingsSessionContext = createContext<ThreadSettingsSessionValue |
329345function ThreadSettingsSessionProvider(
330346 props: ThreadSettingsSessionProps & { readonly children: ReactNode },
331347) {
348 const registry = use(RegistryContext);
332349 const preferences = useAtomValue(mobilePreferencesAtom);
333 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
334 const favoritesLoaded = AsyncResult.isSuccess(preferences);
335 const modelFavorites = favoritesLoaded
350 const configs = useAtomValue(environmentServerConfigsAtom);
351 const config = props.environmentId ? configs.get(props.environmentId) : undefined;
352 const settings = config?.settings;
353 const [manageHidden, setManageHidden] = useState(false);
354 const pendingHidden = useRef(new Set<string>());
355 const pendingFavorites = useRef(
356 new Map<string, { favorite: boolean; settings: ServerSettings; pending: boolean }>(),
357 );
358 const [pendingHiddenKeys, setPendingHiddenKeys] = useState<ReadonlySet<string>>(() => new Set());
359 const providerGroups = useMemo(
360 () =>
361 manageHidden && config
362 ? groupByProvider(
363 buildModelOptions(config, props.selectedModel, props.lockedProviderInstanceId, true),
364 )
365 : props.providerGroups,
366 [
367 config,
368 manageHidden,
369 props.providerGroups,
370 props.selectedModel,
371 props.lockedProviderInstanceId,
372 ],
373 );
374 const legacyFavorites = AsyncResult.isSuccess(preferences)
336375 ? (preferences.value.modelFavorites ?? EMPTY_MODEL_FAVORITES)
337376 : EMPTY_MODEL_FAVORITES;
377 const favoritesLoaded =
378 settings !== undefined && (settings.favorites !== null || AsyncResult.isSuccess(preferences));
379 const modelFavorites = resolveModelPreferences(settings ?? DEFAULT_SERVER_SETTINGS, {
380 favorites: legacyFavorites,
381 }).favorites;
382 const saveSettings = useAtomCommand(serverEnvironment.updateSettings, "model favorites update");
383 useEffect(() => {
384 if (!props.environmentId || !settings || !AsyncResult.isSuccess(preferences)) return;
385 const patch = modelPreferencesMigrationPatch(settings, { favorites: legacyFavorites });
386 if (patch) void saveSettings({ environmentId: props.environmentId, input: { patch } });
387 }, [legacyFavorites, preferences, props.environmentId, saveSettings, settings]);
338388 const favoriteKeys = useMemo(
339389 () =>
340390 new Set(
......@@ -343,19 +393,105 @@ function ThreadSettingsSessionProvider(
343393 [modelFavorites],
344394 );
345395 const toggleFavorite = useCallback(
396 async (option: ModelOption) => {
397 if (!favoritesLoaded || !props.environmentId) return;
398 const currentSettings = registry
399 .get(environmentServerConfigsAtom)
400 .get(props.environmentId)?.settings;
401 if (!currentSettings) return;
402 const favorites = resolveModelPreferences(currentSettings, {
403 favorites: legacyFavorites,
404 }).favorites;
405 const key = JSON.stringify([
406 props.environmentId,
407 option.selection.instanceId,
408 option.selection.model,
409 ]);
410 const previous = pendingFavorites.current.get(key);
411 const previousFavorite =
412 previous && (previous.pending || previous.settings.favorites === currentSettings.favorites)
413 ? previous.favorite
414 : favorites.some(
415 (favorite) =>
416 favorite.provider === option.selection.instanceId &&
417 favorite.model === option.selection.model,
418 );
419 const pending = { favorite: !previousFavorite, settings: currentSettings, pending: true };
420 pendingFavorites.current.set(key, pending);
421 void Haptics.selectionAsync();
422 try {
423 const result = await saveSettings({
424 environmentId: props.environmentId,
425 input: {
426 patch: {
427 setModelFavorites: [
428 {
429 provider: option.selection.instanceId,
430 model: option.selection.model,
431 favorite: pending.favorite,
432 },
433 ],
434 ...(currentSettings.favorites === null
435 ? { migrateModelPreferences: { favorites } }
436 : {}),
437 },
438 },
439 });
440 if (
441 AsyncResult.isSuccess(result) &&
442 registry.get(environmentServerConfigsAtom).get(props.environmentId)?.settings
443 .favorites === currentSettings.favorites
444 ) {
445 pending.pending = false;
446 }
447 } finally {
448 if (pending.pending && pendingFavorites.current.get(key) === pending) {
449 if (
450 previous &&
451 !previous.pending &&
452 registry.get(environmentServerConfigsAtom).get(props.environmentId)?.settings
453 .favorites === previous.settings.favorites
454 ) {
455 pendingFavorites.current.set(key, previous);
456 } else {
457 pendingFavorites.current.delete(key);
458 }
459 }
460 }
461 },
462 [favoritesLoaded, legacyFavorites, props.environmentId, registry, saveSettings],
463 );
464 const toggleHidden = useCallback(
346465 (option: ModelOption) => {
347 if (!favoritesLoaded) return;
466 if (
467 !settings ||
468 !props.environmentId ||
469 !option.canHide ||
470 pendingHidden.current.has(option.key)
471 )
472 return;
473 pendingHidden.current.add(option.key);
474 setPendingHiddenKeys(new Set(pendingHidden.current));
348475 void Haptics.selectionAsync();
349 savePreferences({
350 transform: (current) => ({
351 modelFavorites: toggleModelFavorite(
352 current.modelFavorites ?? EMPTY_MODEL_FAVORITES,
353 option,
354 ),
355 }),
476 void saveSettings({
477 environmentId: props.environmentId,
478 input: {
479 patch: {
480 setModelsHidden: [
481 {
482 provider: option.selection.instanceId,
483 model: option.selection.model,
484 hidden: !option.isHidden,
485 },
486 ],
487 },
488 },
489 }).then(() => {
490 pendingHidden.current.delete(option.key);
491 setPendingHiddenKeys(new Set(pendingHidden.current));
356492 });
357493 },
358 [favoritesLoaded, savePreferences],
494 [props.environmentId, saveSettings, settings],
359495 );
360496 const [showLegacyToggle, setShowLegacyToggle] = useState(false);
361497 const [providerFilter, setProviderFilter] = useState<string | null>(null);
......@@ -473,7 +609,11 @@ function ThreadSettingsSessionProvider(
473609 () => ({
474610 environmentId: props.environmentId,
475611 providerInstanceId: props.providerInstanceId,
476 providerGroups: props.providerGroups,
612 providerGroups,
613 manageHidden,
614 setManageHidden,
615 toggleHidden,
616 pendingHiddenKeys,
477617 runtimeMode: compatibleRuntimeMode,
478618 runtimeModeChoices,
479619 onUpdateRuntimeMode: props.onUpdateRuntimeMode,
......@@ -518,7 +658,10 @@ function ThreadSettingsSessionProvider(
518658 pressModel,
519659 providerFilter,
520660 props.onUpdateRuntimeMode,
521 props.providerGroups,
661 providerGroups,
662 manageHidden,
663 toggleHidden,
664 pendingHiddenKeys,
522665 runtimeModeChoices,
523666 searchQuery,
524667 showLegacyToggle,
......@@ -599,6 +742,12 @@ function ThreadSettingsModelListRow(props: {
599742 isFavorite={session.favoriteKeys.has(props.option.key)}
600743 favoritesLoaded={session.favoritesLoaded}
601744 onToggleFavorite={() => session.toggleFavorite(props.option)}
745 onToggleHidden={
746 session.manageHidden && props.option.canHide
747 ? () => session.toggleHidden(props.option)
748 : undefined
749 }
750 hiddenUpdatePending={session.pendingHiddenKeys.has(props.option.key)}
602751 option={props.option}
603752 selected={session.isDisplayed(props.option)}
604753 />
......@@ -642,7 +791,9 @@ function useThreadSettingsCatalogItems(
642791 }
643792 const driver = group.models[0]?.providerDriver ?? group.providerKey;
644793 const catalogModels =
645 session.showLegacy || session.providerFilter === FAVORITES_PROVIDER_FILTER
794 session.manageHidden ||
795 session.showLegacy ||
796 session.providerFilter === FAVORITES_PROVIDER_FILTER
646797 ? group.models
647798 : group.models.filter(
648799 (model) =>
......@@ -726,6 +877,7 @@ function useThreadSettingsCatalogItems(
726877 session.providerGroups,
727878 session.searchQuery,
728879 session.showLegacy,
880 session.manageHidden,
729881 ],
730882 );
731883}
......@@ -806,21 +958,23 @@ function ThreadSettingsOptionsItem(props: {
806958 </Animated.View>
807959 </Animated.View>
808960
809 {Platform.OS !== "ios" && session.hasLegacyModels ? (
810 <>
811 <Text className="px-5 pb-2 pt-7 text-sm font-t3-medium text-foreground-muted">
812 Catalog
813 </Text>
814 <View className="mx-4 overflow-hidden rounded-2xl bg-grouped-card">
815 <SwitchRow
816 isLast
817 label="Legacy models"
818 onValueChange={session.setShowLegacy}
819 value={session.showLegacy}
820 />
821 </View>
822 </>
823 ) : null}
961 <Text className="px-5 pb-2 pt-7 text-sm font-t3-medium text-foreground-muted">Catalog</Text>
962 <View className="mx-4 overflow-hidden rounded-2xl bg-grouped-card">
963 <SwitchRow
964 label="Manage hidden models"
965 value={session.manageHidden}
966 onValueChange={session.setManageHidden}
967 isLast={Platform.OS === "ios" || !session.hasLegacyModels}
968 />
969 {Platform.OS !== "ios" && session.hasLegacyModels ? (
970 <SwitchRow
971 isLast
972 label="Legacy models"
973 onValueChange={session.setShowLegacy}
974 value={session.showLegacy}
975 />
976 ) : null}
977 </View>
824978 </View>
825979 );
826980}
apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts+9-19
......@@ -6,10 +6,8 @@ import type { ModelOption } from "../../lib/modelOptions";
66import {
77 canCommitPendingModel,
88 favoritesFirst,
9 modelFavoriteKey,
109 modelMatchesCatalogQuery,
1110 pendingModelAfterPress,
12 toggleModelFavorite,
1311} from "./thread-settings-sheet-state";
1412
1513function modelOption(
......@@ -58,23 +56,6 @@ describe("thread settings sheet state", () => {
5856 ]);
5957 });
6058
61 it("adds and removes favorites for one provider instance", () => {
62 const codexModel = modelOption("shared");
63 const otherProvider = ProviderInstanceId.make("codex_personal");
64 const personalModel = {
65 ...codexModel,
66 key: modelFavoriteKey(otherProvider, "shared"),
67 selection: { ...codexModel.selection, instanceId: otherProvider },
68 };
69 const favorites = toggleModelFavorite([], codexModel);
70
71 expect(toggleModelFavorite(favorites, personalModel)).toEqual([
72 { provider: ProviderInstanceId.make("codex"), model: "shared" },
73 { provider: otherProvider, model: "shared" },
74 ]);
75 expect(toggleModelFavorite(favorites, codexModel)).toEqual([]);
76 });
77
7859 it("matches visible model and provider terms", () => {
7960 const model = modelOption("gpt-next");
8061
......@@ -144,6 +125,15 @@ describe("thread settings sheet state", () => {
144125 ).toBe(pressed);
145126 });
146127
128 it("cannot select a hidden model exposed only for visibility management", () => {
129 const pending = modelOption("gpt-next");
130 expect(
131 canCommitPendingModel(pending, [
132 { providerKey: "codex", providerLabel: "Codex", models: [{ ...pending, isHidden: true }] },
133 ]),
134 ).toBe(false);
135 });
136
147137 it("cannot save a staged model after sign-out removes it from the catalog", () => {
148138 const pending = modelOption("gemini-native");
149139 const group = { providerKey: "codex", providerLabel: "Codex", models: [pending] };
apps/mobile/src/features/threads/thread-settings-sheet-state.ts+3-17
......@@ -1,26 +1,10 @@
11import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
22import type { ProviderInstanceId } from "@t3tools/contracts";
33
4export type ModelFavorite = {
5 readonly provider: ProviderInstanceId;
6 readonly model: string;
7};
8
94export function modelFavoriteKey(provider: ProviderInstanceId, model: string): string {
105 return `${provider}:${model}`;
116}
127
13export function toggleModelFavorite(
14 favorites: ReadonlyArray<ModelFavorite>,
15 option: ModelOption,
16): ReadonlyArray<ModelFavorite> {
17 const provider = option.selection.instanceId;
18 const model = option.selection.model;
19 return favorites.some((favorite) => favorite.provider === provider && favorite.model === model)
20 ? favorites.filter((favorite) => favorite.provider !== provider || favorite.model !== model)
21 : [...favorites, { provider, model }];
22}
23
248/** Keep catalog order within each group when favorites move to the front. */
259export function favoritesFirst(
2610 models: ReadonlyArray<ModelOption>,
......@@ -71,7 +55,9 @@ export function canCommitPendingModel(
7155 groups: ReadonlyArray<ProviderGroup>,
7256): boolean {
7357 return groups.some((group) =>
74 group.models.some((model) => model.key === pending.key && !model.isUnavailable),
58 group.models.some(
59 (model) => model.key === pending.key && !model.isUnavailable && !model.isHidden,
60 ),
7561 );
7662}
7763
apps/mobile/src/lib/modelOptions.test.ts+93-1
......@@ -1,6 +1,11 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3tools/contracts";
3import {
4 DEFAULT_SERVER_SETTINGS,
5 ProviderInstanceId,
6 type ModelSelection,
7 type ServerConfig,
8} from "@t3tools/contracts";
49
510import {
611 buildModelOptions,
......@@ -504,3 +509,90 @@ describe("mobile model options", () => {
504509 ).toBeNull();
505510 });
506511});
512
513describe("synchronized mobile model visibility", () => {
514 const provider = ProviderInstanceId.make("codex_work");
515 const other = ProviderInstanceId.make("codex_personal");
516 const models = [
517 { slug: "hidden", name: "Hidden", isCustom: false, capabilities: null },
518 { slug: "first", name: "First", isCustom: false, capabilities: null },
519 { slug: "second", name: "Second", isCustom: false, capabilities: null },
520 { slug: "custom", name: "Custom", isCustom: true, capabilities: null },
521 ];
522 const config = {
523 settings: {
524 ...DEFAULT_SERVER_SETTINGS,
525 providerModelPreferences: {
526 [provider]: {
527 hiddenModels: ["hidden", "custom", "missing"],
528 modelOrder: ["second", "first"],
529 },
530 },
531 },
532 providers: [provider, other].map((instanceId) => ({
533 instanceId,
534 driver: "codex",
535 enabled: true,
536 installed: true,
537 auth: { status: "authenticated" },
538 models,
539 })),
540 } as unknown as ServerConfig;
541
542 it("applies server visibility and ordering independently to each provider instance", () => {
543 const options = buildModelOptions(config, null);
544 expect(
545 options
546 .filter((option) => option.providerKey === provider)
547 .map((option) => option.selection.model),
548 ).toEqual(["second", "first", "custom"]);
549 expect(
550 options
551 .filter((option) => option.providerKey === other)
552 .map((option) => option.selection.model),
553 ).toEqual(["hidden", "first", "second", "custom"]);
554 });
555
556 it("exposes hidden built-ins for management without offering custom visibility controls", () => {
557 const options = buildModelOptions(
558 config,
559 { instanceId: provider, model: "hidden" },
560 provider,
561 true,
562 );
563 expect(options.map((option) => option.selection.model)).toEqual([
564 "second",
565 "first",
566 "hidden",
567 "custom",
568 ]);
569 expect(options.find((option) => option.selection.model === "hidden")).toMatchObject({
570 isHidden: true,
571 canHide: true,
572 });
573 expect(options.find((option) => option.selection.model === "custom")).toMatchObject({
574 isHidden: false,
575 canHide: false,
576 });
577 });
578
579 it.each(["hidden", "missing"])("does not resurrect the hidden fallback %s", (model) => {
580 expect(
581 buildModelOptions(config, { instanceId: provider, model }, provider).map(
582 (option) => option.selection.model,
583 ),
584 ).toEqual(["second", "first", "custom"]);
585 });
586
587 it("keeps custom models visible and restores built-in models when the server clears hidden models", () => {
588 expect(
589 buildModelOptions(config, { instanceId: provider, model: "custom" }, provider).map(
590 (option) => option.selection.model,
591 ),
592 ).toContain("custom");
593 const cleared = { ...config, settings: { ...config.settings, providerModelPreferences: {} } };
594 expect(
595 buildModelOptions(cleared, null, provider).map((option) => option.selection.model),
596 ).toEqual(["hidden", "first", "second", "custom"]);
597 });
598});
apps/mobile/src/lib/modelOptions.ts+22-2
......@@ -22,6 +22,8 @@ export type ModelOption = {
2222 readonly isDefault: boolean;
2323 readonly isLegacy: boolean;
2424 readonly isUnavailable?: boolean;
25 readonly isHidden?: boolean;
26 readonly canHide?: boolean;
2527 readonly capabilities: ModelCapabilities | null;
2628 readonly selection: ModelSelection;
2729 readonly providerUpdateRequired?: ProviderUpdateRequired;
......@@ -167,6 +169,7 @@ export function buildModelOptions(
167169 config: T3ServerConfig | null | undefined,
168170 fallbackModelSelection: ModelSelection | null,
169171 providerInstanceId?: ModelSelection["instanceId"],
172 includeHidden = false,
170173): ReadonlyArray<ModelOption> {
171174 const options = new Map<string, ModelOption>();
172175
......@@ -185,7 +188,15 @@ export function buildModelOptions(
185188 const updateRequired = provider.updateRequiredModels?.length
186189 ? { driver: provider.driver, updateRequiredModels: provider.updateRequiredModels }
187190 : undefined;
188 for (const model of provider.models) {
191 const preferences = config?.settings?.providerModelPreferences?.[provider.instanceId];
192 const hiddenModels = new Set(preferences?.hiddenModels ?? []);
193 const order = new Map((preferences?.modelOrder ?? []).map((slug, index) => [slug, index]));
194 const models = provider.models.filter(
195 (model) => includeHidden || model.isCustom || !hiddenModels.has(model.slug),
196 );
197 if (order.size > 0)
198 models.sort((a, b) => (order.get(a.slug) ?? Infinity) - (order.get(b.slug) ?? Infinity));
199 for (const model of models) {
189200 const key = `${provider.instanceId}:${model.slug}`;
190201 options.set(key, {
191202 key,
......@@ -198,6 +209,8 @@ export function buildModelOptions(
198209 ? {}
199210 : { supportedRuntimeModes: provider.supportedRuntimeModes }),
200211 ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}),
212 isHidden: !model.isCustom && hiddenModels.has(model.slug),
213 canHide: !model.isCustom,
201214 isDefault: model.isDefault === true,
202215 isLegacy: model.isLegacy === true,
203216 ...(updateRequired ? { providerUpdateRequired: updateRequired } : {}),
......@@ -215,7 +228,14 @@ export function buildModelOptions(
215228
216229 if (
217230 fallbackModelSelection &&
218 (providerInstanceId === undefined || fallbackModelSelection.instanceId === providerInstanceId)
231 (providerInstanceId === undefined ||
232 fallbackModelSelection.instanceId === providerInstanceId) &&
233 (!config?.settings?.providerModelPreferences?.[
234 fallbackModelSelection.instanceId
235 ]?.hiddenModels.includes(fallbackModelSelection.model) ||
236 config?.providers
237 .find((provider) => provider.instanceId === fallbackModelSelection.instanceId)
238 ?.models.find((model) => model.slug === fallbackModelSelection.model)?.isCustom)
219239 ) {
220240 const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`;
221241 const existing = options.get(key);
apps/mobile/src/persistence/mobile-preferences.ts+1-1
......@@ -46,7 +46,7 @@ export interface Preferences {
4646 readonly planModeEnabled?: boolean;
4747 /** Device-local counterpart of web's `sidebarWorkingShelfEnabled` beta. */
4848 readonly workingShelfEnabled?: boolean;
49 /** Model favorites belong to this device, like the web client setting. */
49 /** @deprecated Retained to migrate favorites to the connected environment. */
5050 readonly modelFavorites?: ReadonlyArray<{
5151 readonly provider: ProviderInstanceId;
5252 readonly model: string;
apps/server/src/mcp/OrchestratorMcpService.activity.test.ts+5
......@@ -20,6 +20,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg
2020import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
2121import * as ProjectService from "../project/ProjectService.ts";
2222import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
23import * as ServerSettings from "../serverSettings.ts";
2324import * as SecretRequests from "../secrets/SecretRequests.ts";
2425import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
2526import type * as McpInvocationContext from "./McpInvocationContext.ts";
......@@ -146,6 +147,7 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a
146147 ? Effect.succeed(projection)
147148 : Effect.die(`unexpected thread ${input.threadId}`),
148149 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
150 ServerSettings.layerTest(),
149151 Layer.mock(ProviderRegistry.ProviderRegistry)({
150152 getProviders: Effect.succeed([]),
151153 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
......@@ -211,6 +213,7 @@ it("readThread prefers waiting activity status over a newer cancelled queued run
211213 ? Effect.succeed(projection)
212214 : Effect.die(`unexpected thread ${input.threadId}`),
213215 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
216 ServerSettings.layerTest(),
214217 Layer.mock(ProviderRegistry.ProviderRegistry)({
215218 getProviders: Effect.succeed([]),
216219 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
......@@ -324,6 +327,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy
324327 return Effect.die(`unexpected thread ${threadId}`);
325328 },
326329 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
330 ServerSettings.layerTest(),
327331 Layer.mock(ProviderRegistry.ProviderRegistry)({
328332 getProviders: Effect.succeed([]),
329333 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
......@@ -447,6 +451,7 @@ it("readThread and sendToThread reach threads in other projects", async () => {
447451 delivery: "started",
448452 } as unknown as ThreadManagementService.ThreadManagementSendResult),
449453 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
454 ServerSettings.layerTest(),
450455 Layer.mock(ProviderRegistry.ProviderRegistry)({
451456 getProviders: Effect.succeed([]),
452457 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
apps/server/src/mcp/OrchestratorMcpService.test.ts+62
......@@ -34,6 +34,7 @@ import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
3434import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts";
3535import * as ProjectService from "../project/ProjectService.ts";
3636import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
37import * as ServerSettings from "../serverSettings.ts";
3738import * as SecretRequests from "../secrets/SecretRequests.ts";
3839import type { McpInvocationScope } from "./McpInvocationContext.ts";
3940import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts";
......@@ -128,6 +129,7 @@ describe("OrchestratorMcpService", () => {
128129 ),
129130 ),
130131 }),
132 ServerSettings.layerTest(),
131133 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
132134 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
133135 list: () => Effect.succeed([]),
......@@ -227,6 +229,7 @@ describe("OrchestratorMcpService", () => {
227229 : Effect.succeed(awaitsRestart),
228230 dispatch: () => Ref.update(dispatched, (count) => count + 1).pipe(Effect.as({} as never)),
229231 }),
232 ServerSettings.layerTest(),
230233 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
231234 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
232235 list: () => Effect.succeed([]),
......@@ -311,6 +314,7 @@ describe("OrchestratorMcpService", () => {
311314 Effect.as({} as never),
312315 ),
313316 }),
317 ServerSettings.layerTest(),
314318 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
315319 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
316320 list: () => Effect.succeed([]),
......@@ -387,6 +391,7 @@ describe("OrchestratorMcpService", () => {
387391 Effect.andThen(Effect.fail(new Error("simulated stop failure") as never)),
388392 ),
389393 }),
394 ServerSettings.layerTest(),
390395 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
391396 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
392397 list: () => Effect.succeed([]),
......@@ -471,6 +476,7 @@ describe("OrchestratorMcpService", () => {
471476 ),
472477 stopDelegatedTasks: () => Effect.void,
473478 }),
479 ServerSettings.layerTest(),
474480 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
475481 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
476482 list: () => Effect.succeed([]),
......@@ -557,6 +563,7 @@ describe("OrchestratorMcpService", () => {
557563 ),
558564 stopDelegatedTasks: () => Effect.void,
559565 }),
566 ServerSettings.layerTest(),
560567 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
561568 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
562569 list: () => Effect.succeed([]),
......@@ -697,6 +704,7 @@ describe("OrchestratorMcpService", () => {
697704 yield* Ref.set(stoppedBelow, true);
698705 }),
699706 }),
707 ServerSettings.layerTest(),
700708 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
701709 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
702710 list: () => Effect.succeed([]),
......@@ -927,6 +935,7 @@ describe("OrchestratorMcpService provider resolution", () => {
927935 Layer.mock(ThreadManagementService.ThreadManagementService)({
928936 getThreadRecords: () => Effect.succeed(parentProjection([])),
929937 }),
938 ServerSettings.layerTest(),
930939 Layer.mock(ProviderRegistry.ProviderRegistry)({
931940 getProviders: Effect.sync(() => providers),
932941 }),
......@@ -1016,6 +1025,51 @@ describe("OrchestratorMcpService provider resolution", () => {
10161025 }),
10171026 );
10181027
1028 it.effect("leaves models the user hid out of the capability catalog", () =>
1029 Effect.gen(function* () {
1030 const codex = providerSnapshot({
1031 instanceId: codexInstanceId,
1032 driver: ProviderDriverKind.make("codex"),
1033 model: "gpt-5.4",
1034 });
1035 const layerDependencies = Layer.mergeAll(
1036 NodeServices.layer,
1037 Layer.mock(ThreadManagementService.ThreadManagementService)({
1038 getThreadRecords: () => Effect.succeed(parentProjection([])),
1039 }),
1040 ServerSettings.layerTest({
1041 providerModelPreferences: {
1042 [codexInstanceId]: { hiddenModels: ["gpt-5.4-mini", "my-model"], modelOrder: [] },
1043 },
1044 }),
1045 providerRegistryLayer([
1046 {
1047 ...codex,
1048 models: [
1049 ...codex.models,
1050 { slug: "gpt-5.4-mini", name: "Mini", isCustom: false, capabilities: null },
1051 // Custom models cannot be hidden, matching the client pickers.
1052 { slug: "my-model", name: "Mine", isCustom: true, capabilities: null },
1053 ],
1054 },
1055 ]),
1056 adapterRegistryLayer([codexInstanceId]),
1057 Layer.mock(ProjectService.ProjectService)({}),
1058 Layer.mock(SecretRequests.SecretRequests)({}),
1059 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
1060 );
1061
1062 yield* Effect.gen(function* () {
1063 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
1064 const capabilities = yield* service.capabilities(scope);
1065 assert.deepEqual(
1066 capabilities.providers[0]?.models.map((model) => model.id),
1067 ["gpt-5.4", "my-model"],
1068 );
1069 }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))));
1070 }),
1071 );
1072
10191073 it.effect(
10201074 "delegates to an Antigravity instance whose adapter resolves through the registry",
10211075 () =>
......@@ -1070,6 +1124,7 @@ describe("OrchestratorMcpService provider resolution", () => {
10701124 } as never),
10711125 ),
10721126 }),
1127 ServerSettings.layerTest(),
10731128 Layer.mock(ProviderRegistry.ProviderRegistry)({
10741129 getProviders: Effect.succeed([
10751130 providerSnapshot({
......@@ -1167,6 +1222,7 @@ describe("OrchestratorMcpService provider resolution", () => {
11671222 } as never),
11681223 ),
11691224 }),
1225 ServerSettings.layerTest(),
11701226 Layer.mock(ProviderRegistry.ProviderRegistry)({
11711227 getProviders: Effect.succeed([
11721228 providerSnapshot({
......@@ -1221,6 +1277,7 @@ describe("OrchestratorMcpService provider resolution", () => {
12211277 Layer.mock(ThreadManagementService.ThreadManagementService)({
12221278 getThreadRecords: () => Effect.succeed(parentProjection([])),
12231279 }),
1280 ServerSettings.layerTest(),
12241281 providerRegistryLayer([
12251282 providerSnapshot({
12261283 instanceId: codexInstanceId,
......@@ -1329,6 +1386,7 @@ describe("OrchestratorMcpService provider resolution", () => {
13291386 } as never),
13301387 ),
13311388 }),
1389 ServerSettings.layerTest(),
13321390 Layer.mock(ProviderRegistry.ProviderRegistry)({
13331391 getProviders: Effect.succeed([codex, missingCli]),
13341392 refreshInstance: () =>
......@@ -1480,6 +1538,7 @@ describe("OrchestratorMcpService provider resolution", () => {
14801538 } as never),
14811539 ),
14821540 }),
1541 ServerSettings.layerTest(),
14831542 providerRegistryLayer([
14841543 providerSnapshot({
14851544 instanceId: codexInstanceId,
......@@ -1632,6 +1691,7 @@ describe("OrchestratorMcpService provider resolution", () => {
16321691 getThreadShell: (threadId) =>
16331692 Effect.succeed(threadId === boundThreadId ? boundThread : null),
16341693 }),
1694 ServerSettings.layerTest(),
16351695 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
16361696 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
16371697 list: () => Effect.succeed([]),
......@@ -1700,6 +1760,7 @@ describe("OrchestratorMcpService provider resolution", () => {
17001760 // Its turn ended: no run is active.
17011761 getThreadRecords: () => Effect.succeed(idleThreadProjection(shell)),
17021762 }),
1763 ServerSettings.layerTest(),
17031764 Layer.mock(ProviderRegistry.ProviderRegistry)({
17041765 getProviders: Effect.succeed([]),
17051766 }),
......@@ -1789,6 +1850,7 @@ describe("OrchestratorMcpService provider resolution", () => {
17891850 ),
17901851 ),
17911852 }),
1853 ServerSettings.layerTest(),
17921854 Layer.mock(ProviderRegistry.ProviderRegistry)({
17931855 getProviders: Effect.succeed([]),
17941856 }),
apps/server/src/mcp/OrchestratorMcpService.ts+21-7
......@@ -85,6 +85,7 @@ import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts";
8585import * as ProjectService from "../project/ProjectService.ts";
8686import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
8787import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
88import * as ServerSettings from "../serverSettings.ts";
8889import {
8990 clientRuntimeModeCeiling,
9091 type McpInvocationScope,
......@@ -901,6 +902,7 @@ const make = Effect.gen(function* () {
901902 const providerAdapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2;
902903 const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService;
903904 const projects = yield* ProjectService.ProjectService;
905 const serverSettings = yield* ServerSettings.ServerSettingsService;
904906
905907 /** A caller-named project, which must exist before anything is recorded against it. */
906908 const requireProject = (projectId: ProjectId) =>
......@@ -1851,6 +1853,11 @@ const make = Effect.gen(function* () {
18511853 const { parent, limits } = yield* loadCaller(scope);
18521854 const providers = yield* loadProviders;
18531855 const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds();
1856 // Models the user hid from pickers stay out of the catalog agents choose from.
1857 const modelPreferences = yield* serverSettings.getSettings.pipe(
1858 Effect.map((settings) => settings.providerModelPreferences ?? {}),
1859 Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")),
1860 );
18541861 return {
18551862 parentThreadId: parent?.thread.id ?? null,
18561863 inheritedProviderInstanceId: parent?.thread.modelSelection.instanceId ?? null,
......@@ -1867,13 +1874,19 @@ const make = Effect.gen(function* () {
18671874 driverKind: provider.driver,
18681875 displayName: provider?.displayName ?? null,
18691876 models:
1870 provider?.models.map((model) => ({
1871 id: model.slug,
1872 label: model.name ?? null,
1873 ...(model.capabilities?.optionDescriptors === undefined
1874 ? {}
1875 : { options: model.capabilities.optionDescriptors }),
1876 })) ?? [],
1877 provider?.models
1878 .filter(
1879 (model) =>
1880 model.isCustom ||
1881 !modelPreferences[provider.instanceId]?.hiddenModels.includes(model.slug),
1882 )
1883 .map((model) => ({
1884 id: model.slug,
1885 label: model.name ?? null,
1886 ...(model.capabilities?.optionDescriptors === undefined
1887 ? {}
1888 : { options: model.capabilities.optionDescriptors }),
1889 })) ?? [],
18771890 ...(provider.supportedRuntimeModes === undefined ||
18781891 provider.supportedRuntimeModes.length === 0
18791892 ? {}
......@@ -2594,4 +2607,5 @@ export const layer: Layer.Layer<
25942607 | ScheduledTaskService.ScheduledTaskService
25952608 | ProjectService.ProjectService
25962609 | SecretRequests.SecretRequests
2610 | ServerSettings.ServerSettingsService
25972611> = Layer.effect(OrchestratorMcpService, make);
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+3
......@@ -70,6 +70,7 @@ import {
7070import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts";
7171import * as ProjectService from "../project/ProjectService.ts";
7272import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
73import * as ServerSettings from "../serverSettings.ts";
7374import * as SecretRequests from "../secrets/SecretRequests.ts";
7475import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
7576import * as McpHttpServer from "./McpHttpServer.ts";
......@@ -680,6 +681,7 @@ describe("orchestrator MCP toolkit", () => {
680681 Layer.provideMerge(layerOrchestration),
681682 Layer.provide(layerRegistry),
682683 Layer.provide(layerProviderRegistry),
684 Layer.provide(ServerSettings.layerTest()),
683685 Layer.provide(layerScheduledTaskStub),
684686 Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})),
685687 Layer.provide(
......@@ -3825,6 +3827,7 @@ describe("orchestrator MCP toolkit", () => {
38253827 ),
38263828 ),
38273829 Layer.provide(layerProviderRegistry),
3830 Layer.provide(ServerSettings.layerTest()),
38283831 Layer.provide(layerUnusedScheduledTaskStub),
38293832 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
38303833 Layer.provideMerge(
apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts+2
......@@ -33,6 +33,7 @@ import * as ProjectService from "../project/ProjectService.ts";
3333import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
3434import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
3535import * as SecretRequests from "../secrets/SecretRequests.ts";
36import * as ServerSettings from "../serverSettings.ts";
3637import type { McpInvocationScope } from "./McpInvocationContext.ts";
3738import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
3839
......@@ -142,6 +143,7 @@ it.effect("task_cancel ends a delegated child's background native subagent", ()
142143 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
143144 Layer.mock(ProjectService.ProjectService)({}),
144145 Layer.mock(SecretRequests.SecretRequests)({}),
146 ServerSettings.layerTest(),
145147 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
146148 NodeServices.layer,
147149 ),
apps/server/src/mcp/toolkits/core.test.ts+5
......@@ -42,6 +42,7 @@ import * as ProjectService from "../../project/ProjectService.ts";
4242import * as ProviderRegistry from "../../provider/ProviderRegistry.ts";
4343import * as SecretRequests from "../../secrets/SecretRequests.ts";
4444import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts";
45import * as ServerSettings from "../../serverSettings.ts";
4546import * as McpHttpServer from "../McpHttpServer.ts";
4647import * as McpInvocationContext from "../McpInvocationContext.ts";
4748import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts";
......@@ -581,6 +582,7 @@ it.effect("refuses act-as-caller tools to a client caller", () =>
581582 Layer.provideMerge(McpServer.McpServer.layer),
582583 Layer.provide(NodeCrypto.layer),
583584 Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})),
585 Layer.provide(ServerSettings.layerTest()),
584586 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
585587 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
586588 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
......@@ -619,6 +621,7 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode
619621 Layer.provideMerge(McpServer.McpServer.layer),
620622 Layer.provide(NodeCrypto.layer),
621623 Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})),
624 Layer.provide(ServerSettings.layerTest()),
622625 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
623626 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
624627 Layer.provide(
......@@ -716,6 +719,7 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])(
716719 interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"),
717720 }),
718721 ),
722 Layer.provide(ServerSettings.layerTest()),
719723 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
720724 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
721725 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
......@@ -807,6 +811,7 @@ it.effect("discovers workflow stop targets and requires orchestration capability
807811 }),
808812 }),
809813 ),
814 Layer.provide(ServerSettings.layerTest()),
810815 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
811816 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
812817 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
apps/server/src/serverSettings.test.ts+82
......@@ -98,6 +98,88 @@ const recordProviderUsage = (provider: string, instanceId: string | null = provi
9898 });
9999
100100it.layer(NodeServices.layer)("server settings", (it) => {
101 it.effect("serializes concurrent model preference edits without cancelling duplicate adds", () =>
102 Effect.gen(function* () {
103 const service = yield* ServerSettingsModule.ServerSettingsService;
104 const provider = ProviderInstanceId.make("codex_work");
105 yield* Effect.all(
106 [
107 service.updateSettings({
108 setModelFavorites: [{ provider, model: "sol", favorite: true }],
109 }),
110 service.updateSettings({
111 setModelFavorites: [{ provider, model: "sol", favorite: true }],
112 }),
113 service.updateSettings({
114 setModelFavorites: [{ provider, model: "luna", favorite: true }],
115 }),
116 service.updateSettings({ setModelsHidden: [{ provider, model: "astra", hidden: true }] }),
117 service.updateSettings({ setModelsHidden: [{ provider, model: "luna", hidden: true }] }),
118 service.updateSettings({
119 setProviderModelOrder: { provider, modelOrder: ["luna", "sol"] },
120 }),
121 ],
122 { concurrency: "unbounded" },
123 );
124 const settings = yield* service.getSettings;
125 assert.deepEqual(settings.favorites?.map((entry) => entry.model).sort(), ["luna", "sol"]);
126 assert.deepEqual(settings.providerModelPreferences?.[provider]?.hiddenModels.toSorted(), [
127 "astra",
128 "luna",
129 ]);
130 assert.deepEqual(settings.providerModelPreferences?.[provider]?.modelOrder, ["luna", "sol"]);
131 }).pipe(Effect.provide(layerServerSettings())),
132 );
133 it.effect("persists and broadcasts model preferences to other connected clients", () =>
134 Effect.scoped(
135 Effect.gen(function* () {
136 const service = yield* ServerSettingsModule.ServerSettingsService;
137 const config = yield* ServerConfig.ServerConfig;
138 const fs = yield* FileSystem.FileSystem;
139 const provider = ProviderInstanceId.make("codex_work");
140 const patch = {
141 favorites: [{ provider, model: "sol" }],
142 providerModelPreferences: {
143 [provider]: { hiddenModels: ["astra"], modelOrder: ["sol"] },
144 },
145 };
146 const changes = yield* service.subscribeChanges;
147 yield* service.updateSettings(patch);
148 const received = Option.getOrThrow(yield* Stream.runHead(changes));
149 assert.deepEqual(received.favorites, patch.favorites);
150 assert.deepEqual(received.providerModelPreferences, patch.providerModelPreferences);
151 const saved = yield* decodeServerSettingsJson(
152 yield* fs.readFileString(config.settingsPath),
153 );
154 assert.deepEqual(saved.favorites, patch.favorites);
155 assert.deepEqual(saved.providerModelPreferences, patch.providerModelPreferences);
156 const hidden = yield* service.updateSettings({
157 setModelsHidden: [{ provider, model: "sol", hidden: true }],
158 });
159 assert.deepEqual(hidden.providerModelPreferences?.[provider], {
160 hiddenModels: ["astra", "sol"],
161 modelOrder: ["sol"],
162 });
163 const shown = yield* service.updateSettings({
164 setModelsHidden: [{ provider, model: "astra", hidden: false }],
165 });
166 assert.deepEqual(shown.providerModelPreferences?.[provider], {
167 hiddenModels: ["sol"],
168 modelOrder: ["sol"],
169 });
170 assert.deepEqual(
171 (yield* decodeServerSettingsJson(yield* fs.readFileString(config.settingsPath)))
172 .providerModelPreferences,
173 shown.providerModelPreferences,
174 );
175 yield* service.updateSettings({ favorites: [], providerModelPreferences: {} });
176 // An older device's delayed seed cannot undo a clear, even after persistence.
177 const migrated = yield* service.updateSettings({ migrateModelPreferences: patch });
178 assert.deepEqual(migrated.favorites, []);
179 assert.deepEqual(migrated.providerModelPreferences, {});
180 }),
181 ).pipe(Effect.provide(layerServerSettings())),
182 );
101183 it.effect("migrates saved token delivery to paragraph buffering without resetting settings", () =>
102184 Effect.gen(function* () {
103185 const config = yield* ServerConfig.ServerConfig;
apps/web/src/components/chat/ChatComposer.tsx+1
......@@ -5524,6 +5524,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
55245524 />
55255525 ) : null}
55265526 <ProviderModelPicker
5527 environmentId={environmentId}
55275528 compact={false}
55285529 isComposerOwned
55295530 disabled={providerCatalogPending || isSendBusy}
apps/web/src/components/chat/ModelPickerContent.tsx+8-17
......@@ -1,6 +1,7 @@
11import {
22 ANTIGRAVITY_DEFAULT_MODEL,
33 type ProviderInstanceId,
4 type EnvironmentId,
45 type ProviderDriverKind,
56 type ResolvedKeybindingsConfig,
67} from "@t3tools/contracts";
......@@ -37,7 +38,10 @@ import {
3738 resolveShortcutCommand,
3839 shortcutLabelForCommand,
3940} from "../../keybindings";
40import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
41import {
42 useEnvironmentModelPreferences,
43 useToggleEnvironmentModelFavorite,
44} from "~/hooks/useSettings";
4145import { cn } from "~/lib/utils";
4246import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
4347import { TooltipProvider } from "../ui/tooltip";
......@@ -153,6 +157,7 @@ function ModelListSeparator() {
153157}
154158
155159export const ModelPickerContent = memo(function ModelPickerContent(props: {
160 environmentId: EnvironmentId | null;
156161 /** The instance currently selected in the composer (combobox "value"). */
157162 activeInstanceId: ProviderInstanceId;
158163 model: string;
......@@ -202,7 +207,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
202207 const modelListRef = useRef<LegendListRef | null>(null);
203208 const pickerContentRef = useRef<HTMLDivElement>(null);
204209 const highlightedModelKeyRef = useRef<string | null>(null);
205 const favorites = useClientSettings((s) => s.favorites ?? []);
210 const { favorites } = useEnvironmentModelPreferences(props.environmentId);
206211 const activeEntry = props.instanceEntries.find(
207212 (entry) => entry.instanceId === props.activeInstanceId,
208213 );
......@@ -276,8 +281,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
276281 );
277282 const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom);
278283 const keybindings = providedKeybindings ?? serverKeybindings;
279 const updateSettings = useUpdateClientSettings();
280284 const navigate = useNavigate();
285 const toggleFavorite = useToggleEnvironmentModelFavorite(props.environmentId);
281286
282287 const focusSearchInput = useCallback(() => {
283288 searchInputRef.current?.focus({ preventScroll: true });
......@@ -652,20 +657,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
652657 ],
653658 );
654659
655 const toggleFavorite = useCallback(
656 (instanceId: ProviderInstanceId, model: string) => {
657 const newFavorites = [...favorites];
658 const index = newFavorites.findIndex((f) => f.provider === instanceId && f.model === model);
659 if (index >= 0) {
660 newFavorites.splice(index, 1);
661 } else {
662 newFavorites.push({ provider: instanceId, model });
663 }
664 updateSettings({ favorites: newFavorites });
665 },
666 [favorites, updateSettings],
667 );
668
669660 const modelJumpCommandByKey = useMemo(() => {
670661 const mapping = new Map<
671662 string,
apps/web/src/components/chat/ProviderModelPicker.test.tsx+2
......@@ -40,6 +40,7 @@ function renderPicker(input: {
4040 const entry = providerEntry(input.instanceId, input.driver);
4141 return renderToStaticMarkup(
4242 <ProviderModelPicker
43 environmentId={null}
4344 activeInstanceId={instanceId}
4445 model={input.model}
4546 lockedProvider={null}
......@@ -161,6 +162,7 @@ describe("ProviderModelPicker", () => {
161162 const activeEntry = providerEntry("codex_personal", "codex");
162163 const markup = renderToStaticMarkup(
163164 <ProviderModelPicker
165 environmentId={null}
164166 activeInstanceId={activeEntry.instanceId}
165167 model="gpt-5"
166168 lockedProvider={null}
apps/web/src/components/chat/ProviderModelPicker.tsx+3
......@@ -1,6 +1,7 @@
11import {
22 ANTIGRAVITY_DEFAULT_MODEL,
33 type ProviderInstanceId,
4 type EnvironmentId,
45 type ProviderDriverKind,
56 type ResolvedKeybindingsConfig,
67} from "@t3tools/contracts";
......@@ -27,6 +28,7 @@ import { useComposerMenuProps } from "./composerEventScope";
2728import { shortcutLabelForCommand } from "../../keybindings";
2829
2930export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
31 environmentId: EnvironmentId | null;
3032 /**
3133 * The instance currently selected in the composer. Drives the trigger
3234 * icon, label and the default-highlighted combobox row.
......@@ -291,6 +293,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
291293 padding="none"
292294 >
293295 <ModelPickerContent
296 environmentId={props.environmentId}
294297 activeInstanceId={activeInstanceId}
295298 model={props.model}
296299 {...(props.selectedModels !== undefined ? { selectedModels: props.selectedModels } : {})}
apps/web/src/components/settings/ProjectDefaultsSettings.tsx+3-1
......@@ -8,6 +8,7 @@ import { createModelSelection } from "@t3tools/shared/model";
88import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
99import { useNavigate } from "@tanstack/react-router";
1010
11import { mergeEnvironmentSettings } from "../../hooks/useSettings";
1112import { getCustomModelOptionsByInstance } from "../../modelSelection";
1213import {
1314 applyProviderInstanceSettings,
......@@ -106,7 +107,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
106107 candidate.settings,
107108 ).find((option) => option.instanceId === instanceId);
108109 const options = getCustomModelOptionsByInstance(
109 { ...settings, ...candidate.settings },
110 mergeEnvironmentSettings(candidate.settings, settings),
110111 config.providers,
111112 ).get(instanceId);
112113 if (
......@@ -158,6 +159,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
158159 selection && activeEntry ? (
159160 <div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5">
160161 <ProviderModelPicker
162 environmentId={representative?.environmentId ?? null}
161163 activeInstanceId={selection.instanceId}
162164 model={selection.model}
163165 lockedProvider={null}
apps/web/src/components/settings/ProviderInstanceCard.test.ts+2
......@@ -118,6 +118,7 @@ describe("deriveProviderModelsForDisplay", () => {
118118 modelOrder: [],
119119 onHiddenModelsChange: () => undefined,
120120 onFavoriteModelsChange: () => undefined,
121 onToggleFavorite: () => undefined,
121122 onModelOrderChange: () => undefined,
122123 }),
123124 );
......@@ -157,6 +158,7 @@ describe("deriveProviderModelsForDisplay", () => {
157158 modelOrder: [],
158159 onHiddenModelsChange: () => undefined,
159160 onFavoriteModelsChange: () => undefined,
161 onToggleFavorite: () => undefined,
160162 onModelOrderChange: () => undefined,
161163 } as const;
162164
apps/web/src/components/settings/ProviderInstanceCard.tsx+9-3
......@@ -503,8 +503,12 @@ interface ProviderInstanceCardProps {
503503 readonly hiddenModels: ReadonlyArray<string>;
504504 readonly favoriteModels: ReadonlyArray<string>;
505505 readonly modelOrder: ReadonlyArray<string>;
506 readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void;
506 readonly onHiddenModelsChange: (
507 next: ReadonlyArray<string>,
508 affectedModels?: ReadonlyArray<string>,
509 ) => void;
507510 readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;
511 readonly onToggleFavorite: (model: string) => void;
508512 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;
509513 readonly onRunUpdate?: (() => void) | undefined;
510514 readonly onInstallRecommended?: (() => void) | undefined;
......@@ -561,6 +565,7 @@ export function ProviderInstanceCard({
561565 modelOrder,
562566 onHiddenModelsChange,
563567 onFavoriteModelsChange,
568 onToggleFavorite,
564569 onModelOrderChange,
565570 onRunUpdate,
566571 onInstallRecommended,
......@@ -1173,8 +1178,8 @@ export function ProviderInstanceCard({
11731178 <SettingsSection title="Models">
11741179 <div className="px-3 py-3 sm:px-4">
11751180 <p className="mb-3 text-xs text-muted-foreground">
1176 Favorites, visibility, and ordering are saved on this device. Custom models are saved
1177 on the selected environment.
1181 Model preferences and custom models are saved on the selected environment and shared
1182 across connected devices.
11781183 </p>
11791184 <ProviderModelsSection
11801185 canManageCustomModels={!readOnly}
......@@ -1189,6 +1194,7 @@ export function ProviderInstanceCard({
11891194 onChange={updateCustomModels}
11901195 onHiddenModelsChange={onHiddenModelsChange}
11911196 onFavoriteModelsChange={onFavoriteModelsChange}
1197 onToggleFavorite={onToggleFavorite}
11921198 onModelOrderChange={onModelOrderChange}
11931199 />
11941200 </div>
apps/web/src/components/settings/ProviderModelsSection.tsx+12-7
......@@ -145,8 +145,12 @@ interface ProviderModelsSectionProps {
145145 * `providerInstances[id].config`).
146146 */
147147 readonly onChange: (next: ReadonlyArray<CustomModelDefinition>) => void;
148 readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void;
148 readonly onHiddenModelsChange: (
149 next: ReadonlyArray<string>,
150 affectedModels?: ReadonlyArray<string>,
151 ) => void;
149152 readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;
153 readonly onToggleFavorite: (model: string) => void;
150154 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;
151155}
152156
......@@ -174,6 +178,7 @@ export function ProviderModelsSection({
174178 onChange,
175179 onHiddenModelsChange,
176180 onFavoriteModelsChange,
181 onToggleFavorite,
177182 onModelOrderChange,
178183}: ProviderModelsSectionProps) {
179184 const [input, setInput] = useState("");
......@@ -282,16 +287,13 @@ export function ProviderModelsSection({
282287 if (!canWritePreferences || hidden === hiddenModelSet.has(slug)) return;
283288 onHiddenModelsChange(
284289 hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug),
290 [slug],
285291 );
286292 };
287293
288294 const handleToggleFavorite = (slug: string) => {
289295 if (!canWritePreferences) return;
290 if (favoriteModelSet.has(slug)) {
291 onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug));
292 return;
293 }
294 onFavoriteModelsChange([...favoriteModels, slug]);
296 onToggleFavorite(slug);
295297 };
296298
297299 // Rows only trade places with a neighbour in the same group (favorites,
......@@ -533,7 +535,10 @@ export function ProviderModelsSection({
533535 size="xs"
534536 variant="ghost-muted"
535537 onClick={() =>
536 onHiddenModelsChange(nextHiddenModelsForBulkToggle(models, hiddenModels))
538 onHiddenModelsChange(
539 nextHiddenModelsForBulkToggle(models, hiddenModels),
540 builtInModels.map((model) => model.slug),
541 )
537542 }
538543 >
539544 {allBuiltInModelsHidden ? "Enable all" : "Disable all"}
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+11-6
......@@ -108,6 +108,8 @@ vi.mock("../../hooks/useSettings", () => ({
108108 settingsState.updateEnvironmentIds.push(environmentId);
109109 return settingsState.updateSettings;
110110 },
111 useUpdateEnvironmentModelPreferences: () => settingsState.updateSettings,
112 useToggleEnvironmentModelFavorite: () => () => undefined,
111113 usePersistEnvironmentProviderInstanceMutation: (environmentId: EnvironmentId) => {
112114 settingsState.mutationEnvironmentIds.push(environmentId);
113115 return settingsState.mutateProviderInstance;
......@@ -344,16 +346,19 @@ describe("EnvironmentProviderSettings routing", () => {
344346 });
345347
346348 it.each([
347 ["onFavoriteModelsChange", { favorites: [{ provider: codexId, model: "chosen" }] }],
349 [
350 "onFavoriteModelsChange",
351 { setModelFavorites: [{ provider: codexId, model: "chosen", favorite: true }] },
352 ],
348353 [
349354 "onHiddenModelsChange",
350 { providerModelPreferences: { [codexId]: { hiddenModels: ["chosen"], modelOrder: [] } } },
355 { setModelsHidden: [{ provider: codexId, model: "chosen", hidden: true }] },
351356 ],
352357 [
353358 "onModelOrderChange",
354 { providerModelPreferences: { [codexId]: { hiddenModels: [], modelOrder: ["chosen"] } } },
359 { setProviderModelOrder: { provider: codexId, modelOrder: ["chosen"] } },
355360 ],
356 ])("saves %s on this device without changing the selected server", (action, expected) => {
361 ])("saves %s on the selected environment for every connected client", (action, expected) => {
357362 atoms.providers = [provider()];
358363 const panel = renderPanel();
359364 const editor = visitElements(
......@@ -363,8 +368,8 @@ describe("EnvironmentProviderSettings routing", () => {
363368 expect(editor).not.toBeNull();
364369 if (!editor) throw new Error("Provider editor was not rendered");
365370 (editor.props[action] as (models: string[]) => void)(["chosen"]);
366 expect(settingsState.updateClientSettings).toHaveBeenCalledExactlyOnceWith(expected);
367 expect(settingsState.updateSettings).not.toHaveBeenCalled();
371 expect(settingsState.updateSettings).toHaveBeenCalledExactlyOnceWith(expected);
372 expect(settingsState.updateClientSettings).not.toHaveBeenCalled();
368373 });
369374
370375 it("does not substitute another account when the requested instance was removed", () => {
apps/web/src/components/settings/ProviderSettingsPanel.tsx+38-73
......@@ -40,8 +40,9 @@ import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
4040import {
4141 useEnvironmentSettings,
4242 usePersistEnvironmentProviderInstanceMutation,
43 useUpdateClientSettings,
4443 useUpdateEnvironmentSettings,
44 useUpdateEnvironmentModelPreferences,
45 useToggleEnvironmentModelFavorite,
4546} from "../../hooks/useSettings";
4647import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
4748import { cn } from "../../lib/utils";
......@@ -123,20 +124,12 @@ import {
123124 resolveSelectedProviderEnvironmentId,
124125} from "./ProviderSettingsPanel.logic";
125126
126function withoutProviderInstanceKey<V>(
127 record: Readonly<Record<ProviderInstanceId, V>> | undefined,
128 key: ProviderInstanceId,
129): Record<ProviderInstanceId, V> {
130 const next = { ...record } as Record<ProviderInstanceId, V>;
131 delete next[key];
132 return next;
133}
134
135function withoutProviderInstanceFavorites(
136 favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>,
137 instanceId: ProviderInstanceId,
138) {
139 return favorites.filter((favorite) => favorite.provider !== instanceId);
127function modelListChanges(current: ReadonlyArray<string>, next: ReadonlyArray<string>) {
128 const currentSet = new Set(current);
129 const nextSet = new Set(next.map((model) => model.trim()).filter(Boolean));
130 return [...new Set([...currentSet, ...nextSet])]
131 .filter((model) => currentSet.has(model) !== nextSet.has(model))
132 .map((model) => ({ model, selected: nextSet.has(model) }));
140133}
141134
142135function providerConfigString(config: unknown, key: string): string | null {
......@@ -565,8 +558,9 @@ export function EnvironmentProviderSettings({
565558 const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope);
566559 const canRefreshProviders = useEnvironmentScope(environmentId, AuthOrchestrationReadScope);
567560 const updateSettings = useUpdateEnvironmentSettings(environmentId);
561 const updateModelPreferences = useUpdateEnvironmentModelPreferences(environmentId);
562 const toggleModelFavorite = useToggleEnvironmentModelFavorite(environmentId);
568563 const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId);
569 const updateClientSettings = useUpdateClientSettings();
570564 const serverProviders =
571565 useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
572566 const projects = useProjects().filter((project) => project.environmentId === environmentId);
......@@ -873,50 +867,6 @@ export function EnvironmentProviderSettings({
873867 }
874868 };
875869
876 const updateProviderModelPreferences = (
877 instanceId: ProviderInstanceId,
878 next: {
879 readonly hiddenModels: ReadonlyArray<string>;
880 readonly modelOrder: ReadonlyArray<string>;
881 },
882 ) => {
883 const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))];
884 const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))];
885 const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId);
886 updateClientSettings({
887 providerModelPreferences:
888 hiddenModels.length === 0 && modelOrder.length === 0
889 ? rest
890 : {
891 ...rest,
892 [instanceId]: {
893 hiddenModels,
894 modelOrder,
895 },
896 },
897 });
898 };
899
900 const updateProviderFavoriteModels = (
901 instanceId: ProviderInstanceId,
902 nextFavoriteModels: ReadonlyArray<string>,
903 ) => {
904 const favoriteModels = [
905 ...new Set(
906 Arr.filterMap(nextFavoriteModels, (slug) => {
907 const trimmedSlug = slug.trim();
908 return trimmedSlug.length > 0 ? Result.succeed(trimmedSlug) : Result.failVoid;
909 }),
910 ),
911 ];
912 updateClientSettings({
913 favorites: [
914 ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId),
915 ...favoriteModels.map((model) => ({ provider: instanceId, model })),
916 ],
917 });
918 };
919
920870 const resetDefaultInstance = async (driverKind: ProviderDriverKind) => {
921871 const result = await persistProviderInstance({
922872 operation: "remove",
......@@ -1072,19 +1022,34 @@ export function EnvironmentProviderSettings({
10721022 hiddenModels={modelPreferences.hiddenModels}
10731023 favoriteModels={favoriteModels}
10741024 modelOrder={modelPreferences.modelOrder}
1075 onHiddenModelsChange={(hiddenModels) =>
1076 updateProviderModelPreferences(row.instanceId, {
1077 ...modelPreferences,
1078 hiddenModels,
1079 })
1080 }
1081 onFavoriteModelsChange={(next) => updateProviderFavoriteModels(row.instanceId, next)}
1082 onModelOrderChange={(modelOrder) =>
1083 updateProviderModelPreferences(row.instanceId, {
1084 ...modelPreferences,
1085 modelOrder,
1086 })
1087 }
1025 onHiddenModelsChange={(next, affectedModels) => {
1026 const hidden = new Set(next);
1027 const models =
1028 affectedModels ??
1029 modelListChanges(modelPreferences.hiddenModels, next).map((change) => change.model);
1030 void updateModelPreferences({
1031 setModelsHidden: models.map((model) => ({
1032 provider: row.instanceId,
1033 model,
1034 hidden: hidden.has(model),
1035 })),
1036 });
1037 }}
1038 onToggleFavorite={(model) => {
1039 void toggleModelFavorite(row.instanceId, model);
1040 }}
1041 onFavoriteModelsChange={(next) => {
1042 void updateModelPreferences({
1043 setModelFavorites: modelListChanges(favoriteModels, next).map(
1044 ({ model, selected }) => ({ provider: row.instanceId, model, favorite: selected }),
1045 ),
1046 });
1047 }}
1048 onModelOrderChange={(modelOrder) => {
1049 void updateModelPreferences({
1050 setProviderModelOrder: { provider: row.instanceId, modelOrder },
1051 });
1052 }}
10881053 onInstallRecommended={
10891054 !readOnly &&
10901055 liveProvider?.compatibilityAdvisory?.message &&
apps/web/src/components/settings/ScheduledTasksSettings.tsx+1
......@@ -1122,6 +1122,7 @@ function ScheduledTaskEditorDialog({
11221122
11231123 <Field label="Model">
11241124 <ProviderModelPicker
1125 environmentId={environmentId}
11251126 disabled={saving || !connected}
11261127 activeInstanceId={activeInstanceId}
11271128 model={activeModel}
apps/web/src/components/settings/SettingsPanels.tsx+1
......@@ -3259,6 +3259,7 @@ export function GeneralSettingsPanel() {
32593259 ) : (
32603260 <div className="flex flex-wrap items-center justify-end gap-1.5">
32613261 <ProviderModelPicker
3262 environmentId={environmentId}
32623263 activeInstanceId={textGenInstanceId}
32633264 model={textGenModel}
32643265 lockedProvider={null}
apps/web/src/components/settings/SourceControlWritingSettings.tsx+1
......@@ -301,6 +301,7 @@ export function SourceControlWritingSettingsSection() {
301301 ) : null}
302302 {usesDedicatedModel && canEnableDedicatedModel ? (
303303 <ProviderModelPicker
304 environmentId={environmentId}
304305 activeInstanceId={activeSelection.instanceId}
305306 model={activeSelection.model}
306307 lockedProvider={null}
apps/web/src/components/settings/scopedSettings.test.ts+14
......@@ -3,6 +3,7 @@ import {
33 EnvironmentAuthorizationError,
44 EnvironmentId,
55 ProjectId,
6 ProviderInstanceId,
67 type ServerSettings,
78} from "@t3tools/contracts";
89import { describe, expect, it, vi } from "vite-plus/test";
......@@ -644,3 +645,16 @@ describe("partial object patches at project scope", () => {
644645 });
645646 });
646647});
648
649it("routes legacy model preference keys exclusively to the selected environment", () => {
650 const patch = {
651 favorites: [{ provider: ProviderInstanceId.make("codex"), model: "sol" }],
652 providerModelPreferences: {},
653 };
654 const plan = planScopedSettingsPatch(named, environments, patch);
655 expect(plan.clientPatch).toEqual({});
656 expect(plan.hasClientWrite).toBe(false);
657 expect(plan.serverWrites).toEqual([
658 { environmentId: server.environmentId, label: server.label, patch },
659 ]);
660});
apps/web/src/components/settings/scopedSettings.ts+1-1
......@@ -216,7 +216,7 @@ export function planScopedSettingsPatch(
216216 patch: ScopedSettingsPatch,
217217) {
218218 const clientPatch = Object.fromEntries(
219 Object.entries(patch).filter(([key]) => CLIENT_KEYS.has(key)),
219 Object.entries(patch).filter(([key]) => CLIENT_KEYS.has(key) && !SERVER_KEYS.has(key)),
220220 ) as ClientSettingsPatch;
221221 const serverPatch = Object.fromEntries(
222222 Object.entries(patch).filter(([key]) => SERVER_KEYS.has(key)),
apps/web/src/components/settings/useScopedModelAvailability.ts+2-1
......@@ -1,6 +1,7 @@
11import type { ProviderInstanceId, UnifiedSettings } from "@t3tools/contracts";
22import { useCallback } from "react";
33
4import { mergeEnvironmentSettings } from "../../hooks/useSettings";
45import { getCustomModelOptionsByInstance } from "../../modelSelection";
56import {
67 applyProviderInstanceSettings,
......@@ -36,7 +37,7 @@ export function useScopedModelDisabledReason(
3637 candidate.settings,
3738 ).find((option) => option.instanceId === instanceId);
3839 const options = getCustomModelOptionsByInstance(
39 { ...settings, ...candidate.settings },
40 mergeEnvironmentSettings(candidate.settings, settings),
4041 config.providers,
4142 ).get(instanceId);
4243 if (
apps/web/src/components/settings/useScopedSettings.ts+2
......@@ -19,6 +19,7 @@ import {
1919 mergeEnvironmentSettings,
2020 persistClientSettingsPatch,
2121 useClientSettings,
22 useMigrateModelPreferences,
2223} from "../../hooks/useSettings";
2324import { serverEnvironment } from "../../state/server";
2425import { useAtomCommand } from "../../state/use-atom-command";
......@@ -42,6 +43,7 @@ export function useScopedSettings<T = UnifiedSettings>(
4243 const { target } = useSettingsScope();
4344 const clientSettings = useClientSettings();
4445 const serverSettings = target?.settings ?? DEFAULT_SERVER_SETTINGS;
46 useMigrateModelPreferences(target?.environmentId ?? null, target?.settings ?? null);
4547 const settings = useMemo(
4648 () => mergeEnvironmentSettings(serverSettings, clientSettings),
4749 [clientSettings, serverSettings],
apps/web/src/hooks/useSettings.favorites.test.tsx created+252
......@@ -0,0 +1,252 @@
1import { RegistryContext } from "@effect/atom-react";
2import {
3 DEFAULT_SERVER_SETTINGS,
4 EnvironmentId,
5 ProviderInstanceId,
6 type ServerSettings,
7 type ServerSettingsPatch,
8} from "@t3tools/contracts";
9import * as Cause from "effect/Cause";
10import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
11import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
12import { act } from "react";
13import { create, type ReactTestRenderer } from "react-test-renderer";
14import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
15
16const state = vi.hoisted(() => ({
17 settings: new Map<EnvironmentId, Atom.Writable<ServerSettings | null>>(),
18 persist: vi.fn(),
19}));
20
21vi.mock("~/state/server", () => ({
22 serverEnvironment: {
23 settingsValueAtom: (id: EnvironmentId) => state.settings.get(id)!,
24 updateSettings: Symbol("updateSettings"),
25 },
26 primaryServerSettingsAtom: undefined,
27}));
28vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => state.persist }));
29vi.mock("~/localApi", () => ({
30 ensureLocalApi: () => ({ persistence: { getClientSettings: async () => null } }),
31}));
32
33import { useToggleEnvironmentModelFavorite } from "./useSettings";
34
35const primaryId = EnvironmentId.make("primary");
36const remoteId = EnvironmentId.make("remote");
37const provider = ProviderInstanceId.make("codex_work");
38const model = "gpt-6.1-sol";
39const initialSettings = {
40 ...DEFAULT_SERVER_SETTINGS,
41 favorites: [],
42 providerModelPreferences: {},
43} satisfies ServerSettings;
44let registry: AtomRegistry.AtomRegistry;
45let renderer: ReactTestRenderer | undefined;
46let durable: Map<EnvironmentId, ServerSettings>;
47let writes: Array<{
48 environmentId: EnvironmentId;
49 patch: ServerSettingsPatch;
50 finish: (success?: boolean) => void;
51}>;
52
53function FavoriteEditor({ environmentId }: { environmentId: EnvironmentId }) {
54 const toggle = useToggleEnvironmentModelFavorite(environmentId);
55 return <button onClick={() => toggle(provider, model)}>Toggle favorite</button>;
56}
57
58function editor(environmentId: EnvironmentId) {
59 return (
60 <RegistryContext.Provider value={registry}>
61 <FavoriteEditor environmentId={environmentId} />
62 </RegistryContext.Provider>
63 );
64}
65
66async function mount(environmentId = primaryId) {
67 await act(() => {
68 renderer = create(editor(environmentId));
69 });
70}
71
72function click(): Promise<void> {
73 return renderer!.root.findByType("button").props.onClick();
74}
75
76beforeEach(() => {
77 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
78 registry = AtomRegistry.make();
79 state.settings.clear();
80 durable = new Map();
81 writes = [];
82 for (const id of [primaryId, remoteId]) {
83 state.settings.set(id, Atom.make<ServerSettings | null>(initialSettings));
84 durable.set(id, initialSettings);
85 }
86 state.persist.mockReset().mockImplementation(
87 ({
88 environmentId,
89 input,
90 }: {
91 environmentId: EnvironmentId;
92 input: { patch: ServerSettingsPatch };
93 }) =>
94 new Promise((resolve) => {
95 writes.push({
96 environmentId,
97 patch: input.patch,
98 finish: (success = true) => {
99 if (!success) {
100 resolve(AsyncResult.failure(Cause.fail(new Error("save failed"))));
101 return;
102 }
103 const settings = applyServerSettingsPatch(durable.get(environmentId)!, input.patch);
104 durable.set(environmentId, settings);
105 resolve(AsyncResult.success(settings));
106 },
107 });
108 }),
109 );
110});
111
112afterEach(async () => {
113 await act(() => renderer?.unmount());
114 renderer = undefined;
115 registry.dispose();
116 vi.unstubAllGlobals();
117});
118
119describe("environment favorite toggles", () => {
120 it.each([false, true])(
121 "toggles twice before any server echo when initially favorited=%s",
122 async (initiallyFavorited) => {
123 const favorites = initiallyFavorited ? [{ provider, model }] : [];
124 const settings = { ...initialSettings, favorites };
125 registry.set(state.settings.get(primaryId)!, settings);
126 durable.set(primaryId, settings);
127 await mount();
128 const first = click();
129 const second = click();
130 // Preparing the patch is asynchronous even when migration has already completed.
131 await act(async () => {});
132 expect(writes.map((write) => write.patch.setModelFavorites?.[0]?.favorite)).toEqual([
133 !initiallyFavorited,
134 initiallyFavorited,
135 ]);
136 writes[0]!.finish();
137 await first;
138 writes[1]!.finish();
139 await second;
140 expect(durable.get(primaryId)!.favorites).toEqual(favorites);
141 },
142 );
143
144 it("keeps the newest pending intent when an earlier write completes", async () => {
145 await mount();
146 const first = click();
147 const second = click();
148 await act(async () => {});
149 writes[0]!.finish();
150 await first;
151 const third = click();
152 await act(async () => {});
153 expect(writes.map((write) => write.patch.setModelFavorites?.[0]?.favorite)).toEqual([
154 true,
155 false,
156 true,
157 ]);
158 writes[1]!.finish();
159 await second;
160 writes[2]!.finish();
161 await third;
162 expect(durable.get(primaryId)!.favorites).toEqual([{ provider, model }]);
163 });
164
165 it("clears failed intent so a retry still adds the favorite", async () => {
166 await mount();
167 const failed = click();
168 await act(async () => {});
169 writes[0]!.finish(false);
170 await failed;
171 const retry = click();
172 await act(async () => {});
173 writes[1]!.finish();
174 await retry;
175 expect(durable.get(primaryId)!.favorites).toEqual([{ provider, model }]);
176 });
177
178 it("toggles the acknowledged choice when the RPC reply precedes its server echo", async () => {
179 await mount();
180 const first = click();
181 await act(async () => {});
182 writes[0]!.finish();
183 await first;
184 expect(registry.get(state.settings.get(primaryId)!)!.favorites).toEqual([]);
185 const second = click();
186 await act(async () => {});
187 writes[1]!.finish();
188 await second;
189 expect(durable.get(primaryId)!.favorites).toEqual([]);
190 });
191
192 it("uses a later remote change instead of a cached acknowledged choice", async () => {
193 await mount();
194 const first = click();
195 await act(async () => {});
196 writes[0]!.finish();
197 await first;
198 // Another client removed the favorite after this client's add was acknowledged.
199 const remoteSettings = { ...initialSettings, favorites: [] };
200 durable.set(primaryId, remoteSettings);
201 await act(() => registry.set(state.settings.get(primaryId)!, remoteSettings));
202 const second = click();
203 await act(async () => {});
204 writes[1]!.finish();
205 await second;
206 expect(durable.get(primaryId)!.favorites).toEqual([{ provider, model }]);
207 });
208
209 it("preserves an acknowledged add when a later remove fails before its echo", async () => {
210 await mount();
211 const first = click();
212 await act(async () => {});
213 writes[0]!.finish();
214 await first;
215 const failed = click();
216 await act(async () => {});
217 writes[1]!.finish(false);
218 await failed;
219 const retry = click();
220 await act(async () => {});
221 writes[2]!.finish();
222 await retry;
223 expect(durable.get(primaryId)!.favorites).toEqual([]);
224 });
225
226 it("does not reuse another environment's pending intent for the same model", async () => {
227 await mount();
228 const primary = click();
229 await act(() => renderer!.update(editor(remoteId)));
230 const remote = click();
231 await act(async () => {});
232 writes[0]!.finish();
233 await primary;
234 writes[1]!.finish();
235 await remote;
236 expect(durable.get(primaryId)!.favorites).toEqual([{ provider, model }]);
237 expect(durable.get(remoteId)!.favorites).toEqual([{ provider, model }]);
238 });
239
240 it("reads the latest server state even when the click callback predates its echo", async () => {
241 await mount();
242 const previousClick = renderer!.root.findByType("button").props.onClick as () => Promise<void>;
243 const updated = { ...initialSettings, favorites: [{ provider, model }] };
244 durable.set(primaryId, updated);
245 await act(() => registry.set(state.settings.get(primaryId)!, updated));
246 const clicked = previousClick();
247 await act(async () => {});
248 writes[0]!.finish();
249 await clicked;
250 expect(durable.get(primaryId)!.favorites).toEqual([]);
251 });
252});
apps/web/src/hooks/useSettings.test.ts+57
......@@ -23,8 +23,10 @@ import {
2323 mergeEnvironmentSettings,
2424 persistClientSettingsPatch,
2525 persistClientSettingsUpdate,
26 prepareModelPreferencesPatch,
2627 resolveEnvironmentIdentificationMode,
2728} from "./useSettings";
29import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
2830
2931beforeEach(() => {
3032 persistenceMocks.getClientSettings.mockReset().mockResolvedValue(null);
......@@ -45,6 +47,44 @@ describe("client settings hydration", () => {
4547 const onboardingCompletedAt = "2026-09-05T12:00:00.000Z";
4648 const complete = (current: ClientSettings) => ({ ...current, onboardingCompletedAt });
4749
50 it("waits for saved model preferences before applying the first server edit", async () => {
51 const provider = ProviderInstanceId.make("codex_work");
52 let finishRead!: (settings: ClientSettings) => void;
53 persistenceMocks.getClientSettings.mockReturnValueOnce(
54 new Promise((resolve) => {
55 finishRead = resolve;
56 }),
57 );
58 const patch = prepareModelPreferencesPatch(DEFAULT_SERVER_SETTINGS, {
59 setModelFavorites: [{ provider, model: "new", favorite: true }],
60 setModelsHidden: [{ provider, model: "new", hidden: true }],
61 });
62 expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS);
63 finishRead({
64 ...savedSettings,
65 providerModelPreferences: {
66 [provider]: { hiddenModels: ["saved-hidden"], modelOrder: ["saved-order"] },
67 },
68 });
69 const edited = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, await patch);
70 expect(edited.favorites).toEqual([...savedSettings.favorites, { provider, model: "new" }]);
71 expect(edited.providerModelPreferences?.[provider]).toEqual({
72 hiddenModels: ["saved-hidden", "new"],
73 modelOrder: ["saved-order"],
74 });
75 });
76
77 it("does not prepare a model preference write after a failed legacy read", async () => {
78 const failure = new Error("storage unavailable");
79 vi.spyOn(console, "error").mockImplementation(() => undefined);
80 persistenceMocks.getClientSettings.mockRejectedValueOnce(failure);
81 await expect(
82 prepareModelPreferencesPatch(DEFAULT_SERVER_SETTINGS, {
83 setProviderModelOrder: { provider: ProviderInstanceId.make("codex_work"), modelOrder: [] },
84 }),
85 ).rejects.toBe(failure);
86 });
87
4888 it("rejects completion after a failed read and preserves saved preferences on retry", async () => {
4989 const failure = new Error("storage unavailable");
5090 vi.spyOn(console, "error").mockImplementation(() => undefined);
......@@ -451,3 +491,20 @@ describe("onboarding completion persistence", () => {
451491 expect(persist).toHaveBeenLastCalledWith(completedSettings);
452492 });
453493});
494
495it("uses the environment's model preferences after migration, including empty lists", () => {
496 const client = {
497 ...DEFAULT_CLIENT_SETTINGS,
498 favorites: [{ provider: ProviderInstanceId.make("codex_work"), model: "old" }],
499 providerModelPreferences: { codex_work: { hiddenModels: ["old"], modelOrder: [] } },
500 };
501 expect(mergeEnvironmentSettings(DEFAULT_SERVER_SETTINGS, client).favorites).toEqual(
502 client.favorites,
503 );
504 const merged = mergeEnvironmentSettings(
505 { ...DEFAULT_SERVER_SETTINGS, favorites: [], providerModelPreferences: {} },
506 client,
507 );
508 expect(merged.favorites).toEqual([]);
509 expect(merged.providerModelPreferences).toEqual({});
510});
apps/web/src/hooks/useSettings.ts+170-5
......@@ -9,14 +9,15 @@
99 * access is intentionally named as such so environment-sensitive consumers
1010 * cannot silently read the wrong server's settings.
1111 */
12import { useCallback, useMemo, useSyncExternalStore } from "react";
13import { useAtomValue } from "@effect/atom-react";
12import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore, use } from "react";
13import { RegistryContext, useAtomValue } from "@effect/atom-react";
1414import {
1515 DEFAULT_SERVER_SETTINGS,
1616 AuthSettingsWriteScope,
1717 requiredScopesForServerSettingsPatch,
1818 type EnvironmentId,
1919 type ProviderInstanceMutation,
20 type ProviderInstanceId,
2021 ServerSettings,
2122 type ServerSettingsPatch,
2223 sessionGrantsScope,
......@@ -46,6 +47,10 @@ import {
4647 subscribeToThemePreview,
4748 themeAllowsSidebarArtwork,
4849} from "~/themePalette";
50import {
51 modelPreferencesMigrationPatch,
52 resolveModelPreferences,
53} from "@t3tools/shared/serverSettings";
4954import * as Struct from "effect/Struct";
5055import * as Option from "effect/Option";
5156import { AsyncResult, Atom } from "effect/reactivity";
......@@ -318,7 +323,38 @@ export function mergeEnvironmentSettings(
318323): UnifiedSettings {
319324 // Decode drops retired client keys, but older untyped persistence adapters
320325 // can still return them. Server-owned values must always win.
321 return { ...clientSettings, ...serverSettings };
326 return {
327 ...clientSettings,
328 ...serverSettings,
329 ...resolveModelPreferences(serverSettings, clientSettings),
330 };
331}
332
333const pendingModelPreferencesMigrations = new Set<EnvironmentId>();
334
335/** Seed saved client preferences once without overwriting another client's edits. */
336export function useMigrateModelPreferences(
337 environmentId: EnvironmentId | null,
338 settings: ServerSettings | null,
339) {
340 const clientSettings = useClientSettingsValue();
341 const hydrated = useClientSettingsHydrated();
342 const migrate = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
343 useEffect(() => {
344 if (
345 !environmentId ||
346 !settings ||
347 !hydrated ||
348 pendingModelPreferencesMigrations.has(environmentId)
349 )
350 return;
351 const patch = modelPreferencesMigrationPatch(settings, clientSettings);
352 if (!patch) return;
353 pendingModelPreferencesMigrations.add(environmentId);
354 void migrate({ environmentId, input: { patch } }).finally(() =>
355 pendingModelPreferencesMigrations.delete(environmentId),
356 );
357 }, [clientSettings, environmentId, hydrated, migrate, settings]);
322358}
323359
324360function useMergedSettings<T>(
......@@ -391,12 +427,135 @@ export function useLegacySidebarEnabled(): boolean {
391427 return settingsHydrated && legacySidebarEnabled;
392428}
393429
430/** Hydrate legacy preferences before a mutation can claim either server-owned list. */
431export async function prepareModelPreferencesPatch(
432 settings: ServerSettings,
433 patch: ServerSettingsPatch,
434): Promise<ServerSettingsPatch> {
435 if (settings.favorites === null || settings.providerModelPreferences === null) {
436 await ensureClientSettingsHydrated();
437 }
438 return { ...modelPreferencesMigrationPatch(settings, getClientSettings()), ...patch };
439}
440
441export function useUpdateEnvironmentModelPreferences(environmentId: EnvironmentId | null) {
442 const settings = useAtomValue(
443 environmentId ? serverEnvironment.settingsValueAtom(environmentId) : noEnvironmentSettingsAtom,
444 );
445 const save = useAtomCommand(serverEnvironment.updateSettings, "model preferences update");
446 return useCallback(
447 async (patch: ServerSettingsPatch) => {
448 if (!environmentId || !settings) return;
449 let prepared: ServerSettingsPatch;
450 try {
451 prepared = await prepareModelPreferencesPatch(settings, patch);
452 } catch {
453 toastManager.add({
454 type: "error",
455 title: "Model preferences not saved",
456 description: "Could not load saved model preferences. Try again.",
457 });
458 return;
459 }
460 return save({ environmentId, input: { patch: prepared } });
461 },
462 [environmentId, save, settings],
463 );
464}
465
466/** Send the desired state so two clients adding a favorite do not cancel each other. */
467export function useToggleEnvironmentModelFavorite(environmentId: EnvironmentId | null) {
468 const registry = use(RegistryContext);
469 const pendingFavorites = useRef(
470 new Map<string, { favorite: boolean; settings: ServerSettings; pending: boolean }>(),
471 );
472 const save = useUpdateEnvironmentModelPreferences(environmentId);
473 return useCallback(
474 async (provider: ProviderInstanceId, model: string) => {
475 if (!environmentId) return;
476 const settingsAtom = serverEnvironment.settingsValueAtom(environmentId);
477 const settings = registry.get(settingsAtom);
478 if (!settings) return;
479 if (settings.favorites === null) {
480 try {
481 await ensureClientSettingsHydrated();
482 } catch {
483 toastManager.add({
484 type: "error",
485 title: "Favorites not saved",
486 description: "Could not load saved model preferences. Try again.",
487 });
488 return;
489 }
490 }
491 const currentSettings = registry.get(settingsAtom) ?? settings;
492 const favorites = resolveModelPreferences(currentSettings, getClientSettings()).favorites;
493 const key = JSON.stringify([environmentId, provider, model]);
494 const previous = pendingFavorites.current.get(key);
495 // Keep the acknowledged choice until the config stream catches up with the RPC reply.
496 const previousFavorite =
497 previous && (previous.pending || previous.settings.favorites === currentSettings.favorites)
498 ? previous.favorite
499 : favorites.some(
500 (favorite) => favorite.provider === provider && favorite.model === model,
501 );
502 const pending = {
503 favorite: !previousFavorite,
504 settings: currentSettings,
505 pending: true,
506 };
507 pendingFavorites.current.set(key, pending);
508 try {
509 const result = await save({
510 setModelFavorites: [{ provider, model, favorite: pending.favorite }],
511 });
512 if (
513 result &&
514 AsyncResult.isSuccess(result) &&
515 registry.get(settingsAtom)?.favorites === currentSettings.favorites
516 ) {
517 pending.pending = false;
518 }
519 } finally {
520 if (pending.pending && pendingFavorites.current.get(key) === pending) {
521 if (
522 previous &&
523 !previous.pending &&
524 registry.get(settingsAtom)?.favorites === previous.settings.favorites
525 ) {
526 pendingFavorites.current.set(key, previous);
527 } else {
528 pendingFavorites.current.delete(key);
529 }
530 }
531 }
532 },
533 [environmentId, registry, save],
534 );
535}
536
537const noEnvironmentSettingsAtom = Atom.make<ServerSettings | null>(null);
538
539/** Model pickers may render before a settings target has been selected. */
540export function useEnvironmentModelPreferences(environmentId: EnvironmentId | null) {
541 const settings = useAtomValue(
542 environmentId ? serverEnvironment.settingsValueAtom(environmentId) : noEnvironmentSettingsAtom,
543 );
544 const legacy = useClientSettingsValue();
545 useMigrateModelPreferences(environmentId, settings);
546 return useMemo(
547 () => resolveModelPreferences(settings ?? DEFAULT_SERVER_SETTINGS, legacy),
548 [legacy, settings],
549 );
550}
551
394552/** Read current settings for one environment, merged with client-local preferences. */
395553export function useEnvironmentSettings<T = UnifiedSettings>(
396554 environmentId: EnvironmentId,
397555 selector?: (settings: UnifiedSettings) => T,
398556): T {
399557 const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId));
558 useMigrateModelPreferences(environmentId, serverSettings);
400559 return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector);
401560}
402561
......@@ -419,7 +578,13 @@ export function usePersistEnvironmentProviderInstanceMutation(environmentId: Env
419578export function usePrimarySettings<T = UnifiedSettings>(
420579 selector?: (settings: UnifiedSettings) => T,
421580): T {
422 return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector);
581 const environment = usePrimaryEnvironment();
582 const settings = useAtomValue(primaryServerSettingsAtom);
583 useMigrateModelPreferences(
584 environment?.environmentId ?? null,
585 environment?.serverConfig?.settings ?? null,
586 );
587 return useMergedSettings(settings, selector);
423588}
424589
425590export const PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE =
......@@ -618,7 +783,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
618783 return updateSettings;
619784}
620785
621export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) {
786export function useUpdateEnvironmentSettings(environmentId: EnvironmentId | null) {
622787 return useUpdateSettingsTarget(environmentId);
623788}
624789
packages/contracts/src/settings.test.ts+31
......@@ -1080,3 +1080,34 @@ describe("requiredScopesForProjectMutation", () => {
10801080 ).toEqual(["orchestration:operate", "settings:write"]);
10811081 });
10821082});
1083
1084describe("server model preferences", () => {
1085 it("decodes a visibility update scoped to a provider instance and model", () => {
1086 const patch = { setModelsHidden: [{ provider: "codex_work", model: "sol", hidden: false }] };
1087 expect(decodeServerSettingsPatch(patch)).toEqual(patch);
1088 expect(() =>
1089 decodeServerSettingsPatch({ setModelsHidden: [{ provider: "codex_work", model: "sol" }] }),
1090 ).toThrow();
1091 });
1092 it("distinguishes unmigrated lists from deliberately cleared lists", () => {
1093 expect(decodeServerSettings({})).toMatchObject({
1094 favorites: null,
1095 providerModelPreferences: null,
1096 });
1097 const input = { favorites: [], providerModelPreferences: {} };
1098 expect(encodeServerSettings(decodeServerSettings(input))).toMatchObject(input);
1099 expect(decodeServerSettingsPatch(input)).toEqual(input);
1100 });
1101
1102 it("round trips favorites and hidden models keyed by provider instance", () => {
1103 const input = {
1104 favorites: [{ provider: "codex_work", model: "sol" }],
1105 providerModelPreferences: { codex_work: { hiddenModels: ["astra"], modelOrder: ["sol"] } },
1106 };
1107 expect(encodeServerSettings(decodeServerSettings(input))).toMatchObject(input);
1108 expect(decodeServerSettingsPatch(input)).toEqual(input);
1109 expect(decodeServerSettingsPatch({ migrateModelPreferences: input })).toEqual({
1110 migrateModelPreferences: input,
1111 });
1112 });
1113});
packages/contracts/src/settings.ts+51-47
......@@ -299,6 +299,25 @@ export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]);
299299export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]);
300300export type ChatWidth = typeof ChatWidth.Type;
301301
302/** Preferences shared by clients connected to the same environment. */
303export const ModelFavorite = Schema.Struct({
304 // The historical field name is retained; default instance ids also decode old provider-kind favorites.
305 provider: ProviderInstanceId,
306 model: TrimmedNonEmptyString,
307});
308export const ModelFavorites = Schema.Array(ModelFavorite);
309export const ProviderModelPreferences = Schema.Record(
310 ProviderInstanceId,
311 Schema.Struct({
312 hiddenModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
313 modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
314 }),
315);
316export type ModelPreferences = {
317 readonly favorites: typeof ModelFavorites.Type;
318 readonly providerModelPreferences: typeof ProviderModelPreferences.Type;
319};
320
302321export const ClientSettingsSchema = Schema.Struct({
303322 notificationMode: NotificationMode.pipe(
304323 Schema.withDecodingDefault(Effect.succeed("off" as const)),
......@@ -413,31 +432,11 @@ export const ClientSettingsSchema = Schema.Struct({
413432 persistComposerContextStrip: Schema.Boolean.pipe(
414433 Schema.withDecodingDefault(Effect.succeed(false)),
415434 ),
416 // Model favorites. Historically keyed by provider kind, now
417 // widened to `ProviderInstanceId` so users can favorite a specific model
418 // on a custom provider instance (e.g. "Codex Personal · gpt-5") without
419 // the UI collapsing it into the same bucket as the default Codex. The
420 // widening is backward-compatible by construction: prior provider-kind
421 // strings satisfy the `ProviderInstanceId` slug schema, so previously
422 // persisted favorites decode unchanged and continue to point at the
423 // default instance for their kind (because `defaultInstanceIdForDriver(kind)`
424 // uses the same slug). The field name is kept as `provider` for storage
425 // stability; new call sites should treat the value as an instance id.
426 favorites: Schema.Array(
427 Schema.Struct({
428 provider: ProviderInstanceId,
429 model: TrimmedNonEmptyString,
430 }),
431 ).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
432 providerModelPreferences: Schema.Record(
433 ProviderInstanceId,
434 Schema.Struct({
435 hiddenModels: Schema.Array(Schema.String).pipe(
436 Schema.withDecodingDefault(Effect.succeed([])),
437 ),
438 modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
439 }),
440 ).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
435 // Legacy client values retained for migration to the connected environment.
436 favorites: ModelFavorites.pipe(Schema.withDecodingDefault(Effect.succeed([]))),
437 providerModelPreferences: ProviderModelPreferences.pipe(
438 Schema.withDecodingDefault(Effect.succeed({})),
439 ),
441440 pullRequestMergeMethodOverrides: Schema.Record(
442441 TrimmedNonEmptyString,
443442 PullRequestMergeMethod,
......@@ -1060,6 +1059,12 @@ export const StorageCleanupSettings = Schema.Struct({
10601059export type StorageCleanupSettings = typeof StorageCleanupSettings.Type;
10611060
10621061export const ServerSettings = Schema.Struct({
1062 // Null means no client has migrated this preference yet; an empty value is
1063 // authoritative so another device cannot resurrect cleared preferences.
1064 favorites: Schema.NullOr(ModelFavorites).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
1065 providerModelPreferences: Schema.NullOr(ProviderModelPreferences).pipe(
1066 Schema.withDecodingDefault(Effect.succeed(null)),
1067 ),
10631068 worktreeCleanup: WorktreeCleanup.pipe(Schema.withDecodingDefault(Effect.succeed(null))),
10641069 storageCleanup: StorageCleanupSettings.pipe(
10651070 Schema.withDecodingDefault(Effect.succeed(Schema.decodeSync(StorageCleanupSettings)({}))),
......@@ -1384,7 +1389,7 @@ export class ServerSettingsError extends Schema.TaggedError<ServerSettingsError>
13841389
13851390// ── Unified type ─────────────────────────────────────────────────────
13861391
1387export type UnifiedSettings = ServerSettings & ClientSettings;
1392export type UnifiedSettings = Omit<ServerSettings, keyof ModelPreferences> & ClientSettings;
13881393export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = {
13891394 ...DEFAULT_SERVER_SETTINGS,
13901395 ...DEFAULT_CLIENT_SETTINGS,
......@@ -1399,6 +1404,24 @@ const ModelSelectionPatch = Schema.Struct({
13991404});
14001405
14011406export const ServerSettingsPatch = Schema.Struct({
1407 setModelFavorites: Schema.optionalKey(
1408 Schema.Array(Schema.Struct({ ...ModelFavorite.fields, favorite: Schema.Boolean })),
1409 ),
1410 setModelsHidden: Schema.optionalKey(
1411 Schema.Array(Schema.Struct({ ...ModelFavorite.fields, hidden: Schema.Boolean })),
1412 ),
1413 setProviderModelOrder: Schema.optionalKey(
1414 Schema.Struct({ provider: ProviderInstanceId, modelOrder: Schema.Array(TrimmedString) }),
1415 ),
1416 favorites: Schema.optionalKey(ModelFavorites),
1417 providerModelPreferences: Schema.optionalKey(ProviderModelPreferences),
1418 // Applied under the settings service's write lock, only while still unset.
1419 migrateModelPreferences: Schema.optionalKey(
1420 Schema.Struct({
1421 favorites: Schema.optionalKey(ModelFavorites),
1422 providerModelPreferences: Schema.optionalKey(ProviderModelPreferences),
1423 }),
1424 ),
14021425 worktreeCleanup: Schema.optionalKey(
14031426 Schema.NullOr(
14041427 Schema.Union([
......@@ -1606,27 +1629,8 @@ export const ClientSettingsPatch = Schema.Struct({
16061629 fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference),
16071630 fontSmoothing: Schema.optionalKey(Schema.Boolean),
16081631 persistComposerContextStrip: Schema.optionalKey(Schema.Boolean),
1609 favorites: Schema.optionalKey(
1610 Schema.Array(
1611 Schema.Struct({
1612 provider: ProviderInstanceId,
1613 model: TrimmedNonEmptyString,
1614 }),
1615 ),
1616 ),
1617 providerModelPreferences: Schema.optionalKey(
1618 Schema.Record(
1619 ProviderInstanceId,
1620 Schema.Struct({
1621 hiddenModels: Schema.Array(Schema.String).pipe(
1622 Schema.withDecodingDefault(Effect.succeed([])),
1623 ),
1624 modelOrder: Schema.Array(Schema.String).pipe(
1625 Schema.withDecodingDefault(Effect.succeed([])),
1626 ),
1627 }),
1628 ),
1629 ),
1632 favorites: Schema.optionalKey(ModelFavorites),
1633 providerModelPreferences: Schema.optionalKey(ProviderModelPreferences),
16301634 pullRequestMergeMethodOverrides: Schema.optionalKey(
16311635 Schema.Record(TrimmedNonEmptyString, PullRequestMergeMethod),
16321636 ),
packages/shared/src/serverSettings.test.ts+174
......@@ -13,6 +13,8 @@ import { createModelSelection } from "./model.ts";
1313import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts";
1414import {
1515 applyServerSettingsPatch,
16 modelPreferencesMigrationPatch,
17 resolveModelPreferences,
1618 isModelSelectionProviderEnabled,
1719 parsePersistedServerObservabilitySettings,
1820 resolveSourceControlWriterModelSelection,
......@@ -823,3 +825,175 @@ describe("worktreesDirectory", () => {
823825 expect(back.previousWorktreesDirectories).toEqual(["/b"]);
824826 });
825827});
828
829describe("synchronized model preferences", () => {
830 const provider = ProviderInstanceId.make("codex_work");
831 const legacy = {
832 favorites: [{ provider, model: "sol" }],
833 providerModelPreferences: { [provider]: { hiddenModels: ["astra"], modelOrder: ["sol"] } },
834 };
835
836 it("keeps concurrent adds and removes of the same favorite idempotent", () => {
837 const add = { setModelFavorites: [{ provider, model: "sol", favorite: true }] };
838 const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, add);
839 expect(applyServerSettingsPatch(first, add).favorites).toEqual([{ provider, model: "sol" }]);
840 const remove = { setModelFavorites: [{ provider, model: "sol", favorite: false }] };
841 const removed = applyServerSettingsPatch(first, remove);
842 expect(applyServerSettingsPatch(removed, remove).favorites).toEqual([]);
843 });
844
845 it("preserves unrelated models and providers during bulk visibility and order edits", () => {
846 const otherProvider = ProviderInstanceId.make("claude_work");
847 const initial = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
848 providerModelPreferences: {
849 ...legacy.providerModelPreferences,
850 [otherProvider]: { hiddenModels: ["opus"], modelOrder: ["sonnet", "opus"] },
851 },
852 });
853 const mobile = applyServerSettingsPatch(initial, {
854 setModelsHidden: [{ provider, model: "luna", hidden: true }],
855 });
856 const web = applyServerSettingsPatch(mobile, {
857 setModelsHidden: [
858 { provider, model: "astra", hidden: false },
859 { provider, model: "sol", hidden: true },
860 ],
861 setProviderModelOrder: { provider, modelOrder: ["sol", "luna", "astra"] },
862 });
863 expect(web.providerModelPreferences?.[provider]).toEqual({
864 hiddenModels: ["luna", "sol"],
865 modelOrder: ["sol", "luna", "astra"],
866 });
867 expect(web.providerModelPreferences?.[otherProvider]).toEqual(
868 initial.providerModelPreferences?.[otherProvider],
869 );
870 expect(
871 applyServerSettingsPatch(web, {
872 setProviderModelOrder: { provider, modelOrder: [] },
873 }).providerModelPreferences?.[provider],
874 ).toEqual({ hiddenModels: ["luna", "sol"], modelOrder: [] });
875 });
876
877 it("migrates saved lists independently without claiming empty legacy lists", () => {
878 expect(modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, {})).toBeNull();
879 const patch = modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy)!;
880 const migrated = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, patch);
881 expect(resolveModelPreferences(migrated)).toEqual(legacy);
882 expect(modelPreferencesMigrationPatch(migrated, legacy)).toBeNull();
883 const favoritesOnly = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { favorites: [] });
884 expect(modelPreferencesMigrationPatch(favoritesOnly, legacy)).toEqual({
885 migrateModelPreferences: { providerModelPreferences: legacy.providerModelPreferences },
886 });
887 });
888
889 it("keeps explicit clears when a delayed migration arrives from another device", () => {
890 const staleMigration = modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy)!;
891 const cleared = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
892 favorites: [],
893 providerModelPreferences: {},
894 });
895 expect(
896 resolveModelPreferences(applyServerSettingsPatch(cleared, staleMigration), legacy),
897 ).toEqual({
898 favorites: [],
899 providerModelPreferences: {},
900 });
901 });
902
903 it("replaces visibility records so showing every model clears removed entries", () => {
904 const migrated = applyServerSettingsPatch(
905 DEFAULT_SERVER_SETTINGS,
906 modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy)!,
907 );
908 const cleared = applyServerSettingsPatch(migrated, { providerModelPreferences: {} });
909 expect(cleared.providerModelPreferences).toEqual({});
910 expect(cleared.favorites).toEqual(legacy.favorites);
911 });
912
913 it("sets favorites without losing changes from other clients", () => {
914 const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
915 setModelFavorites: [{ provider, model: "sol", favorite: true }],
916 });
917 const second = applyServerSettingsPatch(first, {
918 setModelFavorites: [{ provider, model: "astra", favorite: true }],
919 migrateModelPreferences: { favorites: [] },
920 });
921 expect(second.favorites).toEqual([
922 { provider, model: "sol" },
923 { provider, model: "astra" },
924 ]);
925 const removed = applyServerSettingsPatch(second, {
926 setModelFavorites: [{ provider, model: "sol", favorite: false }],
927 });
928 expect(removed.favorites).toEqual([{ provider, model: "astra" }]);
929 expect(
930 applyServerSettingsPatch(removed, {
931 setModelFavorites: [{ provider, model: "astra", favorite: false }],
932 }).favorites,
933 ).toEqual([]);
934 });
935
936 it("sets visibility against the latest state without replacing other models or their order", () => {
937 const initial = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
938 providerModelPreferences: legacy.providerModelPreferences,
939 });
940 const first = applyServerSettingsPatch(initial, {
941 setModelsHidden: [{ provider, model: "sol", hidden: true }],
942 });
943 const second = applyServerSettingsPatch(first, {
944 setModelsHidden: [{ provider, model: "luna", hidden: true }],
945 });
946 expect(second.providerModelPreferences?.[provider]).toEqual({
947 hiddenModels: ["astra", "sol", "luna"],
948 modelOrder: ["sol"],
949 });
950 const restored = applyServerSettingsPatch(second, {
951 setModelsHidden: [{ provider, model: "sol", hidden: false }],
952 });
953 expect(restored.providerModelPreferences?.[provider]).toEqual({
954 hiddenModels: ["astra", "luna"],
955 modelOrder: ["sol"],
956 });
957 expect(
958 applyServerSettingsPatch(restored, {
959 setModelsHidden: [{ provider, model: "sol", hidden: false }],
960 }).providerModelPreferences,
961 ).toEqual(restored.providerModelPreferences);
962 expect(
963 applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
964 setModelsHidden: [{ provider, model: "sol", hidden: true }],
965 }).providerModelPreferences?.[provider]?.hiddenModels,
966 ).toEqual(["sol"]);
967 });
968
969 it("keeps the same model on different provider instances as separate favorites", () => {
970 const otherProvider = ProviderInstanceId.make("codex_personal");
971 const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
972 setModelFavorites: [{ provider, model: "sol", favorite: true }],
973 });
974 const second = applyServerSettingsPatch(first, {
975 setModelFavorites: [{ provider: otherProvider, model: "sol", favorite: true }],
976 });
977 expect(second.favorites).toEqual([
978 { provider, model: "sol" },
979 { provider: otherProvider, model: "sol" },
980 ]);
981 expect(
982 applyServerSettingsPatch(second, {
983 setModelFavorites: [{ provider, model: "sol", favorite: false }],
984 }).favorites,
985 ).toEqual([{ provider: otherProvider, model: "sol" }]);
986 });
987
988 it("keeps explicit edits when the same patch also carries a legacy migration", () => {
989 const edited = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
990 ...modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy),
991 favorites: [],
992 providerModelPreferences: {},
993 });
994 expect(resolveModelPreferences(edited, legacy)).toEqual({
995 favorites: [],
996 providerModelPreferences: {},
997 });
998 });
999});
packages/shared/src/serverSettings.ts+90
......@@ -1,3 +1,4 @@
1import type { ModelPreferences } from "@t3tools/contracts/settings";
12import {
23 isProviderAvailable,
34 isUnconfiguredDefaultInstanceEnabled,
......@@ -260,6 +261,11 @@ export function applyServerSettingsPatch(
260261 const patch = translateLegacyProjectOverridePatch(current, rawPatch);
261262 const selectionPatch = patch.textGenerationModelSelection;
262263 const {
264 migrateModelPreferences,
265 setModelFavorites,
266 setModelsHidden,
267 setProviderModelOrder,
268 providerModelPreferences,
263269 automaticGitFetchInterval,
264270 providerHealthRefreshInterval,
265271 backgroundActivityProfile,
......@@ -314,9 +320,57 @@ export function applyServerSettingsPatch(
314320 },
315321 }
316322 : undefined;
323 const initialFavorites =
324 patch.favorites ?? current.favorites ?? migrateModelPreferences?.favorites ?? null;
325 const favorites =
326 setModelFavorites?.reduce((current, change) => {
327 const exists = current.some(
328 (favorite) => favorite.provider === change.provider && favorite.model === change.model,
329 );
330 return change.favorite
331 ? exists
332 ? current
333 : [...current, { provider: change.provider, model: change.model }]
334 : current.filter(
335 (favorite) => favorite.provider !== change.provider || favorite.model !== change.model,
336 );
337 }, initialFavorites ?? []) ?? initialFavorites;
338 const initialModelPreferences =
339 providerModelPreferences ??
340 current.providerModelPreferences ??
341 migrateModelPreferences?.providerModelPreferences ??
342 null;
343 let nextModelPreferences = initialModelPreferences;
344 for (const change of setModelsHidden ?? []) {
345 const preferences = nextModelPreferences?.[change.provider] ?? {
346 hiddenModels: [],
347 modelOrder: [],
348 };
349 const hiddenModels = preferences.hiddenModels.filter((model) => model !== change.model);
350 if (change.hidden) hiddenModels.push(change.model);
351 nextModelPreferences = {
352 ...nextModelPreferences,
353 [change.provider]: { ...preferences, hiddenModels },
354 };
355 }
356 if (setProviderModelOrder) {
357 const preferences = nextModelPreferences?.[setProviderModelOrder.provider] ?? {
358 hiddenModels: [],
359 modelOrder: [],
360 };
361 nextModelPreferences = {
362 ...nextModelPreferences,
363 [setProviderModelOrder.provider]: {
364 ...preferences,
365 modelOrder: [...setProviderModelOrder.modelOrder],
366 },
367 };
368 }
317369 const next = deepMerge(current, patchForMerge);
318370 const nextWithReplacementsBase = {
319371 ...next,
372 favorites,
373 providerModelPreferences: nextModelPreferences,
320374 ...(worktreeCleanupPatch === undefined
321375 ? {}
322376 : {
......@@ -459,3 +513,39 @@ export function applyServerSettingsPatch(
459513 textGenerationModelSelection: createModelSelection(instanceId, model, options),
460514 };
461515}
516
517/** Resolve legacy local preferences only until the environment has a saved value. */
518export function resolveModelPreferences(
519 settings: Pick<ServerSettings, "favorites" | "providerModelPreferences">,
520 legacy: Partial<ModelPreferences> = {},
521): ModelPreferences {
522 return {
523 favorites: settings.favorites ?? legacy.favorites ?? [],
524 providerModelPreferences:
525 settings.providerModelPreferences ?? legacy.providerModelPreferences ?? {},
526 };
527}
528
529/** Empty legacy values do not claim ownership before another client can migrate its saved list. */
530export function modelPreferencesMigrationPatch(
531 settings: Pick<ServerSettings, "favorites" | "providerModelPreferences">,
532 legacy: Partial<ModelPreferences>,
533): ServerSettingsPatch | null {
534 const favorites =
535 settings.favorites === null && (legacy.favorites?.length ?? 0) > 0
536 ? legacy.favorites
537 : undefined;
538 const providerModelPreferences =
539 settings.providerModelPreferences === null &&
540 Object.keys(legacy.providerModelPreferences ?? {}).length > 0
541 ? legacy.providerModelPreferences
542 : undefined;
543 return favorites === undefined && providerModelPreferences === undefined
544 ? null
545 : {
546 migrateModelPreferences: {
547 ...(favorites === undefined ? {} : { favorites }),
548 ...(providerModelPreferences === undefined ? {} : { providerModelPreferences }),
549 },
550 };
551}