Skip to content

Commit 4f1d6ca

Browse files
committed
fix: restore workspace thinking persistence with project seeding
1 parent 51a62e3 commit 4f1d6ca

File tree

6 files changed

+63
-140
lines changed

6 files changed

+63
-140
lines changed

src/browser/App.tsx

Lines changed: 31 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sour
3434
import type { ThinkingLevel } from "@/common/types/thinking";
3535
import { CUSTOM_EVENTS } from "@/common/constants/events";
3636
import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents";
37-
import { getThinkingLevelKey, getModelKey } from "@/common/constants/storage";
38-
import { getDefaultModel } from "@/browser/hooks/useModelLRU";
37+
import { getThinkingLevelKey } from "@/common/constants/storage";
3938
import type { BranchListResult } from "@/common/orpc/types";
4039
import { useTelemetry } from "./hooks/useTelemetry";
4140
import { getRuntimeTypeForTelemetry } from "@/common/telemetry";
@@ -275,63 +274,41 @@ function AppInner() {
275274
close: closeCommandPalette,
276275
} = useCommandRegistry();
277276

278-
const getCurrentModelForWorkspace = useCallback(
279-
(workspaceId: string): string => {
280-
if (!workspaceId) return getDefaultModel();
281-
// Prefer live workspace state; fall back to persisted preference
282-
const modelFromStore = workspaceStore.getWorkspaceState(workspaceId)?.currentModel;
283-
return (
284-
modelFromStore ??
285-
readPersistedState<string | null>(getModelKey(workspaceId), null) ??
286-
getDefaultModel()
287-
);
288-
},
289-
[workspaceStore]
290-
);
291-
292-
const getThinkingLevelForWorkspace = useCallback(
293-
(workspaceId: string): ThinkingLevel => {
294-
if (!workspaceId) {
295-
return "off";
296-
}
277+
const getThinkingLevelForWorkspace = useCallback((workspaceId: string): ThinkingLevel => {
278+
if (!workspaceId) {
279+
return "off";
280+
}
297281

298-
try {
299-
const model = getCurrentModelForWorkspace(workspaceId);
300-
const storedLevel = readPersistedState<ThinkingLevel>(getThinkingLevelKey(model), "off");
301-
return THINKING_LEVELS.includes(storedLevel) ? storedLevel : "off";
302-
} catch (error) {
303-
console.warn("Failed to read thinking level", error);
304-
return "off";
305-
}
306-
},
307-
[getCurrentModelForWorkspace]
308-
);
282+
try {
283+
const storedLevel = readPersistedState<ThinkingLevel>(
284+
getThinkingLevelKey(workspaceId),
285+
"off"
286+
);
287+
return THINKING_LEVELS.includes(storedLevel) ? storedLevel : "off";
288+
} catch (error) {
289+
console.warn("Failed to read thinking level", error);
290+
return "off";
291+
}
292+
}, []);
309293

310-
const setThinkingLevelFromPalette = useCallback(
311-
(workspaceId: string, level: ThinkingLevel) => {
312-
if (!workspaceId) {
313-
return;
314-
}
294+
const setThinkingLevelFromPalette = useCallback((workspaceId: string, level: ThinkingLevel) => {
295+
if (!workspaceId) {
296+
return;
297+
}
315298

316-
const model = getCurrentModelForWorkspace(workspaceId);
317-
const normalized = THINKING_LEVELS.includes(level) ? level : "off";
318-
const key = getThinkingLevelKey(model);
299+
const normalized = THINKING_LEVELS.includes(level) ? level : "off";
300+
const key = getThinkingLevelKey(workspaceId);
319301

320-
// Use the utility function which handles localStorage and event dispatch
321-
// ThinkingProvider will pick this up via its listener
322-
updatePersistedState(key, normalized);
302+
updatePersistedState(key, normalized);
323303

324-
// Dispatch toast notification event for UI feedback
325-
if (typeof window !== "undefined") {
326-
window.dispatchEvent(
327-
new CustomEvent(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, {
328-
detail: { workspaceId, level: normalized },
329-
})
330-
);
331-
}
332-
},
333-
[getCurrentModelForWorkspace]
334-
);
304+
if (typeof window !== "undefined") {
305+
window.dispatchEvent(
306+
new CustomEvent(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, {
307+
detail: { workspaceId, level: normalized },
308+
})
309+
);
310+
}
311+
}, []);
335312

336313
const registerParamsRef = useRef<BuildSourcesParams | null>(null);
337314

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

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

406406
persistedPreferences[getModeKey(getProjectScopeId(TEST_PROJECT_PATH))] = "plan";
407-
// Set model preference for the project scope (read by getSendOptionsFromStorage)
408407
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";
408+
persistedPreferences[getThinkingLevelKey(getProjectScopeId(TEST_PROJECT_PATH))] = "high";
411409

