Skip to content

Commit e6de168

Browse files
authored
🤖 refactor: persist thinking level per-model globally (#1135)
## Summary Change thinking level storage from per-workspace to per-model globally. ### Changes - **New storage key**: `thinkingLevel:model:{modelName}` (global) replaces `thinkingLevel:{workspaceId}` (per-workspace) - **ThinkingContext**: Reads model from workspace storage to derive the per-model key - **Simplified keybind**: Toggle now cycles through allowed levels instead of toggle on/off with memory - **Removed `lastThinkingByModel`**: No longer needed with cycle behavior - **No workspace sync**: Thinking level not copied on workspace creation (uses global model preference) ### Benefits - User's thinking preference for a model carries across all workspaces - Clearer mental model — thinking is a property of the model, not the workspace - Net code reduction despite adding functionality ### Testing - All 699 browser tests pass - Typecheck passes - Static checks pass --- _Generated with `mux` • Model: `anthropic:claude-sonnet-4-20250514` • Thinking: `low`_
1 parent cb99a07 commit e6de168

File tree

9 files changed

+105
-121
lines changed

9 files changed

+105
-121
lines changed

docs/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ Avoid mock-heavy tests that verify implementation details rather than behavior.
128128
## Component State & Storage
129129

130130
- Parent components own localStorage interactions; children announce intent only.
131-
- Use `usePersistedState`/`readPersistedState`/`updatePersistedState` helpers—never call `localStorage` directly.
131+
- **Never call `localStorage` directly** — always use `usePersistedState`/`readPersistedState`/`updatePersistedState` helpers. This includes inside `useCallback`, event handlers, and non-React functions. The helpers handle JSON parsing, error recovery, and cross-component sync.
132132
- When a component needs to read persisted state it doesn't own (to avoid layout flash), use `readPersistedState` in `useState` initializer: `useState(() => readPersistedState(key, default))`.
133133
- When multiple components need the same persisted value, use `usePersistedState` with identical keys and `{ listener: true }` for automatic cross-component sync.
134134
- Avoid destructuring props in function signatures; access via `props.field` to keep rename-friendly code.

src/browser/App.tsx

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import { LeftSidebar } from "./components/LeftSidebar";
77
import { ProjectCreateModal } from "./components/ProjectCreateModal";
88
import { AIView } from "./components/AIView";
99
import { ErrorBoundary } from "./components/ErrorBoundary";
10-
import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState";
10+
import {
11+
usePersistedState,
12+
updatePersistedState,
13+
readPersistedState,
14+
} from "./hooks/usePersistedState";
1115
import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds";
1216
import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering";
1317
import { useResumeManager } from "./hooks/useResumeManager";
@@ -30,7 +34,9 @@ import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sour
3034
import type { ThinkingLevel } from "@/common/types/thinking";
3135
import { CUSTOM_EVENTS } from "@/common/constants/events";
3236
import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents";
33-
import { getThinkingLevelKey } from "@/common/constants/storage";
37+
import { getThinkingLevelByModelKey, getModelKey } from "@/common/constants/storage";
38+
import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels";
39+
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
3440
import type { BranchListResult } from "@/common/orpc/types";
3541
import { useTelemetry } from "./hooks/useTelemetry";
3642
import { getRuntimeTypeForTelemetry } from "@/common/telemetry";
@@ -262,50 +268,52 @@ function AppInner() {
262268
close: closeCommandPalette,
263269
} = useCommandRegistry();
264270

265-
const getThinkingLevelForWorkspace = useCallback((workspaceId: string): ThinkingLevel => {
266-
if (!workspaceId) {
267-
return "off";
268-
}
269-
270-
if (typeof window === "undefined" || !window.localStorage) {
271-
return "off";
272-
}
271+
/**
272+
* Get model for a workspace, returning canonical format.
273+
*/
274+
const getModelForWorkspace = useCallback((workspaceId: string): string => {
275+
const defaultModel = getDefaultModel();
276+
const rawModel = readPersistedState<string>(getModelKey(workspaceId), defaultModel);
277+
return migrateGatewayModel(rawModel || defaultModel);
278+
}, []);
273279

274-
try {
275-
const key = getThinkingLevelKey(workspaceId);
276-
const stored = window.localStorage.getItem(key);
277-
if (!stored || stored === "undefined") {
280+
const getThinkingLevelForWorkspace = useCallback(
281+
(workspaceId: string): ThinkingLevel => {
282+
if (!workspaceId) {
278283
return "off";
279284
}
280-
const parsed = JSON.parse(stored) as ThinkingLevel;
281-
return THINKING_LEVELS.includes(parsed) ? parsed : "off";
282-
} catch (error) {
283-
console.warn("Failed to read thinking level", error);
284-
return "off";
285-
}
286-
}, []);
285+
const model = getModelForWorkspace(workspaceId);
286+
const level = readPersistedState<ThinkingLevel>(getThinkingLevelByModelKey(model), "off");
287+
return THINKING_LEVELS.includes(level) ? level : "off";
288+
},
289+
[getModelForWorkspace]
290+
);
287291

288-
const setThinkingLevelFromPalette = useCallback((workspaceId: string, level: ThinkingLevel) => {
289-
if (!workspaceId) {
290-
return;
291-
}
292+
const setThinkingLevelFromPalette = useCallback(
293+
(workspaceId: string, level: ThinkingLevel) => {
294+
if (!workspaceId) {
295+
return;
296+
}
292297

293-
const normalized = THINKING_LEVELS.includes(level) ? level : "off";
294-
const key = getThinkingLevelKey(workspaceId);
298+
const normalized = THINKING_LEVELS.includes(level) ? level : "off";
299+
const model = getModelForWorkspace(workspaceId);
300+
const key = getThinkingLevelByModelKey(model);
295301

296-
// Use the utility function which handles localStorage and event dispatch
297-
// ThinkingProvider will pick this up via its listener
298-
updatePersistedState(key, normalized);
302+
// Use the utility function which handles localStorage and event dispatch
303+
// ThinkingProvider will pick this up via its listener
304+
updatePersistedState(key, normalized);
299305

300-
// Dispatch toast notification event for UI feedback
301-
if (typeof window !== "undefined") {
302-
window.dispatchEvent(
303-
new CustomEvent(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, {
304-
detail: { workspaceId, level: normalized },
305-
})
306-
);
307-
}
308-
}, []);
306+
// Dispatch toast notification event for UI feedback
307+
if (typeof window !== "undefined") {
308+
window.dispatchEvent(
309+
new CustomEvent(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, {
310+
detail: { workspaceId, level: normalized },
311+
})
312+
);
313+
}
314+
},
315+
[getModelForWorkspace]
316+
);
309317

310318
const registerParamsRef = useRef<BuildSourcesParams | null>(null);
311319

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

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
getModeKey,
77
getPendingScopeId,
88
getProjectScopeId,
9-
getThinkingLevelKey,
109
} from "@/common/constants/storage";
1110
import type { SendMessageError as _SendMessageError } from "@/common/types/errors";
1211
import type { WorkspaceChatMessage } from "@/common/orpc/types";
@@ -404,7 +403,6 @@ describe("useCreationWorkspace", () => {
404403
});
405404

406405
persistedPreferences[getModeKey(getProjectScopeId(TEST_PROJECT_PATH))] = "plan";
407-
persistedPreferences[getThinkingLevelKey(getProjectScopeId(TEST_PROJECT_PATH))] = "high";
408406
// Set model preference for the project scope (read by getSendOptionsFromStorage)
409407
persistedPreferences[getModelKey(getProjectScopeId(TEST_PROJECT_PATH))] = "gpt-4";
410408

@@ -460,15 +458,12 @@ describe("useCreationWorkspace", () => {
460458
expect(onWorkspaceCreated.mock.calls[0][0]).toEqual(TEST_METADATA);
461459

462460
const projectModeKey = getModeKey(getProjectScopeId(TEST_PROJECT_PATH));
463-
const projectThinkingKey = getThinkingLevelKey(getProjectScopeId(TEST_PROJECT_PATH));
464461
expect(readPersistedStateCalls).toContainEqual([projectModeKey, null]);
465-
expect(readPersistedStateCalls).toContainEqual([projectThinkingKey, null]);
466462

467463
const modeKey = getModeKey(TEST_WORKSPACE_ID);
468-
const thinkingKey = getThinkingLevelKey(TEST_WORKSPACE_ID);
469464
const pendingInputKey = getInputKey(getPendingScopeId(TEST_PROJECT_PATH));
470465
expect(updatePersistedStateCalls).toContainEqual([modeKey, "plan"]);
471-
expect(updatePersistedStateCalls).toContainEqual([thinkingKey, "high"]);
466+
// Note: thinking level is no longer synced per-workspace, it's stored per-model globally
472467
expect(updatePersistedStateCalls).toContainEqual([pendingInputKey, ""]);
473468
});
474469

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";
@@ -47,13 +45,8 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void
4745
updatePersistedState(getModeKey(workspaceId), projectMode);
4846
}
4947

