Skip to content

Commit 9609900

Browse files
committed
🤖 feat: mode-scoped AI defaults + per-mode overrides
Change-Id: I19d4bc5c5dd1e5b2a38a4a3e6021bf0b8543b839 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 3f74a6d commit 9609900

35 files changed

+1003
-157
lines changed

src/browser/App.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,16 @@ import { ThemeProvider, useTheme, type ThemeMode } from "./contexts/ThemeContext
2626
import { CommandPalette } from "./components/CommandPalette";
2727
import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources";
2828

29+
import type { UIMode } from "@/common/types/mode";
2930
import type { ThinkingLevel } from "@/common/types/thinking";
3031
import { CUSTOM_EVENTS } from "@/common/constants/events";
3132
import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents";
3233
import {
34+
getModeKey,
35+
getModelKey,
3336
getThinkingLevelByModelKey,
3437
getThinkingLevelKey,
35-
getModelKey,
38+
getWorkspaceAISettingsByModeKey,
3639
} from "@/common/constants/storage";
3740
import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels";
3841
import { enforceThinkingPolicy } from "@/common/utils/thinking/policy";
@@ -319,10 +322,33 @@ function AppInner() {
319322
// ThinkingProvider will pick this up via its listener
320323
updatePersistedState(key, effective);
321324

325+
type WorkspaceAISettingsByModeCache = Partial<
326+
Record<UIMode, { model: string; thinkingLevel: ThinkingLevel }>
327+
>;
328+
329+
const mode = readPersistedState<UIMode>(getModeKey(workspaceId), "exec");
330+
331+
updatePersistedState<WorkspaceAISettingsByModeCache>(
332+
getWorkspaceAISettingsByModeKey(workspaceId),
333+
(prev) => {
334+
const record: WorkspaceAISettingsByModeCache =
335+
prev && typeof prev === "object" ? prev : {};
336+
return {
337+
...record,
338+
[mode]: { model, thinkingLevel: effective },
339+
};
340+
},
341+
{}
342+
);
343+
322344
// Persist to backend so the palette change follows the workspace across devices.
323345
if (api) {
324346
api.workspace
325-
.updateAISettings({ workspaceId, aiSettings: { model, thinkingLevel: effective } })
347+
.updateModeAISettings({
348+
workspaceId,
349+
mode,
350+
aiSettings: { model, thinkingLevel: effective },
351+
})
326352
.catch(() => {
327353
// Best-effort only.
328354
});

src/browser/components/AIView.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
import { BashOutputCollapsedIndicator } from "./tools/BashOutputCollapsedIndicator";
3535
import { hasInterruptedStream } from "@/browser/utils/messages/retryEligibility";
3636
import { ThinkingProvider } from "@/browser/contexts/ThinkingContext";
37+
import { WorkspaceModeAISync } from "@/browser/components/WorkspaceModeAISync";
3738
import { ModeProvider } from "@/browser/contexts/ModeContext";
3839
import { ProviderOptionsProvider } from "@/browser/contexts/ProviderOptionsContext";
3940

@@ -838,6 +839,7 @@ export const AIView: React.FC<AIViewProps> = (props) => {
838839

839840
return (
840841
<ModeProvider workspaceId={props.workspaceId}>
842+
<WorkspaceModeAISync workspaceId={props.workspaceId} />
841843
<ProviderOptionsProvider>
842844
<ThinkingProvider workspaceId={props.workspaceId}>
843845
<AIViewInner {...props} />

src/browser/components/ChatInput/index.tsx

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { enforceThinkingPolicy } from "@/common/utils/thinking/policy";
2626
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
2727
import {
2828
getModelKey,
29+
getWorkspaceAISettingsByModeKey,
2930
getInputKey,
3031
getInputImagesKey,
3132
VIM_ENABLED_KEY,
@@ -339,26 +340,49 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
339340

340341
const setPreferredModel = useCallback(
341342
(model: string) => {
343+
type WorkspaceAISettingsByModeCache = Partial<
344+
Record<"plan" | "exec", { model: string; thinkingLevel: ThinkingLevel }>
345+
>;
346+
342347
const canonicalModel = migrateGatewayModel(model);
343348
ensureModelInSettings(canonicalModel); // Ensure model exists in Settings
344349
updatePersistedState(storageKeys.modelKey, canonicalModel); // Update workspace or project-specific
345350

346-
// Workspace variant: persist to backend for cross-device consistency.
347-
if (!api || variant !== "workspace" || !workspaceId) {
351+
if (variant !== "workspace" || !workspaceId) {
348352
return;
349353
}
350354

351355
const effectiveThinkingLevel = enforceThinkingPolicy(canonicalModel, thinkingLevel);
356+
357+
updatePersistedState<WorkspaceAISettingsByModeCache>(
358+
getWorkspaceAISettingsByModeKey(workspaceId),
359+
(prev) => {
360+
const record: WorkspaceAISettingsByModeCache =
361+
prev && typeof prev === "object" ? prev : {};
362+
return {
363+
...record,
364+
[mode]: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel },
365+
};
366+
},
367+
{}
368+
);
369+
370+
// Workspace variant: persist to backend for cross-device consistency.
371+
if (!api) {
372+
return;
373+
}
374+
352375
api.workspace
353-
.updateAISettings({
376+
.updateModeAISettings({
354377
workspaceId,
378+
mode,
355379
aiSettings: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel },
356380
})
357381
.catch(() => {
358382
// Best-effort only. If offline or backend is old, sendMessage will persist.
359383
});
360384
},
361-
[api, storageKeys.modelKey, ensureModelInSettings, thinkingLevel, variant, workspaceId]
385+
[api, mode, storageKeys.modelKey, ensureModelInSettings, thinkingLevel, variant, workspaceId]
362386
);
363387
const deferredModel = useDeferredValue(preferredModel);
364388
const deferredInput = useDeferredValue(input);

src/browser/components/ChatInput/useCreationWorkspace.ts

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
getModelKey,
1414
getModeKey,
1515
getThinkingLevelKey,
16+
getWorkspaceAISettingsByModeKey,
1617
getPendingScopeId,
1718
getProjectScopeId,
1819
} from "@/common/constants/storage";
@@ -54,6 +55,27 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void
5455
if (projectThinkingLevel !== null) {
5556
updatePersistedState(getThinkingLevelKey(workspaceId), projectThinkingLevel);
5657
}
58+
59+
if (projectModel) {
60+
const effectiveMode: UIMode = projectMode ?? "exec";
61+
const effectiveThinking: ThinkingLevel = projectThinkingLevel ?? "off";
62+
63+
updatePersistedState<
64+
Partial<Record<"plan" | "exec", { model: string; thinkingLevel: ThinkingLevel }>>
65+
>(
66+
getWorkspaceAISettingsByModeKey(workspaceId),
67+
(prev) => {
68+
const record = prev && typeof prev === "object" ? prev : {};
69+
return {
70+
...(record as Partial<
71+
Record<"plan" | "exec", { model: string; thinkingLevel: ThinkingLevel }>
72+
>),
73+
[effectiveMode]: { model: projectModel, thinkingLevel: effectiveThinking },
74+
};
75+
},
76+
{}
77+
);
78+
}
5779
}
5880

5981
interface UseCreationWorkspaceReturn {
@@ -205,17 +227,32 @@ export function useCreationWorkspace({
205227

206228
// Best-effort: persist the initial AI settings to the backend immediately so this workspace
207229
// is portable across devices even before the first stream starts.
208-
api.workspace
209-
.updateAISettings({
210-
workspaceId: metadata.id,
211-
aiSettings: {
212-
model: settings.model,
213-
thinkingLevel: settings.thinkingLevel,
214-
},
215-
})
216-
.catch(() => {
217-
// Ignore (offline / older backend). sendMessage will persist as a fallback.
218-
});
230+
try {
231+
api.workspace
232+
.updateModeAISettings({
233+
workspaceId: metadata.id,
234+
mode: settings.mode,
235+
aiSettings: {
236+
model: settings.model,
237+
thinkingLevel: settings.thinkingLevel,
238+
},
239+
})
240+
.catch(() => {
241+
// Ignore (offline / older backend). sendMessage will persist as a fallback.
242+
});
243+
} catch {
244+
api.workspace
245+
.updateAISettings({
246+
workspaceId: metadata.id,
247+
aiSettings: {
248+
model: settings.model,
249+
thinkingLevel: settings.thinkingLevel,
250+
},
251+
})
252+
.catch(() => {
253+
// Ignore (offline / older backend). sendMessage will persist as a fallback.
254+
});
255+
}
219256
// Sync preferences immediately (before switching)
220257
syncCreationPreferences(projectPath, metadata.id);
221258
if (projectPath) {
@@ -259,6 +296,7 @@ export function useCreationWorkspace({
259296
projectScopeId,
260297
onWorkspaceCreated,
261298
getRuntimeString,
299+
settings.mode,
262300
settings.model,
263301
settings.thinkingLevel,
264302
settings.trunkBranch,

src/browser/components/Settings/SettingsModal.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import React from "react";
2-
import { Settings, Key, Cpu, X, Briefcase, FlaskConical, Bot } from "lucide-react";
2+
import { Settings, Key, Cpu, X, Briefcase, FlaskConical, Bot, Layers } from "lucide-react";
33
import { useSettings } from "@/browser/contexts/SettingsContext";
44
import { Dialog, DialogContent, DialogTitle, VisuallyHidden } from "@/browser/components/ui/dialog";
55
import { GeneralSection } from "./sections/GeneralSection";
66
import { TasksSection } from "./sections/TasksSection";
77
import { ProvidersSection } from "./sections/ProvidersSection";
8+
import { ModesSection } from "./sections/ModesSection";
89
import { ModelsSection } from "./sections/ModelsSection";
910
import { Button } from "@/browser/components/ui/button";
1011
import { ProjectSettingsSection } from "./sections/ProjectSettingsSection";
@@ -36,6 +37,12 @@ const SECTIONS: SettingsSection[] = [
3637
icon: <Briefcase className="h-4 w-4" />,
3738
component: ProjectSettingsSection,
3839
},
40+
{
41+
id: "modes",
42+
label: "Modes",
43+
icon: <Layers className="h-4 w-4" />,
44+
component: ModesSection,
45+
},
3946
{
4047
id: "models",
4148
label: "Models",

0 commit comments

Comments
 (0)