From f98ccd52fbb9bf3709a48f2a403c07c7c99b19fa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 06:35:31 +0000 Subject: [PATCH 1/4] test(web): reproduce automatic default model not shown as mixed (#27997) These tests reproduce pingdotgg/t3code#27997: with every selected environment on the automatic default model, the Model row compares only the saved nulls, so it shows the representative environment's resolved model and effort picker even though another environment resolves to a different model. Co-Authored-By: Claude Opus 5.5 --- .../ProjectDefaultsSettings.browser.test.tsx | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 apps/web/src/components/settings/ProjectDefaultsSettings.browser.test.tsx 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..df74f801232bf --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.browser.test.tsx @@ -0,0 +1,113 @@ +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 })) as never, + useEnvironmentProviders: () => 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, slug: 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: [ + { + slug, + name: slug, + isCustom: false, + isDefault: true, + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Effort", + type: "select", + options: [ + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + }, + ], + }, + }, + ], + slashCommands: [], + skills: [], + }; +} + +const environments = [primaryId, remoteId].map((environmentId) => ({ + environmentId, + label: environmentId, + connection: { phase: "connected" }, + serverConfig: { + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + settings: { ...DEFAULT_SERVER_SETTINGS, defaultModelSelection: null }, + environment: { platform: { machine: "server" } }, + }, +})); +// Both environments choose their default automatically, but Codex is off on remote. +const providersByEnvironment = new Map([ + [primaryId, [provider("codex", true, "gpt-6"), provider("claudeAgent", true, "claude-opus-5-5")]], + [remoteId, [provider("codex", false, "gpt-6"), provider("claudeAgent", true, "claude-opus-5-5")]], +]); + +describe("ProjectDefaultsSettings", () => { + it("shows the automatic default model as mixed when environments resolve it differently", async () => { + await using screen = await render( + + {}}> + + + , + ); + + await expect.element(screen.getByRole("heading", { name: "Model" })).toBeVisible(); + // Primary alone resolves to Codex at Medium effort; remote can only resolve to Claude. + 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(); + }); +}); From 87f51cdad2dd56bfbdac163f18fa821773e645db Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 06:38:05 +0000 Subject: [PATCH 2/4] fix(web): automatic default model shows as mixed when environments resolve it differently Fixes pingdotgg/t3code#27997 Co-Authored-By: Claude Opus 5.5 --- .../settings/ProjectDefaultsSettings.tsx | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 43c4419962a02..75bc1d485b7e0 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -48,7 +48,7 @@ function isWorktreeSubmodules(value: string | null): value is WorktreeSubmodules } export function ProjectDefaultsSettings({ category }: { category: ProjectSettingsCategory }) { - const { scope, target, connectedEnvironments } = useSettingsScope(); + const { scope, target, targets, connectedEnvironments } = useSettingsScope(); const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); const navigate = useNavigate(); @@ -76,7 +76,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 + ? [ + resolveDefaultProviderModelSelection( + candidateProviders, + candidate.settings.defaultModelSelection, + ), + ] + : []; + }); + 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( From cf6f6368eb1575c8f23bffd83e47d7fcdd0ddd8b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 06:45:46 +0000 Subject: [PATCH 3/4] fix(web): compare effective default models when deciding the row is mixed Resolve each target's default through the same hidden-model normalization the row displays, so environments that land on the same visible model stay editable. Co-Authored-By: Claude Opus 5.5 --- .../ProjectDefaultsSettings.browser.test.tsx | 139 ++++++++++++------ .../settings/ProjectDefaultsSettings.tsx | 23 +-- 2 files changed, 110 insertions(+), 52 deletions(-) diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.browser.test.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.browser.test.tsx index df74f801232bf..6d83de6da0394 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.browser.test.tsx @@ -13,8 +13,8 @@ import { render } from "../../../test/render"; vi.mock(import("~/state/environments"), async (original) => ({ ...(await original()), - useEnvironments: (() => ({ environments })) as never, - useEnvironmentProviders: () => providersByEnvironment, + useEnvironments: (() => ({ environments: fixture.environments })) as never, + useEnvironmentProviders: () => fixture.providersByEnvironment, usePrimaryEnvironmentId: () => primaryId, })); // Every environment grants settings writes, so the row's controls are live. @@ -37,7 +37,7 @@ import { SettingsScopeProvider } from "./SettingsScopeContext"; const primaryId = EnvironmentId.make("primary"); const remoteId = EnvironmentId.make("remote"); -function provider(driver: string, enabled: boolean, slug: string): ServerProvider { +function provider(driver: string, enabled: boolean, slugs: readonly string[]): ServerProvider { return { instanceId: ProviderInstanceId.make(driver), driver: ProviderDriverKind.make(driver), @@ -47,60 +47,92 @@ function provider(driver: string, enabled: boolean, slug: string): ServerProvide status: "ready", auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", - models: [ - { - slug, - name: slug, - isCustom: false, - isDefault: true, - capabilities: { - optionDescriptors: [ - { - id: "effort", - label: "Effort", - type: "select", - options: [ - { id: "medium", label: "Medium", isDefault: true }, - { id: "high", label: "High" }, - ], - }, - ], - }, + 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: [], }; } -const environments = [primaryId, remoteId].map((environmentId) => ({ - environmentId, - label: environmentId, - connection: { phase: "connected" }, - serverConfig: { - keybindings: DEFAULT_RESOLVED_KEYBINDINGS, - settings: { ...DEFAULT_SERVER_SETTINGS, defaultModelSelection: null }, - environment: { platform: { machine: "server" } }, - }, -})); -// Both environments choose their default automatically, but Codex is off on remote. -const providersByEnvironment = new Map([ - [primaryId, [provider("codex", true, "gpt-6"), provider("claudeAgent", true, "claude-opus-5-5")]], - [remoteId, [provider("codex", false, "gpt-6"), provider("claudeAgent", true, "claude-opus-5-5")]], -]); +interface EnvironmentFixture { + readonly providers: readonly ServerProvider[]; + readonly hiddenClaudeModels?: readonly string[]; +} -describe("ProjectDefaultsSettings", () => { - it("shows the automatic default model as mixed when environments resolve it differently", async () => { - await using screen = await render( +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() { + const stack = new AsyncDisposableStack(); + const screen = stack.use( + await render( {}}> , - ); + ), + ); + await expect.element(screen.getByRole("heading", { name: "Model" })).toBeVisible(); + stack.move(); + return screen; +} - await expect.element(screen.getByRole("heading", { name: "Model" })).toBeVisible(); +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", @@ -110,4 +142,25 @@ describe("ProjectDefaultsSettings", () => { "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(); + }); }); diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 75bc1d485b7e0..24e8ef4f9dc4c 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 } from "../../hooks/useSettings"; import { getCustomModelOptionsByInstance, resolveEffectiveDefaultModelSelection, @@ -47,6 +50,14 @@ 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, targets, connectedEnvironments } = useSettingsScope(); const settings = useScopedSettings(); @@ -61,13 +72,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, @@ -81,9 +86,9 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const candidateProviders = providersByEnvironment.get(candidate.environmentId); return candidateProviders?.length ? [ - resolveDefaultProviderModelSelection( + resolveDisplayedDefaultModel( + mergeEnvironmentSettings(candidate.settings, settings), candidateProviders, - candidate.settings.defaultModelSelection, ), ] : []; From 6f907191b2f81017b06acdc622da25803da8535f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 06:52:42 +0000 Subject: [PATCH 4/4] fix(web): resolve each environment's default against client settings only Merging targets with the representative's merged settings leaked its model preferences into environments that store none. Co-Authored-By: Claude Opus 5.5 --- .../ProjectDefaultsSettings.browser.test.tsx | 19 ++++++++++++++++++- .../settings/ProjectDefaultsSettings.tsx | 5 +++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.browser.test.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.browser.test.tsx index 6d83de6da0394..12e3636f01266 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.browser.test.tsx @@ -109,7 +109,7 @@ function setup(primary: EnvironmentFixture, remote: EnvironmentFixture) { } async function renderModelRow() { - const stack = new AsyncDisposableStack(); + await using stack = new AsyncDisposableStack(); const screen = stack.use( await render( @@ -163,4 +163,21 @@ describe("ProjectDefaultsSettings", () => { "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 24e8ef4f9dc4c..50b5073dd9580 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -9,7 +9,7 @@ import { createModelSelection } from "@t3tools/shared/model"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { useNavigate } from "@tanstack/react-router"; -import { mergeEnvironmentSettings } from "../../hooks/useSettings"; +import { mergeEnvironmentSettings, useClientSettings } from "../../hooks/useSettings"; import { getCustomModelOptionsByInstance, resolveEffectiveDefaultModelSelection, @@ -61,6 +61,7 @@ function resolveDisplayedDefaultModel( export function ProjectDefaultsSettings({ category }: { category: ProjectSettingsCategory }) { const { scope, target, targets, connectedEnvironments } = useSettingsScope(); const settings = useScopedSettings(); + const clientSettings = useClientSettings(); const updateSettings = useUpdateScopedSettings(); const navigate = useNavigate(); const { environments } = useEnvironments(); @@ -87,7 +88,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting return candidateProviders?.length ? [ resolveDisplayedDefaultModel( - mergeEnvironmentSettings(candidate.settings, settings), + mergeEnvironmentSettings(candidate.settings, clientSettings), candidateProviders, ), ]