diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.browser.test.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.browser.test.tsx new file mode 100644 index 0000000000000..12e3636f01266 --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.browser.test.tsx @@ -0,0 +1,183 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { render } from "../../../test/render"; + +vi.mock(import("~/state/environments"), async (original) => ({ + ...(await original()), + useEnvironments: (() => ({ environments: fixture.environments })) as never, + useEnvironmentProviders: () => fixture.providersByEnvironment, + usePrimaryEnvironmentId: () => primaryId, +})); +// Every environment grants settings writes, so the row's controls are live. +vi.mock(import("~/state/session"), async (original) => ({ + ...(await original()), + useEnvironmentScope: () => true, + useEnvironmentsWithScope: (environments) => + new Set(environments.map(({ environmentId }) => environmentId)), +})); +vi.mock(import("~/state/entities"), async (original) => ({ + ...(await original()), + useProjects: () => [], + useThreadShells: () => [], +})); + +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; +import { SettingsScopeProvider } from "./SettingsScopeContext"; + +const primaryId = EnvironmentId.make("primary"); +const remoteId = EnvironmentId.make("remote"); + +function provider(driver: string, enabled: boolean, slugs: readonly string[]): ServerProvider { + return { + instanceId: ProviderInstanceId.make(driver), + driver: ProviderDriverKind.make(driver), + enabled, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: slugs.map((slug, index) => ({ + slug, + name: slug, + isCustom: false, + isDefault: index === 0, + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Effort", + type: "select", + options: [ + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + }, + ], + }, + })), + slashCommands: [], + skills: [], + }; +} + +interface EnvironmentFixture { + readonly providers: readonly ServerProvider[]; + readonly hiddenClaudeModels?: readonly string[]; +} + +const fixture = { + environments: [] as unknown[], + providersByEnvironment: new Map(), +}; + +// Both environments choose their default model automatically. +function setup(primary: EnvironmentFixture, remote: EnvironmentFixture) { + const byId = new Map([ + [primaryId, primary], + [remoteId, remote], + ]); + fixture.environments = [...byId].map(([environmentId, { hiddenClaudeModels }]) => ({ + environmentId, + label: environmentId, + connection: { phase: "connected" }, + serverConfig: { + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + settings: { + ...DEFAULT_SERVER_SETTINGS, + defaultModelSelection: null, + providerModelPreferences: hiddenClaudeModels + ? { claudeAgent: { hiddenModels: [...hiddenClaudeModels], modelOrder: [] } } + : null, + }, + environment: { platform: { machine: "server" } }, + }, + })); + fixture.providersByEnvironment = new Map( + [...byId].map(([environmentId, { providers }]) => [environmentId, providers]), + ); +} + +async function renderModelRow() { + await using stack = new AsyncDisposableStack(); + const screen = stack.use( + await render( + + {}}> + + + , + ), + ); + await expect.element(screen.getByRole("heading", { name: "Model" })).toBeVisible(); + stack.move(); + return screen; +} + +describe("ProjectDefaultsSettings", () => { + it("shows the automatic default model as mixed when environments resolve it differently", async () => { + // Primary alone resolves to Codex at Medium effort; remote can only resolve to Claude. + setup( + { providers: [provider("codex", true, ["gpt-6"]), provider("claudeAgent", true, ["opus"])] }, + { providers: [provider("codex", false, ["gpt-6"]), provider("claudeAgent", true, ["opus"])] }, + ); + await using screen = await renderModelRow(); + + expect( + screen.getByRole("button", { name: "Mixed" }).query(), + "model picker labelled Mixed", + ).not.toBeNull(); + expect( + screen.getByRole("button", { name: "Medium" }).query(), + "effort picker for primary's model", + ).toBeNull(); + }); + + it("keeps the shared automatic default editable when environments resolve it alike", async () => { + // Different catalogs whose defaults are hidden still land on the same visible model. + setup( + { + providers: [provider("claudeAgent", true, ["opus-old", "sonnet"])], + hiddenClaudeModels: ["opus-old"], + }, + { + providers: [provider("claudeAgent", true, ["opus-new", "sonnet"])], + hiddenClaudeModels: ["opus-new"], + }, + ); + await using screen = await renderModelRow(); + + expect(screen.getByRole("button", { name: "Mixed" }).query(), "Mixed label").toBeNull(); + expect( + screen.getByRole("button", { name: "Medium" }).query(), + "effort picker for the shared model", + ).not.toBeNull(); + }); + + it("resolves each environment against its own model preferences", async () => { + // Only primary hides Opus, so remote's automatic default stays Opus. + setup( + { + providers: [provider("claudeAgent", true, ["opus", "sonnet"])], + hiddenClaudeModels: ["opus"], + }, + { providers: [provider("claudeAgent", true, ["opus", "sonnet"])] }, + ); + await using screen = await renderModelRow(); + + expect( + screen.getByRole("button", { name: "Mixed" }).query(), + "model picker labelled Mixed", + ).not.toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 43c4419962a02..50b5073dd9580 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -1,12 +1,15 @@ import { DEFAULT_SERVER_SETTINGS, type ModelSelection, + type ServerProvider, + type UnifiedSettings, type WorktreeSubmodules, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { useNavigate } from "@tanstack/react-router"; +import { mergeEnvironmentSettings, useClientSettings } from "../../hooks/useSettings"; import { getCustomModelOptionsByInstance, resolveEffectiveDefaultModelSelection, @@ -47,9 +50,18 @@ function isWorktreeSubmodules(value: string | null): value is WorktreeSubmodules return value !== null && (WORKTREE_SUBMODULES_OPTIONS as readonly string[]).includes(value); } +function resolveDisplayedDefaultModel( + settings: UnifiedSettings, + providers: ReadonlyArray, +): ModelSelection | null { + const stored = resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection); + return stored ? resolveEffectiveDefaultModelSelection(settings, providers, stored) : null; +} + export function ProjectDefaultsSettings({ category }: { category: ProjectSettingsCategory }) { - const { scope, target, connectedEnvironments } = useSettingsScope(); + const { scope, target, targets, connectedEnvironments } = useSettingsScope(); const settings = useScopedSettings(); + const clientSettings = useClientSettings(); const updateSettings = useUpdateScopedSettings(); const navigate = useNavigate(); const { environments } = useEnvironments(); @@ -61,13 +73,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting (representative?.serverConfig ? providersByEnvironment.get(representative.environmentId) : undefined) ?? EMPTY_SERVER_PROVIDERS; - const storedSelection = resolveDefaultProviderModelSelection( - providers, - settings.defaultModelSelection, - ); - const selection = storedSelection - ? resolveEffectiveDefaultModelSelection(settings, providers, storedSelection) - : null; + const selection = resolveDisplayedDefaultModel(settings, providers); const entries = providerInstanceEntriesFor(providers, settings, target?.projectId ?? null); const modelOptions = getCustomModelOptionsByInstance( settings, @@ -76,7 +82,25 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting selection?.model, ); const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); - const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]); + // Equal saved values can still resolve to different models on each environment's providers. + const resolvedDefaults = targets.flatMap((candidate) => { + const candidateProviders = providersByEnvironment.get(candidate.environmentId); + return candidateProviders?.length + ? [ + resolveDisplayedDefaultModel( + mergeEnvironmentSettings(candidate.settings, clientSettings), + candidateProviders, + ), + ] + : []; + }); + const mixedModel = + useScopedSettingsMixed(["defaultModelSelection"]) || + resolvedDefaults.some( + (resolved) => + resolved?.instanceId !== resolvedDefaults[0]?.instanceId || + resolved?.model !== resolvedDefaults[0]?.model, + ); const mixedFallback = useScopedSettingsMixed(["fallbackModelSelection"]); const fallbackSelection = settings.fallbackModelSelection ?? selection; const fallbackModelOptions = getCustomModelOptionsByInstance(