From f8458952710cc70894bdeb5853d700524a1c7c49 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 11 Oct 2026 03:04:56 -0400 Subject: [PATCH] fix: sync model favorites and visibility across clients (#16816) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- apps/mobile/src/components/AppSymbol.tsx | 2 + .../src/features/threads/ThreadComposer.tsx | 2 + .../threads/ThreadSettingsRows.shared.tsx | 26 +- .../features/threads/ThreadSettingsSheet.tsx | 222 ++++++++++++--- .../thread-settings-sheet-state.test.ts | 28 +- .../threads/thread-settings-sheet-state.ts | 20 +- apps/mobile/src/lib/modelOptions.test.ts | 94 ++++++- apps/mobile/src/lib/modelOptions.ts | 24 +- .../src/persistence/mobile-preferences.ts | 2 +- .../OrchestratorMcpService.activity.test.ts | 5 + .../src/mcp/OrchestratorMcpService.test.ts | 62 +++++ apps/server/src/mcp/OrchestratorMcpService.ts | 28 +- ...OrchestratorMcpToolkit.integration.test.ts | 3 + ...skCancelNativeSubagent.integration.test.ts | 2 + apps/server/src/mcp/toolkits/core.test.ts | 5 + apps/server/src/serverSettings.test.ts | 82 ++++++ apps/web/src/components/chat/ChatComposer.tsx | 1 + .../components/chat/ModelPickerContent.tsx | 25 +- .../chat/ProviderModelPicker.test.tsx | 2 + .../components/chat/ProviderModelPicker.tsx | 3 + .../settings/ProjectDefaultsSettings.tsx | 4 +- .../settings/ProviderInstanceCard.test.ts | 2 + .../settings/ProviderInstanceCard.tsx | 12 +- .../settings/ProviderModelsSection.tsx | 19 +- ...ProviderSettingsPanel.environment.test.tsx | 17 +- .../settings/ProviderSettingsPanel.tsx | 111 +++----- .../settings/ScheduledTasksSettings.tsx | 1 + .../components/settings/SettingsPanels.tsx | 1 + .../settings/SourceControlWritingSettings.tsx | 1 + .../settings/scopedSettings.test.ts | 14 + .../src/components/settings/scopedSettings.ts | 2 +- .../settings/useScopedModelAvailability.ts | 3 +- .../components/settings/useScopedSettings.ts | 2 + .../src/hooks/useSettings.favorites.test.tsx | 252 ++++++++++++++++++ apps/web/src/hooks/useSettings.test.ts | 57 ++++ apps/web/src/hooks/useSettings.ts | 175 +++++++++++- packages/contracts/src/settings.test.ts | 31 +++ packages/contracts/src/settings.ts | 98 +++---- packages/shared/src/serverSettings.test.ts | 174 ++++++++++++ packages/shared/src/serverSettings.ts | 90 +++++++ 40 files changed, 1460 insertions(+), 244 deletions(-) create mode 100644 apps/web/src/hooks/useSettings.favorites.test.tsx diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index c9457a55c83a67f97b5d31754e28294380e61197..740e059d1b8c0a4e5546e8d509d8460a1c9d459a 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -53,6 +53,7 @@ import IconDotsVertical from "@tabler/icons-react-native/IconDotsVertical"; import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal"; import IconEdit from "@tabler/icons-react-native/IconEdit"; import IconExternalLink from "@tabler/icons-react-native/IconExternalLink"; +import IconEyeOff from "@tabler/icons-react-native/IconEyeOff"; import IconEye from "@tabler/icons-react-native/IconEye"; import IconFileText from "@tabler/icons-react-native/IconFileText"; import IconFilter from "@tabler/icons-react-native/IconFilter"; @@ -177,6 +178,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "exclamationmark.triangle": IconAlertTriangle, "exclamationmark.circle": IconAlertCircle, eye: IconEye, + "eye.slash": IconEyeOff, folder: IconFolder, "folder.badge.plus": IconFolderPlus, "folder.fill": IconFolder, diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 6873926fd5dd18c375434f3225cae68b2496789f..4000571c1e8037812efd127ea985118388b3b9c8 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -675,6 +675,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ownerId: settingsOwnerId, environmentId: props.environmentId, providerInstanceId: currentModelSelection.instanceId, + ...(lockedProviderInstanceId ? { lockedProviderInstanceId } : {}), providerGroups: threadProviderGroups, selectedModel: currentModelSelection, reportedModelSelection: props.reportedModelSelection, @@ -700,6 +701,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.onUpdateRuntimeMode, providerOptionDescriptors, settingsOwnerId, + lockedProviderInstanceId, threadProviderGroups, ], ); diff --git a/apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx b/apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx index e9625e2f15564bfbf0a30f0986804e29221bffcb..af2d148f26772f9a5fdbc6e868c9fdffafb48c17 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx @@ -12,6 +12,8 @@ export type ModelRowProps = { readonly isFavorite: boolean; readonly favoritesLoaded: boolean; readonly onToggleFavorite: () => void; + readonly onToggleHidden?: () => void; + readonly hiddenUpdatePending?: boolean; readonly isFirst: boolean; readonly isLast: boolean; }; @@ -52,10 +54,10 @@ export function ModelRowContent( accessibilityRole="radio" accessibilityState={{ checked: props.selected, - disabled: props.option.isUnavailable === true, + disabled: props.option.isUnavailable === true || props.option.isHidden === true, }} className="min-h-11 min-w-0 flex-1 flex-row items-center gap-2 active:opacity-70" - disabled={props.option.isUnavailable} + disabled={props.option.isUnavailable || props.option.isHidden} onPress={props.onPress} > {props.leadingSelection} @@ -77,6 +79,9 @@ export function ModelRowContent( Legacy ) : null} + {props.option.isHidden ? ( + Hidden + ) : null} {props.option.isUnavailable ? ( Unavailable ) : null} @@ -92,6 +97,23 @@ export function ModelRowContent( {props.trailingSelection} + {props.onToggleHidden ? ( + + + + ) : null} ; readonly selectedModel: ModelSelection | null; readonly reportedModelSelection?: ModelSelection | null; @@ -300,6 +312,10 @@ type ThreadSettingsSessionValue = { readonly favoriteKeys: ReadonlySet; readonly favoritesLoaded: boolean; readonly toggleFavorite: (option: ModelOption) => void; + readonly toggleHidden: (option: ModelOption) => void; + readonly pendingHiddenKeys: ReadonlySet; + readonly manageHidden: boolean; + readonly setManageHidden: (value: boolean) => void; readonly runtimeMode: RuntimeMode; readonly runtimeModeChoices: ReturnType; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; @@ -329,12 +345,46 @@ const ThreadSettingsSessionContext = createContext()); + const pendingFavorites = useRef( + new Map(), + ); + const [pendingHiddenKeys, setPendingHiddenKeys] = useState>(() => new Set()); + const providerGroups = useMemo( + () => + manageHidden && config + ? groupByProvider( + buildModelOptions(config, props.selectedModel, props.lockedProviderInstanceId, true), + ) + : props.providerGroups, + [ + config, + manageHidden, + props.providerGroups, + props.selectedModel, + props.lockedProviderInstanceId, + ], + ); + const legacyFavorites = AsyncResult.isSuccess(preferences) ? (preferences.value.modelFavorites ?? EMPTY_MODEL_FAVORITES) : EMPTY_MODEL_FAVORITES; + const favoritesLoaded = + settings !== undefined && (settings.favorites !== null || AsyncResult.isSuccess(preferences)); + const modelFavorites = resolveModelPreferences(settings ?? DEFAULT_SERVER_SETTINGS, { + favorites: legacyFavorites, + }).favorites; + const saveSettings = useAtomCommand(serverEnvironment.updateSettings, "model favorites update"); + useEffect(() => { + if (!props.environmentId || !settings || !AsyncResult.isSuccess(preferences)) return; + const patch = modelPreferencesMigrationPatch(settings, { favorites: legacyFavorites }); + if (patch) void saveSettings({ environmentId: props.environmentId, input: { patch } }); + }, [legacyFavorites, preferences, props.environmentId, saveSettings, settings]); const favoriteKeys = useMemo( () => new Set( @@ -343,19 +393,105 @@ function ThreadSettingsSessionProvider( [modelFavorites], ); const toggleFavorite = useCallback( + async (option: ModelOption) => { + if (!favoritesLoaded || !props.environmentId) return; + const currentSettings = registry + .get(environmentServerConfigsAtom) + .get(props.environmentId)?.settings; + if (!currentSettings) return; + const favorites = resolveModelPreferences(currentSettings, { + favorites: legacyFavorites, + }).favorites; + const key = JSON.stringify([ + props.environmentId, + option.selection.instanceId, + option.selection.model, + ]); + const previous = pendingFavorites.current.get(key); + const previousFavorite = + previous && (previous.pending || previous.settings.favorites === currentSettings.favorites) + ? previous.favorite + : favorites.some( + (favorite) => + favorite.provider === option.selection.instanceId && + favorite.model === option.selection.model, + ); + const pending = { favorite: !previousFavorite, settings: currentSettings, pending: true }; + pendingFavorites.current.set(key, pending); + void Haptics.selectionAsync(); + try { + const result = await saveSettings({ + environmentId: props.environmentId, + input: { + patch: { + setModelFavorites: [ + { + provider: option.selection.instanceId, + model: option.selection.model, + favorite: pending.favorite, + }, + ], + ...(currentSettings.favorites === null + ? { migrateModelPreferences: { favorites } } + : {}), + }, + }, + }); + if ( + AsyncResult.isSuccess(result) && + registry.get(environmentServerConfigsAtom).get(props.environmentId)?.settings + .favorites === currentSettings.favorites + ) { + pending.pending = false; + } + } finally { + if (pending.pending && pendingFavorites.current.get(key) === pending) { + if ( + previous && + !previous.pending && + registry.get(environmentServerConfigsAtom).get(props.environmentId)?.settings + .favorites === previous.settings.favorites + ) { + pendingFavorites.current.set(key, previous); + } else { + pendingFavorites.current.delete(key); + } + } + } + }, + [favoritesLoaded, legacyFavorites, props.environmentId, registry, saveSettings], + ); + const toggleHidden = useCallback( (option: ModelOption) => { - if (!favoritesLoaded) return; + if ( + !settings || + !props.environmentId || + !option.canHide || + pendingHidden.current.has(option.key) + ) + return; + pendingHidden.current.add(option.key); + setPendingHiddenKeys(new Set(pendingHidden.current)); void Haptics.selectionAsync(); - savePreferences({ - transform: (current) => ({ - modelFavorites: toggleModelFavorite( - current.modelFavorites ?? EMPTY_MODEL_FAVORITES, - option, - ), - }), + void saveSettings({ + environmentId: props.environmentId, + input: { + patch: { + setModelsHidden: [ + { + provider: option.selection.instanceId, + model: option.selection.model, + hidden: !option.isHidden, + }, + ], + }, + }, + }).then(() => { + pendingHidden.current.delete(option.key); + setPendingHiddenKeys(new Set(pendingHidden.current)); }); }, - [favoritesLoaded, savePreferences], + [props.environmentId, saveSettings, settings], ); const [showLegacyToggle, setShowLegacyToggle] = useState(false); const [providerFilter, setProviderFilter] = useState(null); @@ -473,7 +609,11 @@ function ThreadSettingsSessionProvider( () => ({ environmentId: props.environmentId, providerInstanceId: props.providerInstanceId, - providerGroups: props.providerGroups, + providerGroups, + manageHidden, + setManageHidden, + toggleHidden, + pendingHiddenKeys, runtimeMode: compatibleRuntimeMode, runtimeModeChoices, onUpdateRuntimeMode: props.onUpdateRuntimeMode, @@ -518,7 +658,10 @@ function ThreadSettingsSessionProvider( pressModel, providerFilter, props.onUpdateRuntimeMode, - props.providerGroups, + providerGroups, + manageHidden, + toggleHidden, + pendingHiddenKeys, runtimeModeChoices, searchQuery, showLegacyToggle, @@ -599,6 +742,12 @@ function ThreadSettingsModelListRow(props: { isFavorite={session.favoriteKeys.has(props.option.key)} favoritesLoaded={session.favoritesLoaded} onToggleFavorite={() => session.toggleFavorite(props.option)} + onToggleHidden={ + session.manageHidden && props.option.canHide + ? () => session.toggleHidden(props.option) + : undefined + } + hiddenUpdatePending={session.pendingHiddenKeys.has(props.option.key)} option={props.option} selected={session.isDisplayed(props.option)} /> @@ -642,7 +791,9 @@ function useThreadSettingsCatalogItems( } const driver = group.models[0]?.providerDriver ?? group.providerKey; const catalogModels = - session.showLegacy || session.providerFilter === FAVORITES_PROVIDER_FILTER + session.manageHidden || + session.showLegacy || + session.providerFilter === FAVORITES_PROVIDER_FILTER ? group.models : group.models.filter( (model) => @@ -726,6 +877,7 @@ function useThreadSettingsCatalogItems( session.providerGroups, session.searchQuery, session.showLegacy, + session.manageHidden, ], ); } @@ -806,21 +958,23 @@ function ThreadSettingsOptionsItem(props: { - {Platform.OS !== "ios" && session.hasLegacyModels ? ( - <> - - Catalog - - - - - - ) : null} + Catalog + + + {Platform.OS !== "ios" && session.hasLegacyModels ? ( + + ) : null} + ); } diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts index d26882762cb3c838e250fe6f9961f0c2678e24e1..61e606aa6dd69faf735bd0e952b118190ff82769 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts @@ -6,10 +6,8 @@ import type { ModelOption } from "../../lib/modelOptions"; import { canCommitPendingModel, favoritesFirst, - modelFavoriteKey, modelMatchesCatalogQuery, pendingModelAfterPress, - toggleModelFavorite, } from "./thread-settings-sheet-state"; function modelOption( @@ -58,23 +56,6 @@ describe("thread settings sheet state", () => { ]); }); - it("adds and removes favorites for one provider instance", () => { - const codexModel = modelOption("shared"); - const otherProvider = ProviderInstanceId.make("codex_personal"); - const personalModel = { - ...codexModel, - key: modelFavoriteKey(otherProvider, "shared"), - selection: { ...codexModel.selection, instanceId: otherProvider }, - }; - const favorites = toggleModelFavorite([], codexModel); - - expect(toggleModelFavorite(favorites, personalModel)).toEqual([ - { provider: ProviderInstanceId.make("codex"), model: "shared" }, - { provider: otherProvider, model: "shared" }, - ]); - expect(toggleModelFavorite(favorites, codexModel)).toEqual([]); - }); - it("matches visible model and provider terms", () => { const model = modelOption("gpt-next"); @@ -144,6 +125,15 @@ describe("thread settings sheet state", () => { ).toBe(pressed); }); + it("cannot select a hidden model exposed only for visibility management", () => { + const pending = modelOption("gpt-next"); + expect( + canCommitPendingModel(pending, [ + { providerKey: "codex", providerLabel: "Codex", models: [{ ...pending, isHidden: true }] }, + ]), + ).toBe(false); + }); + it("cannot save a staged model after sign-out removes it from the catalog", () => { const pending = modelOption("gemini-native"); const group = { providerKey: "codex", providerLabel: "Codex", models: [pending] }; diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts index e600eb3980b8e031f41a17c38cbe2f80ec0727d5..b85d808093d2e17618a91745ce575430bc6eb422 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts @@ -1,26 +1,10 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import type { ProviderInstanceId } from "@t3tools/contracts"; -export type ModelFavorite = { - readonly provider: ProviderInstanceId; - readonly model: string; -}; - export function modelFavoriteKey(provider: ProviderInstanceId, model: string): string { return `${provider}:${model}`; } -export function toggleModelFavorite( - favorites: ReadonlyArray, - option: ModelOption, -): ReadonlyArray { - const provider = option.selection.instanceId; - const model = option.selection.model; - return favorites.some((favorite) => favorite.provider === provider && favorite.model === model) - ? favorites.filter((favorite) => favorite.provider !== provider || favorite.model !== model) - : [...favorites, { provider, model }]; -} - /** Keep catalog order within each group when favorites move to the front. */ export function favoritesFirst( models: ReadonlyArray, @@ -71,7 +55,9 @@ export function canCommitPendingModel( groups: ReadonlyArray, ): boolean { return groups.some((group) => - group.models.some((model) => model.key === pending.key && !model.isUnavailable), + group.models.some( + (model) => model.key === pending.key && !model.isUnavailable && !model.isHidden, + ), ); } diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 463ff5cc3662375cbd3fb5cf43947179f7148291..ccf1e7bfd8204c5f0c4be9a7e1278613475b9b9a 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + ProviderInstanceId, + type ModelSelection, + type ServerConfig, +} from "@t3tools/contracts"; import { buildModelOptions, @@ -504,3 +509,90 @@ describe("mobile model options", () => { ).toBeNull(); }); }); + +describe("synchronized mobile model visibility", () => { + const provider = ProviderInstanceId.make("codex_work"); + const other = ProviderInstanceId.make("codex_personal"); + const models = [ + { slug: "hidden", name: "Hidden", isCustom: false, capabilities: null }, + { slug: "first", name: "First", isCustom: false, capabilities: null }, + { slug: "second", name: "Second", isCustom: false, capabilities: null }, + { slug: "custom", name: "Custom", isCustom: true, capabilities: null }, + ]; + const config = { + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerModelPreferences: { + [provider]: { + hiddenModels: ["hidden", "custom", "missing"], + modelOrder: ["second", "first"], + }, + }, + }, + providers: [provider, other].map((instanceId) => ({ + instanceId, + driver: "codex", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models, + })), + } as unknown as ServerConfig; + + it("applies server visibility and ordering independently to each provider instance", () => { + const options = buildModelOptions(config, null); + expect( + options + .filter((option) => option.providerKey === provider) + .map((option) => option.selection.model), + ).toEqual(["second", "first", "custom"]); + expect( + options + .filter((option) => option.providerKey === other) + .map((option) => option.selection.model), + ).toEqual(["hidden", "first", "second", "custom"]); + }); + + it("exposes hidden built-ins for management without offering custom visibility controls", () => { + const options = buildModelOptions( + config, + { instanceId: provider, model: "hidden" }, + provider, + true, + ); + expect(options.map((option) => option.selection.model)).toEqual([ + "second", + "first", + "hidden", + "custom", + ]); + expect(options.find((option) => option.selection.model === "hidden")).toMatchObject({ + isHidden: true, + canHide: true, + }); + expect(options.find((option) => option.selection.model === "custom")).toMatchObject({ + isHidden: false, + canHide: false, + }); + }); + + it.each(["hidden", "missing"])("does not resurrect the hidden fallback %s", (model) => { + expect( + buildModelOptions(config, { instanceId: provider, model }, provider).map( + (option) => option.selection.model, + ), + ).toEqual(["second", "first", "custom"]); + }); + + it("keeps custom models visible and restores built-in models when the server clears hidden models", () => { + expect( + buildModelOptions(config, { instanceId: provider, model: "custom" }, provider).map( + (option) => option.selection.model, + ), + ).toContain("custom"); + const cleared = { ...config, settings: { ...config.settings, providerModelPreferences: {} } }; + expect( + buildModelOptions(cleared, null, provider).map((option) => option.selection.model), + ).toEqual(["hidden", "first", "second", "custom"]); + }); +}); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 436d6b9996fb746a067363519d43a4d2994644e1..eeae36a336b02e453a39736f90866b6000138c70 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -22,6 +22,8 @@ export type ModelOption = { readonly isDefault: boolean; readonly isLegacy: boolean; readonly isUnavailable?: boolean; + readonly isHidden?: boolean; + readonly canHide?: boolean; readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; readonly providerUpdateRequired?: ProviderUpdateRequired; @@ -167,6 +169,7 @@ export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, providerInstanceId?: ModelSelection["instanceId"], + includeHidden = false, ): ReadonlyArray { const options = new Map(); @@ -185,7 +188,15 @@ export function buildModelOptions( const updateRequired = provider.updateRequiredModels?.length ? { driver: provider.driver, updateRequiredModels: provider.updateRequiredModels } : undefined; - for (const model of provider.models) { + const preferences = config?.settings?.providerModelPreferences?.[provider.instanceId]; + const hiddenModels = new Set(preferences?.hiddenModels ?? []); + const order = new Map((preferences?.modelOrder ?? []).map((slug, index) => [slug, index])); + const models = provider.models.filter( + (model) => includeHidden || model.isCustom || !hiddenModels.has(model.slug), + ); + if (order.size > 0) + models.sort((a, b) => (order.get(a.slug) ?? Infinity) - (order.get(b.slug) ?? Infinity)); + for (const model of models) { const key = `${provider.instanceId}:${model.slug}`; options.set(key, { key, @@ -198,6 +209,8 @@ export function buildModelOptions( ? {} : { supportedRuntimeModes: provider.supportedRuntimeModes }), ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}), + isHidden: !model.isCustom && hiddenModels.has(model.slug), + canHide: !model.isCustom, isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, ...(updateRequired ? { providerUpdateRequired: updateRequired } : {}), @@ -215,7 +228,14 @@ export function buildModelOptions( if ( fallbackModelSelection && - (providerInstanceId === undefined || fallbackModelSelection.instanceId === providerInstanceId) + (providerInstanceId === undefined || + fallbackModelSelection.instanceId === providerInstanceId) && + (!config?.settings?.providerModelPreferences?.[ + fallbackModelSelection.instanceId + ]?.hiddenModels.includes(fallbackModelSelection.model) || + config?.providers + .find((provider) => provider.instanceId === fallbackModelSelection.instanceId) + ?.models.find((model) => model.slug === fallbackModelSelection.model)?.isCustom) ) { const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`; const existing = options.get(key); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 98f6b3d1e8d1cd52d4bbfb1175a50bc6adfcceb7..c5b41fa5a18a33120e778ca90b84a66e71cd15dc 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -46,7 +46,7 @@ export interface Preferences { readonly planModeEnabled?: boolean; /** Device-local counterpart of web's `sidebarWorkingShelfEnabled` beta. */ readonly workingShelfEnabled?: boolean; - /** Model favorites belong to this device, like the web client setting. */ + /** @deprecated Retained to migrate favorites to the connected environment. */ readonly modelFavorites?: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string; diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 7c6f9d28c9508e13a658438c6d7c4cdb05b5eaa9..ce6921d02da42dbbdc16283f5e3c98db14efbe9f 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -20,6 +20,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import type * as McpInvocationContext from "./McpInvocationContext.ts"; @@ -146,6 +147,7 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a ? Effect.succeed(projection) : Effect.die(`unexpected thread ${input.threadId}`), } satisfies Partial), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), } satisfies Partial), @@ -211,6 +213,7 @@ it("readThread prefers waiting activity status over a newer cancelled queued run ? Effect.succeed(projection) : Effect.die(`unexpected thread ${input.threadId}`), } satisfies Partial), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), } satisfies Partial), @@ -324,6 +327,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy return Effect.die(`unexpected thread ${threadId}`); }, } satisfies Partial), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), } satisfies Partial), @@ -447,6 +451,7 @@ it("readThread and sendToThread reach threads in other projects", async () => { delivery: "started", } as unknown as ThreadManagementService.ThreadManagementSendResult), } satisfies Partial), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), } satisfies Partial), diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index bc9ab50276a3853afde26f772fc6d92124e86cca..d0a85e352a2998e6199d8addb872f25727048d47 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -34,6 +34,7 @@ import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts"; @@ -128,6 +129,7 @@ describe("OrchestratorMcpService", () => { ), ), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -227,6 +229,7 @@ describe("OrchestratorMcpService", () => { : Effect.succeed(awaitsRestart), dispatch: () => Ref.update(dispatched, (count) => count + 1).pipe(Effect.as({} as never)), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -311,6 +314,7 @@ describe("OrchestratorMcpService", () => { Effect.as({} as never), ), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -387,6 +391,7 @@ describe("OrchestratorMcpService", () => { Effect.andThen(Effect.fail(new Error("simulated stop failure") as never)), ), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -471,6 +476,7 @@ describe("OrchestratorMcpService", () => { ), stopDelegatedTasks: () => Effect.void, }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -557,6 +563,7 @@ describe("OrchestratorMcpService", () => { ), stopDelegatedTasks: () => Effect.void, }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -697,6 +704,7 @@ describe("OrchestratorMcpService", () => { yield* Ref.set(stoppedBelow, true); }), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -927,6 +935,7 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(parentProjection([])), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.sync(() => providers), }), @@ -1016,6 +1025,51 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect("leaves models the user hid out of the capability catalog", () => + Effect.gen(function* () { + const codex = providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }); + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(parentProjection([])), + }), + ServerSettings.layerTest({ + providerModelPreferences: { + [codexInstanceId]: { hiddenModels: ["gpt-5.4-mini", "my-model"], modelOrder: [] }, + }, + }), + providerRegistryLayer([ + { + ...codex, + models: [ + ...codex.models, + { slug: "gpt-5.4-mini", name: "Mini", isCustom: false, capabilities: null }, + // Custom models cannot be hidden, matching the client pickers. + { slug: "my-model", name: "Mine", isCustom: true, capabilities: null }, + ], + }, + ]), + adapterRegistryLayer([codexInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const capabilities = yield* service.capabilities(scope); + assert.deepEqual( + capabilities.providers[0]?.models.map((model) => model.id), + ["gpt-5.4", "my-model"], + ); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }), + ); + it.effect( "delegates to an Antigravity instance whose adapter resolves through the registry", () => @@ -1070,6 +1124,7 @@ describe("OrchestratorMcpService provider resolution", () => { } as never), ), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([ providerSnapshot({ @@ -1167,6 +1222,7 @@ describe("OrchestratorMcpService provider resolution", () => { } as never), ), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([ providerSnapshot({ @@ -1221,6 +1277,7 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(parentProjection([])), }), + ServerSettings.layerTest(), providerRegistryLayer([ providerSnapshot({ instanceId: codexInstanceId, @@ -1329,6 +1386,7 @@ describe("OrchestratorMcpService provider resolution", () => { } as never), ), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([codex, missingCli]), refreshInstance: () => @@ -1480,6 +1538,7 @@ describe("OrchestratorMcpService provider resolution", () => { } as never), ), }), + ServerSettings.layerTest(), providerRegistryLayer([ providerSnapshot({ instanceId: codexInstanceId, @@ -1632,6 +1691,7 @@ describe("OrchestratorMcpService provider resolution", () => { getThreadShell: (threadId) => Effect.succeed(threadId === boundThreadId ? boundThread : null), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ list: () => Effect.succeed([]), @@ -1700,6 +1760,7 @@ describe("OrchestratorMcpService provider resolution", () => { // Its turn ended: no run is active. getThreadRecords: () => Effect.succeed(idleThreadProjection(shell)), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), }), @@ -1789,6 +1850,7 @@ describe("OrchestratorMcpService provider resolution", () => { ), ), }), + ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), }), diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index bb4bedb74e01a7144de3a6e6fd7c5faaa77116c6..64c8cc020086abe7169a60dca47d8ab396179fe0 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -85,6 +85,7 @@ import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { clientRuntimeModeCeiling, type McpInvocationScope, @@ -901,6 +902,7 @@ const make = Effect.gen(function* () { const providerAdapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2; const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; const projects = yield* ProjectService.ProjectService; + const serverSettings = yield* ServerSettings.ServerSettingsService; /** A caller-named project, which must exist before anything is recorded against it. */ const requireProject = (projectId: ProjectId) => @@ -1851,6 +1853,11 @@ const make = Effect.gen(function* () { const { parent, limits } = yield* loadCaller(scope); const providers = yield* loadProviders; const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds(); + // Models the user hid from pickers stay out of the catalog agents choose from. + const modelPreferences = yield* serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providerModelPreferences ?? {}), + Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")), + ); return { parentThreadId: parent?.thread.id ?? null, inheritedProviderInstanceId: parent?.thread.modelSelection.instanceId ?? null, @@ -1867,13 +1874,19 @@ const make = Effect.gen(function* () { driverKind: provider.driver, displayName: provider?.displayName ?? null, models: - provider?.models.map((model) => ({ - id: model.slug, - label: model.name ?? null, - ...(model.capabilities?.optionDescriptors === undefined - ? {} - : { options: model.capabilities.optionDescriptors }), - })) ?? [], + provider?.models + .filter( + (model) => + model.isCustom || + !modelPreferences[provider.instanceId]?.hiddenModels.includes(model.slug), + ) + .map((model) => ({ + id: model.slug, + label: model.name ?? null, + ...(model.capabilities?.optionDescriptors === undefined + ? {} + : { options: model.capabilities.optionDescriptors }), + })) ?? [], ...(provider.supportedRuntimeModes === undefined || provider.supportedRuntimeModes.length === 0 ? {} @@ -2594,4 +2607,5 @@ export const layer: Layer.Layer< | ScheduledTaskService.ScheduledTaskService | ProjectService.ProjectService | SecretRequests.SecretRequests + | ServerSettings.ServerSettingsService > = Layer.effect(OrchestratorMcpService, make); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index e7e82eaddffbb213849d5e72a6127c671d434b73..d0b1753eddc038ce2ebc5ea386e4a93389bfdf6b 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -70,6 +70,7 @@ import { import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; @@ -680,6 +681,7 @@ describe("orchestrator MCP toolkit", () => { Layer.provideMerge(layerOrchestration), Layer.provide(layerRegistry), Layer.provide(layerProviderRegistry), + Layer.provide(ServerSettings.layerTest()), Layer.provide(layerScheduledTaskStub), Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), Layer.provide( @@ -3825,6 +3827,7 @@ describe("orchestrator MCP toolkit", () => { ), ), Layer.provide(layerProviderRegistry), + Layer.provide(ServerSettings.layerTest()), Layer.provide(layerUnusedScheduledTaskStub), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provideMerge( diff --git a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts index 04c862940ac8bfd9e1980fcf61090a1725bcfbb7..16830049662c0d4e578f4b987ca4c25b4183bd5d 100644 --- a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts +++ b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts @@ -33,6 +33,7 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as ServerSettings from "../serverSettings.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; @@ -142,6 +143,7 @@ it.effect("task_cancel ends a delegated child's background native subagent", () Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + ServerSettings.layerTest(), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), NodeServices.layer, ), diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index e2b058da506833b1f48619fb0dcf0d888c78a7d5..a9e2997282edb0a1ba7953250c3da00d27f09d4c 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -42,6 +42,7 @@ import * as ProjectService from "../../project/ProjectService.ts"; import * as ProviderRegistry from "../../provider/ProviderRegistry.ts"; import * as SecretRequests from "../../secrets/SecretRequests.ts"; import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import * as McpHttpServer from "../McpHttpServer.ts"; import * as McpInvocationContext from "../McpInvocationContext.ts"; import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts"; @@ -581,6 +582,7 @@ it.effect("refuses act-as-caller tools to a client caller", () => Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(ServerSettings.layerTest()), Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), 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 Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(ServerSettings.layerTest()), Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide( @@ -716,6 +719,7 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"), }), ), + Layer.provide(ServerSettings.layerTest()), Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), @@ -807,6 +811,7 @@ it.effect("discovers workflow stop targets and requires orchestration capability }), }), ), + Layer.provide(ServerSettings.layerTest()), Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index d29db34e06647cccf5e32dbafa7e870619e36186..5c7466b472dbdb115c299ef71037af6eea146dca 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -98,6 +98,88 @@ const recordProviderUsage = (provider: string, instanceId: string | null = provi }); it.layer(NodeServices.layer)("server settings", (it) => { + it.effect("serializes concurrent model preference edits without cancelling duplicate adds", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const provider = ProviderInstanceId.make("codex_work"); + yield* Effect.all( + [ + service.updateSettings({ + setModelFavorites: [{ provider, model: "sol", favorite: true }], + }), + service.updateSettings({ + setModelFavorites: [{ provider, model: "sol", favorite: true }], + }), + service.updateSettings({ + setModelFavorites: [{ provider, model: "luna", favorite: true }], + }), + service.updateSettings({ setModelsHidden: [{ provider, model: "astra", hidden: true }] }), + service.updateSettings({ setModelsHidden: [{ provider, model: "luna", hidden: true }] }), + service.updateSettings({ + setProviderModelOrder: { provider, modelOrder: ["luna", "sol"] }, + }), + ], + { concurrency: "unbounded" }, + ); + const settings = yield* service.getSettings; + assert.deepEqual(settings.favorites?.map((entry) => entry.model).sort(), ["luna", "sol"]); + assert.deepEqual(settings.providerModelPreferences?.[provider]?.hiddenModels.toSorted(), [ + "astra", + "luna", + ]); + assert.deepEqual(settings.providerModelPreferences?.[provider]?.modelOrder, ["luna", "sol"]); + }).pipe(Effect.provide(layerServerSettings())), + ); + it.effect("persists and broadcasts model preferences to other connected clients", () => + Effect.scoped( + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const provider = ProviderInstanceId.make("codex_work"); + const patch = { + favorites: [{ provider, model: "sol" }], + providerModelPreferences: { + [provider]: { hiddenModels: ["astra"], modelOrder: ["sol"] }, + }, + }; + const changes = yield* service.subscribeChanges; + yield* service.updateSettings(patch); + const received = Option.getOrThrow(yield* Stream.runHead(changes)); + assert.deepEqual(received.favorites, patch.favorites); + assert.deepEqual(received.providerModelPreferences, patch.providerModelPreferences); + const saved = yield* decodeServerSettingsJson( + yield* fs.readFileString(config.settingsPath), + ); + assert.deepEqual(saved.favorites, patch.favorites); + assert.deepEqual(saved.providerModelPreferences, patch.providerModelPreferences); + const hidden = yield* service.updateSettings({ + setModelsHidden: [{ provider, model: "sol", hidden: true }], + }); + assert.deepEqual(hidden.providerModelPreferences?.[provider], { + hiddenModels: ["astra", "sol"], + modelOrder: ["sol"], + }); + const shown = yield* service.updateSettings({ + setModelsHidden: [{ provider, model: "astra", hidden: false }], + }); + assert.deepEqual(shown.providerModelPreferences?.[provider], { + hiddenModels: ["sol"], + modelOrder: ["sol"], + }); + assert.deepEqual( + (yield* decodeServerSettingsJson(yield* fs.readFileString(config.settingsPath))) + .providerModelPreferences, + shown.providerModelPreferences, + ); + yield* service.updateSettings({ favorites: [], providerModelPreferences: {} }); + // An older device's delayed seed cannot undo a clear, even after persistence. + const migrated = yield* service.updateSettings({ migrateModelPreferences: patch }); + assert.deepEqual(migrated.favorites, []); + assert.deepEqual(migrated.providerModelPreferences, {}); + }), + ).pipe(Effect.provide(layerServerSettings())), + ); it.effect("migrates saved token delivery to paragraph buffering without resetting settings", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index bb10c0aed844fdb0770b369fbb3a2809486c020f..6d7646e521d7a69d1d010092ab0439162e8ef26f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -5524,6 +5524,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /> ) : null} (null); const pickerContentRef = useRef(null); const highlightedModelKeyRef = useRef(null); - const favorites = useClientSettings((s) => s.favorites ?? []); + const { favorites } = useEnvironmentModelPreferences(props.environmentId); const activeEntry = props.instanceEntries.find( (entry) => entry.instanceId === props.activeInstanceId, ); @@ -276,8 +281,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ); const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom); const keybindings = providedKeybindings ?? serverKeybindings; - const updateSettings = useUpdateClientSettings(); const navigate = useNavigate(); + const toggleFavorite = useToggleEnvironmentModelFavorite(props.environmentId); const focusSearchInput = useCallback(() => { searchInputRef.current?.focus({ preventScroll: true }); @@ -652,20 +657,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ], ); - const toggleFavorite = useCallback( - (instanceId: ProviderInstanceId, model: string) => { - const newFavorites = [...favorites]; - const index = newFavorites.findIndex((f) => f.provider === instanceId && f.model === model); - if (index >= 0) { - newFavorites.splice(index, 1); - } else { - newFavorites.push({ provider: instanceId, model }); - } - updateSettings({ favorites: newFavorites }); - }, - [favorites, updateSettings], - ); - const modelJumpCommandByKey = useMemo(() => { const mapping = new Map< string, diff --git a/apps/web/src/components/chat/ProviderModelPicker.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.test.tsx index 54aa051aa63947a2b435f3a8736bbb29de98ae30..3867fcb2bad2503bf185f12d4d759a00c094f4cc 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.test.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.test.tsx @@ -40,6 +40,7 @@ function renderPicker(input: { const entry = providerEntry(input.instanceId, input.driver); return renderToStaticMarkup( { const activeEntry = providerEntry("codex_personal", "codex"); const markup = renderToStaticMarkup( option.instanceId === instanceId); const options = getCustomModelOptionsByInstance( - { ...settings, ...candidate.settings }, + mergeEnvironmentSettings(candidate.settings, settings), config.providers, ).get(instanceId); if ( @@ -158,6 +159,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting selection && activeEntry ? (
{ modelOrder: [], onHiddenModelsChange: () => undefined, onFavoriteModelsChange: () => undefined, + onToggleFavorite: () => undefined, onModelOrderChange: () => undefined, }), ); @@ -157,6 +158,7 @@ describe("deriveProviderModelsForDisplay", () => { modelOrder: [], onHiddenModelsChange: () => undefined, onFavoriteModelsChange: () => undefined, + onToggleFavorite: () => undefined, onModelOrderChange: () => undefined, } as const; diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 45d4eb8c6fe165dfa34a6fd7411e319883856341..5bbbc8c222ada22bb1ef83830d0ad888f62e432f 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -503,8 +503,12 @@ interface ProviderInstanceCardProps { readonly hiddenModels: ReadonlyArray; readonly favoriteModels: ReadonlyArray; readonly modelOrder: ReadonlyArray; - readonly onHiddenModelsChange: (next: ReadonlyArray) => void; + readonly onHiddenModelsChange: ( + next: ReadonlyArray, + affectedModels?: ReadonlyArray, + ) => void; readonly onFavoriteModelsChange: (next: ReadonlyArray) => void; + readonly onToggleFavorite: (model: string) => void; readonly onModelOrderChange: (next: ReadonlyArray) => void; readonly onRunUpdate?: (() => void) | undefined; readonly onInstallRecommended?: (() => void) | undefined; @@ -561,6 +565,7 @@ export function ProviderInstanceCard({ modelOrder, onHiddenModelsChange, onFavoriteModelsChange, + onToggleFavorite, onModelOrderChange, onRunUpdate, onInstallRecommended, @@ -1173,8 +1178,8 @@ export function ProviderInstanceCard({

- Favorites, visibility, and ordering are saved on this device. Custom models are saved - on the selected environment. + Model preferences and custom models are saved on the selected environment and shared + across connected devices.

diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 1cb3e311147e9b75c6d739c3aa2e8c877743ec57..5c584bbad510f7249374159477eb09c25b1d5166 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -145,8 +145,12 @@ interface ProviderModelsSectionProps { * `providerInstances[id].config`). */ readonly onChange: (next: ReadonlyArray) => void; - readonly onHiddenModelsChange: (next: ReadonlyArray) => void; + readonly onHiddenModelsChange: ( + next: ReadonlyArray, + affectedModels?: ReadonlyArray, + ) => void; readonly onFavoriteModelsChange: (next: ReadonlyArray) => void; + readonly onToggleFavorite: (model: string) => void; readonly onModelOrderChange: (next: ReadonlyArray) => void; } @@ -174,6 +178,7 @@ export function ProviderModelsSection({ onChange, onHiddenModelsChange, onFavoriteModelsChange, + onToggleFavorite, onModelOrderChange, }: ProviderModelsSectionProps) { const [input, setInput] = useState(""); @@ -282,16 +287,13 @@ export function ProviderModelsSection({ if (!canWritePreferences || hidden === hiddenModelSet.has(slug)) return; onHiddenModelsChange( hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug), + [slug], ); }; const handleToggleFavorite = (slug: string) => { if (!canWritePreferences) return; - if (favoriteModelSet.has(slug)) { - onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug)); - return; - } - onFavoriteModelsChange([...favoriteModels, slug]); + onToggleFavorite(slug); }; // Rows only trade places with a neighbour in the same group (favorites, @@ -533,7 +535,10 @@ export function ProviderModelsSection({ size="xs" variant="ghost-muted" onClick={() => - onHiddenModelsChange(nextHiddenModelsForBulkToggle(models, hiddenModels)) + onHiddenModelsChange( + nextHiddenModelsForBulkToggle(models, hiddenModels), + builtInModels.map((model) => model.slug), + ) } > {allBuiltInModelsHidden ? "Enable all" : "Disable all"} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx index 35a4bcbd33a98d597c57fbc00fd3f5821fae8a3c..bee9a6f493260a5866c8acbedc4e723e76771dc3 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx @@ -108,6 +108,8 @@ vi.mock("../../hooks/useSettings", () => ({ settingsState.updateEnvironmentIds.push(environmentId); return settingsState.updateSettings; }, + useUpdateEnvironmentModelPreferences: () => settingsState.updateSettings, + useToggleEnvironmentModelFavorite: () => () => undefined, usePersistEnvironmentProviderInstanceMutation: (environmentId: EnvironmentId) => { settingsState.mutationEnvironmentIds.push(environmentId); return settingsState.mutateProviderInstance; @@ -344,16 +346,19 @@ describe("EnvironmentProviderSettings routing", () => { }); it.each([ - ["onFavoriteModelsChange", { favorites: [{ provider: codexId, model: "chosen" }] }], + [ + "onFavoriteModelsChange", + { setModelFavorites: [{ provider: codexId, model: "chosen", favorite: true }] }, + ], [ "onHiddenModelsChange", - { providerModelPreferences: { [codexId]: { hiddenModels: ["chosen"], modelOrder: [] } } }, + { setModelsHidden: [{ provider: codexId, model: "chosen", hidden: true }] }, ], [ "onModelOrderChange", - { providerModelPreferences: { [codexId]: { hiddenModels: [], modelOrder: ["chosen"] } } }, + { setProviderModelOrder: { provider: codexId, modelOrder: ["chosen"] } }, ], - ])("saves %s on this device without changing the selected server", (action, expected) => { + ])("saves %s on the selected environment for every connected client", (action, expected) => { atoms.providers = [provider()]; const panel = renderPanel(); const editor = visitElements( @@ -363,8 +368,8 @@ describe("EnvironmentProviderSettings routing", () => { expect(editor).not.toBeNull(); if (!editor) throw new Error("Provider editor was not rendered"); (editor.props[action] as (models: string[]) => void)(["chosen"]); - expect(settingsState.updateClientSettings).toHaveBeenCalledExactlyOnceWith(expected); - expect(settingsState.updateSettings).not.toHaveBeenCalled(); + expect(settingsState.updateSettings).toHaveBeenCalledExactlyOnceWith(expected); + expect(settingsState.updateClientSettings).not.toHaveBeenCalled(); }); it("does not substitute another account when the requested instance was removed", () => { diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 73a40f2ba4df1cfbcd58b3226730f37820574157..76938b5599904812afb580486a1c8f0f5f1c5b0e 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -40,8 +40,9 @@ import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; import { useEnvironmentSettings, usePersistEnvironmentProviderInstanceMutation, - useUpdateClientSettings, useUpdateEnvironmentSettings, + useUpdateEnvironmentModelPreferences, + useToggleEnvironmentModelFavorite, } from "../../hooks/useSettings"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { cn } from "../../lib/utils"; @@ -123,20 +124,12 @@ import { resolveSelectedProviderEnvironmentId, } from "./ProviderSettingsPanel.logic"; -function withoutProviderInstanceKey( - record: Readonly> | undefined, - key: ProviderInstanceId, -): Record { - const next = { ...record } as Record; - delete next[key]; - return next; -} - -function withoutProviderInstanceFavorites( - favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>, - instanceId: ProviderInstanceId, -) { - return favorites.filter((favorite) => favorite.provider !== instanceId); +function modelListChanges(current: ReadonlyArray, next: ReadonlyArray) { + const currentSet = new Set(current); + const nextSet = new Set(next.map((model) => model.trim()).filter(Boolean)); + return [...new Set([...currentSet, ...nextSet])] + .filter((model) => currentSet.has(model) !== nextSet.has(model)) + .map((model) => ({ model, selected: nextSet.has(model) })); } function providerConfigString(config: unknown, key: string): string | null { @@ -565,8 +558,9 @@ export function EnvironmentProviderSettings({ const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope); const canRefreshProviders = useEnvironmentScope(environmentId, AuthOrchestrationReadScope); const updateSettings = useUpdateEnvironmentSettings(environmentId); + const updateModelPreferences = useUpdateEnvironmentModelPreferences(environmentId); + const toggleModelFavorite = useToggleEnvironmentModelFavorite(environmentId); const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId); - const updateClientSettings = useUpdateClientSettings(); const serverProviders = useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; const projects = useProjects().filter((project) => project.environmentId === environmentId); @@ -873,50 +867,6 @@ export function EnvironmentProviderSettings({ } }; - const updateProviderModelPreferences = ( - instanceId: ProviderInstanceId, - next: { - readonly hiddenModels: ReadonlyArray; - readonly modelOrder: ReadonlyArray; - }, - ) => { - const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; - const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; - const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); - updateClientSettings({ - providerModelPreferences: - hiddenModels.length === 0 && modelOrder.length === 0 - ? rest - : { - ...rest, - [instanceId]: { - hiddenModels, - modelOrder, - }, - }, - }); - }; - - const updateProviderFavoriteModels = ( - instanceId: ProviderInstanceId, - nextFavoriteModels: ReadonlyArray, - ) => { - const favoriteModels = [ - ...new Set( - Arr.filterMap(nextFavoriteModels, (slug) => { - const trimmedSlug = slug.trim(); - return trimmedSlug.length > 0 ? Result.succeed(trimmedSlug) : Result.failVoid; - }), - ), - ]; - updateClientSettings({ - favorites: [ - ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), - ...favoriteModels.map((model) => ({ provider: instanceId, model })), - ], - }); - }; - const resetDefaultInstance = async (driverKind: ProviderDriverKind) => { const result = await persistProviderInstance({ operation: "remove", @@ -1072,19 +1022,34 @@ export function EnvironmentProviderSettings({ hiddenModels={modelPreferences.hiddenModels} favoriteModels={favoriteModels} modelOrder={modelPreferences.modelOrder} - onHiddenModelsChange={(hiddenModels) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - hiddenModels, - }) - } - onFavoriteModelsChange={(next) => updateProviderFavoriteModels(row.instanceId, next)} - onModelOrderChange={(modelOrder) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - modelOrder, - }) - } + onHiddenModelsChange={(next, affectedModels) => { + const hidden = new Set(next); + const models = + affectedModels ?? + modelListChanges(modelPreferences.hiddenModels, next).map((change) => change.model); + void updateModelPreferences({ + setModelsHidden: models.map((model) => ({ + provider: row.instanceId, + model, + hidden: hidden.has(model), + })), + }); + }} + onToggleFavorite={(model) => { + void toggleModelFavorite(row.instanceId, model); + }} + onFavoriteModelsChange={(next) => { + void updateModelPreferences({ + setModelFavorites: modelListChanges(favoriteModels, next).map( + ({ model, selected }) => ({ provider: row.instanceId, model, favorite: selected }), + ), + }); + }} + onModelOrderChange={(modelOrder) => { + void updateModelPreferences({ + setProviderModelOrder: { provider: row.instanceId, modelOrder }, + }); + }} onInstallRecommended={ !readOnly && liveProvider?.compatibilityAdvisory?.message && diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 34c3896d87fca46edca7ee1e872461e909b3d64a..7b941f97a79e3aae2f8b8db80846307322f98c5c 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -1122,6 +1122,7 @@ function ScheduledTaskEditorDialog({ { }); }); }); + +it("routes legacy model preference keys exclusively to the selected environment", () => { + const patch = { + favorites: [{ provider: ProviderInstanceId.make("codex"), model: "sol" }], + providerModelPreferences: {}, + }; + const plan = planScopedSettingsPatch(named, environments, patch); + expect(plan.clientPatch).toEqual({}); + expect(plan.hasClientWrite).toBe(false); + expect(plan.serverWrites).toEqual([ + { environmentId: server.environmentId, label: server.label, patch }, + ]); +}); diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index 537c3bb381b3e9705b09927cca2a8eb004b38d84..f4fe6e41905cba44ed7dfa759cc4857d580e0c48 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -216,7 +216,7 @@ export function planScopedSettingsPatch( patch: ScopedSettingsPatch, ) { const clientPatch = Object.fromEntries( - Object.entries(patch).filter(([key]) => CLIENT_KEYS.has(key)), + Object.entries(patch).filter(([key]) => CLIENT_KEYS.has(key) && !SERVER_KEYS.has(key)), ) as ClientSettingsPatch; const serverPatch = Object.fromEntries( Object.entries(patch).filter(([key]) => SERVER_KEYS.has(key)), diff --git a/apps/web/src/components/settings/useScopedModelAvailability.ts b/apps/web/src/components/settings/useScopedModelAvailability.ts index a851a1876adfa6502dcd1d4939169403ce9c2c84..d188d29f2285cf6da827436279a2315c6945e074 100644 --- a/apps/web/src/components/settings/useScopedModelAvailability.ts +++ b/apps/web/src/components/settings/useScopedModelAvailability.ts @@ -1,6 +1,7 @@ import type { ProviderInstanceId, UnifiedSettings } from "@t3tools/contracts"; import { useCallback } from "react"; +import { mergeEnvironmentSettings } from "../../hooks/useSettings"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { applyProviderInstanceSettings, @@ -36,7 +37,7 @@ export function useScopedModelDisabledReason( candidate.settings, ).find((option) => option.instanceId === instanceId); const options = getCustomModelOptionsByInstance( - { ...settings, ...candidate.settings }, + mergeEnvironmentSettings(candidate.settings, settings), config.providers, ).get(instanceId); if ( diff --git a/apps/web/src/components/settings/useScopedSettings.ts b/apps/web/src/components/settings/useScopedSettings.ts index 4bed07267b133b0501e4eda0d18815c24ee6eb71..a67d88fb9bcdc87868562b005e92e8d92e290e9e 100644 --- a/apps/web/src/components/settings/useScopedSettings.ts +++ b/apps/web/src/components/settings/useScopedSettings.ts @@ -19,6 +19,7 @@ import { mergeEnvironmentSettings, persistClientSettingsPatch, useClientSettings, + useMigrateModelPreferences, } from "../../hooks/useSettings"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -42,6 +43,7 @@ export function useScopedSettings( const { target } = useSettingsScope(); const clientSettings = useClientSettings(); const serverSettings = target?.settings ?? DEFAULT_SERVER_SETTINGS; + useMigrateModelPreferences(target?.environmentId ?? null, target?.settings ?? null); const settings = useMemo( () => mergeEnvironmentSettings(serverSettings, clientSettings), [clientSettings, serverSettings], diff --git a/apps/web/src/hooks/useSettings.favorites.test.tsx b/apps/web/src/hooks/useSettings.favorites.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ce5a9665c87dd281085d72a21f2976115815e20a --- /dev/null +++ b/apps/web/src/hooks/useSettings.favorites.test.tsx @@ -0,0 +1,252 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProviderInstanceId, + type ServerSettings, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + settings: new Map>(), + persist: vi.fn(), +})); + +vi.mock("~/state/server", () => ({ + serverEnvironment: { + settingsValueAtom: (id: EnvironmentId) => state.settings.get(id)!, + updateSettings: Symbol("updateSettings"), + }, + primaryServerSettingsAtom: undefined, +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => state.persist })); +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: { getClientSettings: async () => null } }), +})); + +import { useToggleEnvironmentModelFavorite } from "./useSettings"; + +const primaryId = EnvironmentId.make("primary"); +const remoteId = EnvironmentId.make("remote"); +const provider = ProviderInstanceId.make("codex_work"); +const model = "gpt-6.1-sol"; +const initialSettings = { + ...DEFAULT_SERVER_SETTINGS, + favorites: [], + providerModelPreferences: {}, +} satisfies ServerSettings; +let registry: AtomRegistry.AtomRegistry; +let renderer: ReactTestRenderer | undefined; +let durable: Map; +let writes: Array<{ + environmentId: EnvironmentId; + patch: ServerSettingsPatch; + finish: (success?: boolean) => void; +}>; + +function FavoriteEditor({ environmentId }: { environmentId: EnvironmentId }) { + const toggle = useToggleEnvironmentModelFavorite(environmentId); + return ; +} + +function editor(environmentId: EnvironmentId) { + return ( + + + + ); +} + +async function mount(environmentId = primaryId) { + await act(() => { + renderer = create(editor(environmentId)); + }); +} + +function click(): Promise { + return renderer!.root.findByType("button").props.onClick(); +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + registry = AtomRegistry.make(); + state.settings.clear(); + durable = new Map(); + writes = []; + for (const id of [primaryId, remoteId]) { + state.settings.set(id, Atom.make(initialSettings)); + durable.set(id, initialSettings); + } + state.persist.mockReset().mockImplementation( + ({ + environmentId, + input, + }: { + environmentId: EnvironmentId; + input: { patch: ServerSettingsPatch }; + }) => + new Promise((resolve) => { + writes.push({ + environmentId, + patch: input.patch, + finish: (success = true) => { + if (!success) { + resolve(AsyncResult.failure(Cause.fail(new Error("save failed")))); + return; + } + const settings = applyServerSettingsPatch(durable.get(environmentId)!, input.patch); + durable.set(environmentId, settings); + resolve(AsyncResult.success(settings)); + }, + }); + }), + ); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + registry.dispose(); + vi.unstubAllGlobals(); +}); + +describe("environment favorite toggles", () => { + it.each([false, true])( + "toggles twice before any server echo when initially favorited=%s", + async (initiallyFavorited) => { + const favorites = initiallyFavorited ? [{ provider, model }] : []; + const settings = { ...initialSettings, favorites }; + registry.set(state.settings.get(primaryId)!, settings); + durable.set(primaryId, settings); + await mount(); + const first = click(); + const second = click(); + // Preparing the patch is asynchronous even when migration has already completed. + await act(async () => {}); + expect(writes.map((write) => write.patch.setModelFavorites?.[0]?.favorite)).toEqual([ + !initiallyFavorited, + initiallyFavorited, + ]); + writes[0]!.finish(); + await first; + writes[1]!.finish(); + await second; + expect(durable.get(primaryId)!.favorites).toEqual(favorites); + }, + ); + + it("keeps the newest pending intent when an earlier write completes", async () => { + await mount(); + const first = click(); + const second = click(); + await act(async () => {}); + writes[0]!.finish(); + await first; + const third = click(); + await act(async () => {}); + expect(writes.map((write) => write.patch.setModelFavorites?.[0]?.favorite)).toEqual([ + true, + false, + true, + ]); + writes[1]!.finish(); + await second; + writes[2]!.finish(); + await third; + expect(durable.get(primaryId)!.favorites).toEqual([{ provider, model }]); + }); + + it("clears failed intent so a retry still adds the favorite", async () => { + await mount(); + const failed = click(); + await act(async () => {}); + writes[0]!.finish(false); + await failed; + const retry = click(); + await act(async () => {}); + writes[1]!.finish(); + await retry; + expect(durable.get(primaryId)!.favorites).toEqual([{ provider, model }]); + }); + + it("toggles the acknowledged choice when the RPC reply precedes its server echo", async () => { + await mount(); + const first = click(); + await act(async () => {}); + writes[0]!.finish(); + await first; + expect(registry.get(state.settings.get(primaryId)!)!.favorites).toEqual([]); + const second = click(); + await act(async () => {}); + writes[1]!.finish(); + await second; + expect(durable.get(primaryId)!.favorites).toEqual([]); + }); + + it("uses a later remote change instead of a cached acknowledged choice", async () => { + await mount(); + const first = click(); + await act(async () => {}); + writes[0]!.finish(); + await first; + // Another client removed the favorite after this client's add was acknowledged. + const remoteSettings = { ...initialSettings, favorites: [] }; + durable.set(primaryId, remoteSettings); + await act(() => registry.set(state.settings.get(primaryId)!, remoteSettings)); + const second = click(); + await act(async () => {}); + writes[1]!.finish(); + await second; + expect(durable.get(primaryId)!.favorites).toEqual([{ provider, model }]); + }); + + it("preserves an acknowledged add when a later remove fails before its echo", async () => { + await mount(); + const first = click(); + await act(async () => {}); + writes[0]!.finish(); + await first; + const failed = click(); + await act(async () => {}); + writes[1]!.finish(false); + await failed; + const retry = click(); + await act(async () => {}); + writes[2]!.finish(); + await retry; + expect(durable.get(primaryId)!.favorites).toEqual([]); + }); + + it("does not reuse another environment's pending intent for the same model", async () => { + await mount(); + const primary = click(); + await act(() => renderer!.update(editor(remoteId))); + const remote = click(); + await act(async () => {}); + writes[0]!.finish(); + await primary; + writes[1]!.finish(); + await remote; + expect(durable.get(primaryId)!.favorites).toEqual([{ provider, model }]); + expect(durable.get(remoteId)!.favorites).toEqual([{ provider, model }]); + }); + + it("reads the latest server state even when the click callback predates its echo", async () => { + await mount(); + const previousClick = renderer!.root.findByType("button").props.onClick as () => Promise; + const updated = { ...initialSettings, favorites: [{ provider, model }] }; + durable.set(primaryId, updated); + await act(() => registry.set(state.settings.get(primaryId)!, updated)); + const clicked = previousClick(); + await act(async () => {}); + writes[0]!.finish(); + await clicked; + expect(durable.get(primaryId)!.favorites).toEqual([]); + }); +}); diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index eeb0ca524a6401dd610dc6ebac97b8b47376ef6e..d90aa58c4d90a6a7f3279c2bbecbf84354910ee1 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -23,8 +23,10 @@ import { mergeEnvironmentSettings, persistClientSettingsPatch, persistClientSettingsUpdate, + prepareModelPreferencesPatch, resolveEnvironmentIdentificationMode, } from "./useSettings"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; beforeEach(() => { persistenceMocks.getClientSettings.mockReset().mockResolvedValue(null); @@ -45,6 +47,44 @@ describe("client settings hydration", () => { const onboardingCompletedAt = "2026-09-05T12:00:00.000Z"; const complete = (current: ClientSettings) => ({ ...current, onboardingCompletedAt }); + it("waits for saved model preferences before applying the first server edit", async () => { + const provider = ProviderInstanceId.make("codex_work"); + let finishRead!: (settings: ClientSettings) => void; + persistenceMocks.getClientSettings.mockReturnValueOnce( + new Promise((resolve) => { + finishRead = resolve; + }), + ); + const patch = prepareModelPreferencesPatch(DEFAULT_SERVER_SETTINGS, { + setModelFavorites: [{ provider, model: "new", favorite: true }], + setModelsHidden: [{ provider, model: "new", hidden: true }], + }); + expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS); + finishRead({ + ...savedSettings, + providerModelPreferences: { + [provider]: { hiddenModels: ["saved-hidden"], modelOrder: ["saved-order"] }, + }, + }); + const edited = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, await patch); + expect(edited.favorites).toEqual([...savedSettings.favorites, { provider, model: "new" }]); + expect(edited.providerModelPreferences?.[provider]).toEqual({ + hiddenModels: ["saved-hidden", "new"], + modelOrder: ["saved-order"], + }); + }); + + it("does not prepare a model preference write after a failed legacy read", async () => { + const failure = new Error("storage unavailable"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + persistenceMocks.getClientSettings.mockRejectedValueOnce(failure); + await expect( + prepareModelPreferencesPatch(DEFAULT_SERVER_SETTINGS, { + setProviderModelOrder: { provider: ProviderInstanceId.make("codex_work"), modelOrder: [] }, + }), + ).rejects.toBe(failure); + }); + it("rejects completion after a failed read and preserves saved preferences on retry", async () => { const failure = new Error("storage unavailable"); vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -451,3 +491,20 @@ describe("onboarding completion persistence", () => { expect(persist).toHaveBeenLastCalledWith(completedSettings); }); }); + +it("uses the environment's model preferences after migration, including empty lists", () => { + const client = { + ...DEFAULT_CLIENT_SETTINGS, + favorites: [{ provider: ProviderInstanceId.make("codex_work"), model: "old" }], + providerModelPreferences: { codex_work: { hiddenModels: ["old"], modelOrder: [] } }, + }; + expect(mergeEnvironmentSettings(DEFAULT_SERVER_SETTINGS, client).favorites).toEqual( + client.favorites, + ); + const merged = mergeEnvironmentSettings( + { ...DEFAULT_SERVER_SETTINGS, favorites: [], providerModelPreferences: {} }, + client, + ); + expect(merged.favorites).toEqual([]); + expect(merged.providerModelPreferences).toEqual({}); +}); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index d61abb34f5535a6ee27b46700043cc3022699120..027373a455624c958b42b534d8e6b5fc91d614ba 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -9,14 +9,15 @@ * access is intentionally named as such so environment-sensitive consumers * cannot silently read the wrong server's settings. */ -import { useCallback, useMemo, useSyncExternalStore } from "react"; -import { useAtomValue } from "@effect/atom-react"; +import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore, use } from "react"; +import { RegistryContext, useAtomValue } from "@effect/atom-react"; import { DEFAULT_SERVER_SETTINGS, AuthSettingsWriteScope, requiredScopesForServerSettingsPatch, type EnvironmentId, type ProviderInstanceMutation, + type ProviderInstanceId, ServerSettings, type ServerSettingsPatch, sessionGrantsScope, @@ -46,6 +47,10 @@ import { subscribeToThemePreview, themeAllowsSidebarArtwork, } from "~/themePalette"; +import { + modelPreferencesMigrationPatch, + resolveModelPreferences, +} from "@t3tools/shared/serverSettings"; import * as Struct from "effect/Struct"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -318,7 +323,38 @@ export function mergeEnvironmentSettings( ): UnifiedSettings { // Decode drops retired client keys, but older untyped persistence adapters // can still return them. Server-owned values must always win. - return { ...clientSettings, ...serverSettings }; + return { + ...clientSettings, + ...serverSettings, + ...resolveModelPreferences(serverSettings, clientSettings), + }; +} + +const pendingModelPreferencesMigrations = new Set(); + +/** Seed saved client preferences once without overwriting another client's edits. */ +export function useMigrateModelPreferences( + environmentId: EnvironmentId | null, + settings: ServerSettings | null, +) { + const clientSettings = useClientSettingsValue(); + const hydrated = useClientSettingsHydrated(); + const migrate = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); + useEffect(() => { + if ( + !environmentId || + !settings || + !hydrated || + pendingModelPreferencesMigrations.has(environmentId) + ) + return; + const patch = modelPreferencesMigrationPatch(settings, clientSettings); + if (!patch) return; + pendingModelPreferencesMigrations.add(environmentId); + void migrate({ environmentId, input: { patch } }).finally(() => + pendingModelPreferencesMigrations.delete(environmentId), + ); + }, [clientSettings, environmentId, hydrated, migrate, settings]); } function useMergedSettings( @@ -391,12 +427,135 @@ export function useLegacySidebarEnabled(): boolean { return settingsHydrated && legacySidebarEnabled; } +/** Hydrate legacy preferences before a mutation can claim either server-owned list. */ +export async function prepareModelPreferencesPatch( + settings: ServerSettings, + patch: ServerSettingsPatch, +): Promise { + if (settings.favorites === null || settings.providerModelPreferences === null) { + await ensureClientSettingsHydrated(); + } + return { ...modelPreferencesMigrationPatch(settings, getClientSettings()), ...patch }; +} + +export function useUpdateEnvironmentModelPreferences(environmentId: EnvironmentId | null) { + const settings = useAtomValue( + environmentId ? serverEnvironment.settingsValueAtom(environmentId) : noEnvironmentSettingsAtom, + ); + const save = useAtomCommand(serverEnvironment.updateSettings, "model preferences update"); + return useCallback( + async (patch: ServerSettingsPatch) => { + if (!environmentId || !settings) return; + let prepared: ServerSettingsPatch; + try { + prepared = await prepareModelPreferencesPatch(settings, patch); + } catch { + toastManager.add({ + type: "error", + title: "Model preferences not saved", + description: "Could not load saved model preferences. Try again.", + }); + return; + } + return save({ environmentId, input: { patch: prepared } }); + }, + [environmentId, save, settings], + ); +} + +/** Send the desired state so two clients adding a favorite do not cancel each other. */ +export function useToggleEnvironmentModelFavorite(environmentId: EnvironmentId | null) { + const registry = use(RegistryContext); + const pendingFavorites = useRef( + new Map(), + ); + const save = useUpdateEnvironmentModelPreferences(environmentId); + return useCallback( + async (provider: ProviderInstanceId, model: string) => { + if (!environmentId) return; + const settingsAtom = serverEnvironment.settingsValueAtom(environmentId); + const settings = registry.get(settingsAtom); + if (!settings) return; + if (settings.favorites === null) { + try { + await ensureClientSettingsHydrated(); + } catch { + toastManager.add({ + type: "error", + title: "Favorites not saved", + description: "Could not load saved model preferences. Try again.", + }); + return; + } + } + const currentSettings = registry.get(settingsAtom) ?? settings; + const favorites = resolveModelPreferences(currentSettings, getClientSettings()).favorites; + const key = JSON.stringify([environmentId, provider, model]); + const previous = pendingFavorites.current.get(key); + // Keep the acknowledged choice until the config stream catches up with the RPC reply. + const previousFavorite = + previous && (previous.pending || previous.settings.favorites === currentSettings.favorites) + ? previous.favorite + : favorites.some( + (favorite) => favorite.provider === provider && favorite.model === model, + ); + const pending = { + favorite: !previousFavorite, + settings: currentSettings, + pending: true, + }; + pendingFavorites.current.set(key, pending); + try { + const result = await save({ + setModelFavorites: [{ provider, model, favorite: pending.favorite }], + }); + if ( + result && + AsyncResult.isSuccess(result) && + registry.get(settingsAtom)?.favorites === currentSettings.favorites + ) { + pending.pending = false; + } + } finally { + if (pending.pending && pendingFavorites.current.get(key) === pending) { + if ( + previous && + !previous.pending && + registry.get(settingsAtom)?.favorites === previous.settings.favorites + ) { + pendingFavorites.current.set(key, previous); + } else { + pendingFavorites.current.delete(key); + } + } + } + }, + [environmentId, registry, save], + ); +} + +const noEnvironmentSettingsAtom = Atom.make(null); + +/** Model pickers may render before a settings target has been selected. */ +export function useEnvironmentModelPreferences(environmentId: EnvironmentId | null) { + const settings = useAtomValue( + environmentId ? serverEnvironment.settingsValueAtom(environmentId) : noEnvironmentSettingsAtom, + ); + const legacy = useClientSettingsValue(); + useMigrateModelPreferences(environmentId, settings); + return useMemo( + () => resolveModelPreferences(settings ?? DEFAULT_SERVER_SETTINGS, legacy), + [legacy, settings], + ); +} + /** Read current settings for one environment, merged with client-local preferences. */ export function useEnvironmentSettings( environmentId: EnvironmentId, selector?: (settings: UnifiedSettings) => T, ): T { const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId)); + useMigrateModelPreferences(environmentId, serverSettings); return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector); } @@ -419,7 +578,13 @@ export function usePersistEnvironmentProviderInstanceMutation(environmentId: Env export function usePrimarySettings( selector?: (settings: UnifiedSettings) => T, ): T { - return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector); + const environment = usePrimaryEnvironment(); + const settings = useAtomValue(primaryServerSettingsAtom); + useMigrateModelPreferences( + environment?.environmentId ?? null, + environment?.serverConfig?.settings ?? null, + ); + return useMergedSettings(settings, selector); } export const PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE = @@ -618,7 +783,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { return updateSettings; } -export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { +export function useUpdateEnvironmentSettings(environmentId: EnvironmentId | null) { return useUpdateSettingsTarget(environmentId); } diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 675344df4189487d078ab8df1a8c0f5204100f07..60145dba1a05c528df201cc6b8ad07220b0139aa 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1080,3 +1080,34 @@ describe("requiredScopesForProjectMutation", () => { ).toEqual(["orchestration:operate", "settings:write"]); }); }); + +describe("server model preferences", () => { + it("decodes a visibility update scoped to a provider instance and model", () => { + const patch = { setModelsHidden: [{ provider: "codex_work", model: "sol", hidden: false }] }; + expect(decodeServerSettingsPatch(patch)).toEqual(patch); + expect(() => + decodeServerSettingsPatch({ setModelsHidden: [{ provider: "codex_work", model: "sol" }] }), + ).toThrow(); + }); + it("distinguishes unmigrated lists from deliberately cleared lists", () => { + expect(decodeServerSettings({})).toMatchObject({ + favorites: null, + providerModelPreferences: null, + }); + const input = { favorites: [], providerModelPreferences: {} }; + expect(encodeServerSettings(decodeServerSettings(input))).toMatchObject(input); + expect(decodeServerSettingsPatch(input)).toEqual(input); + }); + + it("round trips favorites and hidden models keyed by provider instance", () => { + const input = { + favorites: [{ provider: "codex_work", model: "sol" }], + providerModelPreferences: { codex_work: { hiddenModels: ["astra"], modelOrder: ["sol"] } }, + }; + expect(encodeServerSettings(decodeServerSettings(input))).toMatchObject(input); + expect(decodeServerSettingsPatch(input)).toEqual(input); + expect(decodeServerSettingsPatch({ migrateModelPreferences: input })).toEqual({ + migrateModelPreferences: input, + }); + }); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a8f3a74eb0117283c00a4f940b40ae4c35b43c20..e325729428ae8cf64c3a21a33177ad177fa92566 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -299,6 +299,25 @@ export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]); export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]); export type ChatWidth = typeof ChatWidth.Type; +/** Preferences shared by clients connected to the same environment. */ +export const ModelFavorite = Schema.Struct({ + // The historical field name is retained; default instance ids also decode old provider-kind favorites. + provider: ProviderInstanceId, + model: TrimmedNonEmptyString, +}); +export const ModelFavorites = Schema.Array(ModelFavorite); +export const ProviderModelPreferences = Schema.Record( + ProviderInstanceId, + Schema.Struct({ + hiddenModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + }), +); +export type ModelPreferences = { + readonly favorites: typeof ModelFavorites.Type; + readonly providerModelPreferences: typeof ProviderModelPreferences.Type; +}; + export const ClientSettingsSchema = Schema.Struct({ notificationMode: NotificationMode.pipe( Schema.withDecodingDefault(Effect.succeed("off" as const)), @@ -413,31 +432,11 @@ export const ClientSettingsSchema = Schema.Struct({ persistComposerContextStrip: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), - // Model favorites. Historically keyed by provider kind, now - // widened to `ProviderInstanceId` so users can favorite a specific model - // on a custom provider instance (e.g. "Codex Personal · gpt-5") without - // the UI collapsing it into the same bucket as the default Codex. The - // widening is backward-compatible by construction: prior provider-kind - // strings satisfy the `ProviderInstanceId` slug schema, so previously - // persisted favorites decode unchanged and continue to point at the - // default instance for their kind (because `defaultInstanceIdForDriver(kind)` - // uses the same slug). The field name is kept as `provider` for storage - // stability; new call sites should treat the value as an instance id. - favorites: Schema.Array( - Schema.Struct({ - provider: ProviderInstanceId, - model: TrimmedNonEmptyString, - }), - ).pipe(Schema.withDecodingDefault(Effect.succeed([]))), - providerModelPreferences: Schema.Record( - ProviderInstanceId, - Schema.Struct({ - hiddenModels: Schema.Array(Schema.String).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - ), - modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), - }), - ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + // Legacy client values retained for migration to the connected environment. + favorites: ModelFavorites.pipe(Schema.withDecodingDefault(Effect.succeed([]))), + providerModelPreferences: ProviderModelPreferences.pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), pullRequestMergeMethodOverrides: Schema.Record( TrimmedNonEmptyString, PullRequestMergeMethod, @@ -1060,6 +1059,12 @@ export const StorageCleanupSettings = Schema.Struct({ export type StorageCleanupSettings = typeof StorageCleanupSettings.Type; export const ServerSettings = Schema.Struct({ + // Null means no client has migrated this preference yet; an empty value is + // authoritative so another device cannot resurrect cleared preferences. + favorites: Schema.NullOr(ModelFavorites).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + providerModelPreferences: Schema.NullOr(ProviderModelPreferences).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), worktreeCleanup: WorktreeCleanup.pipe(Schema.withDecodingDefault(Effect.succeed(null))), storageCleanup: StorageCleanupSettings.pipe( Schema.withDecodingDefault(Effect.succeed(Schema.decodeSync(StorageCleanupSettings)({}))), @@ -1384,7 +1389,7 @@ export class ServerSettingsError extends Schema.TaggedError // ── Unified type ───────────────────────────────────────────────────── -export type UnifiedSettings = ServerSettings & ClientSettings; +export type UnifiedSettings = Omit & ClientSettings; export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = { ...DEFAULT_SERVER_SETTINGS, ...DEFAULT_CLIENT_SETTINGS, @@ -1399,6 +1404,24 @@ const ModelSelectionPatch = Schema.Struct({ }); export const ServerSettingsPatch = Schema.Struct({ + setModelFavorites: Schema.optionalKey( + Schema.Array(Schema.Struct({ ...ModelFavorite.fields, favorite: Schema.Boolean })), + ), + setModelsHidden: Schema.optionalKey( + Schema.Array(Schema.Struct({ ...ModelFavorite.fields, hidden: Schema.Boolean })), + ), + setProviderModelOrder: Schema.optionalKey( + Schema.Struct({ provider: ProviderInstanceId, modelOrder: Schema.Array(TrimmedString) }), + ), + favorites: Schema.optionalKey(ModelFavorites), + providerModelPreferences: Schema.optionalKey(ProviderModelPreferences), + // Applied under the settings service's write lock, only while still unset. + migrateModelPreferences: Schema.optionalKey( + Schema.Struct({ + favorites: Schema.optionalKey(ModelFavorites), + providerModelPreferences: Schema.optionalKey(ProviderModelPreferences), + }), + ), worktreeCleanup: Schema.optionalKey( Schema.NullOr( Schema.Union([ @@ -1606,27 +1629,8 @@ export const ClientSettingsPatch = Schema.Struct({ fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference), fontSmoothing: Schema.optionalKey(Schema.Boolean), persistComposerContextStrip: Schema.optionalKey(Schema.Boolean), - favorites: Schema.optionalKey( - Schema.Array( - Schema.Struct({ - provider: ProviderInstanceId, - model: TrimmedNonEmptyString, - }), - ), - ), - providerModelPreferences: Schema.optionalKey( - Schema.Record( - ProviderInstanceId, - Schema.Struct({ - hiddenModels: Schema.Array(Schema.String).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - ), - modelOrder: Schema.Array(Schema.String).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - ), - }), - ), - ), + favorites: Schema.optionalKey(ModelFavorites), + providerModelPreferences: Schema.optionalKey(ProviderModelPreferences), pullRequestMergeMethodOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, PullRequestMergeMethod), ), diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index fe58c361d4a4465aad53d68e44349baad91c93c2..8a0d21470d6b970d2fa656a34f56977aff683db9 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -13,6 +13,8 @@ import { createModelSelection } from "./model.ts"; import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts"; import { applyServerSettingsPatch, + modelPreferencesMigrationPatch, + resolveModelPreferences, isModelSelectionProviderEnabled, parsePersistedServerObservabilitySettings, resolveSourceControlWriterModelSelection, @@ -823,3 +825,175 @@ describe("worktreesDirectory", () => { expect(back.previousWorktreesDirectories).toEqual(["/b"]); }); }); + +describe("synchronized model preferences", () => { + const provider = ProviderInstanceId.make("codex_work"); + const legacy = { + favorites: [{ provider, model: "sol" }], + providerModelPreferences: { [provider]: { hiddenModels: ["astra"], modelOrder: ["sol"] } }, + }; + + it("keeps concurrent adds and removes of the same favorite idempotent", () => { + const add = { setModelFavorites: [{ provider, model: "sol", favorite: true }] }; + const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, add); + expect(applyServerSettingsPatch(first, add).favorites).toEqual([{ provider, model: "sol" }]); + const remove = { setModelFavorites: [{ provider, model: "sol", favorite: false }] }; + const removed = applyServerSettingsPatch(first, remove); + expect(applyServerSettingsPatch(removed, remove).favorites).toEqual([]); + }); + + it("preserves unrelated models and providers during bulk visibility and order edits", () => { + const otherProvider = ProviderInstanceId.make("claude_work"); + const initial = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + providerModelPreferences: { + ...legacy.providerModelPreferences, + [otherProvider]: { hiddenModels: ["opus"], modelOrder: ["sonnet", "opus"] }, + }, + }); + const mobile = applyServerSettingsPatch(initial, { + setModelsHidden: [{ provider, model: "luna", hidden: true }], + }); + const web = applyServerSettingsPatch(mobile, { + setModelsHidden: [ + { provider, model: "astra", hidden: false }, + { provider, model: "sol", hidden: true }, + ], + setProviderModelOrder: { provider, modelOrder: ["sol", "luna", "astra"] }, + }); + expect(web.providerModelPreferences?.[provider]).toEqual({ + hiddenModels: ["luna", "sol"], + modelOrder: ["sol", "luna", "astra"], + }); + expect(web.providerModelPreferences?.[otherProvider]).toEqual( + initial.providerModelPreferences?.[otherProvider], + ); + expect( + applyServerSettingsPatch(web, { + setProviderModelOrder: { provider, modelOrder: [] }, + }).providerModelPreferences?.[provider], + ).toEqual({ hiddenModels: ["luna", "sol"], modelOrder: [] }); + }); + + it("migrates saved lists independently without claiming empty legacy lists", () => { + expect(modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, {})).toBeNull(); + const patch = modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy)!; + const migrated = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, patch); + expect(resolveModelPreferences(migrated)).toEqual(legacy); + expect(modelPreferencesMigrationPatch(migrated, legacy)).toBeNull(); + const favoritesOnly = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { favorites: [] }); + expect(modelPreferencesMigrationPatch(favoritesOnly, legacy)).toEqual({ + migrateModelPreferences: { providerModelPreferences: legacy.providerModelPreferences }, + }); + }); + + it("keeps explicit clears when a delayed migration arrives from another device", () => { + const staleMigration = modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy)!; + const cleared = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + favorites: [], + providerModelPreferences: {}, + }); + expect( + resolveModelPreferences(applyServerSettingsPatch(cleared, staleMigration), legacy), + ).toEqual({ + favorites: [], + providerModelPreferences: {}, + }); + }); + + it("replaces visibility records so showing every model clears removed entries", () => { + const migrated = applyServerSettingsPatch( + DEFAULT_SERVER_SETTINGS, + modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy)!, + ); + const cleared = applyServerSettingsPatch(migrated, { providerModelPreferences: {} }); + expect(cleared.providerModelPreferences).toEqual({}); + expect(cleared.favorites).toEqual(legacy.favorites); + }); + + it("sets favorites without losing changes from other clients", () => { + const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + setModelFavorites: [{ provider, model: "sol", favorite: true }], + }); + const second = applyServerSettingsPatch(first, { + setModelFavorites: [{ provider, model: "astra", favorite: true }], + migrateModelPreferences: { favorites: [] }, + }); + expect(second.favorites).toEqual([ + { provider, model: "sol" }, + { provider, model: "astra" }, + ]); + const removed = applyServerSettingsPatch(second, { + setModelFavorites: [{ provider, model: "sol", favorite: false }], + }); + expect(removed.favorites).toEqual([{ provider, model: "astra" }]); + expect( + applyServerSettingsPatch(removed, { + setModelFavorites: [{ provider, model: "astra", favorite: false }], + }).favorites, + ).toEqual([]); + }); + + it("sets visibility against the latest state without replacing other models or their order", () => { + const initial = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + providerModelPreferences: legacy.providerModelPreferences, + }); + const first = applyServerSettingsPatch(initial, { + setModelsHidden: [{ provider, model: "sol", hidden: true }], + }); + const second = applyServerSettingsPatch(first, { + setModelsHidden: [{ provider, model: "luna", hidden: true }], + }); + expect(second.providerModelPreferences?.[provider]).toEqual({ + hiddenModels: ["astra", "sol", "luna"], + modelOrder: ["sol"], + }); + const restored = applyServerSettingsPatch(second, { + setModelsHidden: [{ provider, model: "sol", hidden: false }], + }); + expect(restored.providerModelPreferences?.[provider]).toEqual({ + hiddenModels: ["astra", "luna"], + modelOrder: ["sol"], + }); + expect( + applyServerSettingsPatch(restored, { + setModelsHidden: [{ provider, model: "sol", hidden: false }], + }).providerModelPreferences, + ).toEqual(restored.providerModelPreferences); + expect( + applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + setModelsHidden: [{ provider, model: "sol", hidden: true }], + }).providerModelPreferences?.[provider]?.hiddenModels, + ).toEqual(["sol"]); + }); + + it("keeps the same model on different provider instances as separate favorites", () => { + const otherProvider = ProviderInstanceId.make("codex_personal"); + const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + setModelFavorites: [{ provider, model: "sol", favorite: true }], + }); + const second = applyServerSettingsPatch(first, { + setModelFavorites: [{ provider: otherProvider, model: "sol", favorite: true }], + }); + expect(second.favorites).toEqual([ + { provider, model: "sol" }, + { provider: otherProvider, model: "sol" }, + ]); + expect( + applyServerSettingsPatch(second, { + setModelFavorites: [{ provider, model: "sol", favorite: false }], + }).favorites, + ).toEqual([{ provider: otherProvider, model: "sol" }]); + }); + + it("keeps explicit edits when the same patch also carries a legacy migration", () => { + const edited = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + ...modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy), + favorites: [], + providerModelPreferences: {}, + }); + expect(resolveModelPreferences(edited, legacy)).toEqual({ + favorites: [], + providerModelPreferences: {}, + }); + }); +}); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 85af5bb1fdd6118ebd36864d7377c41bf25b2878..e6a6660de57f65d33d06a99cb1d7794c67b9cfd1 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,3 +1,4 @@ +import type { ModelPreferences } from "@t3tools/contracts/settings"; import { isProviderAvailable, isUnconfiguredDefaultInstanceEnabled, @@ -260,6 +261,11 @@ export function applyServerSettingsPatch( const patch = translateLegacyProjectOverridePatch(current, rawPatch); const selectionPatch = patch.textGenerationModelSelection; const { + migrateModelPreferences, + setModelFavorites, + setModelsHidden, + setProviderModelOrder, + providerModelPreferences, automaticGitFetchInterval, providerHealthRefreshInterval, backgroundActivityProfile, @@ -314,9 +320,57 @@ export function applyServerSettingsPatch( }, } : undefined; + const initialFavorites = + patch.favorites ?? current.favorites ?? migrateModelPreferences?.favorites ?? null; + const favorites = + setModelFavorites?.reduce((current, change) => { + const exists = current.some( + (favorite) => favorite.provider === change.provider && favorite.model === change.model, + ); + return change.favorite + ? exists + ? current + : [...current, { provider: change.provider, model: change.model }] + : current.filter( + (favorite) => favorite.provider !== change.provider || favorite.model !== change.model, + ); + }, initialFavorites ?? []) ?? initialFavorites; + const initialModelPreferences = + providerModelPreferences ?? + current.providerModelPreferences ?? + migrateModelPreferences?.providerModelPreferences ?? + null; + let nextModelPreferences = initialModelPreferences; + for (const change of setModelsHidden ?? []) { + const preferences = nextModelPreferences?.[change.provider] ?? { + hiddenModels: [], + modelOrder: [], + }; + const hiddenModels = preferences.hiddenModels.filter((model) => model !== change.model); + if (change.hidden) hiddenModels.push(change.model); + nextModelPreferences = { + ...nextModelPreferences, + [change.provider]: { ...preferences, hiddenModels }, + }; + } + if (setProviderModelOrder) { + const preferences = nextModelPreferences?.[setProviderModelOrder.provider] ?? { + hiddenModels: [], + modelOrder: [], + }; + nextModelPreferences = { + ...nextModelPreferences, + [setProviderModelOrder.provider]: { + ...preferences, + modelOrder: [...setProviderModelOrder.modelOrder], + }, + }; + } const next = deepMerge(current, patchForMerge); const nextWithReplacementsBase = { ...next, + favorites, + providerModelPreferences: nextModelPreferences, ...(worktreeCleanupPatch === undefined ? {} : { @@ -459,3 +513,39 @@ export function applyServerSettingsPatch( textGenerationModelSelection: createModelSelection(instanceId, model, options), }; } + +/** Resolve legacy local preferences only until the environment has a saved value. */ +export function resolveModelPreferences( + settings: Pick, + legacy: Partial = {}, +): ModelPreferences { + return { + favorites: settings.favorites ?? legacy.favorites ?? [], + providerModelPreferences: + settings.providerModelPreferences ?? legacy.providerModelPreferences ?? {}, + }; +} + +/** Empty legacy values do not claim ownership before another client can migrate its saved list. */ +export function modelPreferencesMigrationPatch( + settings: Pick, + legacy: Partial, +): ServerSettingsPatch | null { + const favorites = + settings.favorites === null && (legacy.favorites?.length ?? 0) > 0 + ? legacy.favorites + : undefined; + const providerModelPreferences = + settings.providerModelPreferences === null && + Object.keys(legacy.providerModelPreferences ?? {}).length > 0 + ? legacy.providerModelPreferences + : undefined; + return favorites === undefined && providerModelPreferences === undefined + ? null + : { + migrateModelPreferences: { + ...(favorites === undefined ? {} : { favorites }), + ...(providerModelPreferences === undefined ? {} : { providerModelPreferences }), + }, + }; +} -- 2.54.0