412410
draftSettingsState = createDraftSettingsHarness({
413411
runtimeMode: "ssh",
@@ -461,14 +459,15 @@ describe("useCreationWorkspace", () => {
461459
expect(onWorkspaceCreated.mock.calls[0][0]).toEqual(TEST_METADATA);
462460

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

468466
const modeKey = getModeKey(TEST_WORKSPACE_ID);
467+
const thinkingKey = getThinkingLevelKey(TEST_WORKSPACE_ID);
469468
const pendingInputKey = getInputKey(getPendingScopeId(TEST_PROJECT_PATH));
470469
expect(updatePersistedStateCalls).toContainEqual([modeKey, "plan"]);
471-
// Note: Thinking level is now per-model, not per-workspace, so no sync to workspace
470+
expect(updatePersistedStateCalls).toContainEqual([thinkingKey, "high"]);
472471
expect(updatePersistedStateCalls).toContainEqual([pendingInputKey, ""]);
473472
});
474473

src/browser/components/ChatInput/useCreationWorkspace.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import {
1212
getModeKey,
1313
getPendingScopeId,
1414
getProjectScopeId,
15+
getThinkingLevelKey,
1516
} from "@/common/constants/storage";
1617
import type { Toast } from "@/browser/components/ChatInputToast";
1718
import { createErrorToast } from "@/browser/components/ChatInputToasts";
1819
import { useAPI } from "@/browser/contexts/API";
1920
import type { ImagePart } from "@/common/orpc/types";
2021
import { useWorkspaceName, type WorkspaceNameState } from "@/browser/hooks/useWorkspaceName";
22+
import type { ThinkingLevel } from "@/common/types/thinking";
2123

2224
interface UseCreationWorkspaceOptions {
2325
projectPath: string;
@@ -30,7 +32,6 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void
3032
const projectScopeId = getProjectScopeId(projectPath);
3133

3234
// Sync model from project scope to workspace scope
33-
// This ensures the model used for creation is persisted for future resumes
3435
const projectModel = readPersistedState<string | null>(getModelKey(projectScopeId), null);
3536
if (projectModel) {
3637
updatePersistedState(getModelKey(workspaceId), projectModel);
@@ -41,8 +42,14 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void
4142
updatePersistedState(getModeKey(workspaceId), projectMode);
4243
}
4344

44-
// Note: Thinking level is now stored per-model, not per-workspace,
45-
// so no syncing needed here
45+
// Use the project's last thinking level to seed the new workspace
46+
const projectThinking = readPersistedState<ThinkingLevel | null>(
47+
getThinkingLevelKey(projectScopeId),
48+
null
49+
);
50+
if (projectThinking) {
51+
updatePersistedState(getThinkingLevelKey(workspaceId), projectThinking);
52+
}
4653
}
4754

4855
interface UseCreationWorkspaceReturn {

src/browser/contexts/ThinkingContext.tsx

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

1711
interface ThinkingContextType {
1812
thinkingLevel: ThinkingLevel;
@@ -22,73 +16,23 @@ interface ThinkingContextType {
2216
const ThinkingContext = createContext<ThinkingContextType | undefined>(undefined);
2317

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

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-
*/
3824
export const ThinkingProvider: React.FC<ThinkingProviderProps> = ({
3925
workspaceId,
4026
projectPath,
4127
children,
4228
}) => {
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");
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>(key, "off", {
33+
listener: true,
5534
});
5635

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]
90-
);
91-
9236
return (
9337
<ThinkingContext.Provider value={{ thinkingLevel, setThinkingLevel }}>
9438
{children}

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

src/common/constants/storage.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,10 @@ 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 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-
}
52+
* Helper to create a thinking level storage key for a scope (workspace or project).
53+
* Format: "thinkingLevel:{scopeId}"
54+
*/
55+
export const getThinkingLevelKey = (scopeId: string): string => `thinkingLevel:${scopeId}`;
6156

6257
/**
6358
* Get the localStorage key for the user's preferred model for a workspace
@@ -222,14 +217,15 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string>
222217
getModelKey,
223218
getInputKey,
224219
getModeKey,
220+
getThinkingLevelKey,
225221
getAutoRetryKey,
226222
getRetryStateKey,
227223
getReviewExpandStateKey,
228224
getFileTreeExpandStateKey,
229225
getReviewSearchStateKey,
230226
getAutoCompactionEnabledKey,
231227
getStatusUrlKey,
232-
// Note: getAutoCompactionThresholdKey and getThinkingLevelKey are per-model, not per-workspace
228+
// Note: getAutoCompactionThresholdKey is per-model, not per-workspace
233229
];
234230

235231
/**

0 commit comments

Comments
 (0)