Skip to content

Commit 96987c2

Browse files
committed
feat: persist thinking level preference per model
Thinking level is now stored per model instead of per workspace: - When switching models, the thinking level for that model is loaded - When changing thinking level, it's saved for the current model - This allows different models to have different thinking preferences Storage key changes: - thinkingLevel:{modelName} - main thinking level per model - lastActiveThinking:{modelName} - for toggle keybind restoration Removed: - lastThinkingByModel:{model} (consolidated into main storage) - Workspace-based thinking level sync during workspace creation The toggle keybind (Ctrl+Shift+T) still remembers the last active (non-off) level for quick on/off toggling.
1 parent c65df0d commit 96987c2

File tree

8 files changed

+121
-62
lines changed

8 files changed

+121
-62
lines changed

src/browser/App.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sour
3030
import type { ThinkingLevel } from "@/common/types/thinking";
3131
import { CUSTOM_EVENTS } from "@/common/constants/events";
3232
import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents";
33-
import { getThinkingLevelKey } from "@/common/constants/storage";
33+
import { getThinkingLevelKey, getModelKey } from "@/common/constants/storage";
34+
import { getDefaultModel } from "@/browser/hooks/useModelLRU";
3435
import type { BranchListResult } from "@/common/orpc/types";
3536
import { useTelemetry } from "./hooks/useTelemetry";
3637
import { getRuntimeTypeForTelemetry } from "@/common/telemetry";
@@ -272,7 +273,12 @@ function AppInner() {
272273
}
273274