50-
const projectThinking = readPersistedState<ThinkingLevel | null>(
51-
getThinkingLevelKey(projectScopeId),
52-
null
53-
);
54-
if (projectThinking) {
55-
updatePersistedState(getThinkingLevelKey(workspaceId), projectThinking);
56-
}
48+
// Note: thinking level is stored per-model globally, not per-workspace,
49+
// so no sync is needed here
5750
}
5851

5952
interface UseCreationWorkspaceReturn {

src/browser/components/ThinkingSlider.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import React, { useEffect, useId } from "react";
2-
import type { ThinkingLevel, ThinkingLevelOn } from "@/common/types/thinking";
2+
import type { ThinkingLevel } from "@/common/types/thinking";
33
import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel";
44
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
55
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
66
import { getThinkingPolicyForModel } from "@/browser/utils/thinking/policy";
7-
import { updatePersistedState } from "@/browser/hooks/usePersistedState";
8-
import { getLastThinkingByModelKey } from "@/common/constants/storage";
97

108
// Uses CSS variable --color-thinking-mode for theme compatibility
119
// Glow is applied via CSS using color-mix with the theme color
@@ -147,12 +145,6 @@ export const ThinkingSliderComponent: React.FC<ThinkingControlProps> = ({ modelS
147145

148146
const handleThinkingLevelChange = (newLevel: ThinkingLevel) => {
149147
setThinkingLevel(newLevel);
150-
// Also save to lastThinkingByModel for Ctrl+Shift+T toggle memory
151-
// Only save active levels (not "off") - matches useAIViewKeybinds logic
152-
if (newLevel !== "off") {
153-
const lastThinkingKey = getLastThinkingByModelKey(modelString);
154-
updatePersistedState(lastThinkingKey, newLevel as ThinkingLevelOn);
155-
}
156148
};
157149

158150
// Cycle through allowed thinking levels
@@ -207,7 +199,7 @@ export const ThinkingSliderComponent: React.FC<ThinkingControlProps> = ({ modelS
207199
</div>
208200
</TooltipTrigger>
209201
<TooltipContent align="center">
210-
Thinking: {formatKeybind(KEYBINDS.TOGGLE_THINKING)} to toggle. Click level to cycle.
202+
Thinking: {formatKeybind(KEYBINDS.TOGGLE_THINKING)} to cycle. Saved per model.
211203
</TooltipContent>
212204
</Tooltip>
213205
);

src/browser/contexts/ThinkingContext.tsx

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import type { ReactNode } from "react";
22
import React, { createContext, useContext } from "react";
33
import type { ThinkingLevel } from "@/common/types/thinking";
4-
import { usePersistedState } from "@/browser/hooks/usePersistedState";
5-
import {
6-
getThinkingLevelKey,
7-
getProjectScopeId,
8-
GLOBAL_SCOPE_ID,
9-
} from "@/common/constants/storage";
4+
import { usePersistedState, readPersistedState } from "@/browser/hooks/usePersistedState";
5+
import { getThinkingLevelByModelKey, getModelKey } from "@/common/constants/storage";
6+
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
7+
import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels";
108

119
interface ThinkingContextType {
1210
thinkingLevel: ThinkingLevel;
@@ -16,19 +14,41 @@ interface ThinkingContextType {
1614
const ThinkingContext = createContext<ThinkingContextType | undefined>(undefined);
1715

1816
interface ThinkingProviderProps {
19-
workspaceId?: string; // Workspace-scoped storage (highest priority)
20-
projectPath?: string; // Project-scoped storage (fallback if no workspaceId)
17+
workspaceId?: string; // For existing workspaces
18+
projectPath?: string; // For workspace creation (uses project-scoped model key)
2119
children: ReactNode;
2220
}
2321

22+
/**
23+
* Reads the current model from localStorage for the given scope.
24+
* Returns canonical model format (after gateway migration).
25+
*/
26+
function getScopedModel(workspaceId?: string, projectPath?: string): string {
27+
const defaultModel = getDefaultModel();
28+
// Use workspace-scoped model key if available, otherwise project-scoped
29+
const modelKey = workspaceId
30+
? getModelKey(workspaceId)
31+
: projectPath
32+
? getModelKey(`__project__/${projectPath}`)
33+
: null;
34+
35+
if (!modelKey) {
36+
return defaultModel;
37+
}
38+
39+
const rawModel = readPersistedState<string>(modelKey, defaultModel);
40+
// Normalize to canonical format (e.g., strip legacy gateway prefix)
41+
return migrateGatewayModel(rawModel || defaultModel);
42+
}
43+
2444
export const ThinkingProvider: React.FC<ThinkingProviderProps> = ({
2545
workspaceId,
2646
projectPath,
2747
children,
2848
}) => {
29-
// Priority: workspace-scoped > project-scoped > global
30-
const scopeId = workspaceId ?? (projectPath ? getProjectScopeId(projectPath) : GLOBAL_SCOPE_ID);
31-
const key = getThinkingLevelKey(scopeId);
49+
// Read current model from localStorage (non-reactive, re-reads on each render)
50+
const modelString = getScopedModel(workspaceId, projectPath);
51+
const key = getThinkingLevelByModelKey(modelString);
3252
const [thinkingLevel, setThinkingLevel] = usePersistedState<ThinkingLevel>(
3353
key,
3454
"off",

src/browser/hooks/useAIViewKeybinds.ts

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
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";
6-
import type { ThinkingLevel, ThinkingLevelOn } from "@/common/types/thinking";
7-
import { DEFAULT_THINKING_LEVEL } from "@/common/types/thinking";
4+
import { getModelKey } from "@/common/constants/storage";
5+
import { readPersistedState } from "@/browser/hooks/usePersistedState";
6+
import type { ThinkingLevel } from "@/common/types/thinking";
87
import { getThinkingPolicyForModel } from "@/browser/utils/thinking/policy";
98
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
109
import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator";
@@ -32,7 +31,7 @@ interface UseAIViewKeybindsParams {
3231
* Manages keyboard shortcuts for AIView:
3332
* - Esc (non-vim) or Ctrl+C (vim): Interrupt stream (always, regardless of selection)
3433
* - Ctrl+I: Focus chat input
35-
* - Ctrl+Shift+T: Toggle thinking level
34+
* - Ctrl+Shift+T: Cycle thinking level through allowed values for current model
3635
* - Ctrl+G: Jump to bottom
3736
* - Ctrl+T: Open terminal
3837
* - Ctrl+Shift+E: Open in editor
@@ -99,40 +98,25 @@ export function useAIViewKeybinds({
9998
return;
10099
}
101100

102-
// Toggle thinking works even when focused in input fields
101+
// Cycle thinking level - works even when focused in input fields
103102
if (matchesKeybind(e, KEYBINDS.TOGGLE_THINKING)) {
104103
e.preventDefault();
105104

106105
// Get selected model from localStorage (what user sees in UI)
107106
// Fall back to message history model, then to the Settings default model
108-
// This matches the same logic as useSendMessageOptions
109107
const selectedModel = readPersistedState<string | null>(getModelKey(workspaceId), null);
110108
const modelToUse = selectedModel ?? currentModel ?? getDefaultModel();
111109

112-
// Storage key for remembering this model's last-used active thinking level
113-
const lastThinkingKey = getLastThinkingByModelKey(modelToUse);
114-
115-
// Special-case: if model has single-option policy (e.g., gpt-5-pro only supports HIGH),
116-
// the toggle is a no-op to avoid confusing state transitions.
110+
// Get allowed levels for this model
117111
const allowed = getThinkingPolicyForModel(modelToUse);
118-
if (allowed.length === 1) {
119-
return; // No toggle for single-option policies
112+
if (allowed.length <= 1) {
113+
return; // No cycling for single-option policies
120114
}
121115

122-
if (currentWorkspaceThinking !== "off") {
123-
// Thinking is currently ON - save the level for this model and turn it off
124-
// Type system ensures we can only store active levels (not "off")
125-
const activeLevel: ThinkingLevelOn = currentWorkspaceThinking;
126-
updatePersistedState(lastThinkingKey, activeLevel);
127-
setThinkingLevel("off");
128-
} else {
129-
// Thinking is currently OFF - restore the last level used for this model
130-
const lastUsedThinkingForModel = readPersistedState<ThinkingLevelOn>(
131-
lastThinkingKey,
132-
DEFAULT_THINKING_LEVEL
133-
);
134-
setThinkingLevel(lastUsedThinkingForModel);
135-
}
116+
// Cycle to the next allowed level
117+
const currentIndex = allowed.indexOf(currentWorkspaceThinking);
118+
const nextIndex = (currentIndex + 1) % allowed.length;
119+
setThinkingLevel(allowed[nextIndex]);
136120
return;
137121
}
138122

src/browser/utils/messages/sendOptions.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getModelKey, getThinkingLevelKey, getModeKey } from "@/common/constants/storage";
1+
import { getModelKey, getThinkingLevelByModelKey, getModeKey } from "@/common/constants/storage";
22
import { modeToToolPolicy } from "@/common/utils/ui/modeUtils";
33
import { readPersistedState } from "@/browser/hooks/usePersistedState";
44
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
@@ -47,9 +47,9 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio
4747
// Transform to gateway format if gateway is enabled for this model
4848
const model = toGatewayModel(baseModel);
4949

50-
// Read thinking level (workspace-specific)
50+
// Read thinking level (per-model global storage)
5151
const thinkingLevel = readPersistedState<ThinkingLevel>(
52-
getThinkingLevelKey(workspaceId),
52+
getThinkingLevelByModelKey(baseModel),
5353
WORKSPACE_DEFAULTS.thinkingLevel
5454
);
5555

0 commit comments

Comments
 (0)