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";...@@ -53,6 +53,7 @@ import IconDotsVertical from "@tabler/icons-react-native/IconDotsVertical";
53import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal";53import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal";
54import IconEdit from "@tabler/icons-react-native/IconEdit";54import IconEdit from "@tabler/icons-react-native/IconEdit";
55import IconExternalLink from "@tabler/icons-react-native/IconExternalLink";55import IconExternalLink from "@tabler/icons-react-native/IconExternalLink";
56import IconEyeOff from "@tabler/icons-react-native/IconEyeOff";
56import IconEye from "@tabler/icons-react-native/IconEye";57import IconEye from "@tabler/icons-react-native/IconEye";
57import IconFileText from "@tabler/icons-react-native/IconFileText";58import IconFileText from "@tabler/icons-react-native/IconFileText";
58import IconFilter from "@tabler/icons-react-native/IconFilter";59import IconFilter from "@tabler/icons-react-native/IconFilter";
...@@ -177,6 +178,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {...@@ -177,6 +178,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
177 "exclamationmark.triangle": IconAlertTriangle,178 "exclamationmark.triangle": IconAlertTriangle,
178 "exclamationmark.circle": IconAlertCircle,179 "exclamationmark.circle": IconAlertCircle,
179 eye: IconEye,180 eye: IconEye,
181 "eye.slash": IconEyeOff,
180 folder: IconFolder,182 folder: IconFolder,
181 "folder.badge.plus": IconFolderPlus,183 "folder.badge.plus": IconFolderPlus,
182 "folder.fill": IconFolder,184 "folder.fill": IconFolder,
apps/mobile/src/features/threads/ThreadComposer.tsx+2
...@@ -675,6 +675,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -675,6 +675,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
675 ownerId: settingsOwnerId,675 ownerId: settingsOwnerId,
676 environmentId: props.environmentId,676 environmentId: props.environmentId,
677 providerInstanceId: currentModelSelection.instanceId,677 providerInstanceId: currentModelSelection.instanceId,
678 ...(lockedProviderInstanceId ? { lockedProviderInstanceId } : {}),
678 providerGroups: threadProviderGroups,679 providerGroups: threadProviderGroups,
679 selectedModel: currentModelSelection,680 selectedModel: currentModelSelection,
680 reportedModelSelection: props.reportedModelSelection,681 reportedModelSelection: props.reportedModelSelection,
...@@ -700,6 +701,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -700,6 +701,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
700 props.onUpdateRuntimeMode,701 props.onUpdateRuntimeMode,
701 providerOptionDescriptors,702 providerOptionDescriptors,
702 settingsOwnerId,703 settingsOwnerId,
704 lockedProviderInstanceId,
703 threadProviderGroups,705 threadProviderGroups,
704 ],706 ],
705 );707 );
apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx+24-2
...@@ -12,6 +12,8 @@ export type ModelRowProps = {...@@ -12,6 +12,8 @@ export type ModelRowProps = {
12 readonly isFavorite: boolean;12 readonly isFavorite: boolean;
13 readonly favoritesLoaded: boolean;13 readonly favoritesLoaded: boolean;
14 readonly onToggleFavorite: () => void;14 readonly onToggleFavorite: () => void;
15 readonly onToggleHidden?: () => void;
16 readonly hiddenUpdatePending?: boolean;
15 readonly isFirst: boolean;17 readonly isFirst: boolean;
16 readonly isLast: boolean;18 readonly isLast: boolean;
17};19};
...@@ -52,10 +54,10 @@ export function ModelRowContent(...@@ -52,10 +54,10 @@ export function ModelRowContent(
52 accessibilityRole="radio"54 accessibilityRole="radio"
53 accessibilityState={{55 accessibilityState={{
54 checked: props.selected,56 checked: props.selected,
55 disabled: props.option.isUnavailable === true,57 disabled: props.option.isUnavailable === true || props.option.isHidden === true,
56 }}58 }}
57 className="min-h-11 min-w-0 flex-1 flex-row items-center gap-2 active:opacity-70"59 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}
59 onPress={props.onPress}61 onPress={props.onPress}
60 >62 >
61 {props.leadingSelection}63 {props.leadingSelection}
...@@ -77,6 +79,9 @@ export function ModelRowContent(...@@ -77,6 +79,9 @@ export function ModelRowContent(
77 <Text className="text-3xs font-t3-bold text-foreground-muted">Legacy</Text>79 <Text className="text-3xs font-t3-bold text-foreground-muted">Legacy</Text>
78 </View>80 </View>
79 ) : null}81 ) : null}
82 {props.option.isHidden ? (
83 <Text className="text-xs text-foreground-muted">Hidden</Text>
84 ) : null}
80 {props.option.isUnavailable ? (85 {props.option.isUnavailable ? (
81 <Text className="text-xs text-foreground">Unavailable</Text>86 <Text className="text-xs text-foreground">Unavailable</Text>
82 ) : null}87 ) : null}
...@@ -92,6 +97,23 @@ export function ModelRowContent(...@@ -92,6 +97,23 @@ export function ModelRowContent(
92 </View>97 </View>
93 {props.trailingSelection}98 {props.trailingSelection}
94 </Pressable>99 </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}
95 <Pressable117 <Pressable
96 accessibilityLabel={`${props.isFavorite ? "Remove from" : "Add to"} favorites: ${118 accessibilityLabel={`${props.isFavorite ? "Remove from" : "Add to"} favorites: ${
97 props.option.providerLabel119 props.option.providerLabel
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+188-34
...@@ -1,4 +1,9 @@...@@ -1,4 +1,9 @@
1import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator";1import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator";
2import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
3import {
4 modelPreferencesMigrationPatch,
5 resolveModelPreferences,
6} from "@t3tools/shared/serverSettings";
2import type {7import type {
3 EnvironmentId,8 EnvironmentId,
4 ModelSelection,9 ModelSelection,
...@@ -6,9 +11,10 @@ import type {...@@ -6,9 +11,10 @@ import type {
6 ProviderOptionDescriptor,11 ProviderOptionDescriptor,
7 ProviderOptionSelection,12 ProviderOptionSelection,
8 RuntimeMode,13 RuntimeMode,
14 ServerSettings,
9} from "@t3tools/contracts";15} from "@t3tools/contracts";
10import type { LegendListRenderItemProps } from "@legendapp/list/react-native";16import type { LegendListRenderItemProps } from "@legendapp/list/react-native";
11import { useAtomSet, useAtomValue } from "@effect/atom-react";17import { RegistryContext, useAtomValue } from "@effect/atom-react";
12import { AnimatedLegendList } from "@legendapp/list/reanimated";18import { AnimatedLegendList } from "@legendapp/list/reanimated";
13import {19import {
14 getProviderOptionCurrentLabel,20 getProviderOptionCurrentLabel,
...@@ -25,6 +31,7 @@ import {...@@ -25,6 +31,7 @@ import {
25 useCallback,31 useCallback,
26 useEffect,32 useEffect,
27 useMemo,33 useMemo,
34 useRef,
28 useState,35 useState,
29 type ReactNode,36 type ReactNode,
30} from "react";37} from "react";
...@@ -49,7 +56,12 @@ import { MaterialIconButton } from "../../components/MaterialIconButton";...@@ -49,7 +56,12 @@ import { MaterialIconButton } from "../../components/MaterialIconButton";
49import { ProviderIcon } from "../../components/ProviderIcon";56import { ProviderIcon } from "../../components/ProviderIcon";
50import { ThemedSwitch } from "../../components/ThemedSwitch";57import { ThemedSwitch } from "../../components/ThemedSwitch";
51import { cn } from "../../lib/cn";58import { 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";
53import { applyProviderOptionSelection } from "../../lib/providerOptions";65import { applyProviderOptionSelection } from "../../lib/providerOptions";
54import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";66import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
55import { useUniwindTheme } from "../../lib/useUniwindTheme";67import { useUniwindTheme } from "../../lib/useUniwindTheme";
...@@ -62,7 +74,7 @@ import {...@@ -62,7 +74,7 @@ import {
62import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";74import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
63import { ChatGptSharingStatus } from "./ChatGptSharingStatus";75import { ChatGptSharingStatus } from "./ChatGptSharingStatus";
64import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server";76import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server";
65import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";77import { mobilePreferencesAtom } from "../../state/preferences";
66import { useAtomCommand } from "../../state/use-atom-command";78import { useAtomCommand } from "../../state/use-atom-command";
67import { useNewTaskFlow } from "./new-task-flow-provider";79import { useNewTaskFlow } from "./new-task-flow-provider";
68import { MaterialScreenContent } from "../../components/MaterialScreenContent";80import { MaterialScreenContent } from "../../components/MaterialScreenContent";
...@@ -88,7 +100,6 @@ import {...@@ -88,7 +100,6 @@ import {
88 modelMatchesCatalogQuery,100 modelMatchesCatalogQuery,
89 pendingModelAfterPress,101 pendingModelAfterPress,
90 providerSectionIsCollapsed,102 providerSectionIsCollapsed,
91 toggleModelFavorite,
92} from "./thread-settings-sheet-state";103} from "./thread-settings-sheet-state";
93import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels";104import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels";
94105
...@@ -242,6 +253,7 @@ type ThreadSettingsSubmenuPage =...@@ -242,6 +253,7 @@ type ThreadSettingsSubmenuPage =
242type ThreadSettingsSessionProps = {253type ThreadSettingsSessionProps = {
243 readonly environmentId: EnvironmentId | null;254 readonly environmentId: EnvironmentId | null;
244 readonly providerInstanceId?: ProviderInstanceId;255 readonly providerInstanceId?: ProviderInstanceId;
256 readonly lockedProviderInstanceId?: ProviderInstanceId;
245 readonly providerGroups: ReadonlyArray<ProviderGroup>;257 readonly providerGroups: ReadonlyArray<ProviderGroup>;
246 readonly selectedModel: ModelSelection | null;258 readonly selectedModel: ModelSelection | null;
247 readonly reportedModelSelection?: ModelSelection | null;259 readonly reportedModelSelection?: ModelSelection | null;
...@@ -300,6 +312,10 @@ type ThreadSettingsSessionValue = {...@@ -300,6 +312,10 @@ type ThreadSettingsSessionValue = {
300 readonly favoriteKeys: ReadonlySet<string>;312 readonly favoriteKeys: ReadonlySet<string>;
301 readonly favoritesLoaded: boolean;313 readonly favoritesLoaded: boolean;
302 readonly toggleFavorite: (option: ModelOption) => void;314 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;
303 readonly runtimeMode: RuntimeMode;319 readonly runtimeMode: RuntimeMode;
304 readonly runtimeModeChoices: ReturnType<typeof runtimeModeChoicesForSupportedModes>;320 readonly runtimeModeChoices: ReturnType<typeof runtimeModeChoicesForSupportedModes>;
305 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;321 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
...@@ -329,12 +345,46 @@ const ThreadSettingsSessionContext = createContext<ThreadSettingsSessionValue |...@@ -329,12 +345,46 @@ const ThreadSettingsSessionContext = createContext<ThreadSettingsSessionValue |
329function ThreadSettingsSessionProvider(345function ThreadSettingsSessionProvider(
330 props: ThreadSettingsSessionProps & { readonly children: ReactNode },346 props: ThreadSettingsSessionProps & { readonly children: ReactNode },
331) {347) {
348 const registry = use(RegistryContext);
332 const preferences = useAtomValue(mobilePreferencesAtom);349 const preferences = useAtomValue(mobilePreferencesAtom);
333 const savePreferences = useAtomSet(updateMobilePreferencesAtom);350 const configs = useAtomValue(environmentServerConfigsAtom);
334 const favoritesLoaded = AsyncResult.isSuccess(preferences);351 const config = props.environmentId ? configs.get(props.environmentId) : undefined;
335 const modelFavorites = favoritesLoaded352 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)
336 ? (preferences.value.modelFavorites ?? EMPTY_MODEL_FAVORITES)375 ? (preferences.value.modelFavorites ?? EMPTY_MODEL_FAVORITES)
337 : EMPTY_MODEL_FAVORITES;376 : 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]);
338 const favoriteKeys = useMemo(388 const favoriteKeys = useMemo(
339 () =>389 () =>
340 new Set(390 new Set(
...@@ -343,19 +393,105 @@ function ThreadSettingsSessionProvider(...@@ -343,19 +393,105 @@ function ThreadSettingsSessionProvider(
343 [modelFavorites],393 [modelFavorites],
344 );394 );
345 const toggleFavorite = useCallback(395 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(
346 (option: ModelOption) => {465 (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));
348 void Haptics.selectionAsync();475 void Haptics.selectionAsync();
349 savePreferences({476 void saveSettings({
350 transform: (current) => ({477 environmentId: props.environmentId,
351 modelFavorites: toggleModelFavorite(478 input: {
352 current.modelFavorites ?? EMPTY_MODEL_FAVORITES,479 patch: {
353 option,480 setModelsHidden: [
354 ),481 {
355 }),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));
356 });492 });
357 },493 },
358 [favoritesLoaded, savePreferences],494 [props.environmentId, saveSettings, settings],
359 );495 );
360 const [showLegacyToggle, setShowLegacyToggle] = useState(false);496 const [showLegacyToggle, setShowLegacyToggle] = useState(false);
361 const [providerFilter, setProviderFilter] = useState<string | null>(null);497 const [providerFilter, setProviderFilter] = useState<string | null>(null);
...@@ -473,7 +609,11 @@ function ThreadSettingsSessionProvider(...@@ -473,7 +609,11 @@ function ThreadSettingsSessionProvider(
473 () => ({609 () => ({
474 environmentId: props.environmentId,610 environmentId: props.environmentId,
475 providerInstanceId: props.providerInstanceId,611 providerInstanceId: props.providerInstanceId,
476 providerGroups: props.providerGroups,612 providerGroups,
613 manageHidden,
614 setManageHidden,
615 toggleHidden,
616 pendingHiddenKeys,
477 runtimeMode: compatibleRuntimeMode,617 runtimeMode: compatibleRuntimeMode,
478 runtimeModeChoices,618 runtimeModeChoices,
479 onUpdateRuntimeMode: props.onUpdateRuntimeMode,619 onUpdateRuntimeMode: props.onUpdateRuntimeMode,
...@@ -518,7 +658,10 @@ function ThreadSettingsSessionProvider(...@@ -518,7 +658,10 @@ function ThreadSettingsSessionProvider(
518 pressModel,658 pressModel,
519 providerFilter,659 providerFilter,
520 props.onUpdateRuntimeMode,660 props.onUpdateRuntimeMode,
521 props.providerGroups,661 providerGroups,
662 manageHidden,
663 toggleHidden,
664 pendingHiddenKeys,
522 runtimeModeChoices,665 runtimeModeChoices,
523 searchQuery,666 searchQuery,
524 showLegacyToggle,667 showLegacyToggle,
...@@ -599,6 +742,12 @@ function ThreadSettingsModelListRow(props: {...@@ -599,6 +742,12 @@ function ThreadSettingsModelListRow(props: {
599 isFavorite={session.favoriteKeys.has(props.option.key)}742 isFavorite={session.favoriteKeys.has(props.option.key)}
600 favoritesLoaded={session.favoritesLoaded}743 favoritesLoaded={session.favoritesLoaded}
601 onToggleFavorite={() => session.toggleFavorite(props.option)}744 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)}
602 option={props.option}751 option={props.option}
603 selected={session.isDisplayed(props.option)}752 selected={session.isDisplayed(props.option)}
604 />753 />
...@@ -642,7 +791,9 @@ function useThreadSettingsCatalogItems(...@@ -642,7 +791,9 @@ function useThreadSettingsCatalogItems(
642 }791 }
643 const driver = group.models[0]?.providerDriver ?? group.providerKey;792 const driver = group.models[0]?.providerDriver ?? group.providerKey;
644 const catalogModels =793 const catalogModels =
645 session.showLegacy || session.providerFilter === FAVORITES_PROVIDER_FILTER794 session.manageHidden ||
795 session.showLegacy ||
796 session.providerFilter === FAVORITES_PROVIDER_FILTER
646 ? group.models797 ? group.models
647 : group.models.filter(798 : group.models.filter(
648 (model) =>799 (model) =>
...@@ -726,6 +877,7 @@ function useThreadSettingsCatalogItems(...@@ -726,6 +877,7 @@ function useThreadSettingsCatalogItems(
726 session.providerGroups,877 session.providerGroups,
727 session.searchQuery,878 session.searchQuery,
728 session.showLegacy,879 session.showLegacy,
880 session.manageHidden,
729 ],881 ],
730 );882 );
731}883}
...@@ -806,21 +958,23 @@ function ThreadSettingsOptionsItem(props: {...@@ -806,21 +958,23 @@ function ThreadSettingsOptionsItem(props: {
806 </Animated.View>958 </Animated.View>
807 </Animated.View>959 </Animated.View>
808960
809 {Platform.OS !== "ios" && session.hasLegacyModels ? (961 <Text className="px-5 pb-2 pt-7 text-sm font-t3-medium text-foreground-muted">Catalog</Text>
810 <>962 <View className="mx-4 overflow-hidden rounded-2xl bg-grouped-card">
811 <Text className="px-5 pb-2 pt-7 text-sm font-t3-medium text-foreground-muted">963 <SwitchRow
812 Catalog964 label="Manage hidden models"
813 </Text>965 value={session.manageHidden}
814 <View className="mx-4 overflow-hidden rounded-2xl bg-grouped-card">966 onValueChange={session.setManageHidden}
815 <SwitchRow967 isLast={Platform.OS === "ios" || !session.hasLegacyModels}
816 isLast968 />
817 label="Legacy models"969 {Platform.OS !== "ios" && session.hasLegacyModels ? (
818 onValueChange={session.setShowLegacy}970 <SwitchRow
819 value={session.showLegacy}971 isLast
820 />972 label="Legacy models"
821 </View>973 onValueChange={session.setShowLegacy}
822 </>974 value={session.showLegacy}
823 ) : null}975 />
976 ) : null}
977 </View>
824 </View>978 </View>
825 );979 );
826}980}
apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts+9-19
...@@ -6,10 +6,8 @@ import type { ModelOption } from "../../lib/modelOptions";...@@ -6,10 +6,8 @@ import type { ModelOption } from "../../lib/modelOptions";
6import {6import {
7 canCommitPendingModel,7 canCommitPendingModel,
8 favoritesFirst,8 favoritesFirst,
9 modelFavoriteKey,
10 modelMatchesCatalogQuery,9 modelMatchesCatalogQuery,
11 pendingModelAfterPress,10 pendingModelAfterPress,
12 toggleModelFavorite,
13} from "./thread-settings-sheet-state";11} from "./thread-settings-sheet-state";
1412
15function modelOption(13function modelOption(
...@@ -58,23 +56,6 @@ describe("thread settings sheet state", () => {...@@ -58,23 +56,6 @@ describe("thread settings sheet state", () => {
58 ]);56 ]);
59 });57 });
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
78 it("matches visible model and provider terms", () => {59 it("matches visible model and provider terms", () => {
79 const model = modelOption("gpt-next");60 const model = modelOption("gpt-next");
8061
...@@ -144,6 +125,15 @@ describe("thread settings sheet state", () => {...@@ -144,6 +125,15 @@ describe("thread settings sheet state", () => {
144 ).toBe(pressed);125 ).toBe(pressed);
145 });126 });
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
147 it("cannot save a staged model after sign-out removes it from the catalog", () => {137 it("cannot save a staged model after sign-out removes it from the catalog", () => {
148 const pending = modelOption("gemini-native");138 const pending = modelOption("gemini-native");
149 const group = { providerKey: "codex", providerLabel: "Codex", models: [pending] };139 const group = { providerKey: "codex", providerLabel: "Codex", models: [pending] };
apps/mobile/src/features/threads/thread-settings-sheet-state.ts+3-17
...@@ -1,26 +1,10 @@...@@ -1,26 +1,10 @@
1import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";1import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
2import type { ProviderInstanceId } from "@t3tools/contracts";2import type { ProviderInstanceId } from "@t3tools/contracts";
33
4export type ModelFavorite = {
5 readonly provider: ProviderInstanceId;
6 readonly model: string;
7};
8
9export function modelFavoriteKey(provider: ProviderInstanceId, model: string): string {4export function modelFavoriteKey(provider: ProviderInstanceId, model: string): string {
10 return `${provider}:${model}`;5 return `${provider}:${model}`;
11}6}
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
24/** Keep catalog order within each group when favorites move to the front. */8/** Keep catalog order within each group when favorites move to the front. */
25export function favoritesFirst(9export function favoritesFirst(
26 models: ReadonlyArray<ModelOption>,10 models: ReadonlyArray<ModelOption>,
...@@ -71,7 +55,9 @@ export function canCommitPendingModel(...@@ -71,7 +55,9 @@ export function canCommitPendingModel(
71 groups: ReadonlyArray<ProviderGroup>,55 groups: ReadonlyArray<ProviderGroup>,
72): boolean {56): boolean {
73 return groups.some((group) =>57 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 ),
75 );61 );
76}62}
7763
apps/mobile/src/lib/modelOptions.test.ts+93-1
...@@ -1,6 +1,11 @@...@@ -1,6 +1,11 @@
1import { describe, expect, it } from "vite-plus/test";1import { 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
5import {10import {
6 buildModelOptions,11 buildModelOptions,
...@@ -504,3 +509,90 @@ describe("mobile model options", () => {...@@ -504,3 +509,90 @@ describe("mobile model options", () => {
504 ).toBeNull();509 ).toBeNull();
505 });510 });
506});511});
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 = {...@@ -22,6 +22,8 @@ export type ModelOption = {
22 readonly isDefault: boolean;22 readonly isDefault: boolean;
23 readonly isLegacy: boolean;23 readonly isLegacy: boolean;
24 readonly isUnavailable?: boolean;24 readonly isUnavailable?: boolean;
25 readonly isHidden?: boolean;
26 readonly canHide?: boolean;
25 readonly capabilities: ModelCapabilities | null;27 readonly capabilities: ModelCapabilities | null;
26 readonly selection: ModelSelection;28 readonly selection: ModelSelection;
27 readonly providerUpdateRequired?: ProviderUpdateRequired;29 readonly providerUpdateRequired?: ProviderUpdateRequired;
...@@ -167,6 +169,7 @@ export function buildModelOptions(...@@ -167,6 +169,7 @@ export function buildModelOptions(
167 config: T3ServerConfig | null | undefined,169 config: T3ServerConfig | null | undefined,
168 fallbackModelSelection: ModelSelection | null,170 fallbackModelSelection: ModelSelection | null,
169 providerInstanceId?: ModelSelection["instanceId"],171 providerInstanceId?: ModelSelection["instanceId"],
172 includeHidden = false,
170): ReadonlyArray<ModelOption> {173): ReadonlyArray<ModelOption> {
171 const options = new Map<string, ModelOption>();174 const options = new Map<string, ModelOption>();
172175
...@@ -185,7 +188,15 @@ export function buildModelOptions(...@@ -185,7 +188,15 @@ export function buildModelOptions(
185 const updateRequired = provider.updateRequiredModels?.length188 const updateRequired = provider.updateRequiredModels?.length
186 ? { driver: provider.driver, updateRequiredModels: provider.updateRequiredModels }189 ? { driver: provider.driver, updateRequiredModels: provider.updateRequiredModels }
187 : undefined;190 : 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) {
189 const key = `${provider.instanceId}:${model.slug}`;200 const key = `${provider.instanceId}:${model.slug}`;
190 options.set(key, {201 options.set(key, {
191 key,202 key,
...@@ -198,6 +209,8 @@ export function buildModelOptions(...@@ -198,6 +209,8 @@ export function buildModelOptions(
198 ? {}209 ? {}
199 : { supportedRuntimeModes: provider.supportedRuntimeModes }),210 : { supportedRuntimeModes: provider.supportedRuntimeModes }),
200 ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}),211 ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}),
212 isHidden: !model.isCustom && hiddenModels.has(model.slug),
213 canHide: !model.isCustom,
201 isDefault: model.isDefault === true,214 isDefault: model.isDefault === true,
202 isLegacy: model.isLegacy === true,215 isLegacy: model.isLegacy === true,
203 ...(updateRequired ? { providerUpdateRequired: updateRequired } : {}),216 ...(updateRequired ? { providerUpdateRequired: updateRequired } : {}),
...@@ -215,7 +228,14 @@ export function buildModelOptions(...@@ -215,7 +228,14 @@ export function buildModelOptions(
215228
216 if (229 if (
217 fallbackModelSelection &&230 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)
219 ) {239 ) {
220 const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`;240 const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`;
221 const existing = options.get(key);241 const existing = options.get(key);
apps/mobile/src/persistence/mobile-preferences.ts+1-1
...@@ -46,7 +46,7 @@ export interface Preferences {...@@ -46,7 +46,7 @@ export interface Preferences {
46 readonly planModeEnabled?: boolean;46 readonly planModeEnabled?: boolean;
47 /** Device-local counterpart of web's `sidebarWorkingShelfEnabled` beta. */47 /** Device-local counterpart of web's `sidebarWorkingShelfEnabled` beta. */
48 readonly workingShelfEnabled?: boolean;48 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. */
50 readonly modelFavorites?: ReadonlyArray<{50 readonly modelFavorites?: ReadonlyArray<{
51 readonly provider: ProviderInstanceId;51 readonly provider: ProviderInstanceId;
52 readonly model: string;52 readonly model: string;
apps/server/src/mcp/OrchestratorMcpService.activity.test.ts+5
...@@ -20,6 +20,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg...@@ -20,6 +20,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg
20import * as ProviderRegistry from "../provider/ProviderRegistry.ts";20import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
21import * as ProjectService from "../project/ProjectService.ts";21import * as ProjectService from "../project/ProjectService.ts";
22import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";22import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
23import * as ServerSettings from "../serverSettings.ts";
23import * as SecretRequests from "../secrets/SecretRequests.ts";24import * as SecretRequests from "../secrets/SecretRequests.ts";
24import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";25import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
25import type * as McpInvocationContext from "./McpInvocationContext.ts";26import type * as McpInvocationContext from "./McpInvocationContext.ts";
...@@ -146,6 +147,7 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a...@@ -146,6 +147,7 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a
146 ? Effect.succeed(projection)147 ? Effect.succeed(projection)
147 : Effect.die(`unexpected thread ${input.threadId}`),148 : Effect.die(`unexpected thread ${input.threadId}`),
148 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),149 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
150 ServerSettings.layerTest(),
149 Layer.mock(ProviderRegistry.ProviderRegistry)({151 Layer.mock(ProviderRegistry.ProviderRegistry)({
150 getProviders: Effect.succeed([]),152 getProviders: Effect.succeed([]),
151 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),153 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
...@@ -211,6 +213,7 @@ it("readThread prefers waiting activity status over a newer cancelled queued run...@@ -211,6 +213,7 @@ it("readThread prefers waiting activity status over a newer cancelled queued run
211 ? Effect.succeed(projection)213 ? Effect.succeed(projection)
212 : Effect.die(`unexpected thread ${input.threadId}`),214 : Effect.die(`unexpected thread ${input.threadId}`),
213 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),215 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
216 ServerSettings.layerTest(),
214 Layer.mock(ProviderRegistry.ProviderRegistry)({217 Layer.mock(ProviderRegistry.ProviderRegistry)({
215 getProviders: Effect.succeed([]),218 getProviders: Effect.succeed([]),
216 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),219 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
...@@ -324,6 +327,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy...@@ -324,6 +327,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy
324 return Effect.die(`unexpected thread ${threadId}`);327 return Effect.die(`unexpected thread ${threadId}`);
325 },328 },
326 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),329 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
330 ServerSettings.layerTest(),
327 Layer.mock(ProviderRegistry.ProviderRegistry)({331 Layer.mock(ProviderRegistry.ProviderRegistry)({
328 getProviders: Effect.succeed([]),332 getProviders: Effect.succeed([]),
329 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),333 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
...@@ -447,6 +451,7 @@ it("readThread and sendToThread reach threads in other projects", async () => {...@@ -447,6 +451,7 @@ it("readThread and sendToThread reach threads in other projects", async () => {
447 delivery: "started",451 delivery: "started",
448 } as unknown as ThreadManagementService.ThreadManagementSendResult),452 } as unknown as ThreadManagementService.ThreadManagementSendResult),
449 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),453 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
454 ServerSettings.layerTest(),
450 Layer.mock(ProviderRegistry.ProviderRegistry)({455 Layer.mock(ProviderRegistry.ProviderRegistry)({
451 getProviders: Effect.succeed([]),456 getProviders: Effect.succeed([]),
452 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),457 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
apps/server/src/mcp/OrchestratorMcpService.test.ts+62
...@@ -34,6 +34,7 @@ import * as ProviderRegistry from "../provider/ProviderRegistry.ts";...@@ -34,6 +34,7 @@ import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
34import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts";34import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts";
35import * as ProjectService from "../project/ProjectService.ts";35import * as ProjectService from "../project/ProjectService.ts";
36import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";36import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
37import * as ServerSettings from "../serverSettings.ts";
37import * as SecretRequests from "../secrets/SecretRequests.ts";38import * as SecretRequests from "../secrets/SecretRequests.ts";
38import type { McpInvocationScope } from "./McpInvocationContext.ts";39import type { McpInvocationScope } from "./McpInvocationContext.ts";
39import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts";40import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts";
...@@ -128,6 +129,7 @@ describe("OrchestratorMcpService", () => {...@@ -128,6 +129,7 @@ describe("OrchestratorMcpService", () => {
128 ),129 ),
129 ),130 ),
130 }),131 }),
132 ServerSettings.layerTest(),
131 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),133 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
132 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({134 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
133 list: () => Effect.succeed([]),135 list: () => Effect.succeed([]),
...@@ -227,6 +229,7 @@ describe("OrchestratorMcpService", () => {...@@ -227,6 +229,7 @@ describe("OrchestratorMcpService", () => {
227 : Effect.succeed(awaitsRestart),229 : Effect.succeed(awaitsRestart),
228 dispatch: () => Ref.update(dispatched, (count) => count + 1).pipe(Effect.as({} as never)),230 dispatch: () => Ref.update(dispatched, (count) => count + 1).pipe(Effect.as({} as never)),
229 }),231 }),
232 ServerSettings.layerTest(),
230 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),233 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
231 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({234 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
232 list: () => Effect.succeed([]),235 list: () => Effect.succeed([]),
...@@ -311,6 +314,7 @@ describe("OrchestratorMcpService", () => {...@@ -311,6 +314,7 @@ describe("OrchestratorMcpService", () => {
311 Effect.as({} as never),314 Effect.as({} as never),
312 ),315 ),
313 }),316 }),
317 ServerSettings.layerTest(),
314 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),318 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
315 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({319 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
316 list: () => Effect.succeed([]),320 list: () => Effect.succeed([]),
...@@ -387,6 +391,7 @@ describe("OrchestratorMcpService", () => {...@@ -387,6 +391,7 @@ describe("OrchestratorMcpService", () => {
387 Effect.andThen(Effect.fail(new Error("simulated stop failure") as never)),391 Effect.andThen(Effect.fail(new Error("simulated stop failure") as never)),
388 ),392 ),
389 }),393 }),
394 ServerSettings.layerTest(),
390 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),395 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
391 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({396 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
392 list: () => Effect.succeed([]),397 list: () => Effect.succeed([]),
...@@ -471,6 +476,7 @@ describe("OrchestratorMcpService", () => {...@@ -471,6 +476,7 @@ describe("OrchestratorMcpService", () => {
471 ),476 ),
472 stopDelegatedTasks: () => Effect.void,477 stopDelegatedTasks: () => Effect.void,
473 }),478 }),
479 ServerSettings.layerTest(),
474 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),480 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
475 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({481 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
476 list: () => Effect.succeed([]),482 list: () => Effect.succeed([]),
...@@ -557,6 +563,7 @@ describe("OrchestratorMcpService", () => {...@@ -557,6 +563,7 @@ describe("OrchestratorMcpService", () => {
557 ),563 ),
558 stopDelegatedTasks: () => Effect.void,564 stopDelegatedTasks: () => Effect.void,
559 }),565 }),
566 ServerSettings.layerTest(),
560 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),567 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
561 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({568 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
562 list: () => Effect.succeed([]),569 list: () => Effect.succeed([]),
...@@ -697,6 +704,7 @@ describe("OrchestratorMcpService", () => {...@@ -697,6 +704,7 @@ describe("OrchestratorMcpService", () => {
697 yield* Ref.set(stoppedBelow, true);704 yield* Ref.set(stoppedBelow, true);
698 }),705 }),
699 }),706 }),
707 ServerSettings.layerTest(),
700 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),708 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
701 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({709 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
702 list: () => Effect.succeed([]),710 list: () => Effect.succeed([]),
...@@ -927,6 +935,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -927,6 +935,7 @@ describe("OrchestratorMcpService provider resolution", () => {
927 Layer.mock(ThreadManagementService.ThreadManagementService)({935 Layer.mock(ThreadManagementService.ThreadManagementService)({
928 getThreadRecords: () => Effect.succeed(parentProjection([])),936 getThreadRecords: () => Effect.succeed(parentProjection([])),
929 }),937 }),
938 ServerSettings.layerTest(),
930 Layer.mock(ProviderRegistry.ProviderRegistry)({939 Layer.mock(ProviderRegistry.ProviderRegistry)({
931 getProviders: Effect.sync(() => providers),940 getProviders: Effect.sync(() => providers),
932 }),941 }),
...@@ -1016,6 +1025,51 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1016,6 +1025,51 @@ describe("OrchestratorMcpService provider resolution", () => {
1016 }),1025 }),
1017 );1026 );
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
1019 it.effect(1073 it.effect(
1020 "delegates to an Antigravity instance whose adapter resolves through the registry",1074 "delegates to an Antigravity instance whose adapter resolves through the registry",
1021 () =>1075 () =>
...@@ -1070,6 +1124,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1070,6 +1124,7 @@ describe("OrchestratorMcpService provider resolution", () => {
1070 } as never),1124 } as never),
1071 ),1125 ),
1072 }),1126 }),
1127 ServerSettings.layerTest(),
1073 Layer.mock(ProviderRegistry.ProviderRegistry)({1128 Layer.mock(ProviderRegistry.ProviderRegistry)({
1074 getProviders: Effect.succeed([1129 getProviders: Effect.succeed([
1075 providerSnapshot({1130 providerSnapshot({
...@@ -1167,6 +1222,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1167,6 +1222,7 @@ describe("OrchestratorMcpService provider resolution", () => {
1167 } as never),1222 } as never),
1168 ),1223 ),
1169 }),1224 }),
1225 ServerSettings.layerTest(),
1170 Layer.mock(ProviderRegistry.ProviderRegistry)({1226 Layer.mock(ProviderRegistry.ProviderRegistry)({
1171 getProviders: Effect.succeed([1227 getProviders: Effect.succeed([
1172 providerSnapshot({1228 providerSnapshot({
...@@ -1221,6 +1277,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1221,6 +1277,7 @@ describe("OrchestratorMcpService provider resolution", () => {
1221 Layer.mock(ThreadManagementService.ThreadManagementService)({1277 Layer.mock(ThreadManagementService.ThreadManagementService)({
1222 getThreadRecords: () => Effect.succeed(parentProjection([])),1278 getThreadRecords: () => Effect.succeed(parentProjection([])),
1223 }),1279 }),
1280 ServerSettings.layerTest(),
1224 providerRegistryLayer([1281 providerRegistryLayer([
1225 providerSnapshot({1282 providerSnapshot({
1226 instanceId: codexInstanceId,1283 instanceId: codexInstanceId,
...@@ -1329,6 +1386,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1329,6 +1386,7 @@ describe("OrchestratorMcpService provider resolution", () => {
1329 } as never),1386 } as never),
1330 ),1387 ),
1331 }),1388 }),
1389 ServerSettings.layerTest(),
1332 Layer.mock(ProviderRegistry.ProviderRegistry)({1390 Layer.mock(ProviderRegistry.ProviderRegistry)({
1333 getProviders: Effect.succeed([codex, missingCli]),1391 getProviders: Effect.succeed([codex, missingCli]),
1334 refreshInstance: () =>1392 refreshInstance: () =>
...@@ -1480,6 +1538,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1480,6 +1538,7 @@ describe("OrchestratorMcpService provider resolution", () => {
1480 } as never),1538 } as never),
1481 ),1539 ),
1482 }),1540 }),
1541 ServerSettings.layerTest(),
1483 providerRegistryLayer([1542 providerRegistryLayer([
1484 providerSnapshot({1543 providerSnapshot({
1485 instanceId: codexInstanceId,1544 instanceId: codexInstanceId,
...@@ -1632,6 +1691,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1632,6 +1691,7 @@ describe("OrchestratorMcpService provider resolution", () => {
1632 getThreadShell: (threadId) =>1691 getThreadShell: (threadId) =>
1633 Effect.succeed(threadId === boundThreadId ? boundThread : null),1692 Effect.succeed(threadId === boundThreadId ? boundThread : null),
1634 }),1693 }),
1694 ServerSettings.layerTest(),
1635 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),1695 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
1636 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({1696 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
1637 list: () => Effect.succeed([]),1697 list: () => Effect.succeed([]),
...@@ -1700,6 +1760,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1700,6 +1760,7 @@ describe("OrchestratorMcpService provider resolution", () => {
1700 // Its turn ended: no run is active.1760 // Its turn ended: no run is active.
1701 getThreadRecords: () => Effect.succeed(idleThreadProjection(shell)),1761 getThreadRecords: () => Effect.succeed(idleThreadProjection(shell)),
1702 }),1762 }),
1763 ServerSettings.layerTest(),
1703 Layer.mock(ProviderRegistry.ProviderRegistry)({1764 Layer.mock(ProviderRegistry.ProviderRegistry)({
1704 getProviders: Effect.succeed([]),1765 getProviders: Effect.succeed([]),
1705 }),1766 }),
...@@ -1789,6 +1850,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1789,6 +1850,7 @@ describe("OrchestratorMcpService provider resolution", () => {
1789 ),1850 ),
1790 ),1851 ),
1791 }),1852 }),
1853 ServerSettings.layerTest(),
1792 Layer.mock(ProviderRegistry.ProviderRegistry)({1854 Layer.mock(ProviderRegistry.ProviderRegistry)({
1793 getProviders: Effect.succeed([]),1855 getProviders: Effect.succeed([]),
1794 }),1856 }),
apps/server/src/mcp/OrchestratorMcpService.ts+21-7
...@@ -85,6 +85,7 @@ import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts";...@@ -85,6 +85,7 @@ import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts";
85import * as ProjectService from "../project/ProjectService.ts";85import * as ProjectService from "../project/ProjectService.ts";
86import * as ProviderRegistry from "../provider/ProviderRegistry.ts";86import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
87import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";87import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
88import * as ServerSettings from "../serverSettings.ts";
88import {89import {
89 clientRuntimeModeCeiling,90 clientRuntimeModeCeiling,
90 type McpInvocationScope,91 type McpInvocationScope,
...@@ -901,6 +902,7 @@ const make = Effect.gen(function* () {...@@ -901,6 +902,7 @@ const make = Effect.gen(function* () {
901 const providerAdapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2;902 const providerAdapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2;
902 const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService;903 const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService;
903 const projects = yield* ProjectService.ProjectService;904 const projects = yield* ProjectService.ProjectService;
905 const serverSettings = yield* ServerSettings.ServerSettingsService;
904906
905 /** A caller-named project, which must exist before anything is recorded against it. */907 /** A caller-named project, which must exist before anything is recorded against it. */
906 const requireProject = (projectId: ProjectId) =>908 const requireProject = (projectId: ProjectId) =>
...@@ -1851,6 +1853,11 @@ const make = Effect.gen(function* () {...@@ -1851,6 +1853,11 @@ const make = Effect.gen(function* () {
1851 const { parent, limits } = yield* loadCaller(scope);1853 const { parent, limits } = yield* loadCaller(scope);
1852 const providers = yield* loadProviders;1854 const providers = yield* loadProviders;
1853 const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds();1855 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 );
1854 return {1861 return {
1855 parentThreadId: parent?.thread.id ?? null,1862 parentThreadId: parent?.thread.id ?? null,
1856 inheritedProviderInstanceId: parent?.thread.modelSelection.instanceId ?? null,1863 inheritedProviderInstanceId: parent?.thread.modelSelection.instanceId ?? null,
...@@ -1867,13 +1874,19 @@ const make = Effect.gen(function* () {...@@ -1867,13 +1874,19 @@ const make = Effect.gen(function* () {
1867 driverKind: provider.driver,1874 driverKind: provider.driver,
1868 displayName: provider?.displayName ?? null,1875 displayName: provider?.displayName ?? null,
1869 models:1876 models:
1870 provider?.models.map((model) => ({1877 provider?.models
1871 id: model.slug,1878 .filter(
1872 label: model.name ?? null,1879 (model) =>
1873 ...(model.capabilities?.optionDescriptors === undefined1880 model.isCustom ||
1874 ? {}1881 !modelPreferences[provider.instanceId]?.hiddenModels.includes(model.slug),
1875 : { options: model.capabilities.optionDescriptors }),1882 )
1876 })) ?? [],1883 .map((model) => ({
1884 id: model.slug,
1885 label: model.name ?? null,
1886 ...(model.capabilities?.optionDescriptors === undefined
1887 ? {}
1888 : { options: model.capabilities.optionDescriptors }),
1889 })) ?? [],
1877 ...(provider.supportedRuntimeModes === undefined ||1890 ...(provider.supportedRuntimeModes === undefined ||
1878 provider.supportedRuntimeModes.length === 01891 provider.supportedRuntimeModes.length === 0
1879 ? {}1892 ? {}
...@@ -2594,4 +2607,5 @@ export const layer: Layer.Layer<...@@ -2594,4 +2607,5 @@ export const layer: Layer.Layer<
2594 | ScheduledTaskService.ScheduledTaskService2607 | ScheduledTaskService.ScheduledTaskService
2595 | ProjectService.ProjectService2608 | ProjectService.ProjectService
2596 | SecretRequests.SecretRequests2609 | SecretRequests.SecretRequests
2610 | ServerSettings.ServerSettingsService
2597> = Layer.effect(OrchestratorMcpService, make);2611> = Layer.effect(OrchestratorMcpService, make);
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+3
...@@ -70,6 +70,7 @@ import {...@@ -70,6 +70,7 @@ import {
70import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts";70import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts";
71import * as ProjectService from "../project/ProjectService.ts";71import * as ProjectService from "../project/ProjectService.ts";
72import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";72import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
73import * as ServerSettings from "../serverSettings.ts";
73import * as SecretRequests from "../secrets/SecretRequests.ts";74import * as SecretRequests from "../secrets/SecretRequests.ts";
74import * as ServerSecretStore from "../auth/ServerSecretStore.ts";75import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
75import * as McpHttpServer from "./McpHttpServer.ts";76import * as McpHttpServer from "./McpHttpServer.ts";
...@@ -680,6 +681,7 @@ describe("orchestrator MCP toolkit", () => {...@@ -680,6 +681,7 @@ describe("orchestrator MCP toolkit", () => {
680 Layer.provideMerge(layerOrchestration),681 Layer.provideMerge(layerOrchestration),
681 Layer.provide(layerRegistry),682 Layer.provide(layerRegistry),
682 Layer.provide(layerProviderRegistry),683 Layer.provide(layerProviderRegistry),
684 Layer.provide(ServerSettings.layerTest()),
683 Layer.provide(layerScheduledTaskStub),685 Layer.provide(layerScheduledTaskStub),
684 Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})),686 Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})),
685 Layer.provide(687 Layer.provide(
...@@ -3825,6 +3827,7 @@ describe("orchestrator MCP toolkit", () => {...@@ -3825,6 +3827,7 @@ describe("orchestrator MCP toolkit", () => {
3825 ),3827 ),
3826 ),3828 ),
3827 Layer.provide(layerProviderRegistry),3829 Layer.provide(layerProviderRegistry),
3830 Layer.provide(ServerSettings.layerTest()),
3828 Layer.provide(layerUnusedScheduledTaskStub),3831 Layer.provide(layerUnusedScheduledTaskStub),
3829 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),3832 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
3830 Layer.provideMerge(3833 Layer.provideMerge(
apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts+2
...@@ -33,6 +33,7 @@ import * as ProjectService from "../project/ProjectService.ts";...@@ -33,6 +33,7 @@ import * as ProjectService from "../project/ProjectService.ts";
33import * as ProviderRegistry from "../provider/ProviderRegistry.ts";33import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
34import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";34import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
35import * as SecretRequests from "../secrets/SecretRequests.ts";35import * as SecretRequests from "../secrets/SecretRequests.ts";
36import * as ServerSettings from "../serverSettings.ts";
36import type { McpInvocationScope } from "./McpInvocationContext.ts";37import type { McpInvocationScope } from "./McpInvocationContext.ts";
37import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";38import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
3839
...@@ -142,6 +143,7 @@ it.effect("task_cancel ends a delegated child's background native subagent", ()...@@ -142,6 +143,7 @@ it.effect("task_cancel ends a delegated child's background native subagent", ()
142 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),143 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
143 Layer.mock(ProjectService.ProjectService)({}),144 Layer.mock(ProjectService.ProjectService)({}),
144 Layer.mock(SecretRequests.SecretRequests)({}),145 Layer.mock(SecretRequests.SecretRequests)({}),
146 ServerSettings.layerTest(),
145 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),147 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
146 NodeServices.layer,148 NodeServices.layer,
147 ),149 ),
apps/server/src/mcp/toolkits/core.test.ts+5
...@@ -42,6 +42,7 @@ import * as ProjectService from "../../project/ProjectService.ts";...@@ -42,6 +42,7 @@ import * as ProjectService from "../../project/ProjectService.ts";
42import * as ProviderRegistry from "../../provider/ProviderRegistry.ts";42import * as ProviderRegistry from "../../provider/ProviderRegistry.ts";
43import * as SecretRequests from "../../secrets/SecretRequests.ts";43import * as SecretRequests from "../../secrets/SecretRequests.ts";
44import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts";44import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts";
45import * as ServerSettings from "../../serverSettings.ts";
45import * as McpHttpServer from "../McpHttpServer.ts";46import * as McpHttpServer from "../McpHttpServer.ts";
46import * as McpInvocationContext from "../McpInvocationContext.ts";47import * as McpInvocationContext from "../McpInvocationContext.ts";
47import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts";48import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts";
...@@ -581,6 +582,7 @@ it.effect("refuses act-as-caller tools to a client caller", () =>...@@ -581,6 +582,7 @@ it.effect("refuses act-as-caller tools to a client caller", () =>
581 Layer.provideMerge(McpServer.McpServer.layer),582 Layer.provideMerge(McpServer.McpServer.layer),
582 Layer.provide(NodeCrypto.layer),583 Layer.provide(NodeCrypto.layer),
583 Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})),584 Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})),
585 Layer.provide(ServerSettings.layerTest()),
584 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),586 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
585 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),587 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
586 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),588 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...@@ -619,6 +621,7 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode
619 Layer.provideMerge(McpServer.McpServer.layer),621 Layer.provideMerge(McpServer.McpServer.layer),
620 Layer.provide(NodeCrypto.layer),622 Layer.provide(NodeCrypto.layer),
621 Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})),623 Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})),
624 Layer.provide(ServerSettings.layerTest()),
622 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),625 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
623 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),626 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
624 Layer.provide(627 Layer.provide(
...@@ -716,6 +719,7 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])(...@@ -716,6 +719,7 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])(
716 interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"),719 interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"),
717 }),720 }),
718 ),721 ),
722 Layer.provide(ServerSettings.layerTest()),
719 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),723 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
720 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),724 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
721 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),725 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
...@@ -807,6 +811,7 @@ it.effect("discovers workflow stop targets and requires orchestration capability...@@ -807,6 +811,7 @@ it.effect("discovers workflow stop targets and requires orchestration capability
807 }),811 }),
808 }),812 }),
809 ),813 ),
814 Layer.provide(ServerSettings.layerTest()),
810 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),815 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
811 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),816 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
812 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),817 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...@@ -98,6 +98,88 @@ const recordProviderUsage = (provider: string, instanceId: string | null = provi
98 });98 });
9999
100it.layer(NodeServices.layer)("server settings", (it) => {100it.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 );
101 it.effect("migrates saved token delivery to paragraph buffering without resetting settings", () =>183 it.effect("migrates saved token delivery to paragraph buffering without resetting settings", () =>
102 Effect.gen(function* () {184 Effect.gen(function* () {
103 const config = yield* ServerConfig.ServerConfig;185 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)...@@ -5524,6 +5524,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5524 />5524 />
5525 ) : null}5525 ) : null}
5526 <ProviderModelPicker5526 <ProviderModelPicker
5527 environmentId={environmentId}
5527 compact={false}5528 compact={false}
5528 isComposerOwned5529 isComposerOwned
5529 disabled={providerCatalogPending || isSendBusy}5530 disabled={providerCatalogPending || isSendBusy}
apps/web/src/components/chat/ModelPickerContent.tsx+8-17
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import {1import {
2 ANTIGRAVITY_DEFAULT_MODEL,2 ANTIGRAVITY_DEFAULT_MODEL,
3 type ProviderInstanceId,3 type ProviderInstanceId,
4 type EnvironmentId,
4 type ProviderDriverKind,5 type ProviderDriverKind,
5 type ResolvedKeybindingsConfig,6 type ResolvedKeybindingsConfig,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
...@@ -37,7 +38,10 @@ import {...@@ -37,7 +38,10 @@ import {
37 resolveShortcutCommand,38 resolveShortcutCommand,
38 shortcutLabelForCommand,39 shortcutLabelForCommand,
39} from "../../keybindings";40} from "../../keybindings";
40import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";41import {
42 useEnvironmentModelPreferences,
43 useToggleEnvironmentModelFavorite,
44} from "~/hooks/useSettings";
41import { cn } from "~/lib/utils";45import { cn } from "~/lib/utils";
42import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";46import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
43import { TooltipProvider } from "../ui/tooltip";47import { TooltipProvider } from "../ui/tooltip";
...@@ -153,6 +157,7 @@ function ModelListSeparator() {...@@ -153,6 +157,7 @@ function ModelListSeparator() {
153}157}
154158
155export const ModelPickerContent = memo(function ModelPickerContent(props: {159export const ModelPickerContent = memo(function ModelPickerContent(props: {
160 environmentId: EnvironmentId | null;
156 /** The instance currently selected in the composer (combobox "value"). */161 /** The instance currently selected in the composer (combobox "value"). */
157 activeInstanceId: ProviderInstanceId;162 activeInstanceId: ProviderInstanceId;
158 model: string;163 model: string;
...@@ -202,7 +207,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -202,7 +207,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
202 const modelListRef = useRef<LegendListRef | null>(null);207 const modelListRef = useRef<LegendListRef | null>(null);
203 const pickerContentRef = useRef<HTMLDivElement>(null);208 const pickerContentRef = useRef<HTMLDivElement>(null);
204 const highlightedModelKeyRef = useRef<string | null>(null);209 const highlightedModelKeyRef = useRef<string | null>(null);
205 const favorites = useClientSettings((s) => s.favorites ?? []);210 const { favorites } = useEnvironmentModelPreferences(props.environmentId);
206 const activeEntry = props.instanceEntries.find(211 const activeEntry = props.instanceEntries.find(
207 (entry) => entry.instanceId === props.activeInstanceId,212 (entry) => entry.instanceId === props.activeInstanceId,
208 );213 );
...@@ -276,8 +281,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -276,8 +281,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
276 );281 );
277 const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom);282 const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom);
278 const keybindings = providedKeybindings ?? serverKeybindings;283 const keybindings = providedKeybindings ?? serverKeybindings;
279 const updateSettings = useUpdateClientSettings();
280 const navigate = useNavigate();284 const navigate = useNavigate();
285 const toggleFavorite = useToggleEnvironmentModelFavorite(props.environmentId);
281286
282 const focusSearchInput = useCallback(() => {287 const focusSearchInput = useCallback(() => {
283 searchInputRef.current?.focus({ preventScroll: true });288 searchInputRef.current?.focus({ preventScroll: true });
...@@ -652,20 +657,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -652,20 +657,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
652 ],657 ],
653 );658 );
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
669 const modelJumpCommandByKey = useMemo(() => {660 const modelJumpCommandByKey = useMemo(() => {
670 const mapping = new Map<661 const mapping = new Map<
671 string,662 string,
apps/web/src/components/chat/ProviderModelPicker.test.tsx+2
...@@ -40,6 +40,7 @@ function renderPicker(input: {...@@ -40,6 +40,7 @@ function renderPicker(input: {
40 const entry = providerEntry(input.instanceId, input.driver);40 const entry = providerEntry(input.instanceId, input.driver);
41 return renderToStaticMarkup(41 return renderToStaticMarkup(
42 <ProviderModelPicker42 <ProviderModelPicker
43 environmentId={null}
43 activeInstanceId={instanceId}44 activeInstanceId={instanceId}
44 model={input.model}45 model={input.model}
45 lockedProvider={null}46 lockedProvider={null}
...@@ -161,6 +162,7 @@ describe("ProviderModelPicker", () => {...@@ -161,6 +162,7 @@ describe("ProviderModelPicker", () => {
161 const activeEntry = providerEntry("codex_personal", "codex");162 const activeEntry = providerEntry("codex_personal", "codex");
162 const markup = renderToStaticMarkup(163 const markup = renderToStaticMarkup(
163 <ProviderModelPicker164 <ProviderModelPicker
165 environmentId={null}
164 activeInstanceId={activeEntry.instanceId}166 activeInstanceId={activeEntry.instanceId}
165 model="gpt-5"167 model="gpt-5"
166 lockedProvider={null}168 lockedProvider={null}
apps/web/src/components/chat/ProviderModelPicker.tsx+3
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import {1import {
2 ANTIGRAVITY_DEFAULT_MODEL,2 ANTIGRAVITY_DEFAULT_MODEL,
3 type ProviderInstanceId,3 type ProviderInstanceId,
4 type EnvironmentId,
4 type ProviderDriverKind,5 type ProviderDriverKind,
5 type ResolvedKeybindingsConfig,6 type ResolvedKeybindingsConfig,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
...@@ -27,6 +28,7 @@ import { useComposerMenuProps } from "./composerEventScope";...@@ -27,6 +28,7 @@ import { useComposerMenuProps } from "./composerEventScope";
27import { shortcutLabelForCommand } from "../../keybindings";28import { shortcutLabelForCommand } from "../../keybindings";
2829
29export const ProviderModelPicker = memo(function ProviderModelPicker(props: {30export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
31 environmentId: EnvironmentId | null;
30 /**32 /**
31 * The instance currently selected in the composer. Drives the trigger33 * The instance currently selected in the composer. Drives the trigger
32 * icon, label and the default-highlighted combobox row.34 * icon, label and the default-highlighted combobox row.
...@@ -291,6 +293,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -291,6 +293,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
291 padding="none"293 padding="none"
292 >294 >
293 <ModelPickerContent295 <ModelPickerContent
296 environmentId={props.environmentId}
294 activeInstanceId={activeInstanceId}297 activeInstanceId={activeInstanceId}
295 model={props.model}298 model={props.model}
296 {...(props.selectedModels !== undefined ? { selectedModels: props.selectedModels } : {})}299 {...(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";...@@ -8,6 +8,7 @@ import { createModelSelection } from "@t3tools/shared/model";
8import { resolveProjectSettings } from "@t3tools/shared/projectSettings";8import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
9import { useNavigate } from "@tanstack/react-router";9import { useNavigate } from "@tanstack/react-router";
1010
11import { mergeEnvironmentSettings } from "../../hooks/useSettings";
11import { getCustomModelOptionsByInstance } from "../../modelSelection";12import { getCustomModelOptionsByInstance } from "../../modelSelection";
12import {13import {
13 applyProviderInstanceSettings,14 applyProviderInstanceSettings,
...@@ -106,7 +107,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting...@@ -106,7 +107,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
106 candidate.settings,107 candidate.settings,
107 ).find((option) => option.instanceId === instanceId);108 ).find((option) => option.instanceId === instanceId);
108 const options = getCustomModelOptionsByInstance(109 const options = getCustomModelOptionsByInstance(
109 { ...settings, ...candidate.settings },110 mergeEnvironmentSettings(candidate.settings, settings),
110 config.providers,111 config.providers,
111 ).get(instanceId);112 ).get(instanceId);
112 if (113 if (
...@@ -158,6 +159,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting...@@ -158,6 +159,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
158 selection && activeEntry ? (159 selection && activeEntry ? (
159 <div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5">160 <div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5">
160 <ProviderModelPicker161 <ProviderModelPicker
162 environmentId={representative?.environmentId ?? null}
161 activeInstanceId={selection.instanceId}163 activeInstanceId={selection.instanceId}
162 model={selection.model}164 model={selection.model}
163 lockedProvider={null}165 lockedProvider={null}
apps/web/src/components/settings/ProviderInstanceCard.test.ts+2
...@@ -118,6 +118,7 @@ describe("deriveProviderModelsForDisplay", () => {...@@ -118,6 +118,7 @@ describe("deriveProviderModelsForDisplay", () => {
118 modelOrder: [],118 modelOrder: [],
119 onHiddenModelsChange: () => undefined,119 onHiddenModelsChange: () => undefined,
120 onFavoriteModelsChange: () => undefined,120 onFavoriteModelsChange: () => undefined,
121 onToggleFavorite: () => undefined,
121 onModelOrderChange: () => undefined,122 onModelOrderChange: () => undefined,
122 }),123 }),
123 );124 );
...@@ -157,6 +158,7 @@ describe("deriveProviderModelsForDisplay", () => {...@@ -157,6 +158,7 @@ describe("deriveProviderModelsForDisplay", () => {
157 modelOrder: [],158 modelOrder: [],
158 onHiddenModelsChange: () => undefined,159 onHiddenModelsChange: () => undefined,
159 onFavoriteModelsChange: () => undefined,160 onFavoriteModelsChange: () => undefined,
161 onToggleFavorite: () => undefined,
160 onModelOrderChange: () => undefined,162 onModelOrderChange: () => undefined,
161 } as const;163 } as const;
162164
apps/web/src/components/settings/ProviderInstanceCard.tsx+9-3
...@@ -503,8 +503,12 @@ interface ProviderInstanceCardProps {...@@ -503,8 +503,12 @@ interface ProviderInstanceCardProps {
503 readonly hiddenModels: ReadonlyArray<string>;503 readonly hiddenModels: ReadonlyArray<string>;
504 readonly favoriteModels: ReadonlyArray<string>;504 readonly favoriteModels: ReadonlyArray<string>;
505 readonly modelOrder: ReadonlyArray<string>;505 readonly modelOrder: ReadonlyArray<string>;
506 readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void;506 readonly onHiddenModelsChange: (
507 next: ReadonlyArray<string>,
508 affectedModels?: ReadonlyArray<string>,
509 ) => void;
507 readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;510 readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;
511 readonly onToggleFavorite: (model: string) => void;
508 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;512 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;
509 readonly onRunUpdate?: (() => void) | undefined;513 readonly onRunUpdate?: (() => void) | undefined;
510 readonly onInstallRecommended?: (() => void) | undefined;514 readonly onInstallRecommended?: (() => void) | undefined;
...@@ -561,6 +565,7 @@ export function ProviderInstanceCard({...@@ -561,6 +565,7 @@ export function ProviderInstanceCard({
561 modelOrder,565 modelOrder,
562 onHiddenModelsChange,566 onHiddenModelsChange,
563 onFavoriteModelsChange,567 onFavoriteModelsChange,
568 onToggleFavorite,
564 onModelOrderChange,569 onModelOrderChange,
565 onRunUpdate,570 onRunUpdate,
566 onInstallRecommended,571 onInstallRecommended,
...@@ -1173,8 +1178,8 @@ export function ProviderInstanceCard({...@@ -1173,8 +1178,8 @@ export function ProviderInstanceCard({
1173 <SettingsSection title="Models">1178 <SettingsSection title="Models">
1174 <div className="px-3 py-3 sm:px-4">1179 <div className="px-3 py-3 sm:px-4">
1175 <p className="mb-3 text-xs text-muted-foreground">1180 <p className="mb-3 text-xs text-muted-foreground">
1176 Favorites, visibility, and ordering are saved on this device. Custom models are saved1181 Model preferences and custom models are saved on the selected environment and shared
1177 on the selected environment.1182 across connected devices.
1178 </p>1183 </p>
1179 <ProviderModelsSection1184 <ProviderModelsSection
1180 canManageCustomModels={!readOnly}1185 canManageCustomModels={!readOnly}
...@@ -1189,6 +1194,7 @@ export function ProviderInstanceCard({...@@ -1189,6 +1194,7 @@ export function ProviderInstanceCard({
1189 onChange={updateCustomModels}1194 onChange={updateCustomModels}
1190 onHiddenModelsChange={onHiddenModelsChange}1195 onHiddenModelsChange={onHiddenModelsChange}
1191 onFavoriteModelsChange={onFavoriteModelsChange}1196 onFavoriteModelsChange={onFavoriteModelsChange}
1197 onToggleFavorite={onToggleFavorite}
1192 onModelOrderChange={onModelOrderChange}1198 onModelOrderChange={onModelOrderChange}
1193 />1199 />
1194 </div>1200 </div>
apps/web/src/components/settings/ProviderModelsSection.tsx+12-7
...@@ -145,8 +145,12 @@ interface ProviderModelsSectionProps {...@@ -145,8 +145,12 @@ interface ProviderModelsSectionProps {
145 * `providerInstances[id].config`).145 * `providerInstances[id].config`).
146 */146 */
147 readonly onChange: (next: ReadonlyArray<CustomModelDefinition>) => void;147 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;
149 readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;152 readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;
153 readonly onToggleFavorite: (model: string) => void;
150 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;154 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;
151}155}
152156
...@@ -174,6 +178,7 @@ export function ProviderModelsSection({...@@ -174,6 +178,7 @@ export function ProviderModelsSection({
174 onChange,178 onChange,
175 onHiddenModelsChange,179 onHiddenModelsChange,
176 onFavoriteModelsChange,180 onFavoriteModelsChange,
181 onToggleFavorite,
177 onModelOrderChange,182 onModelOrderChange,
178}: ProviderModelsSectionProps) {183}: ProviderModelsSectionProps) {
179 const [input, setInput] = useState("");184 const [input, setInput] = useState("");
...@@ -282,16 +287,13 @@ export function ProviderModelsSection({...@@ -282,16 +287,13 @@ export function ProviderModelsSection({
282 if (!canWritePreferences || hidden === hiddenModelSet.has(slug)) return;287 if (!canWritePreferences || hidden === hiddenModelSet.has(slug)) return;
283 onHiddenModelsChange(288 onHiddenModelsChange(
284 hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug),289 hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug),
290 [slug],
285 );291 );
286 };292 };
287293
288 const handleToggleFavorite = (slug: string) => {294 const handleToggleFavorite = (slug: string) => {
289 if (!canWritePreferences) return;295 if (!canWritePreferences) return;
290 if (favoriteModelSet.has(slug)) {296 onToggleFavorite(slug);
291 onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug));
292 return;
293 }
294 onFavoriteModelsChange([...favoriteModels, slug]);
295 };297 };
296298
297 // Rows only trade places with a neighbour in the same group (favorites,299 // Rows only trade places with a neighbour in the same group (favorites,
...@@ -533,7 +535,10 @@ export function ProviderModelsSection({...@@ -533,7 +535,10 @@ export function ProviderModelsSection({
533 size="xs"535 size="xs"
534 variant="ghost-muted"536 variant="ghost-muted"
535 onClick={() =>537 onClick={() =>
536 onHiddenModelsChange(nextHiddenModelsForBulkToggle(models, hiddenModels))538 onHiddenModelsChange(
539 nextHiddenModelsForBulkToggle(models, hiddenModels),
540 builtInModels.map((model) => model.slug),
541 )
537 }542 }
538 >543 >
539 {allBuiltInModelsHidden ? "Enable all" : "Disable all"}544 {allBuiltInModelsHidden ? "Enable all" : "Disable all"}
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+11-6
...@@ -108,6 +108,8 @@ vi.mock("../../hooks/useSettings", () => ({...@@ -108,6 +108,8 @@ vi.mock("../../hooks/useSettings", () => ({
108 settingsState.updateEnvironmentIds.push(environmentId);108 settingsState.updateEnvironmentIds.push(environmentId);
109 return settingsState.updateSettings;109 return settingsState.updateSettings;
110 },110 },
111 useUpdateEnvironmentModelPreferences: () => settingsState.updateSettings,
112 useToggleEnvironmentModelFavorite: () => () => undefined,
111 usePersistEnvironmentProviderInstanceMutation: (environmentId: EnvironmentId) => {113 usePersistEnvironmentProviderInstanceMutation: (environmentId: EnvironmentId) => {
112 settingsState.mutationEnvironmentIds.push(environmentId);114 settingsState.mutationEnvironmentIds.push(environmentId);
113 return settingsState.mutateProviderInstance;115 return settingsState.mutateProviderInstance;
...@@ -344,16 +346,19 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -344,16 +346,19 @@ describe("EnvironmentProviderSettings routing", () => {
344 });346 });
345347
346 it.each([348 it.each([
347 ["onFavoriteModelsChange", { favorites: [{ provider: codexId, model: "chosen" }] }],349 [
350 "onFavoriteModelsChange",
351 { setModelFavorites: [{ provider: codexId, model: "chosen", favorite: true }] },
352 ],
348 [353 [
349 "onHiddenModelsChange",354 "onHiddenModelsChange",
350 { providerModelPreferences: { [codexId]: { hiddenModels: ["chosen"], modelOrder: [] } } },355 { setModelsHidden: [{ provider: codexId, model: "chosen", hidden: true }] },
351 ],356 ],
352 [357 [
353 "onModelOrderChange",358 "onModelOrderChange",
354 { providerModelPreferences: { [codexId]: { hiddenModels: [], modelOrder: ["chosen"] } } },359 { setProviderModelOrder: { provider: codexId, modelOrder: ["chosen"] } },
355 ],360 ],
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) => {
357 atoms.providers = [provider()];362 atoms.providers = [provider()];
358 const panel = renderPanel();363 const panel = renderPanel();
359 const editor = visitElements(364 const editor = visitElements(
...@@ -363,8 +368,8 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -363,8 +368,8 @@ describe("EnvironmentProviderSettings routing", () => {
363 expect(editor).not.toBeNull();368 expect(editor).not.toBeNull();
364 if (!editor) throw new Error("Provider editor was not rendered");369 if (!editor) throw new Error("Provider editor was not rendered");
365 (editor.props[action] as (models: string[]) => void)(["chosen"]);370 (editor.props[action] as (models: string[]) => void)(["chosen"]);
366 expect(settingsState.updateClientSettings).toHaveBeenCalledExactlyOnceWith(expected);371 expect(settingsState.updateSettings).toHaveBeenCalledExactlyOnceWith(expected);
367 expect(settingsState.updateSettings).not.toHaveBeenCalled();372 expect(settingsState.updateClientSettings).not.toHaveBeenCalled();
368 });373 });
369374
370 it("does not substitute another account when the requested instance was removed", () => {375 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";...@@ -40,8 +40,9 @@ import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
40import {40import {
41 useEnvironmentSettings,41 useEnvironmentSettings,
42 usePersistEnvironmentProviderInstanceMutation,42 usePersistEnvironmentProviderInstanceMutation,
43 useUpdateClientSettings,
44 useUpdateEnvironmentSettings,43 useUpdateEnvironmentSettings,
44 useUpdateEnvironmentModelPreferences,
45 useToggleEnvironmentModelFavorite,
45} from "../../hooks/useSettings";46} from "../../hooks/useSettings";
46import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";47import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
47import { cn } from "../../lib/utils";48import { cn } from "../../lib/utils";
...@@ -123,20 +124,12 @@ import {...@@ -123,20 +124,12 @@ import {
123 resolveSelectedProviderEnvironmentId,124 resolveSelectedProviderEnvironmentId,
124} from "./ProviderSettingsPanel.logic";125} from "./ProviderSettingsPanel.logic";
125126
126function withoutProviderInstanceKey<V>(127function modelListChanges(current: ReadonlyArray<string>, next: ReadonlyArray<string>) {
127 record: Readonly<Record<ProviderInstanceId, V>> | undefined,128 const currentSet = new Set(current);
128 key: ProviderInstanceId,129 const nextSet = new Set(next.map((model) => model.trim()).filter(Boolean));
129): Record<ProviderInstanceId, V> {130 return [...new Set([...currentSet, ...nextSet])]
130 const next = { ...record } as Record<ProviderInstanceId, V>;131 .filter((model) => currentSet.has(model) !== nextSet.has(model))
131 delete next[key];132 .map((model) => ({ model, selected: nextSet.has(model) }));
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);
140}133}
141134
142function providerConfigString(config: unknown, key: string): string | null {135function providerConfigString(config: unknown, key: string): string | null {
...@@ -565,8 +558,9 @@ export function EnvironmentProviderSettings({...@@ -565,8 +558,9 @@ export function EnvironmentProviderSettings({
565 const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope);558 const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope);
566 const canRefreshProviders = useEnvironmentScope(environmentId, AuthOrchestrationReadScope);559 const canRefreshProviders = useEnvironmentScope(environmentId, AuthOrchestrationReadScope);
567 const updateSettings = useUpdateEnvironmentSettings(environmentId);560 const updateSettings = useUpdateEnvironmentSettings(environmentId);
561 const updateModelPreferences = useUpdateEnvironmentModelPreferences(environmentId);
562 const toggleModelFavorite = useToggleEnvironmentModelFavorite(environmentId);
568 const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId);563 const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId);
569 const updateClientSettings = useUpdateClientSettings();
570 const serverProviders =564 const serverProviders =
571 useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;565 useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
572 const projects = useProjects().filter((project) => project.environmentId === environmentId);566 const projects = useProjects().filter((project) => project.environmentId === environmentId);
...@@ -873,50 +867,6 @@ export function EnvironmentProviderSettings({...@@ -873,50 +867,6 @@ export function EnvironmentProviderSettings({
873 }867 }
874 };868 };
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
920 const resetDefaultInstance = async (driverKind: ProviderDriverKind) => {870 const resetDefaultInstance = async (driverKind: ProviderDriverKind) => {
921 const result = await persistProviderInstance({871 const result = await persistProviderInstance({
922 operation: "remove",872 operation: "remove",
...@@ -1072,19 +1022,34 @@ export function EnvironmentProviderSettings({...@@ -1072,19 +1022,34 @@ export function EnvironmentProviderSettings({
1072 hiddenModels={modelPreferences.hiddenModels}1022 hiddenModels={modelPreferences.hiddenModels}
1073 favoriteModels={favoriteModels}1023 favoriteModels={favoriteModels}
1074 modelOrder={modelPreferences.modelOrder}1024 modelOrder={modelPreferences.modelOrder}
1075 onHiddenModelsChange={(hiddenModels) =>1025 onHiddenModelsChange={(next, affectedModels) => {
1076 updateProviderModelPreferences(row.instanceId, {1026 const hidden = new Set(next);
1077 ...modelPreferences,1027 const models =
1078 hiddenModels,1028 affectedModels ??
1079 })1029 modelListChanges(modelPreferences.hiddenModels, next).map((change) => change.model);
1080 }1030 void updateModelPreferences({
1081 onFavoriteModelsChange={(next) => updateProviderFavoriteModels(row.instanceId, next)}1031 setModelsHidden: models.map((model) => ({
1082 onModelOrderChange={(modelOrder) =>1032 provider: row.instanceId,
1083 updateProviderModelPreferences(row.instanceId, {1033 model,
1084 ...modelPreferences,1034 hidden: hidden.has(model),
1085 modelOrder,1035 })),
1086 })1036 });
1087 }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 }}
1088 onInstallRecommended={1053 onInstallRecommended={
1089 !readOnly &&1054 !readOnly &&
1090 liveProvider?.compatibilityAdvisory?.message &&1055 liveProvider?.compatibilityAdvisory?.message &&
apps/web/src/components/settings/ScheduledTasksSettings.tsx+1
...@@ -1122,6 +1122,7 @@ function ScheduledTaskEditorDialog({...@@ -1122,6 +1122,7 @@ function ScheduledTaskEditorDialog({
11221122
1123 <Field label="Model">1123 <Field label="Model">
1124 <ProviderModelPicker1124 <ProviderModelPicker
1125 environmentId={environmentId}
1125 disabled={saving || !connected}1126 disabled={saving || !connected}
1126 activeInstanceId={activeInstanceId}1127 activeInstanceId={activeInstanceId}
1127 model={activeModel}1128 model={activeModel}
apps/web/src/components/settings/SettingsPanels.tsx+1
...@@ -3259,6 +3259,7 @@ export function GeneralSettingsPanel() {...@@ -3259,6 +3259,7 @@ export function GeneralSettingsPanel() {
3259 ) : (3259 ) : (
3260 <div className="flex flex-wrap items-center justify-end gap-1.5">3260 <div className="flex flex-wrap items-center justify-end gap-1.5">
3261 <ProviderModelPicker3261 <ProviderModelPicker
3262 environmentId={environmentId}
3262 activeInstanceId={textGenInstanceId}3263 activeInstanceId={textGenInstanceId}
3263 model={textGenModel}3264 model={textGenModel}
3264 lockedProvider={null}3265 lockedProvider={null}
apps/web/src/components/settings/SourceControlWritingSettings.tsx+1
...@@ -301,6 +301,7 @@ export function SourceControlWritingSettingsSection() {...@@ -301,6 +301,7 @@ export function SourceControlWritingSettingsSection() {
301 ) : null}301 ) : null}
302 {usesDedicatedModel && canEnableDedicatedModel ? (302 {usesDedicatedModel && canEnableDedicatedModel ? (
303 <ProviderModelPicker303 <ProviderModelPicker
304 environmentId={environmentId}
304 activeInstanceId={activeSelection.instanceId}305 activeInstanceId={activeSelection.instanceId}
305 model={activeSelection.model}306 model={activeSelection.model}
306 lockedProvider={null}307 lockedProvider={null}
apps/web/src/components/settings/scopedSettings.test.ts+14
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 EnvironmentAuthorizationError,3 EnvironmentAuthorizationError,
4 EnvironmentId,4 EnvironmentId,
5 ProjectId,5 ProjectId,
6 ProviderInstanceId,
6 type ServerSettings,7 type ServerSettings,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import { describe, expect, it, vi } from "vite-plus/test";9import { describe, expect, it, vi } from "vite-plus/test";
...@@ -644,3 +645,16 @@ describe("partial object patches at project scope", () => {...@@ -644,3 +645,16 @@ describe("partial object patches at project scope", () => {
644 });645 });
645 });646 });
646});647});
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(...@@ -216,7 +216,7 @@ export function planScopedSettingsPatch(
216 patch: ScopedSettingsPatch,216 patch: ScopedSettingsPatch,
217) {217) {
218 const clientPatch = Object.fromEntries(218 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)),
220 ) as ClientSettingsPatch;220 ) as ClientSettingsPatch;
221 const serverPatch = Object.fromEntries(221 const serverPatch = Object.fromEntries(
222 Object.entries(patch).filter(([key]) => SERVER_KEYS.has(key)),222 Object.entries(patch).filter(([key]) => SERVER_KEYS.has(key)),
apps/web/src/components/settings/useScopedModelAvailability.ts+2-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type { ProviderInstanceId, UnifiedSettings } from "@t3tools/contracts";1import type { ProviderInstanceId, UnifiedSettings } from "@t3tools/contracts";
2import { useCallback } from "react";2import { useCallback } from "react";
33
4import { mergeEnvironmentSettings } from "../../hooks/useSettings";
4import { getCustomModelOptionsByInstance } from "../../modelSelection";5import { getCustomModelOptionsByInstance } from "../../modelSelection";
5import {6import {
6 applyProviderInstanceSettings,7 applyProviderInstanceSettings,
...@@ -36,7 +37,7 @@ export function useScopedModelDisabledReason(...@@ -36,7 +37,7 @@ export function useScopedModelDisabledReason(
36 candidate.settings,37 candidate.settings,
37 ).find((option) => option.instanceId === instanceId);38 ).find((option) => option.instanceId === instanceId);
38 const options = getCustomModelOptionsByInstance(39 const options = getCustomModelOptionsByInstance(
39 { ...settings, ...candidate.settings },40 mergeEnvironmentSettings(candidate.settings, settings),
40 config.providers,41 config.providers,
41 ).get(instanceId);42 ).get(instanceId);
42 if (43 if (
apps/web/src/components/settings/useScopedSettings.ts+2
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 mergeEnvironmentSettings,19 mergeEnvironmentSettings,
20 persistClientSettingsPatch,20 persistClientSettingsPatch,
21 useClientSettings,21 useClientSettings,
22 useMigrateModelPreferences,
22} from "../../hooks/useSettings";23} from "../../hooks/useSettings";
23import { serverEnvironment } from "../../state/server";24import { serverEnvironment } from "../../state/server";
24import { useAtomCommand } from "../../state/use-atom-command";25import { useAtomCommand } from "../../state/use-atom-command";
...@@ -42,6 +43,7 @@ export function useScopedSettings<T = UnifiedSettings>(...@@ -42,6 +43,7 @@ export function useScopedSettings<T = UnifiedSettings>(
42 const { target } = useSettingsScope();43 const { target } = useSettingsScope();
43 const clientSettings = useClientSettings();44 const clientSettings = useClientSettings();
44 const serverSettings = target?.settings ?? DEFAULT_SERVER_SETTINGS;45 const serverSettings = target?.settings ?? DEFAULT_SERVER_SETTINGS;
46 useMigrateModelPreferences(target?.environmentId ?? null, target?.settings ?? null);
45 const settings = useMemo(47 const settings = useMemo(
46 () => mergeEnvironmentSettings(serverSettings, clientSettings),48 () => mergeEnvironmentSettings(serverSettings, clientSettings),
47 [clientSettings, serverSettings],49 [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 {...@@ -23,8 +23,10 @@ import {
23 mergeEnvironmentSettings,23 mergeEnvironmentSettings,
24 persistClientSettingsPatch,24 persistClientSettingsPatch,
25 persistClientSettingsUpdate,25 persistClientSettingsUpdate,
26 prepareModelPreferencesPatch,
26 resolveEnvironmentIdentificationMode,27 resolveEnvironmentIdentificationMode,
27} from "./useSettings";28} from "./useSettings";
29import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
2830
29beforeEach(() => {31beforeEach(() => {
30 persistenceMocks.getClientSettings.mockReset().mockResolvedValue(null);32 persistenceMocks.getClientSettings.mockReset().mockResolvedValue(null);
...@@ -45,6 +47,44 @@ describe("client settings hydration", () => {...@@ -45,6 +47,44 @@ describe("client settings hydration", () => {
45 const onboardingCompletedAt = "2026-09-05T12:00:00.000Z";47 const onboardingCompletedAt = "2026-09-05T12:00:00.000Z";
46 const complete = (current: ClientSettings) => ({ ...current, onboardingCompletedAt });48 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
48 it("rejects completion after a failed read and preserves saved preferences on retry", async () => {88 it("rejects completion after a failed read and preserves saved preferences on retry", async () => {
49 const failure = new Error("storage unavailable");89 const failure = new Error("storage unavailable");
50 vi.spyOn(console, "error").mockImplementation(() => undefined);90 vi.spyOn(console, "error").mockImplementation(() => undefined);
...@@ -451,3 +491,20 @@ describe("onboarding completion persistence", () => {...@@ -451,3 +491,20 @@ describe("onboarding completion persistence", () => {
451 expect(persist).toHaveBeenLastCalledWith(completedSettings);491 expect(persist).toHaveBeenLastCalledWith(completedSettings);
452 });492 });
453});493});
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 @@...@@ -9,14 +9,15 @@
9 * access is intentionally named as such so environment-sensitive consumers9 * access is intentionally named as such so environment-sensitive consumers
10 * cannot silently read the wrong server's settings.10 * cannot silently read the wrong server's settings.
11 */11 */
12import { useCallback, useMemo, useSyncExternalStore } from "react";12import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore, use } from "react";
13import { useAtomValue } from "@effect/atom-react";13import { RegistryContext, useAtomValue } from "@effect/atom-react";
14import {14import {
15 DEFAULT_SERVER_SETTINGS,15 DEFAULT_SERVER_SETTINGS,
16 AuthSettingsWriteScope,16 AuthSettingsWriteScope,
17 requiredScopesForServerSettingsPatch,17 requiredScopesForServerSettingsPatch,
18 type EnvironmentId,18 type EnvironmentId,
19 type ProviderInstanceMutation,19 type ProviderInstanceMutation,
20 type ProviderInstanceId,
20 ServerSettings,21 ServerSettings,
21 type ServerSettingsPatch,22 type ServerSettingsPatch,
22 sessionGrantsScope,23 sessionGrantsScope,
...@@ -46,6 +47,10 @@ import {...@@ -46,6 +47,10 @@ import {
46 subscribeToThemePreview,47 subscribeToThemePreview,
47 themeAllowsSidebarArtwork,48 themeAllowsSidebarArtwork,
48} from "~/themePalette";49} from "~/themePalette";
50import {
51 modelPreferencesMigrationPatch,
52 resolveModelPreferences,
53} from "@t3tools/shared/serverSettings";
49import * as Struct from "effect/Struct";54import * as Struct from "effect/Struct";
50import * as Option from "effect/Option";55import * as Option from "effect/Option";
51import { AsyncResult, Atom } from "effect/reactivity";56import { AsyncResult, Atom } from "effect/reactivity";
...@@ -318,7 +323,38 @@ export function mergeEnvironmentSettings(...@@ -318,7 +323,38 @@ export function mergeEnvironmentSettings(
318): UnifiedSettings {323): UnifiedSettings {
319 // Decode drops retired client keys, but older untyped persistence adapters324 // Decode drops retired client keys, but older untyped persistence adapters
320 // can still return them. Server-owned values must always win.325 // 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]);
322}358}
323359
324function useMergedSettings<T>(360function useMergedSettings<T>(
...@@ -391,12 +427,135 @@ export function useLegacySidebarEnabled(): boolean {...@@ -391,12 +427,135 @@ export function useLegacySidebarEnabled(): boolean {
391 return settingsHydrated && legacySidebarEnabled;427 return settingsHydrated && legacySidebarEnabled;
392}428}
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
394/** Read current settings for one environment, merged with client-local preferences. */552/** Read current settings for one environment, merged with client-local preferences. */
395export function useEnvironmentSettings<T = UnifiedSettings>(553export function useEnvironmentSettings<T = UnifiedSettings>(
396 environmentId: EnvironmentId,554 environmentId: EnvironmentId,
397 selector?: (settings: UnifiedSettings) => T,555 selector?: (settings: UnifiedSettings) => T,
398): T {556): T {
399 const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId));557 const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId));
558 useMigrateModelPreferences(environmentId, serverSettings);
400 return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector);559 return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector);
401}560}
402561
...@@ -419,7 +578,13 @@ export function usePersistEnvironmentProviderInstanceMutation(environmentId: Env...@@ -419,7 +578,13 @@ export function usePersistEnvironmentProviderInstanceMutation(environmentId: Env
419export function usePrimarySettings<T = UnifiedSettings>(578export function usePrimarySettings<T = UnifiedSettings>(
420 selector?: (settings: UnifiedSettings) => T,579 selector?: (settings: UnifiedSettings) => T,
421): T {580): 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);
423}588}
424589
425export const PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE =590export const PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE =
...@@ -618,7 +783,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -618,7 +783,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
618 return updateSettings;783 return updateSettings;
619}784}
620785
621export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) {786export function useUpdateEnvironmentSettings(environmentId: EnvironmentId | null) {
622 return useUpdateSettingsTarget(environmentId);787 return useUpdateSettingsTarget(environmentId);
623}788}
624789
packages/contracts/src/settings.test.ts+31
...@@ -1080,3 +1080,34 @@ describe("requiredScopesForProjectMutation", () => {...@@ -1080,3 +1080,34 @@ describe("requiredScopesForProjectMutation", () => {
1080 ).toEqual(["orchestration:operate", "settings:write"]);1080 ).toEqual(["orchestration:operate", "settings:write"]);
1081 });1081 });
1082});1082});
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"]);...@@ -299,6 +299,25 @@ export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]);
299export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]);299export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]);
300export type ChatWidth = typeof ChatWidth.Type;300export 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
302export const ClientSettingsSchema = Schema.Struct({321export const ClientSettingsSchema = Schema.Struct({
303 notificationMode: NotificationMode.pipe(322 notificationMode: NotificationMode.pipe(
304 Schema.withDecodingDefault(Effect.succeed("off" as const)),323 Schema.withDecodingDefault(Effect.succeed("off" as const)),
...@@ -413,31 +432,11 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -413,31 +432,11 @@ export const ClientSettingsSchema = Schema.Struct({
413 persistComposerContextStrip: Schema.Boolean.pipe(432 persistComposerContextStrip: Schema.Boolean.pipe(
414 Schema.withDecodingDefault(Effect.succeed(false)),433 Schema.withDecodingDefault(Effect.succeed(false)),
415 ),434 ),
416 // Model favorites. Historically keyed by provider kind, now435 // Legacy client values retained for migration to the connected environment.
417 // widened to `ProviderInstanceId` so users can favorite a specific model436 favorites: ModelFavorites.pipe(Schema.withDecodingDefault(Effect.succeed([]))),
418 // on a custom provider instance (e.g. "Codex Personal · gpt-5") without437 providerModelPreferences: ProviderModelPreferences.pipe(
419 // the UI collapsing it into the same bucket as the default Codex. The438 Schema.withDecodingDefault(Effect.succeed({})),
420 // widening is backward-compatible by construction: prior provider-kind439 ),
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({}))),
441 pullRequestMergeMethodOverrides: Schema.Record(440 pullRequestMergeMethodOverrides: Schema.Record(
442 TrimmedNonEmptyString,441 TrimmedNonEmptyString,
443 PullRequestMergeMethod,442 PullRequestMergeMethod,
...@@ -1060,6 +1059,12 @@ export const StorageCleanupSettings = Schema.Struct({...@@ -1060,6 +1059,12 @@ export const StorageCleanupSettings = Schema.Struct({
1060export type StorageCleanupSettings = typeof StorageCleanupSettings.Type;1059export type StorageCleanupSettings = typeof StorageCleanupSettings.Type;
10611060
1062export const ServerSettings = Schema.Struct({1061export 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 ),
1063 worktreeCleanup: WorktreeCleanup.pipe(Schema.withDecodingDefault(Effect.succeed(null))),1068 worktreeCleanup: WorktreeCleanup.pipe(Schema.withDecodingDefault(Effect.succeed(null))),
1064 storageCleanup: StorageCleanupSettings.pipe(1069 storageCleanup: StorageCleanupSettings.pipe(
1065 Schema.withDecodingDefault(Effect.succeed(Schema.decodeSync(StorageCleanupSettings)({}))),1070 Schema.withDecodingDefault(Effect.succeed(Schema.decodeSync(StorageCleanupSettings)({}))),
...@@ -1384,7 +1389,7 @@ export class ServerSettingsError extends Schema.TaggedError<ServerSettingsError>...@@ -1384,7 +1389,7 @@ export class ServerSettingsError extends Schema.TaggedError<ServerSettingsError>
13841389
1385// ── Unified type ─────────────────────────────────────────────────────1390// ── Unified type ─────────────────────────────────────────────────────
13861391
1387export type UnifiedSettings = ServerSettings & ClientSettings;1392export type UnifiedSettings = Omit<ServerSettings, keyof ModelPreferences> & ClientSettings;
1388export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = {1393export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = {
1389 ...DEFAULT_SERVER_SETTINGS,1394 ...DEFAULT_SERVER_SETTINGS,
1390 ...DEFAULT_CLIENT_SETTINGS,1395 ...DEFAULT_CLIENT_SETTINGS,
...@@ -1399,6 +1404,24 @@ const ModelSelectionPatch = Schema.Struct({...@@ -1399,6 +1404,24 @@ const ModelSelectionPatch = Schema.Struct({
1399});1404});
14001405
1401export const ServerSettingsPatch = Schema.Struct({1406export 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 ),
1402 worktreeCleanup: Schema.optionalKey(1425 worktreeCleanup: Schema.optionalKey(
1403 Schema.NullOr(1426 Schema.NullOr(
1404 Schema.Union([1427 Schema.Union([
...@@ -1606,27 +1629,8 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -1606,27 +1629,8 @@ export const ClientSettingsPatch = Schema.Struct({
1606 fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference),1629 fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference),
1607 fontSmoothing: Schema.optionalKey(Schema.Boolean),1630 fontSmoothing: Schema.optionalKey(Schema.Boolean),
1608 persistComposerContextStrip: Schema.optionalKey(Schema.Boolean),1631 persistComposerContextStrip: Schema.optionalKey(Schema.Boolean),
1609 favorites: Schema.optionalKey(1632 favorites: Schema.optionalKey(ModelFavorites),
1610 Schema.Array(1633 providerModelPreferences: Schema.optionalKey(ProviderModelPreferences),
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 ),
1630 pullRequestMergeMethodOverrides: Schema.optionalKey(1634 pullRequestMergeMethodOverrides: Schema.optionalKey(
1631 Schema.Record(TrimmedNonEmptyString, PullRequestMergeMethod),1635 Schema.Record(TrimmedNonEmptyString, PullRequestMergeMethod),
1632 ),1636 ),
packages/shared/src/serverSettings.test.ts+174
...@@ -13,6 +13,8 @@ import { createModelSelection } from "./model.ts";...@@ -13,6 +13,8 @@ import { createModelSelection } from "./model.ts";
13import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts";13import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts";
14import {14import {
15 applyServerSettingsPatch,15 applyServerSettingsPatch,
16 modelPreferencesMigrationPatch,
17 resolveModelPreferences,
16 isModelSelectionProviderEnabled,18 isModelSelectionProviderEnabled,
17 parsePersistedServerObservabilitySettings,19 parsePersistedServerObservabilitySettings,
18 resolveSourceControlWriterModelSelection,20 resolveSourceControlWriterModelSelection,
...@@ -823,3 +825,175 @@ describe("worktreesDirectory", () => {...@@ -823,3 +825,175 @@ describe("worktreesDirectory", () => {
823 expect(back.previousWorktreesDirectories).toEqual(["/b"]);825 expect(back.previousWorktreesDirectories).toEqual(["/b"]);
824 });826 });
825});827});
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 @@...@@ -1,3 +1,4 @@
1import type { ModelPreferences } from "@t3tools/contracts/settings";
1import {2import {
2 isProviderAvailable,3 isProviderAvailable,
3 isUnconfiguredDefaultInstanceEnabled,4 isUnconfiguredDefaultInstanceEnabled,
...@@ -260,6 +261,11 @@ export function applyServerSettingsPatch(...@@ -260,6 +261,11 @@ export function applyServerSettingsPatch(
260 const patch = translateLegacyProjectOverridePatch(current, rawPatch);261 const patch = translateLegacyProjectOverridePatch(current, rawPatch);
261 const selectionPatch = patch.textGenerationModelSelection;262 const selectionPatch = patch.textGenerationModelSelection;
262 const {263 const {
264 migrateModelPreferences,
265 setModelFavorites,
266 setModelsHidden,
267 setProviderModelOrder,
268 providerModelPreferences,
263 automaticGitFetchInterval,269 automaticGitFetchInterval,
264 providerHealthRefreshInterval,270 providerHealthRefreshInterval,
265 backgroundActivityProfile,271 backgroundActivityProfile,
...@@ -314,9 +320,57 @@ export function applyServerSettingsPatch(...@@ -314,9 +320,57 @@ export function applyServerSettingsPatch(
314 },320 },
315 }321 }
316 : undefined;322 : 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 }
317 const next = deepMerge(current, patchForMerge);369 const next = deepMerge(current, patchForMerge);
318 const nextWithReplacementsBase = {370 const nextWithReplacementsBase = {
319 ...next,371 ...next,
372 favorites,
373 providerModelPreferences: nextModelPreferences,
320 ...(worktreeCleanupPatch === undefined374 ...(worktreeCleanupPatch === undefined
321 ? {}375 ? {}
322 : {376 : {
...@@ -459,3 +513,39 @@ export function applyServerSettingsPatch(...@@ -459,3 +513,39 @@ export function applyServerSettingsPatch(
459 textGenerationModelSelection: createModelSelection(instanceId, model, options),513 textGenerationModelSelection: createModelSelection(instanceId, model, options),
460 };514 };
461}515}
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}