274275
try {
275-
const key = getThinkingLevelKey(workspaceId);
276+
// First get the model for this workspace, then get thinking level for that model
277+
const modelKey = getModelKey(workspaceId);
278+
const modelStored = window.localStorage.getItem(modelKey);
279+
const model = modelStored ? (JSON.parse(modelStored) as string) : getDefaultModel();
280+
281+
const key = getThinkingLevelKey(model);
276282
const stored = window.localStorage.getItem(key);
277283
if (!stored || stored === "undefined") {
278284
return "off";
@@ -290,8 +296,13 @@ function AppInner() {
290296
return;
291297
}
292298

299+
// Get the model for this workspace to set thinking level for it
300+
const modelKey = getModelKey(workspaceId);
301+
const modelStored = window.localStorage?.getItem(modelKey);
302+
const model = modelStored ? (JSON.parse(modelStored) as string) : getDefaultModel();
303+
293304
const normalized = THINKING_LEVELS.includes(level) ? level : "off";
294-
const key = getThinkingLevelKey(workspaceId);
305+
const key = getThinkingLevelKey(model);
295306

296307
// Use the utility function which handles localStorage and event dispatch
297308
// ThinkingProvider will pick this up via its listener

src/browser/components/ChatInput/useCreationWorkspace.test.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -404,9 +404,10 @@ describe("useCreationWorkspace", () => {
404404
});
405405

406406
persistedPreferences[getModeKey(getProjectScopeId(TEST_PROJECT_PATH))] = "plan";
407-
persistedPreferences[getThinkingLevelKey(getProjectScopeId(TEST_PROJECT_PATH))] = "high";
408407
// Set model preference for the project scope (read by getSendOptionsFromStorage)
409408
persistedPreferences[getModelKey(getProjectScopeId(TEST_PROJECT_PATH))] = "gpt-4";
409+
// Thinking level is now stored per-model, not per-workspace/project
410+
persistedPreferences[getThinkingLevelKey("gpt-4")] = "high";
410411

411412
draftSettingsState = createDraftSettingsHarness({
412413
runtimeMode: "ssh",
@@ -460,15 +461,14 @@ describe("useCreationWorkspace", () => {
460461
expect(onWorkspaceCreated.mock.calls[0][0]).toEqual(TEST_METADATA);
461462

462463
const projectModeKey = getModeKey(getProjectScopeId(TEST_PROJECT_PATH));
463-
const projectThinkingKey = getThinkingLevelKey(getProjectScopeId(TEST_PROJECT_PATH));
464464
expect(readPersistedStateCalls).toContainEqual([projectModeKey, null]);
465-
expect(readPersistedStateCalls).toContainEqual([projectThinkingKey, null]);
465+
// Thinking level is now read per-model (gpt-4), not per-project
466+
expect(readPersistedStateCalls).toContainEqual([getThinkingLevelKey("gpt-4"), "off"]);
466467

467468
const modeKey = getModeKey(TEST_WORKSPACE_ID);
468-
const thinkingKey = getThinkingLevelKey(TEST_WORKSPACE_ID);
469469
const pendingInputKey = getInputKey(getPendingScopeId(TEST_PROJECT_PATH));
470470
expect(updatePersistedStateCalls).toContainEqual([modeKey, "plan"]);
471-
expect(updatePersistedStateCalls).toContainEqual([thinkingKey, "high"]);
471+
// Note: Thinking level is now per-model, not per-workspace, so no sync to workspace
472472
expect(updatePersistedStateCalls).toContainEqual([pendingInputKey, ""]);
473473
});
474474

src/browser/components/ChatInput/useCreationWorkspace.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { useState, useEffect, useCallback } from "react";
22
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
33
import type { RuntimeConfig, RuntimeMode } from "@/common/types/runtime";
44
import type { UIMode } from "@/common/types/mode";
5-
import type { ThinkingLevel } from "@/common/types/thinking";
65
import { parseRuntimeString } from "@/browser/utils/chatCommands";
76
import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings";
87
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
@@ -13,7 +12,6 @@ import {
1312
getModeKey,
1413
getPendingScopeId,
1514
getProjectScopeId,
16-
getThinkingLevelKey,
1715
} from "@/common/constants/storage";
1816
import type { Toast } from "@/browser/components/ChatInputToast";
1917
import { createErrorToast } from "@/browser/components/ChatInputToasts";
@@ -43,13 +41,8 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void
4341
updatePersistedState(getModeKey(workspaceId), projectMode);
4442
}
4543

46-
const projectThinking = readPersistedState<ThinkingLevel | null>(
47-
getThinkingLevelKey(projectScopeId),
48-
null
49-
);
50-
if (projectThinking) {
51-
updatePersistedState(getThinkingLevelKey(workspaceId), projectThinking);
52-
}
44+
// Note: Thinking level is now stored per-model, not per-workspace,
45+
// so no syncing needed here
5346
}
5447

5548
interface UseCreationWorkspaceReturn {

src/browser/components/ThinkingSlider.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { TooltipWrapper, Tooltip } from "./Tooltip";
55
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
66
import { getThinkingPolicyForModel } from "@/browser/utils/thinking/policy";
77
import { updatePersistedState } from "@/browser/hooks/usePersistedState";
8-
import { getLastThinkingByModelKey } from "@/common/constants/storage";
8+
import { getLastActiveThinkingKey } from "@/common/constants/storage";
99

1010
// Uses CSS variable --color-thinking-mode for theme compatibility
1111
// Glow is applied via CSS using color-mix with the theme color
@@ -144,12 +144,11 @@ export const ThinkingSliderComponent: React.FC<ThinkingControlProps> = ({ modelS
144144
};
145145

146146
const handleThinkingLevelChange = (newLevel: ThinkingLevel) => {
147+
// ThinkingContext handles per-model persistence automatically
147148
setThinkingLevel(newLevel);
148-
// Also save to lastThinkingByModel for Ctrl+Shift+T toggle memory
149-
// Only save active levels (not "off") - matches useAIViewKeybinds logic
149+
// Also save active level for toggle keybind (Ctrl+Shift+T) to restore
150150
if (newLevel !== "off") {
151-
const lastThinkingKey = getLastThinkingByModelKey(modelString);
152-
updatePersistedState(lastThinkingKey, newLevel as ThinkingLevelOn);
151+
updatePersistedState(getLastActiveThinkingKey(modelString), newLevel as ThinkingLevelOn);
153152
}
154153
};
155154

src/browser/contexts/ThinkingContext.tsx

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import type { ReactNode } from "react";
2-
import React, { createContext, useContext } from "react";
2+
import React, { createContext, useContext, useEffect, useState, useCallback } from "react";
33
import type { ThinkingLevel } from "@/common/types/thinking";
4-
import { usePersistedState } from "@/browser/hooks/usePersistedState";
4+
import {
5+
usePersistedState,
6+
readPersistedState,
7+
updatePersistedState,
8+
} from "@/browser/hooks/usePersistedState";
59
import {
610
getThinkingLevelKey,
711
getProjectScopeId,
12+
getModelKey,
813
GLOBAL_SCOPE_ID,
914
} from "@/common/constants/storage";
15+
import { getDefaultModel } from "@/browser/hooks/useModelLRU";
1016

1117
interface ThinkingContextType {
1218
thinkingLevel: ThinkingLevel;
@@ -16,23 +22,71 @@ interface ThinkingContextType {
1622
const ThinkingContext = createContext<ThinkingContextType | undefined>(undefined);
1723

1824
interface ThinkingProviderProps {
19-
workspaceId?: string; // Workspace-scoped storage (highest priority)
20-
projectPath?: string; // Project-scoped storage (fallback if no workspaceId)
25+
workspaceId?: string; // Workspace-scoped storage for model selection
26+
projectPath?: string; // Project-scoped storage for model selection (fallback)
2127
children: ReactNode;
2228
}
2329

30+
/**
31+
* ThinkingProvider manages thinking level state per model.
32+
*
33+
* The thinking level is stored per model (e.g., "thinkingLevel:claude-sonnet-4-20250514")
34+
* so users can set different levels for different models and have them remembered.
35+
*
36+
* When the selected model changes, the thinking level is loaded from that model's storage.
37+
*/
2438
export const ThinkingProvider: React.FC<ThinkingProviderProps> = ({
2539
workspaceId,
2640
projectPath,
2741
children,
2842
}) => {
29-
// Priority: workspace-scoped > project-scoped > global
30-
const scopeId = workspaceId ?? (projectPath ? getProjectScopeId(projectPath) : GLOBAL_SCOPE_ID);
31-
const key = getThinkingLevelKey(scopeId);
32-
const [thinkingLevel, setThinkingLevel] = usePersistedState<ThinkingLevel>(
33-
key,
34-
"off",
35-
{ listener: true } // Listen for changes from command palette and other sources
43+
// Derive model storage scope (workspace or project)
44+
const modelScopeId =
45+
workspaceId ?? (projectPath ? getProjectScopeId(projectPath) : GLOBAL_SCOPE_ID);
46+
const modelKey = getModelKey(modelScopeId);
47+
48+
// Listen for model changes in this scope
49+
const [selectedModel] = usePersistedState<string | null>(modelKey, null, { listener: true });
50+
const currentModel = selectedModel ?? getDefaultModel();
51+
52+
// Local state for thinking level (managed per model)
53+
const [thinkingLevel, setThinkingLevelState] = useState<ThinkingLevel>(() => {
54+
return readPersistedState<ThinkingLevel>(getThinkingLevelKey(currentModel), "off");
55+
});
56+
57+
// When model changes, load that model's thinking level
58+
useEffect(() => {
59+
const modelThinkingKey = getThinkingLevelKey(currentModel);
60+
const modelThinkingLevel = readPersistedState<ThinkingLevel>(modelThinkingKey, "off");
61+
setThinkingLevelState(modelThinkingLevel);
62+
}, [currentModel]);
63+
64+
// Listen for storage events (from command palette or other sources)
65+
useEffect(() => {
66+
const modelThinkingKey = getThinkingLevelKey(currentModel);
67+
68+
const handleStorage = (e: StorageEvent) => {
69+
if (e.key === modelThinkingKey && e.newValue) {
70+
try {
71+
const parsed = JSON.parse(e.newValue) as ThinkingLevel;
72+
setThinkingLevelState(parsed);
73+
} catch {
74+
// Invalid JSON, ignore
75+
}
76+
}
77+
};
78+
79+
window.addEventListener("storage", handleStorage);
80+
return () => window.removeEventListener("storage", handleStorage);
81+
}, [currentModel]);
82+
83+
// Save thinking level to current model's storage
84+
const setThinkingLevel = useCallback(
85+
(level: ThinkingLevel) => {
86+
setThinkingLevelState(level);
87+
updatePersistedState(getThinkingLevelKey(currentModel), level);
88+
},
89+
[currentModel]
3690
);
3791

3892
return (

src/browser/hooks/useAIViewKeybinds.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { useEffect } from "react";
22
import type { ChatInputAPI } from "@/browser/components/ChatInput";
33
import { matchesKeybind, KEYBINDS, isEditableElement } from "@/browser/utils/ui/keybinds";
4-
import { getLastThinkingByModelKey, getModelKey } from "@/common/constants/storage";
5-
import { updatePersistedState, readPersistedState } from "@/browser/hooks/usePersistedState";
4+
import { getModelKey, getLastActiveThinkingKey } from "@/common/constants/storage";
5+
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
66
import type { ThinkingLevel, ThinkingLevelOn } from "@/common/types/thinking";
77
import { DEFAULT_THINKING_LEVEL } from "@/common/types/thinking";
88
import { getThinkingPolicyForModel } from "@/browser/utils/thinking/policy";
@@ -106,9 +106,6 @@ export function useAIViewKeybinds({
106106
const selectedModel = readPersistedState<string | null>(getModelKey(workspaceId), null);
107107
const modelToUse = selectedModel ?? currentModel ?? getDefaultModel();
108108

109-
// Storage key for remembering this model's last-used active thinking level
110-
const lastThinkingKey = getLastThinkingByModelKey(modelToUse);
111-
112109
// Special-case: if model has single-option policy (e.g., gpt-5-pro only supports HIGH),
113110
// the toggle is a no-op to avoid confusing state transitions.
114111
const allowed = getThinkingPolicyForModel(modelToUse);
@@ -117,18 +114,17 @@ export function useAIViewKeybinds({
117114
}
118115

119116
if (currentWorkspaceThinking !== "off") {
120-
// Thinking is currently ON - save the level for this model and turn it off
121-
// Type system ensures we can only store active levels (not "off")
117+
// Thinking is currently ON - save the active level and turn it off
122118
const activeLevel: ThinkingLevelOn = currentWorkspaceThinking;
123-
updatePersistedState(lastThinkingKey, activeLevel);
119+
updatePersistedState(getLastActiveThinkingKey(modelToUse), activeLevel);
124120
setThinkingLevel("off");
125121
} else {
126-
// Thinking is currently OFF - restore the last level used for this model
127-
const lastUsedThinkingForModel = readPersistedState<ThinkingLevelOn>(
128-
lastThinkingKey,
122+
// Thinking is currently OFF - restore last active level for this model
123+
const lastActive = readPersistedState<ThinkingLevelOn>(
124+
getLastActiveThinkingKey(modelToUse),
129125
DEFAULT_THINKING_LEVEL
130126
);
131-
setThinkingLevel(lastUsedThinkingForModel);
127+
setThinkingLevel(lastActive);
132128
}
133129
return;
134130
}

src/browser/utils/messages/sendOptions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio
4040
// Read model preference (workspace-specific), fallback to LRU default
4141
const model = readPersistedState<string>(getModelKey(workspaceId), getDefaultModel());
4242

43-
// Read thinking level (workspace-specific)
43+
// Read thinking level (per-model)
4444
const thinkingLevel = readPersistedState<ThinkingLevel>(
45-
getThinkingLevelKey(workspaceId),
45+
getThinkingLevelKey(model),
4646
WORKSPACE_DEFAULTS.thinkingLevel
4747
);
4848

src/common/constants/storage.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,26 @@ export const SELECTED_WORKSPACE_KEY = "selectedWorkspace";
4949
export const EXPANDED_PROJECTS_KEY = "expandedProjects";
5050

5151
/**
52-
* Helper to create a thinking level storage key for a workspace
53-
* Format: "thinkingLevel:{workspaceId}"
52+
* Helper to create a thinking level storage key for a model.
53+
* Format: "thinkingLevel:{modelName}"
54+
*
55+
* Thinking level is now persisted per-model so users can set different
56+
* levels for different models and have them remembered when switching.
57+
*/
58+
export function getThinkingLevelKey(modelName: string): string {
59+
return `thinkingLevel:${modelName}`;
60+
}
61+
62+
/**
63+
* Get the localStorage key for the last *active* thinking level used for a model.
64+
* Format: "lastActiveThinking:{modelName}"
65+
*
66+
* Used by the toggle keybind (Ctrl+Shift+T) to restore the previous non-off level.
67+
* Stores only active levels ("low" | "medium" | "high"), never "off".
5468
*/
55-
export const getThinkingLevelKey = (workspaceId: string): string => `thinkingLevel:${workspaceId}`;
69+
export function getLastActiveThinkingKey(modelName: string): string {
70+
return `lastActiveThinking:${modelName}`;
71+
}
5672

5773
/**
5874
* Get the localStorage key for the user's preferred model for a workspace
@@ -83,15 +99,6 @@ export function getRetryStateKey(workspaceId: string): string {
8399
return `${workspaceId}-retryState`;
84100
}
85101

86-
/**
87-
* Get the localStorage key for the last active thinking level used for a model
88-
* Stores only active levels ("low" | "medium" | "high"), never "off"
89-
* Format: "lastThinkingByModel:{modelName}"
90-
*/
91-
export function getLastThinkingByModelKey(modelName: string): string {
92-
return `lastThinkingByModel:${modelName}`;
93-
}
94-
95102
/**
96103
* Get storage key for cancelled compaction tracking.
97104
* Stores compaction-request user message ID to verify freshness across reloads.
@@ -226,15 +233,14 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string>
226233
getModelKey,
227234
getInputKey,
228235
getModeKey,
229-
getThinkingLevelKey,
230236
getAutoRetryKey,
231237
getRetryStateKey,
232238
getReviewExpandStateKey,
233239
getFileTreeExpandStateKey,
234240
getReviewSearchStateKey,
235241
getAutoCompactionEnabledKey,
236242
getStatusUrlKey,
237-
// Note: getAutoCompactionThresholdKey is per-model, not per-workspace
243+
// Note: getAutoCompactionThresholdKey and getThinkingLevelKey are per-model, not per-workspace
238244
];
239245

240246
/**
@@ -246,7 +252,7 @@ const EPHEMERAL_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string>
246252

247253
/**
248254
* Copy all workspace-specific localStorage keys from source to destination workspace
249-
* This includes: model, input, mode, thinking level, auto-retry, retry state, review expand state, file tree expand state
255+
* This includes: model, input, mode, auto-retry, retry state, review expand state, file tree expand state
250256
*/
251257
export function copyWorkspaceStorage(sourceWorkspaceId: string, destWorkspaceId: string): void {
252258
for (const getKey of PERSISTENT_WORKSPACE_KEY_FUNCTIONS) {

0 commit comments

Comments
 (0)