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