Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions apps/web/src/components/settings/ProjectDefaultsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 &&
Expand Down
29 changes: 19 additions & 10 deletions apps/web/src/components/settings/scopedSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]));
Expand All @@ -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,
Expand Down Expand Up @@ -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)),
Expand All @@ -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<string, unknown> = { ...current };
for (const [key, value] of Object.entries<unknown>(serverPatch)) {
if (key === "worktreeCleanup" && serverPatch.worktreeCleanup?.mode === "custom") {
const localPatch = localize(envId, serverPatch);
for (const [key, value] of Object.entries<unknown>(localPatch)) {
if (key === "worktreeCleanup" && localPatch.worktreeCleanup?.mode === "custom") {
next[key] = {
mode: "custom",
rules: {
...resolveWorktreeCleanup(settings, projectId),
...serverPatch.worktreeCleanup.rules,
...localPatch.worktreeCleanup.rules,
},
};
continue;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
),
},
];
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <output>{reason ?? "available"}</output>;
}

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(<DisabledReason />, { router: false }); // hook only, no routes
await expect.element(screen.getByRole("status")).toHaveTextContent("available");
});
});
11 changes: 9 additions & 2 deletions apps/web/src/components/settings/useScopedModelAvailability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getCustomModelOptionsByInstance } from "../../modelSelection";
import {
applyProviderInstanceSettings,
deriveProviderInstanceEntries,
matchingProviderInstanceId,
type ProviderInstanceEntry,
} from "../../providerInstances";
import { useEnvironmentProviders, useEnvironments } from "../../state/environments";
Expand Down Expand Up @@ -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 ||
Expand Down
67 changes: 65 additions & 2 deletions apps/web/src/components/settings/useScopedSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<T extends ModelSelectionFields>(
settings: T,
source: ReadonlyArray<ServerProvider>,
target: ReadonlyArray<ServerProvider>,
): 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: <T extends ModelSelectionFields>(
environmentId: EnvironmentId,
settings: T,
): T => {
const destination = providersByEnvironment.get(environmentId);
return shown && destination
? localizeModelSelections(settings, shown, destination)
: settings;
},
toShown: <T extends ModelSelectionFields>(environmentId: EnvironmentId, settings: T): T => {
const source = providersByEnvironment.get(environmentId);
return shown && source ? localizeModelSelections(settings, source, shown) : settings;
},
};
}

export function useScopedSettings<T = UnifiedSettings>(
selector?: (settings: UnifiedSettings) => T,
): T {
Expand All @@ -39,7 +95,13 @@ export function useScopedSettings<T = UnifiedSettings>(

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)[]) {
Expand Down Expand Up @@ -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() {
Expand Down
29 changes: 16 additions & 13 deletions apps/web/src/modelSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,27 +320,30 @@ export function defaultModelSelectionsAreMixed(
projectId: ProjectId | null;
settings: ServerSettings;
providers: ReadonlyArray<ServerProvider>;
/** 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]));
Expand Down
Loading
Loading