diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 182b8bc5b1fd..fc7de2974865 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -13,7 +13,7 @@ import { defaultModelSelectionsAreMixed, getCustomModelOptionsByInstance, } from "../../modelSelection"; -import { providerInstanceEntriesFor } from "../../providerInstances"; +import { localizeModelSelection, providerInstanceEntriesFor } from "../../providerInstances"; import { useEnvironmentProviders, useEnvironments } from "../../state/environments"; import { firstManagedDenial } from "../../state/managedPolicy"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; @@ -80,11 +80,17 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); const mixedModel = defaultModelSelectionsAreMixed( clientSettings, - targets.map((candidate) => ({ - projectId: candidate.projectId, - settings: candidate.settings, - providers: providersByEnvironment.get(candidate.environmentId) ?? EMPTY_SERVER_PROVIDERS, - })), + targets.map((candidate) => { + const candidateProviders = + providersByEnvironment.get(candidate.environmentId) ?? EMPTY_SERVER_PROVIDERS; + return { + projectId: candidate.projectId, + settings: candidate.settings, + providers: candidateProviders, + toShown: (selection: ModelSelection) => + localizeModelSelection(selection, candidateProviders, providers), + }; + }), ); const automaticModel = targets.length > 0 && diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index 2c771db70f15..85ceb72c981c 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -183,6 +183,7 @@ function projectOverrideWrites( current: ProjectSettingsOverrides, settings: ServerSettings, projectId: ProjectId, + environmentId: EnvironmentId, ) => ProjectSettingsOverrides | null, ): ScopedServerWrite[] { const byId = new Map(environments.map((environment) => [environment.environmentId, environment])); @@ -193,7 +194,8 @@ function projectOverrideWrites( continue; } const settings = environment.serverConfig.settings; - const entry = update(settings.projectSettingsOverrides[member.id] ?? {}, settings, member.id); + const current = settings.projectSettingsOverrides[member.id] ?? {}; + const entry = update(current, settings, member.id, member.environmentId); const existing = writes.get(member.environmentId); writes.set(member.environmentId, { environmentId: member.environmentId, @@ -221,6 +223,11 @@ export function planScopedSettingsPatch( scope: ResolvedSettingsScope, environments: readonly ScopedSettingsEnvironment[], patch: ScopedSettingsPatch, + /** Rewrites the server patch for one destination, such as its own provider instance ids. */ + localize: (environmentId: EnvironmentId, patch: ServerSettingsPatch) => ServerSettingsPatch = ( + _environmentId, + serverPatch, + ) => serverPatch, ) { const clientPatch = Object.fromEntries( Object.entries(patch).filter(([key]) => CLIENT_KEYS.has(key) && !SERVER_KEYS.has(key)), @@ -240,18 +247,19 @@ export function planScopedSettingsPatch( : isProjectScope ? unscopableKeys.length > 0 ? [] - : projectOverrideWrites(scope, environments, (current, settings, projectId) => { + : projectOverrideWrites(scope, environments, (current, settings, projectId, envId) => { // Override entries store whole objects; complete partial patches from each target's effective // value. const effective = resolveProjectSettings(settings, projectId).settings; const next: Record = { ...current }; - for (const [key, value] of Object.entries(serverPatch)) { - if (key === "worktreeCleanup" && serverPatch.worktreeCleanup?.mode === "custom") { + const localPatch = localize(envId, serverPatch); + for (const [key, value] of Object.entries(localPatch)) { + if (key === "worktreeCleanup" && localPatch.worktreeCleanup?.mode === "custom") { next[key] = { mode: "custom", rules: { ...resolveWorktreeCleanup(settings, projectId), - ...serverPatch.worktreeCleanup.rules, + ...localPatch.worktreeCleanup.rules, }, }; continue; @@ -287,6 +295,7 @@ export function planScopedSettingsPatch( ? connectedEnvironments.flatMap((environment) => { const previous = environment.serverConfig?.settings; if (!previous) return []; + const localPatch = localize(environment.environmentId, serverPatch); return [ { environmentId: environment.environmentId, @@ -295,25 +304,25 @@ export function planScopedSettingsPatch( patch: forDestination( previous, previous.worktreeCleanup != null && - serverPatch.storageCleanup && - Object.keys(serverPatch.storageCleanup).some((key) => + localPatch.storageCleanup && + Object.keys(localPatch.storageCleanup).some((key) => key.startsWith("worktree"), ) ? { - ...serverPatch, + ...localPatch, worktreeCleanup: { mode: "custom", rules: { ...resolveWorktreeCleanup(previous, null), ...Object.fromEntries( - Object.entries(serverPatch.storageCleanup).filter(([key]) => + Object.entries(localPatch.storageCleanup).filter(([key]) => key.startsWith("worktree"), ), ), }, }, } - : serverPatch, + : localPatch, ), }, ]; diff --git a/apps/web/src/components/settings/useScopedModelAvailability.browser.test.tsx b/apps/web/src/components/settings/useScopedModelAvailability.browser.test.tsx new file mode 100644 index 000000000000..22252371866f --- /dev/null +++ b/apps/web/src/components/settings/useScopedModelAvailability.browser.test.tsx @@ -0,0 +1,91 @@ +import { render } from "../../../test/render"; +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { deriveProviderInstanceEntries } from "../../providerInstances"; + +const laptop = EnvironmentId.make("laptop"); +const server = EnvironmentId.make("server"); + +function codexAccount(instanceId: string): ServerProvider { + return { + instanceId: ProviderInstanceId.make(instanceId), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { + status: "authenticated", + type: "chatgpt", + email: "gmail@example.test", + workspaceId: "personal", + }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [{ slug: "gpt-6-luna", name: "GPT-6 Luna", isCustom: false, capabilities: {} }], + slashCommands: [], + skills: [], + }; +} + +const providers = new Map([ + [laptop, [codexAccount("codex_laptop_gmail")]], + [server, [codexAccount("codex_server_gmail")]], +]); + +function settingsFor(environmentId: EnvironmentId) { + return { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: Object.fromEntries( + (providers.get(environmentId) ?? []).map((provider) => [ + provider.instanceId, + { driver: provider.driver }, + ]), + ), + }; +} + +vi.mock(import("./SettingsScopeContext"), async (original) => ({ + ...(await original()), + useSettingsScope: (() => ({ + targets: [laptop, server].map((environmentId) => ({ + environmentId, + projectId: null, + settings: settingsFor(environmentId), + })), + })) as never, +})); +vi.mock(import("../../state/environments"), async (original) => ({ + ...(await original()), + useEnvironments: (() => ({ + environments: [ + { environmentId: laptop, label: "Laptop", serverConfig: {} }, + { environmentId: server, label: "Server", serverConfig: {} }, + ], + })) as never, + useEnvironmentProviders: (() => providers) as never, +})); + +import { useScopedModelDisabledReason } from "./useScopedModelAvailability"; + +function DisabledReason() { + const reason = useScopedModelDisabledReason( + settingsFor(laptop), + deriveProviderInstanceEntries(providers.get(laptop) ?? []), + )(ProviderInstanceId.make("codex_laptop_gmail"), "gpt-6-luna"); + return {reason ?? "available"}; +} + +describe("useScopedModelDisabledReason", () => { + // Both environments are signed in to the same account under their own instance id. + it("offers a model every selected environment reaches through the same account", async () => { + await using screen = await render(, { router: false }); // hook only, no routes + await expect.element(screen.getByRole("status")).toHaveTextContent("available"); + }); +}); diff --git a/apps/web/src/components/settings/useScopedModelAvailability.ts b/apps/web/src/components/settings/useScopedModelAvailability.ts index 2471825cb3d1..d5237c05b3d1 100644 --- a/apps/web/src/components/settings/useScopedModelAvailability.ts +++ b/apps/web/src/components/settings/useScopedModelAvailability.ts @@ -9,6 +9,7 @@ import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, + matchingProviderInstanceId, type ProviderInstanceEntry, } from "../../providerInstances"; import { useEnvironmentProviders, useEnvironments } from "../../state/environments"; @@ -43,15 +44,21 @@ export function useScopedModelDisabledReason( } const providers = providersByEnvironment.get(candidate.environmentId); if (!environment?.serverConfig || !providers) continue; + const localInstanceId = + matchingProviderInstanceId( + instanceId, + sourceEntry ? [sourceEntry.snapshot] : [], + providers, + ) ?? instanceId; const entry = applyProviderInstanceSettings( deriveProviderInstanceEntries(providers), candidate.settings, candidate.projectId, - ).find((option) => option.instanceId === instanceId); + ).find((option) => option.instanceId === localInstanceId); const options = getCustomModelOptionsByInstance( mergeEnvironmentSettings(candidate.settings, settings), providers, - ).get(instanceId); + ).get(localInstanceId); if ( !entry?.enabled || !entry.isAvailable || diff --git a/apps/web/src/components/settings/useScopedSettings.ts b/apps/web/src/components/settings/useScopedSettings.ts index 5fb9d048a9e3..440b4261960b 100644 --- a/apps/web/src/components/settings/useScopedSettings.ts +++ b/apps/web/src/components/settings/useScopedSettings.ts @@ -9,12 +9,17 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { DEFAULT_SERVER_SETTINGS, + type EnvironmentId, type ProjectScopedServerSettingKey, + type ProviderInstanceId, + type ServerProvider, type ServerSettings, type UnifiedSettings, } from "@t3tools/contracts"; import { persistClientSettingsPatch, useMergedSettings } from "../../hooks/useSettings"; +import { localizeModelSelection } from "../../providerInstances"; +import { useEnvironmentProviders } from "../../state/environments"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { toastManager } from "../ui/toast"; @@ -30,6 +35,57 @@ import { type ScopedSettingsPatch, } from "./scopedSettings"; +const MODEL_SELECTION_KEYS = [ + "defaultModelSelection", + "fallbackModelSelection", + "textGenerationModelSelection", + "sourceControlWriterModelSelection", + "reviewGuideModelSelection", + "speechPostProcessingModelSelection", +] as const; + +type ModelSelectionFields = { + readonly [K in (typeof MODEL_SELECTION_KEYS)[number]]?: { + readonly instanceId?: ProviderInstanceId; + } | null; +}; + +function localizeModelSelections( + settings: T, + source: ReadonlyArray, + target: ReadonlyArray, +): T { + let next: T | null = null; + for (const key of MODEL_SELECTION_KEYS) { + const value = settings[key]; + const localized = value ? localizeModelSelection(value, source, target) : value; + if (localized !== value) Object.assign((next ??= { ...settings }), { [key]: localized }); + } + return next ?? settings; +} + +/** Pickers show the scope target's instance ids; each environment stores its own. */ +function useModelSelectionLocalizer() { + const { target } = useSettingsScope(); + const providersByEnvironment = useEnvironmentProviders(); + const shown = target ? providersByEnvironment.get(target.environmentId) : undefined; + return { + toEnvironment: ( + environmentId: EnvironmentId, + settings: T, + ): T => { + const destination = providersByEnvironment.get(environmentId); + return shown && destination + ? localizeModelSelections(settings, shown, destination) + : settings; + }, + toShown: (environmentId: EnvironmentId, settings: T): T => { + const source = providersByEnvironment.get(environmentId); + return shown && source ? localizeModelSelections(settings, source, shown) : settings; + }, + }; +} + export function useScopedSettings( selector?: (settings: UnifiedSettings) => T, ): T { @@ -39,7 +95,13 @@ export function useScopedSettings( export function useScopedSettingsMixed(keys: readonly (keyof ServerSettings)[]): boolean { const { targets } = useSettingsScope(); - return scopedSettingsAreMixed(targets, keys); + const { toShown } = useModelSelectionLocalizer(); + return scopedSettingsAreMixed( + targets.map((candidate) => ({ + settings: toShown(candidate.environmentId, candidate.settings), + })), + keys, + ); } export function useScopedSettingSource(keys: readonly (keyof ServerSettings)[]) { @@ -100,9 +162,10 @@ function useRunScopedPlan() { export function useUpdateScopedSettings() { const { scope, environments } = useSettingsScope(); const run = useRunScopedPlan(); + const { toEnvironment } = useModelSelectionLocalizer(); /** `onFailed` runs when any target rejects or drops the patch, so a draft can fall back. */ return (patch: ScopedSettingsPatch, options?: { readonly onFailed?: () => void }) => - run(planScopedSettingsPatch(scope, environments, patch), options?.onFailed); + run(planScopedSettingsPatch(scope, environments, patch, toEnvironment), options?.onFailed); } export function useClearScopedSettings() { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 61be2ece7632..5f1bbcaad07f 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -320,27 +320,30 @@ export function defaultModelSelectionsAreMixed( projectId: ProjectId | null; settings: ServerSettings; providers: ReadonlyArray; + /** Maps this target's instance ids onto the shown environment's, so equal accounts compare equal. */ + toShown?: (selection: ModelSelection) => ModelSelection; }>, ): boolean { - const selections = targets.map(({ projectId, settings, providers }) => { + const selections = targets.map(({ projectId, settings, providers, toShown = (s) => s }) => { const stored = settings.defaultModelSelection; const selection = stored === null ? resolveProjectDefaultProviderModelSelection(providers, settings, projectId) : resolveDefaultProviderModelSelection(providers, stored); + const effective = selection + ? resolveEffectiveDefaultModelSelection( + { + ...clientSettings, + ...settings, + ...resolveModelPreferences(settings, clientSettings), + }, + providers, + selection, + ) + : null; return { - stored, - effective: selection - ? resolveEffectiveDefaultModelSelection( - { - ...clientSettings, - ...settings, - ...resolveModelPreferences(settings, clientSettings), - }, - providers, - selection, - ) - : null, + stored: stored === null ? null : toShown(stored), + effective: effective ? toShown(effective) : null, }; }); return selections.some((selection) => !Equal.equals(selection, selections[0])); diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 891f3312ae83..cd92ab8a039d 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -13,6 +13,7 @@ import { deriveProviderInstanceEntries, getDefaultProviderInstanceModel, isProviderInstancePickerReady, + matchingProviderInstanceId, resolveDefaultProviderModelSelection, resolveSelectableProviderInstance, shouldShowInstanceBadge, @@ -281,6 +282,33 @@ describe("brandProviderInstanceEntries", () => { }); }); +describe("matchingProviderInstanceId", () => { + it("maps an instance to the one signed in to the same account elsewhere", () => { + const codex = ProviderDriverKind.make("codex"); + const signedIn = (instanceId: string, email: string, workspaceId = "personal") => ({ + ...provider({ provider: codex, instanceId }), + auth: { status: "authenticated" as const, email, workspaceId }, + }); + const id = ProviderInstanceId.make; + const laptop = [ + signedIn("codex_laptop", "a@example.test"), + provider({ provider: codex, instanceId: "codex" }), + ]; + const match = (target: ServerProvider[], instanceId = "codex_laptop") => + matchingProviderInstanceId(id(instanceId), laptop, target); + + expect(match([signedIn("codex_server", "A@example.test")])).toBe("codex_server"); + expect(match([signedIn("codex_laptop", "b@example.test")])).toBe("codex_laptop"); + expect(match([provider({ provider: codex, instanceId: "codex" })], "codex")).toBe("codex"); + expect(match([signedIn("codex_server", "b@example.test")])).toBeNull(); + expect(match([signedIn("codex_server", "a@example.test", "team")])).toBeNull(); + expect( + match([signedIn("codex_one", "a@example.test"), signedIn("codex_two", "a@example.test")]), + ).toBeNull(); + expect(match([provider({ provider: codex, instanceId: "codex_server" })])).toBeNull(); + }); +}); + describe("resolveSelectableProviderInstance", () => { it("falls back to the first enabled and available instance", () => { const disabled = ProviderInstanceId.make("codex"); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index c2120f60e31c..c0077845de12 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -80,6 +80,42 @@ export function deriveProviderInstanceEntries( }); } +function providerAccountKey(provider: ServerProvider): string | null { + const email = provider.auth.email?.trim().toLowerCase(); + return provider.auth.status === "authenticated" && email + ? `${provider.driver}:${email}:${provider.auth.workspaceId ?? ""}` + : null; +} + +/** + * Instance ids are machine-local, so the same signed-in account can have a different id on + * another environment. Prefers the same id, then the one instance signed in to the same account. + */ +export function matchingProviderInstanceId( + instanceId: ProviderInstanceId, + source: ReadonlyArray, + target: ReadonlyArray, +): ProviderInstanceId | null { + if (target.some((provider) => provider.instanceId === instanceId)) return instanceId; + const sourceProvider = source.find((provider) => provider.instanceId === instanceId); + const key = sourceProvider ? providerAccountKey(sourceProvider) : null; + if (key === null) return null; + const matches = target.filter((provider) => providerAccountKey(provider) === key); + return matches.length === 1 ? matches[0]!.instanceId : null; +} + +export function localizeModelSelection( + selection: T, + source: ReadonlyArray, + target: ReadonlyArray, +): T { + if (selection.instanceId === undefined) return selection; + const instanceId = matchingProviderInstanceId(selection.instanceId, source, target); + return instanceId === null || instanceId === selection.instanceId + ? selection + : { ...selection, instanceId }; +} + /** Instance ids repeat across environments; lookups must stay scoped. */ export function deriveProviderEntriesByEnvironment( providersByEnvironment: Iterable<