feat(projects): pin a thinking level next to a project's model

A project could pin the model new chats start on, but not the level to
run it at: the default cascade dropped any variant as soon as a project
model won, and only ever considered the global one — which belongs to
the global model.

Projects now carry `defaultVariant` alongside `defaultModel`, stored and
sanitized only next to that model, and the cascade passes it through.
Both controls sit in one "Defaults for new chats" group laid out like the
Sessions defaults, and the level appears only for models that offer them.
This commit is contained in:
Bohdan Triapitsyn
2026-08-22 20:31:31 +03:00
parent e2e55756e2
commit 2740c7b3a2
25 changed files with 322 additions and 34 deletions
@@ -671,6 +671,57 @@ describe('useConfigStore provider persistence', () => {
expect(getConfigCalls).toBe(0);
});
test('a project default carries its own thinking level', async () => {
// The project pins a model plus the level to run it at. Before, the level
// was dropped and only the global settings variant was ever considered —
// and that one belongs to the global model, not this project's.
const projectProvider = provider('anthropic', 'claude-opus-5', { high: {}, low: {} });
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [projectProvider],
agents: [testAgent('build')],
currentProviderId: '',
currentModelId: '',
currentVariant: undefined,
settingsDefaultModel: undefined,
settingsDefaultVariant: 'low',
selectionSource: 'auto',
directoryScoped: {},
});
useConfigStore.getState().applyDefaultModelAgentSelection({
projectDefaultModel: 'anthropic/claude-opus-5',
projectDefaultVariant: 'high',
});
const state = useConfigStore.getState();
expect(state.currentProviderId).toBe('anthropic');
expect(state.currentModelId).toBe('claude-opus-5');
expect(state.currentVariant).toBe('high');
});
test('a thinking level the project model does not offer is ignored', async () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('anthropic', 'claude-opus-5')],
agents: [testAgent('build')],
currentProviderId: '',
currentModelId: '',
currentVariant: undefined,
settingsDefaultModel: undefined,
settingsDefaultVariant: undefined,
selectionSource: 'auto',
directoryScoped: {},
});
useConfigStore.getState().applyDefaultModelAgentSelection({
projectDefaultModel: 'anthropic/claude-opus-5',
projectDefaultVariant: 'high',
});
expect(useConfigStore.getState().currentVariant).toBe(undefined);
});
test('manual selection survives an in-flight loadAgents refresh', async () => {
const pendingAgents = deferred<TestAgent[]>();
listAgentsImpl = async () => pendingAgents.promise;
+7 -2
View File
@@ -291,6 +291,7 @@ const resolveDefaultAgentModelSelection = ({
agents,
providers,
projectDefaultModel,
projectDefaultVariant,
settingsDefaultAgent,
settingsDefaultModel,
settingsDefaultVariant,
@@ -300,6 +301,7 @@ const resolveDefaultAgentModelSelection = ({
agents: Agent[];
providers: ProviderWithModelList[];
projectDefaultModel?: string;
projectDefaultVariant?: string;
settingsDefaultAgent?: string;
settingsDefaultModel?: string;
settingsDefaultVariant?: string;
@@ -355,7 +357,9 @@ const resolveDefaultAgentModelSelection = ({
if (parsed && hasProviderModel(providers, parsed.providerId, parsed.modelId)) {
providerId = parsed.providerId;
modelId = parsed.modelId;
variant = resolveVariant(providerId, modelId, projectDefaultModel ? undefined : settingsDefaultVariant);
// A project default carries its own variant; the settings variant
// belongs to the settings model and must not leak onto it.
variant = resolveVariant(providerId, modelId, projectDefaultModel ? projectDefaultVariant : settingsDefaultVariant);
}
}
@@ -1097,7 +1101,7 @@ interface ConfigStore {
cycleCurrentVariant: () => void;
getCurrentModelVariants: () => string[];
setAgent: (agentName: string | undefined) => void;
applyDefaultModelAgentSelection: (options?: { projectDefaultModel?: string }) => void;
applyDefaultModelAgentSelection: (options?: { projectDefaultModel?: string; projectDefaultVariant?: string }) => void;
applyOpenCodeConfigDefaults: (directory?: string | null, source?: string, config?: Config) => void;
setSelectedProvider: (providerId: string) => void;
setSettingsDefaultModel: (model: string | undefined) => void;
@@ -2603,6 +2607,7 @@ export const useConfigStore = create<ConfigStore>()(
agents,
providers,
projectDefaultModel: options?.projectDefaultModel,
projectDefaultVariant: options?.projectDefaultVariant,
settingsDefaultAgent,
settingsDefaultModel,
settingsDefaultVariant,
@@ -19,3 +19,50 @@ describe("useProjectsStore settings synchronization", () => {
expect(useProjectsStore.getState().manualProjectOrder).toEqual([])
})
})
describe("useProjectsStore default model and thinking level", () => {
const seed = (project: ProjectEntry) => {
useProjectsStore.setState({
projects: [project],
activeProjectId: project.id,
manualProjectOrder: [project.id],
})
}
test("keeps a thinking level next to the model it belongs to", () => {
seed({ id: "project-a", path: "/repo" } as ProjectEntry)
useProjectsStore.getState().updateProjectMeta("project-a", {
defaultModel: "anthropic/claude-opus-5",
defaultVariant: "high",
})
const project = useProjectsStore.getState().projects[0]
expect(project?.defaultModel).toBe("anthropic/claude-opus-5")
expect(project?.defaultVariant).toBe("high")
})
test("drops the thinking level when the model is cleared", () => {
seed({
id: "project-a",
path: "/repo",
defaultModel: "anthropic/claude-opus-5",
defaultVariant: "high",
} as ProjectEntry)
useProjectsStore.getState().updateProjectMeta("project-a", { defaultModel: null })
const project = useProjectsStore.getState().projects[0]
expect(project?.defaultModel).toBe(undefined)
expect(project?.defaultVariant).toBe(undefined)
})
test("ignores a thinking level that arrives without a model", () => {
useProjectsStore.getState().synchronizeFromSettings({
projects: [{ id: "project-a", path: "/repo", defaultVariant: "high" }],
} as DesktopSettings)
const project = useProjectsStore.getState().projects[0]
expect(project?.defaultVariant).toBe(undefined)
})
})
@@ -60,6 +60,7 @@ interface ProjectsStore {
color?: string | null;
iconBackground?: string | null;
defaultModel?: string | null;
defaultVariant?: string | null;
}) => void;
uploadProjectIcon: (id: string, file: File) => Promise<{ ok: boolean; error?: string }>;
removeProjectIcon: (id: string) => Promise<{ ok: boolean; error?: string }>;
@@ -291,6 +292,10 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => {
const defaultModel = normalizeDefaultModel(candidate.defaultModel);
if (defaultModel) {
project.defaultModel = defaultModel;
// A variant only means something next to the model it belongs to.
if (typeof candidate.defaultVariant === 'string' && candidate.defaultVariant.trim().length > 0) {
project.defaultVariant = candidate.defaultVariant.trim();
}
}
if (candidate.iconBackground === null) {
project.iconBackground = null;
@@ -521,6 +526,7 @@ const vscodeWorkspaceProjectsEqual = (left: ProjectEntry[], right: ProjectEntry[
&& leftProject.color === rightProject.color
&& leftProject.iconBackground === rightProject.iconBackground
&& leftProject.defaultModel === rightProject.defaultModel
&& leftProject.defaultVariant === rightProject.defaultVariant
&& leftProject.addedAt === rightProject.addedAt
&& leftProject.lastOpenedAt === rightProject.lastOpenedAt
&& leftProject.sidebarCollapsed === rightProject.sidebarCollapsed
@@ -724,6 +730,7 @@ export const useProjectsStore = create<ProjectsStore>()(
color?: string | null;
iconBackground?: string | null;
defaultModel?: string | null;
defaultVariant?: string | null;
}) => {
if (isVSCodeProjectsRuntime) {
return;
@@ -749,6 +756,19 @@ export const useProjectsStore = create<ProjectsStore>()(
delete updated.defaultModel;
}
}
if (meta.defaultVariant !== undefined) {
const trimmed = meta.defaultVariant?.trim();
if (trimmed) {
updated.defaultVariant = trimmed;
} else {
delete updated.defaultVariant;
}
}
// A variant without its model is meaningless, and the model may have
// just been cleared in this same update.
if (!updated.defaultModel) {
delete updated.defaultVariant;
}
return updated;
});
set({ projects: nextProjects });