| author | |
| committer | |
| log | f8458952710cc70894bdeb5853d700524a1c7c49 |
| tree | fae44412ae416bac0229d78c34821e5a0a6c0e49 |
| parent | bb9b9aae57ce5614c9c26d540ca51c805cdbf09b |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 53 | import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal"; | 53 | import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal"; |
| 54 | import IconEdit from "@tabler/icons-react-native/IconEdit"; | 54 | import IconEdit from "@tabler/icons-react-native/IconEdit"; |
| 55 | import IconExternalLink from "@tabler/icons-react-native/IconExternalLink"; | 55 | import IconExternalLink from "@tabler/icons-react-native/IconExternalLink"; |
| 56 | import IconEyeOff from "@tabler/icons-react-native/IconEyeOff"; | ||
| 56 | import IconEye from "@tabler/icons-react-native/IconEye"; | 57 | import IconEye from "@tabler/icons-react-native/IconEye"; |
| 57 | import IconFileText from "@tabler/icons-react-native/IconFileText"; | 58 | import IconFileText from "@tabler/icons-react-native/IconFileText"; |
| 58 | import IconFilter from "@tabler/icons-react-native/IconFilter"; | 59 | import 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 | <Pressable | 117 | <Pressable |
| 96 | accessibilityLabel={`${props.isFavorite ? "Remove from" : "Add to"} favorites: ${ | 118 | accessibilityLabel={`${props.isFavorite ? "Remove from" : "Add to"} favorites: ${ |
| 97 | props.option.providerLabel | 119 | props.option.providerLabel |
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+188-34| ... | @@ -1,4 +1,9 @@ | ... | @@ -1,4 +1,9 @@ |
| 1 | import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; | 1 | import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; |
| 2 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; | ||
| 3 | import { | ||
| 4 | modelPreferencesMigrationPatch, | ||
| 5 | resolveModelPreferences, | ||
| 6 | } from "@t3tools/shared/serverSettings"; | ||
| 2 | import type { | 7 | import 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"; |
| 10 | import type { LegendListRenderItemProps } from "@legendapp/list/react-native"; | 16 | import type { LegendListRenderItemProps } from "@legendapp/list/react-native"; |
| 11 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | 17 | import { RegistryContext, useAtomValue } from "@effect/atom-react"; |
| 12 | import { AnimatedLegendList } from "@legendapp/list/reanimated"; | 18 | import { AnimatedLegendList } from "@legendapp/list/reanimated"; |
| 13 | import { | 19 | import { |
| 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"; |
| 49 | import { ProviderIcon } from "../../components/ProviderIcon"; | 56 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 50 | import { ThemedSwitch } from "../../components/ThemedSwitch"; | 57 | import { ThemedSwitch } from "../../components/ThemedSwitch"; |
| 51 | import { cn } from "../../lib/cn"; | 58 | import { cn } from "../../lib/cn"; |
| 52 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; | 59 | import { |
| 60 | buildModelOptions, | ||
| 61 | groupByProvider, | ||
| 62 | type ModelOption, | ||
| 63 | type ProviderGroup, | ||
| 64 | } from "../../lib/modelOptions"; | ||
| 53 | import { applyProviderOptionSelection } from "../../lib/providerOptions"; | 65 | import { applyProviderOptionSelection } from "../../lib/providerOptions"; |
| 54 | import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; | 66 | import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; |
| 55 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 67 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| ... | @@ -62,7 +74,7 @@ import { | ... | @@ -62,7 +74,7 @@ import { |
| 62 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 74 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 63 | import { ChatGptSharingStatus } from "./ChatGptSharingStatus"; | 75 | import { ChatGptSharingStatus } from "./ChatGptSharingStatus"; |
| 64 | import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server"; | 76 | import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server"; |
| 65 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | 77 | import { mobilePreferencesAtom } from "../../state/preferences"; |
| 66 | import { useAtomCommand } from "../../state/use-atom-command"; | 78 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 67 | import { useNewTaskFlow } from "./new-task-flow-provider"; | 79 | import { useNewTaskFlow } from "./new-task-flow-provider"; |
| 68 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | 80 | import { 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"; |
| 93 | import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels"; | 104 | import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels"; |
| 94 | 105 | ||
| ... | @@ -242,6 +253,7 @@ type ThreadSettingsSubmenuPage = | ... | @@ -242,6 +253,7 @@ type ThreadSettingsSubmenuPage = |
| 242 | type ThreadSettingsSessionProps = { | 253 | type 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 | |
| 329 | function ThreadSettingsSessionProvider( | 345 | function 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 = favoritesLoaded | 352 | const settings = config?.settings; |
| 353 | const [manageHidden, setManageHidden] = useState(false); | ||
| 354 | const pendingHidden = useRef(new Set<string>()); | ||
| 355 | const pendingFavorites = useRef( | ||
| 356 | new Map<string, { favorite: boolean; settings: ServerSettings; pending: boolean }>(), | ||
| 357 | ); | ||
| 358 | const [pendingHiddenKeys, setPendingHiddenKeys] = useState<ReadonlySet<string>>(() => new Set()); | ||
| 359 | const providerGroups = useMemo( | ||
| 360 | () => | ||
| 361 | manageHidden && config | ||
| 362 | ? groupByProvider( | ||
| 363 | buildModelOptions(config, props.selectedModel, props.lockedProviderInstanceId, true), | ||
| 364 | ) | ||
| 365 | : props.providerGroups, | ||
| 366 | [ | ||
| 367 | config, | ||
| 368 | manageHidden, | ||
| 369 | props.providerGroups, | ||
| 370 | props.selectedModel, | ||
| 371 | props.lockedProviderInstanceId, | ||
| 372 | ], | ||
| 373 | ); | ||
| 374 | const legacyFavorites = AsyncResult.isSuccess(preferences) | ||
| 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_FILTER | 794 | session.manageHidden || |
| 795 | session.showLegacy || | ||
| 796 | session.providerFilter === FAVORITES_PROVIDER_FILTER | ||
| 646 | ? group.models | 797 | ? 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> |
| 808 | 960 | ||
| 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 | Catalog | 964 | 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 | <SwitchRow | 967 | isLast={Platform.OS === "ios" || !session.hasLegacyModels} |
| 816 | isLast | 968 | /> |
| 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"; |
| 6 | import { | 6 | import { |
| 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"; |
| 14 | 12 | ||
| 15 | function modelOption( | 13 | function modelOption( |
| ... | @@ -58,23 +56,6 @@ describe("thread settings sheet state", () => { | ... | @@ -58,23 +56,6 @@ describe("thread settings sheet state", () => { |
| 58 | ]); | 56 | ]); |
| 59 | }); | 57 | }); |
| 60 | 58 | ||
| 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"); |
| 80 | 61 | ||
| ... | @@ -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 | }); |
| 146 | 127 | ||
| 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 @@ |
| 1 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; | 1 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; |
| 2 | import type { ProviderInstanceId } from "@t3tools/contracts"; | 2 | import type { ProviderInstanceId } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | export type ModelFavorite = { | ||
| 5 | readonly provider: ProviderInstanceId; | ||
| 6 | readonly model: string; | ||
| 7 | }; | ||
| 8 | |||
| 9 | export function modelFavoriteKey(provider: ProviderInstanceId, model: string): string { | 4 | export function modelFavoriteKey(provider: ProviderInstanceId, model: string): string { |
| 10 | return `${provider}:${model}`; | 5 | return `${provider}:${model}`; |
| 11 | } | 6 | } |
| 12 | 7 | ||
| 13 | export 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. */ |
| 25 | export function favoritesFirst( | 9 | export 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 | } |
| 77 | 63 |
apps/mobile/src/lib/modelOptions.test.ts+93-1| ... | @@ -1,6 +1,11 @@ | ... | @@ -1,6 +1,11 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3tools/contracts"; | 3 | import { |
| 4 | DEFAULT_SERVER_SETTINGS, | ||
| 5 | ProviderInstanceId, | ||
| 6 | type ModelSelection, | ||
| 7 | type ServerConfig, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 4 | 9 | ||
| 5 | import { | 10 | import { |
| 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 | |||
| 513 | describe("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>(); |
| 172 | 175 | ||
| ... | @@ -185,7 +188,15 @@ export function buildModelOptions( | ... | @@ -185,7 +188,15 @@ export function buildModelOptions( |
| 185 | const updateRequired = provider.updateRequiredModels?.length | 188 | 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( |
| 215 | 228 | ||
| 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 |
| 20 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; | 20 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 21 | import * as ProjectService from "../project/ProjectService.ts"; | 21 | import * as ProjectService from "../project/ProjectService.ts"; |
| 22 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 22 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 23 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 23 | import * as SecretRequests from "../secrets/SecretRequests.ts"; | 24 | import * as SecretRequests from "../secrets/SecretRequests.ts"; |
| 24 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 25 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 25 | import type * as McpInvocationContext from "./McpInvocationContext.ts"; | 26 | import 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"; |
| 34 | import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts"; | 34 | import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts"; |
| 35 | import * as ProjectService from "../project/ProjectService.ts"; | 35 | import * as ProjectService from "../project/ProjectService.ts"; |
| 36 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 36 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 37 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 37 | import * as SecretRequests from "../secrets/SecretRequests.ts"; | 38 | import * as SecretRequests from "../secrets/SecretRequests.ts"; |
| 38 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; | 39 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; |
| 39 | import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts"; | 40 | import { 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 | ); |
| 1018 | 1027 | ||
| 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"; |
| 85 | import * as ProjectService from "../project/ProjectService.ts"; | 85 | import * as ProjectService from "../project/ProjectService.ts"; |
| 86 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; | 86 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 87 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 87 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 88 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 88 | import { | 89 | import { |
| 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; | ||
| 904 | 906 | ||
| 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 === undefined | 1880 | 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 === 0 | 1891 | 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.ScheduledTaskService | 2607 | | ScheduledTaskService.ScheduledTaskService |
| 2595 | | ProjectService.ProjectService | 2608 | | ProjectService.ProjectService |
| 2596 | | SecretRequests.SecretRequests | 2609 | | 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 { |
| 70 | import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts"; | 70 | import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts"; |
| 71 | import * as ProjectService from "../project/ProjectService.ts"; | 71 | import * as ProjectService from "../project/ProjectService.ts"; |
| 72 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 72 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 73 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 73 | import * as SecretRequests from "../secrets/SecretRequests.ts"; | 74 | import * as SecretRequests from "../secrets/SecretRequests.ts"; |
| 74 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 75 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 75 | import * as McpHttpServer from "./McpHttpServer.ts"; | 76 | import * 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"; |
| 33 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; | 33 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 34 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 34 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 35 | import * as SecretRequests from "../secrets/SecretRequests.ts"; | 35 | import * as SecretRequests from "../secrets/SecretRequests.ts"; |
| 36 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 36 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; | 37 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; |
| 37 | import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; | 38 | import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; |
| 38 | 39 | ||
| ... | @@ -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"; |
| 42 | import * as ProviderRegistry from "../../provider/ProviderRegistry.ts"; | 42 | import * as ProviderRegistry from "../../provider/ProviderRegistry.ts"; |
| 43 | import * as SecretRequests from "../../secrets/SecretRequests.ts"; | 43 | import * as SecretRequests from "../../secrets/SecretRequests.ts"; |
| 44 | import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; | 44 | import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; |
| 45 | import * as ServerSettings from "../../serverSettings.ts"; | ||
| 45 | import * as McpHttpServer from "../McpHttpServer.ts"; | 46 | import * as McpHttpServer from "../McpHttpServer.ts"; |
| 46 | import * as McpInvocationContext from "../McpInvocationContext.ts"; | 47 | import * as McpInvocationContext from "../McpInvocationContext.ts"; |
| 47 | import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts"; | 48 | import * 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 | }); |
| 99 | 99 | ||
| 100 | it.layer(NodeServices.layer)("server settings", (it) => { | 100 | it.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 | <ProviderModelPicker | 5526 | <ProviderModelPicker |
| 5527 | environmentId={environmentId} | ||
| 5527 | compact={false} | 5528 | compact={false} |
| 5528 | isComposerOwned | 5529 | 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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 40 | import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; | 41 | import { |
| 42 | useEnvironmentModelPreferences, | ||
| 43 | useToggleEnvironmentModelFavorite, | ||
| 44 | } from "~/hooks/useSettings"; | ||
| 41 | import { cn } from "~/lib/utils"; | 45 | import { cn } from "~/lib/utils"; |
| 42 | import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; | 46 | import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; |
| 43 | import { TooltipProvider } from "../ui/tooltip"; | 47 | import { TooltipProvider } from "../ui/tooltip"; |
| ... | @@ -153,6 +157,7 @@ function ModelListSeparator() { | ... | @@ -153,6 +157,7 @@ function ModelListSeparator() { |
| 153 | } | 157 | } |
| 154 | 158 | ||
| 155 | export const ModelPickerContent = memo(function ModelPickerContent(props: { | 159 | export 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); | ||
| 281 | 286 | ||
| 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 | ); |
| 654 | 659 | ||
| 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 | <ProviderModelPicker | 42 | <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 | <ProviderModelPicker | 164 | <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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 27 | import { shortcutLabelForCommand } from "../../keybindings"; | 28 | import { shortcutLabelForCommand } from "../../keybindings"; |
| 28 | 29 | ||
| 29 | export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | 30 | export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 31 | environmentId: EnvironmentId | null; | ||
| 30 | /** | 32 | /** |
| 31 | * The instance currently selected in the composer. Drives the trigger | 33 | * 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 | <ModelPickerContent | 295 | <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"; |
| 8 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | 8 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 9 | import { useNavigate } from "@tanstack/react-router"; | 9 | import { useNavigate } from "@tanstack/react-router"; |
| 10 | 10 | ||
| 11 | import { mergeEnvironmentSettings } from "../../hooks/useSettings"; | ||
| 11 | import { getCustomModelOptionsByInstance } from "../../modelSelection"; | 12 | import { getCustomModelOptionsByInstance } from "../../modelSelection"; |
| 12 | import { | 13 | import { |
| 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 | <ProviderModelPicker | 161 | <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; |
| 162 | 164 |
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 saved | 1181 | 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 | <ProviderModelsSection | 1184 | <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 | } |
| 152 | 156 | ||
| ... | @@ -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 | }; |
| 287 | 293 | ||
| 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 | }; |
| 296 | 298 | ||
| 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 | }); |
| 345 | 347 | ||
| 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 | }); |
| 369 | 374 | ||
| 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"; |
| 40 | import { | 40 | import { |
| 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"; |
| 46 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; | 47 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; |
| 47 | import { cn } from "../../lib/utils"; | 48 | import { 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"; |
| 125 | 126 | ||
| 126 | function withoutProviderInstanceKey<V>( | 127 | function 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 | |||
| 135 | function withoutProviderInstanceFavorites( | ||
| 136 | favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>, | ||
| 137 | instanceId: ProviderInstanceId, | ||
| 138 | ) { | ||
| 139 | return favorites.filter((favorite) => favorite.provider !== instanceId); | ||
| 140 | } | 133 | } |
| 141 | 134 | ||
| 142 | function providerConfigString(config: unknown, key: string): string | null { | 135 | function 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 | }; |
| 875 | 869 | ||
| 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({ |
| 1122 | 1122 | ||
| 1123 | <Field label="Model"> | 1123 | <Field label="Model"> |
| 1124 | <ProviderModelPicker | 1124 | <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 | <ProviderModelPicker | 3261 | <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 | <ProviderModelPicker | 303 | <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"; |
| 8 | import { describe, expect, it, vi } from "vite-plus/test"; | 9 | import { 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 | |||
| 649 | it("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 @@ |
| 1 | import type { ProviderInstanceId, UnifiedSettings } from "@t3tools/contracts"; | 1 | import type { ProviderInstanceId, UnifiedSettings } from "@t3tools/contracts"; |
| 2 | import { useCallback } from "react"; | 2 | import { useCallback } from "react"; |
| 3 | 3 | ||
| 4 | import { mergeEnvironmentSettings } from "../../hooks/useSettings"; | ||
| 4 | import { getCustomModelOptionsByInstance } from "../../modelSelection"; | 5 | import { getCustomModelOptionsByInstance } from "../../modelSelection"; |
| 5 | import { | 6 | import { |
| 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"; |
| 23 | import { serverEnvironment } from "../../state/server"; | 24 | import { serverEnvironment } from "../../state/server"; |
| 24 | import { useAtomCommand } from "../../state/use-atom-command"; | 25 | import { 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 @@ | ||
| 1 | import { RegistryContext } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | DEFAULT_SERVER_SETTINGS, | ||
| 4 | EnvironmentId, | ||
| 5 | ProviderInstanceId, | ||
| 6 | type ServerSettings, | ||
| 7 | type ServerSettingsPatch, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as Cause from "effect/Cause"; | ||
| 10 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; | ||
| 11 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | ||
| 12 | import { act } from "react"; | ||
| 13 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 14 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 15 | |||
| 16 | const state = vi.hoisted(() => ({ | ||
| 17 | settings: new Map<EnvironmentId, Atom.Writable<ServerSettings | null>>(), | ||
| 18 | persist: vi.fn(), | ||
| 19 | })); | ||
| 20 | |||
| 21 | vi.mock("~/state/server", () => ({ | ||
| 22 | serverEnvironment: { | ||
| 23 | settingsValueAtom: (id: EnvironmentId) => state.settings.get(id)!, | ||
| 24 | updateSettings: Symbol("updateSettings"), | ||
| 25 | }, | ||
| 26 | primaryServerSettingsAtom: undefined, | ||
| 27 | })); | ||
| 28 | vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => state.persist })); | ||
| 29 | vi.mock("~/localApi", () => ({ | ||
| 30 | ensureLocalApi: () => ({ persistence: { getClientSettings: async () => null } }), | ||
| 31 | })); | ||
| 32 | |||
| 33 | import { useToggleEnvironmentModelFavorite } from "./useSettings"; | ||
| 34 | |||
| 35 | const primaryId = EnvironmentId.make("primary"); | ||
| 36 | const remoteId = EnvironmentId.make("remote"); | ||
| 37 | const provider = ProviderInstanceId.make("codex_work"); | ||
| 38 | const model = "gpt-6.1-sol"; | ||
| 39 | const initialSettings = { | ||
| 40 | ...DEFAULT_SERVER_SETTINGS, | ||
| 41 | favorites: [], | ||
| 42 | providerModelPreferences: {}, | ||
| 43 | } satisfies ServerSettings; | ||
| 44 | let registry: AtomRegistry.AtomRegistry; | ||
| 45 | let renderer: ReactTestRenderer | undefined; | ||
| 46 | let durable: Map<EnvironmentId, ServerSettings>; | ||
| 47 | let writes: Array<{ | ||
| 48 | environmentId: EnvironmentId; | ||
| 49 | patch: ServerSettingsPatch; | ||
| 50 | finish: (success?: boolean) => void; | ||
| 51 | }>; | ||
| 52 | |||
| 53 | function FavoriteEditor({ environmentId }: { environmentId: EnvironmentId }) { | ||
| 54 | const toggle = useToggleEnvironmentModelFavorite(environmentId); | ||
| 55 | return <button onClick={() => toggle(provider, model)}>Toggle favorite</button>; | ||
| 56 | } | ||
| 57 | |||
| 58 | function editor(environmentId: EnvironmentId) { | ||
| 59 | return ( | ||
| 60 | <RegistryContext.Provider value={registry}> | ||
| 61 | <FavoriteEditor environmentId={environmentId} /> | ||
| 62 | </RegistryContext.Provider> | ||
| 63 | ); | ||
| 64 | } | ||
| 65 | |||
| 66 | async function mount(environmentId = primaryId) { | ||
| 67 | await act(() => { | ||
| 68 | renderer = create(editor(environmentId)); | ||
| 69 | }); | ||
| 70 | } | ||
| 71 | |||
| 72 | function click(): Promise<void> { | ||
| 73 | return renderer!.root.findByType("button").props.onClick(); | ||
| 74 | } | ||
| 75 | |||
| 76 | beforeEach(() => { | ||
| 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 | |||
| 112 | afterEach(async () => { | ||
| 113 | await act(() => renderer?.unmount()); | ||
| 114 | renderer = undefined; | ||
| 115 | registry.dispose(); | ||
| 116 | vi.unstubAllGlobals(); | ||
| 117 | }); | ||
| 118 | |||
| 119 | describe("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"; |
| 29 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | ||
| 28 | 30 | ||
| 29 | beforeEach(() => { | 31 | beforeEach(() => { |
| 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 }); |
| 47 | 49 | ||
| 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 | |||
| 495 | it("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 consumers | 9 | * 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 | */ |
| 12 | import { useCallback, useMemo, useSyncExternalStore } from "react"; | 12 | import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore, use } from "react"; |
| 13 | import { useAtomValue } from "@effect/atom-react"; | 13 | import { RegistryContext, useAtomValue } from "@effect/atom-react"; |
| 14 | import { | 14 | import { |
| 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"; |
| 50 | import { | ||
| 51 | modelPreferencesMigrationPatch, | ||
| 52 | resolveModelPreferences, | ||
| 53 | } from "@t3tools/shared/serverSettings"; | ||
| 49 | import * as Struct from "effect/Struct"; | 54 | import * as Struct from "effect/Struct"; |
| 50 | import * as Option from "effect/Option"; | 55 | import * as Option from "effect/Option"; |
| 51 | import { AsyncResult, Atom } from "effect/reactivity"; | 56 | import { 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 adapters | 324 | // 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 | |||
| 333 | const pendingModelPreferencesMigrations = new Set<EnvironmentId>(); | ||
| 334 | |||
| 335 | /** Seed saved client preferences once without overwriting another client's edits. */ | ||
| 336 | export 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 | } |
| 323 | 359 | ||
| 324 | function useMergedSettings<T>( | 360 | function 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 | } |
| 393 | 429 | ||
| 430 | /** Hydrate legacy preferences before a mutation can claim either server-owned list. */ | ||
| 431 | export 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 | |||
| 441 | export 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. */ | ||
| 467 | export 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 | |||
| 537 | const noEnvironmentSettingsAtom = Atom.make<ServerSettings | null>(null); | ||
| 538 | |||
| 539 | /** Model pickers may render before a settings target has been selected. */ | ||
| 540 | export 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. */ |
| 395 | export function useEnvironmentSettings<T = UnifiedSettings>( | 553 | export 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 | } |
| 402 | 561 | ||
| ... | @@ -419,7 +578,13 @@ export function usePersistEnvironmentProviderInstanceMutation(environmentId: Env | ... | @@ -419,7 +578,13 @@ export function usePersistEnvironmentProviderInstanceMutation(environmentId: Env |
| 419 | export function usePrimarySettings<T = UnifiedSettings>( | 578 | export 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 | } |
| 424 | 589 | ||
| 425 | export const PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE = | 590 | export 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 | } |
| 620 | 785 | ||
| 621 | export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { | 786 | export function useUpdateEnvironmentSettings(environmentId: EnvironmentId | null) { |
| 622 | return useUpdateSettingsTarget(environmentId); | 787 | return useUpdateSettingsTarget(environmentId); |
| 623 | } | 788 | } |
| 624 | 789 |
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 | |||
| 1084 | describe("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"]); |
| 299 | export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]); | 299 | export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]); |
| 300 | export type ChatWidth = typeof ChatWidth.Type; | 300 | export type ChatWidth = typeof ChatWidth.Type; |
| 301 | 301 | ||
| 302 | /** Preferences shared by clients connected to the same environment. */ | ||
| 303 | export 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 | }); | ||
| 308 | export const ModelFavorites = Schema.Array(ModelFavorite); | ||
| 309 | export 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 | ); | ||
| 316 | export type ModelPreferences = { | ||
| 317 | readonly favorites: typeof ModelFavorites.Type; | ||
| 318 | readonly providerModelPreferences: typeof ProviderModelPreferences.Type; | ||
| 319 | }; | ||
| 320 | |||
| 302 | export const ClientSettingsSchema = Schema.Struct({ | 321 | export 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, now | 435 | // Legacy client values retained for migration to the connected environment. |
| 417 | // widened to `ProviderInstanceId` so users can favorite a specific model | 436 | favorites: ModelFavorites.pipe(Schema.withDecodingDefault(Effect.succeed([]))), |
| 418 | // on a custom provider instance (e.g. "Codex Personal · gpt-5") without | 437 | providerModelPreferences: ProviderModelPreferences.pipe( |
| 419 | // the UI collapsing it into the same bucket as the default Codex. The | 438 | Schema.withDecodingDefault(Effect.succeed({})), |
| 420 | // widening is backward-compatible by construction: prior provider-kind | 439 | ), |
| 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({ |
| 1060 | export type StorageCleanupSettings = typeof StorageCleanupSettings.Type; | 1059 | export type StorageCleanupSettings = typeof StorageCleanupSettings.Type; |
| 1061 | 1060 | ||
| 1062 | export const ServerSettings = Schema.Struct({ | 1061 | export 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> |
| 1384 | 1389 | ||
| 1385 | // ── Unified type ───────────────────────────────────────────────────── | 1390 | // ── Unified type ───────────────────────────────────────────────────── |
| 1386 | 1391 | ||
| 1387 | export type UnifiedSettings = ServerSettings & ClientSettings; | 1392 | export type UnifiedSettings = Omit<ServerSettings, keyof ModelPreferences> & ClientSettings; |
| 1388 | export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = { | 1393 | export 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 | }); |
| 1400 | 1405 | ||
| 1401 | export const ServerSettingsPatch = Schema.Struct({ | 1406 | export 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"; |
| 13 | import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts"; | 13 | import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts"; |
| 14 | import { | 14 | import { |
| 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 | |||
| 829 | describe("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 @@ |
| 1 | import type { ModelPreferences } from "@t3tools/contracts/settings"; | ||
| 1 | import { | 2 | import { |
| 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 === undefined | 374 | ...(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. */ | ||
| 518 | export 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. */ | ||
| 530 | export 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 | } |