From 96987c2c6e604929091a435649699d4cd7edfd18 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 6 Dec 2025 14:21:32 -0600 Subject: [PATCH 1/4] 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. --- src/browser/App.tsx | 17 ++++- .../ChatInput/useCreationWorkspace.test.tsx | 10 +-- .../ChatInput/useCreationWorkspace.ts | 11 +-- src/browser/components/ThinkingSlider.tsx | 9 +-- src/browser/contexts/ThinkingContext.tsx | 76 ++++++++++++++++--- src/browser/hooks/useAIViewKeybinds.ts | 20 ++--- src/browser/utils/messages/sendOptions.ts | 4 +- src/common/constants/storage.ts | 36 +++++---- 8 files changed, 121 insertions(+), 62 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 20874b4c3b..e16313893f 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -30,7 +30,8 @@ import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sour import type { ThinkingLevel } from "@/common/types/thinking"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents"; -import { getThinkingLevelKey } from "@/common/constants/storage"; +import { getThinkingLevelKey, getModelKey } from "@/common/constants/storage"; +import { getDefaultModel } from "@/browser/hooks/useModelLRU"; import type { BranchListResult } from "@/common/orpc/types"; import { useTelemetry } from "./hooks/useTelemetry"; import { getRuntimeTypeForTelemetry } from "@/common/telemetry"; @@ -272,7 +273,12 @@ function AppInner() { } try { - const key = getThinkingLevelKey(workspaceId); + // First get the model for this workspace, then get thinking level for that model + const modelKey = getModelKey(workspaceId); + const modelStored = window.localStorage.getItem(modelKey); + const model = modelStored ? (JSON.parse(modelStored) as string) : getDefaultModel(); + + const key = getThinkingLevelKey(model); const stored = window.localStorage.getItem(key); if (!stored || stored === "undefined") { return "off"; @@ -290,8 +296,13 @@ function AppInner() { return; } + // Get the model for this workspace to set thinking level for it + const modelKey = getModelKey(workspaceId); + const modelStored = window.localStorage?.getItem(modelKey); + const model = modelStored ? (JSON.parse(modelStored) as string) : getDefaultModel(); + const normalized = THINKING_LEVELS.includes(level) ? level : "off"; - const key = getThinkingLevelKey(workspaceId); + const key = getThinkingLevelKey(model); // Use the utility function which handles localStorage and event dispatch // ThinkingProvider will pick this up via its listener diff --git a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx index c1d0eb4e7a..0f78af54ed 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx @@ -404,9 +404,10 @@ describe("useCreationWorkspace", () => { }); persistedPreferences[getModeKey(getProjectScopeId(TEST_PROJECT_PATH))] = "plan"; - persistedPreferences[getThinkingLevelKey(getProjectScopeId(TEST_PROJECT_PATH))] = "high"; // Set model preference for the project scope (read by getSendOptionsFromStorage) persistedPreferences[getModelKey(getProjectScopeId(TEST_PROJECT_PATH))] = "gpt-4"; + // Thinking level is now stored per-model, not per-workspace/project + persistedPreferences[getThinkingLevelKey("gpt-4")] = "high"; draftSettingsState = createDraftSettingsHarness({ runtimeMode: "ssh", @@ -460,15 +461,14 @@ describe("useCreationWorkspace", () => { expect(onWorkspaceCreated.mock.calls[0][0]).toEqual(TEST_METADATA); const projectModeKey = getModeKey(getProjectScopeId(TEST_PROJECT_PATH)); - const projectThinkingKey = getThinkingLevelKey(getProjectScopeId(TEST_PROJECT_PATH)); expect(readPersistedStateCalls).toContainEqual([projectModeKey, null]); - expect(readPersistedStateCalls).toContainEqual([projectThinkingKey, null]); + // Thinking level is now read per-model (gpt-4), not per-project + expect(readPersistedStateCalls).toContainEqual([getThinkingLevelKey("gpt-4"), "off"]); const modeKey = getModeKey(TEST_WORKSPACE_ID); - const thinkingKey = getThinkingLevelKey(TEST_WORKSPACE_ID); const pendingInputKey = getInputKey(getPendingScopeId(TEST_PROJECT_PATH)); expect(updatePersistedStateCalls).toContainEqual([modeKey, "plan"]); - expect(updatePersistedStateCalls).toContainEqual([thinkingKey, "high"]); + // Note: Thinking level is now per-model, not per-workspace, so no sync to workspace expect(updatePersistedStateCalls).toContainEqual([pendingInputKey, ""]); }); diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index d169d9001b..408ae56d25 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -2,7 +2,6 @@ import { useState, useEffect, useCallback } from "react"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { RuntimeConfig, RuntimeMode } from "@/common/types/runtime"; import type { UIMode } from "@/common/types/mode"; -import type { ThinkingLevel } from "@/common/types/thinking"; import { parseRuntimeString } from "@/browser/utils/chatCommands"; import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; @@ -13,7 +12,6 @@ import { getModeKey, getPendingScopeId, getProjectScopeId, - getThinkingLevelKey, } from "@/common/constants/storage"; import type { Toast } from "@/browser/components/ChatInputToast"; import { createErrorToast } from "@/browser/components/ChatInputToasts"; @@ -43,13 +41,8 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void updatePersistedState(getModeKey(workspaceId), projectMode); } - const projectThinking = readPersistedState( - getThinkingLevelKey(projectScopeId), - null - ); - if (projectThinking) { - updatePersistedState(getThinkingLevelKey(workspaceId), projectThinking); - } + // Note: Thinking level is now stored per-model, not per-workspace, + // so no syncing needed here } interface UseCreationWorkspaceReturn { diff --git a/src/browser/components/ThinkingSlider.tsx b/src/browser/components/ThinkingSlider.tsx index d05124c097..eacc4b6e8d 100644 --- a/src/browser/components/ThinkingSlider.tsx +++ b/src/browser/components/ThinkingSlider.tsx @@ -5,7 +5,7 @@ import { TooltipWrapper, Tooltip } from "./Tooltip"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { getThinkingPolicyForModel } from "@/browser/utils/thinking/policy"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; -import { getLastThinkingByModelKey } from "@/common/constants/storage"; +import { getLastActiveThinkingKey } from "@/common/constants/storage"; // Uses CSS variable --color-thinking-mode for theme compatibility // Glow is applied via CSS using color-mix with the theme color @@ -144,12 +144,11 @@ export const ThinkingSliderComponent: React.FC = ({ modelS }; const handleThinkingLevelChange = (newLevel: ThinkingLevel) => { + // ThinkingContext handles per-model persistence automatically setThinkingLevel(newLevel); - // Also save to lastThinkingByModel for Ctrl+Shift+T toggle memory - // Only save active levels (not "off") - matches useAIViewKeybinds logic + // Also save active level for toggle keybind (Ctrl+Shift+T) to restore if (newLevel !== "off") { - const lastThinkingKey = getLastThinkingByModelKey(modelString); - updatePersistedState(lastThinkingKey, newLevel as ThinkingLevelOn); + updatePersistedState(getLastActiveThinkingKey(modelString), newLevel as ThinkingLevelOn); } }; diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 613e927606..3f07b2f5ce 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -1,12 +1,18 @@ import type { ReactNode } from "react"; -import React, { createContext, useContext } from "react"; +import React, { createContext, useContext, useEffect, useState, useCallback } from "react"; import type { ThinkingLevel } from "@/common/types/thinking"; -import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { + usePersistedState, + readPersistedState, + updatePersistedState, +} from "@/browser/hooks/usePersistedState"; import { getThinkingLevelKey, getProjectScopeId, + getModelKey, GLOBAL_SCOPE_ID, } from "@/common/constants/storage"; +import { getDefaultModel } from "@/browser/hooks/useModelLRU"; interface ThinkingContextType { thinkingLevel: ThinkingLevel; @@ -16,23 +22,71 @@ interface ThinkingContextType { const ThinkingContext = createContext(undefined); interface ThinkingProviderProps { - workspaceId?: string; // Workspace-scoped storage (highest priority) - projectPath?: string; // Project-scoped storage (fallback if no workspaceId) + workspaceId?: string; // Workspace-scoped storage for model selection + projectPath?: string; // Project-scoped storage for model selection (fallback) children: ReactNode; } +/** + * ThinkingProvider manages thinking level state per model. + * + * The thinking level is stored per model (e.g., "thinkingLevel:claude-sonnet-4-20250514") + * so users can set different levels for different models and have them remembered. + * + * When the selected model changes, the thinking level is loaded from that model's storage. + */ export const ThinkingProvider: React.FC = ({ workspaceId, projectPath, children, }) => { - // Priority: workspace-scoped > project-scoped > global - const scopeId = workspaceId ?? (projectPath ? getProjectScopeId(projectPath) : GLOBAL_SCOPE_ID); - const key = getThinkingLevelKey(scopeId); - const [thinkingLevel, setThinkingLevel] = usePersistedState( - key, - "off", - { listener: true } // Listen for changes from command palette and other sources + // Derive model storage scope (workspace or project) + const modelScopeId = + workspaceId ?? (projectPath ? getProjectScopeId(projectPath) : GLOBAL_SCOPE_ID); + const modelKey = getModelKey(modelScopeId); + + // Listen for model changes in this scope + const [selectedModel] = usePersistedState(modelKey, null, { listener: true }); + const currentModel = selectedModel ?? getDefaultModel(); + + // Local state for thinking level (managed per model) + const [thinkingLevel, setThinkingLevelState] = useState(() => { + return readPersistedState(getThinkingLevelKey(currentModel), "off"); + }); + + // When model changes, load that model's thinking level + useEffect(() => { + const modelThinkingKey = getThinkingLevelKey(currentModel); + const modelThinkingLevel = readPersistedState(modelThinkingKey, "off"); + setThinkingLevelState(modelThinkingLevel); + }, [currentModel]); + + // Listen for storage events (from command palette or other sources) + useEffect(() => { + const modelThinkingKey = getThinkingLevelKey(currentModel); + + const handleStorage = (e: StorageEvent) => { + if (e.key === modelThinkingKey && e.newValue) { + try { + const parsed = JSON.parse(e.newValue) as ThinkingLevel; + setThinkingLevelState(parsed); + } catch { + // Invalid JSON, ignore + } + } + }; + + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, [currentModel]); + + // Save thinking level to current model's storage + const setThinkingLevel = useCallback( + (level: ThinkingLevel) => { + setThinkingLevelState(level); + updatePersistedState(getThinkingLevelKey(currentModel), level); + }, + [currentModel] ); return ( diff --git a/src/browser/hooks/useAIViewKeybinds.ts b/src/browser/hooks/useAIViewKeybinds.ts index 62bbbe7b5e..1a2c043726 100644 --- a/src/browser/hooks/useAIViewKeybinds.ts +++ b/src/browser/hooks/useAIViewKeybinds.ts @@ -1,8 +1,8 @@ import { useEffect } from "react"; import type { ChatInputAPI } from "@/browser/components/ChatInput"; import { matchesKeybind, KEYBINDS, isEditableElement } from "@/browser/utils/ui/keybinds"; -import { getLastThinkingByModelKey, getModelKey } from "@/common/constants/storage"; -import { updatePersistedState, readPersistedState } from "@/browser/hooks/usePersistedState"; +import { getModelKey, getLastActiveThinkingKey } from "@/common/constants/storage"; +import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import type { ThinkingLevel, ThinkingLevelOn } from "@/common/types/thinking"; import { DEFAULT_THINKING_LEVEL } from "@/common/types/thinking"; import { getThinkingPolicyForModel } from "@/browser/utils/thinking/policy"; @@ -106,9 +106,6 @@ export function useAIViewKeybinds({ const selectedModel = readPersistedState(getModelKey(workspaceId), null); const modelToUse = selectedModel ?? currentModel ?? getDefaultModel(); - // Storage key for remembering this model's last-used active thinking level - const lastThinkingKey = getLastThinkingByModelKey(modelToUse); - // Special-case: if model has single-option policy (e.g., gpt-5-pro only supports HIGH), // the toggle is a no-op to avoid confusing state transitions. const allowed = getThinkingPolicyForModel(modelToUse); @@ -117,18 +114,17 @@ export function useAIViewKeybinds({ } if (currentWorkspaceThinking !== "off") { - // Thinking is currently ON - save the level for this model and turn it off - // Type system ensures we can only store active levels (not "off") + // Thinking is currently ON - save the active level and turn it off const activeLevel: ThinkingLevelOn = currentWorkspaceThinking; - updatePersistedState(lastThinkingKey, activeLevel); + updatePersistedState(getLastActiveThinkingKey(modelToUse), activeLevel); setThinkingLevel("off"); } else { - // Thinking is currently OFF - restore the last level used for this model - const lastUsedThinkingForModel = readPersistedState( - lastThinkingKey, + // Thinking is currently OFF - restore last active level for this model + const lastActive = readPersistedState( + getLastActiveThinkingKey(modelToUse), DEFAULT_THINKING_LEVEL ); - setThinkingLevel(lastUsedThinkingForModel); + setThinkingLevel(lastActive); } return; } diff --git a/src/browser/utils/messages/sendOptions.ts b/src/browser/utils/messages/sendOptions.ts index e8bfb07c7b..6e604bcd54 100644 --- a/src/browser/utils/messages/sendOptions.ts +++ b/src/browser/utils/messages/sendOptions.ts @@ -40,9 +40,9 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio // Read model preference (workspace-specific), fallback to LRU default const model = readPersistedState(getModelKey(workspaceId), getDefaultModel()); - // Read thinking level (workspace-specific) + // Read thinking level (per-model) const thinkingLevel = readPersistedState( - getThinkingLevelKey(workspaceId), + getThinkingLevelKey(model), WORKSPACE_DEFAULTS.thinkingLevel ); diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 5987a6be42..c986048b55 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -49,10 +49,26 @@ export const SELECTED_WORKSPACE_KEY = "selectedWorkspace"; export const EXPANDED_PROJECTS_KEY = "expandedProjects"; /** - * Helper to create a thinking level storage key for a workspace - * Format: "thinkingLevel:{workspaceId}" + * Helper to create a thinking level storage key for a model. + * Format: "thinkingLevel:{modelName}" + * + * Thinking level is now persisted per-model so users can set different + * levels for different models and have them remembered when switching. + */ +export function getThinkingLevelKey(modelName: string): string { + return `thinkingLevel:${modelName}`; +} + +/** + * Get the localStorage key for the last *active* thinking level used for a model. + * Format: "lastActiveThinking:{modelName}" + * + * Used by the toggle keybind (Ctrl+Shift+T) to restore the previous non-off level. + * Stores only active levels ("low" | "medium" | "high"), never "off". */ -export const getThinkingLevelKey = (workspaceId: string): string => `thinkingLevel:${workspaceId}`; +export function getLastActiveThinkingKey(modelName: string): string { + return `lastActiveThinking:${modelName}`; +} /** * Get the localStorage key for the user's preferred model for a workspace @@ -83,15 +99,6 @@ export function getRetryStateKey(workspaceId: string): string { return `${workspaceId}-retryState`; } -/** - * Get the localStorage key for the last active thinking level used for a model - * Stores only active levels ("low" | "medium" | "high"), never "off" - * Format: "lastThinkingByModel:{modelName}" - */ -export function getLastThinkingByModelKey(modelName: string): string { - return `lastThinkingByModel:${modelName}`; -} - /** * Get storage key for cancelled compaction tracking. * Stores compaction-request user message ID to verify freshness across reloads. @@ -226,7 +233,6 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> getModelKey, getInputKey, getModeKey, - getThinkingLevelKey, getAutoRetryKey, getRetryStateKey, getReviewExpandStateKey, @@ -234,7 +240,7 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> getReviewSearchStateKey, getAutoCompactionEnabledKey, getStatusUrlKey, - // Note: getAutoCompactionThresholdKey is per-model, not per-workspace + // Note: getAutoCompactionThresholdKey and getThinkingLevelKey are per-model, not per-workspace ]; /** @@ -246,7 +252,7 @@ const EPHEMERAL_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> /** * Copy all workspace-specific localStorage keys from source to destination workspace - * This includes: model, input, mode, thinking level, auto-retry, retry state, review expand state, file tree expand state + * This includes: model, input, mode, auto-retry, retry state, review expand state, file tree expand state */ export function copyWorkspaceStorage(sourceWorkspaceId: string, destWorkspaceId: string): void { for (const getKey of PERSISTENT_WORKSPACE_KEY_FUNCTIONS) { From 52de062cdb6d9b238428e508046ca6fe932784c1 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 6 Dec 2025 14:29:28 -0600 Subject: [PATCH 2/4] fix: align thinking toggle with slider and drop extra state --- src/browser/components/ThinkingSlider.tsx | 8 +------ src/browser/hooks/useAIViewKeybinds.ts | 27 ++++++++--------------- src/common/constants/storage.ts | 11 --------- 3 files changed, 10 insertions(+), 36 deletions(-) diff --git a/src/browser/components/ThinkingSlider.tsx b/src/browser/components/ThinkingSlider.tsx index eacc4b6e8d..3057eee38e 100644 --- a/src/browser/components/ThinkingSlider.tsx +++ b/src/browser/components/ThinkingSlider.tsx @@ -1,11 +1,9 @@ import React, { useEffect, useId } from "react"; -import type { ThinkingLevel, ThinkingLevelOn } from "@/common/types/thinking"; +import type { ThinkingLevel } from "@/common/types/thinking"; import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel"; import { TooltipWrapper, Tooltip } from "./Tooltip"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { getThinkingPolicyForModel } from "@/browser/utils/thinking/policy"; -import { updatePersistedState } from "@/browser/hooks/usePersistedState"; -import { getLastActiveThinkingKey } from "@/common/constants/storage"; // Uses CSS variable --color-thinking-mode for theme compatibility // Glow is applied via CSS using color-mix with the theme color @@ -146,10 +144,6 @@ export const ThinkingSliderComponent: React.FC = ({ modelS const handleThinkingLevelChange = (newLevel: ThinkingLevel) => { // ThinkingContext handles per-model persistence automatically setThinkingLevel(newLevel); - // Also save active level for toggle keybind (Ctrl+Shift+T) to restore - if (newLevel !== "off") { - updatePersistedState(getLastActiveThinkingKey(modelString), newLevel as ThinkingLevelOn); - } }; // Cycle through allowed thinking levels diff --git a/src/browser/hooks/useAIViewKeybinds.ts b/src/browser/hooks/useAIViewKeybinds.ts index 1a2c043726..03d708af62 100644 --- a/src/browser/hooks/useAIViewKeybinds.ts +++ b/src/browser/hooks/useAIViewKeybinds.ts @@ -1,10 +1,9 @@ import { useEffect } from "react"; import type { ChatInputAPI } from "@/browser/components/ChatInput"; import { matchesKeybind, KEYBINDS, isEditableElement } from "@/browser/utils/ui/keybinds"; -import { getModelKey, getLastActiveThinkingKey } from "@/common/constants/storage"; -import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; -import type { ThinkingLevel, ThinkingLevelOn } from "@/common/types/thinking"; -import { DEFAULT_THINKING_LEVEL } from "@/common/types/thinking"; +import { getModelKey } from "@/common/constants/storage"; +import { readPersistedState } from "@/browser/hooks/usePersistedState"; +import type { ThinkingLevel } from "@/common/types/thinking"; import { getThinkingPolicyForModel } from "@/browser/utils/thinking/policy"; import { getDefaultModel } from "@/browser/hooks/useModelLRU"; import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator"; @@ -109,23 +108,15 @@ export function useAIViewKeybinds({ // Special-case: if model has single-option policy (e.g., gpt-5-pro only supports HIGH), // the toggle is a no-op to avoid confusing state transitions. const allowed = getThinkingPolicyForModel(modelToUse); - if (allowed.length === 1) { + if (allowed.length <= 1) { return; // No toggle for single-option policies } - if (currentWorkspaceThinking !== "off") { - // Thinking is currently ON - save the active level and turn it off - const activeLevel: ThinkingLevelOn = currentWorkspaceThinking; - updatePersistedState(getLastActiveThinkingKey(modelToUse), activeLevel); - setThinkingLevel("off"); - } else { - // Thinking is currently OFF - restore last active level for this model - const lastActive = readPersistedState( - getLastActiveThinkingKey(modelToUse), - DEFAULT_THINKING_LEVEL - ); - setThinkingLevel(lastActive); - } + // Cycle through the allowed levels (same order as the slider cycles) + const currentIndex = allowed.indexOf(currentWorkspaceThinking); + const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % allowed.length; + const nextLevel = allowed[nextIndex]; + setThinkingLevel(nextLevel); return; } diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index c986048b55..72253727cc 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -59,17 +59,6 @@ export function getThinkingLevelKey(modelName: string): string { return `thinkingLevel:${modelName}`; } -/** - * Get the localStorage key for the last *active* thinking level used for a model. - * Format: "lastActiveThinking:{modelName}" - * - * Used by the toggle keybind (Ctrl+Shift+T) to restore the previous non-off level. - * Stores only active levels ("low" | "medium" | "high"), never "off". - */ -export function getLastActiveThinkingKey(modelName: string): string { - return `lastActiveThinking:${modelName}`; -} - /** * Get the localStorage key for the user's preferred model for a workspace */ From 800eff2f04edddd094e0ce95660784136c3d5a44 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 6 Dec 2025 14:42:43 -0600 Subject: [PATCH 3/4] fix: use persisted reader for palette thinking level --- src/browser/App.tsx | 101 +++++++++++++++++++++++--------------------- 1 file changed, 54 insertions(+), 47 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index e16313893f..2c2ac73177 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -7,7 +7,11 @@ import { LeftSidebar } from "./components/LeftSidebar"; import { ProjectCreateModal } from "./components/ProjectCreateModal"; import { AIView } from "./components/AIView"; import { ErrorBoundary } from "./components/ErrorBoundary"; -import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState"; +import { + usePersistedState, + updatePersistedState, + readPersistedState, +} from "./hooks/usePersistedState"; import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering"; import { useResumeManager } from "./hooks/useResumeManager"; @@ -263,60 +267,63 @@ function AppInner() { close: closeCommandPalette, } = useCommandRegistry(); - const getThinkingLevelForWorkspace = useCallback((workspaceId: string): ThinkingLevel => { - if (!workspaceId) { - return "off"; - } - - if (typeof window === "undefined" || !window.localStorage) { - return "off"; - } - - try { - // First get the model for this workspace, then get thinking level for that model - const modelKey = getModelKey(workspaceId); - const modelStored = window.localStorage.getItem(modelKey); - const model = modelStored ? (JSON.parse(modelStored) as string) : getDefaultModel(); + const getCurrentModelForWorkspace = useCallback( + (workspaceId: string): string => { + if (!workspaceId) return getDefaultModel(); + // Prefer live workspace state; fall back to persisted preference + const modelFromStore = workspaceStore.getWorkspaceState(workspaceId)?.currentModel; + return ( + modelFromStore ?? + readPersistedState(getModelKey(workspaceId), null) ?? + getDefaultModel() + ); + }, + [workspaceStore] + ); - const key = getThinkingLevelKey(model); - const stored = window.localStorage.getItem(key); - if (!stored || stored === "undefined") { + const getThinkingLevelForWorkspace = useCallback( + (workspaceId: string): ThinkingLevel => { + if (!workspaceId) { return "off"; } - const parsed = JSON.parse(stored) as ThinkingLevel; - return THINKING_LEVELS.includes(parsed) ? parsed : "off"; - } catch (error) { - console.warn("Failed to read thinking level", error); - return "off"; - } - }, []); - const setThinkingLevelFromPalette = useCallback((workspaceId: string, level: ThinkingLevel) => { - if (!workspaceId) { - return; - } + try { + const model = getCurrentModelForWorkspace(workspaceId); + const storedLevel = readPersistedState(getThinkingLevelKey(model), "off"); + return THINKING_LEVELS.includes(storedLevel) ? storedLevel : "off"; + } catch (error) { + console.warn("Failed to read thinking level", error); + return "off"; + } + }, + [getCurrentModelForWorkspace] + ); - // Get the model for this workspace to set thinking level for it - const modelKey = getModelKey(workspaceId); - const modelStored = window.localStorage?.getItem(modelKey); - const model = modelStored ? (JSON.parse(modelStored) as string) : getDefaultModel(); + const setThinkingLevelFromPalette = useCallback( + (workspaceId: string, level: ThinkingLevel) => { + if (!workspaceId) { + return; + } - const normalized = THINKING_LEVELS.includes(level) ? level : "off"; - const key = getThinkingLevelKey(model); + const model = getCurrentModelForWorkspace(workspaceId); + const normalized = THINKING_LEVELS.includes(level) ? level : "off"; + const key = getThinkingLevelKey(model); - // Use the utility function which handles localStorage and event dispatch - // ThinkingProvider will pick this up via its listener - updatePersistedState(key, normalized); + // Use the utility function which handles localStorage and event dispatch + // ThinkingProvider will pick this up via its listener + updatePersistedState(key, normalized); - // Dispatch toast notification event for UI feedback - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, { - detail: { workspaceId, level: normalized }, - }) - ); - } - }, []); + // Dispatch toast notification event for UI feedback + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, { + detail: { workspaceId, level: normalized }, + }) + ); + } + }, + [getCurrentModelForWorkspace] + ); const registerParamsRef = useRef(null); From 0220fe184d0af89cc93d4774062b968e43ea51c9 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 6 Dec 2025 15:07:22 -0600 Subject: [PATCH 4/4] fix: restore workspace thinking persistence with project seeding --- src/browser/App.tsx | 85 +++++++------------ .../ChatInput/useCreationWorkspace.test.tsx | 11 ++- .../ChatInput/useCreationWorkspace.ts | 13 ++- src/browser/contexts/ThinkingContext.tsx | 74 ++-------------- src/browser/utils/messages/sendOptions.ts | 4 +- src/common/constants/storage.ts | 16 ++-- 6 files changed, 63 insertions(+), 140 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 2c2ac73177..2712c916be 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -34,8 +34,7 @@ import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sour import type { ThinkingLevel } from "@/common/types/thinking"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents"; -import { getThinkingLevelKey, getModelKey } from "@/common/constants/storage"; -import { getDefaultModel } from "@/browser/hooks/useModelLRU"; +import { getThinkingLevelKey } from "@/common/constants/storage"; import type { BranchListResult } from "@/common/orpc/types"; import { useTelemetry } from "./hooks/useTelemetry"; import { getRuntimeTypeForTelemetry } from "@/common/telemetry"; @@ -267,63 +266,41 @@ function AppInner() { close: closeCommandPalette, } = useCommandRegistry(); - const getCurrentModelForWorkspace = useCallback( - (workspaceId: string): string => { - if (!workspaceId) return getDefaultModel(); - // Prefer live workspace state; fall back to persisted preference - const modelFromStore = workspaceStore.getWorkspaceState(workspaceId)?.currentModel; - return ( - modelFromStore ?? - readPersistedState(getModelKey(workspaceId), null) ?? - getDefaultModel() - ); - }, - [workspaceStore] - ); - - const getThinkingLevelForWorkspace = useCallback( - (workspaceId: string): ThinkingLevel => { - if (!workspaceId) { - return "off"; - } + const getThinkingLevelForWorkspace = useCallback((workspaceId: string): ThinkingLevel => { + if (!workspaceId) { + return "off"; + } - try { - const model = getCurrentModelForWorkspace(workspaceId); - const storedLevel = readPersistedState(getThinkingLevelKey(model), "off"); - return THINKING_LEVELS.includes(storedLevel) ? storedLevel : "off"; - } catch (error) { - console.warn("Failed to read thinking level", error); - return "off"; - } - }, - [getCurrentModelForWorkspace] - ); + try { + const storedLevel = readPersistedState( + getThinkingLevelKey(workspaceId), + "off" + ); + return THINKING_LEVELS.includes(storedLevel) ? storedLevel : "off"; + } catch (error) { + console.warn("Failed to read thinking level", error); + return "off"; + } + }, []); - const setThinkingLevelFromPalette = useCallback( - (workspaceId: string, level: ThinkingLevel) => { - if (!workspaceId) { - return; - } + const setThinkingLevelFromPalette = useCallback((workspaceId: string, level: ThinkingLevel) => { + if (!workspaceId) { + return; + } - const model = getCurrentModelForWorkspace(workspaceId); - const normalized = THINKING_LEVELS.includes(level) ? level : "off"; - const key = getThinkingLevelKey(model); + const normalized = THINKING_LEVELS.includes(level) ? level : "off"; + const key = getThinkingLevelKey(workspaceId); - // Use the utility function which handles localStorage and event dispatch - // ThinkingProvider will pick this up via its listener - updatePersistedState(key, normalized); + updatePersistedState(key, normalized); - // Dispatch toast notification event for UI feedback - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, { - detail: { workspaceId, level: normalized }, - }) - ); - } - }, - [getCurrentModelForWorkspace] - ); + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, { + detail: { workspaceId, level: normalized }, + }) + ); + } + }, []); const registerParamsRef = useRef(null); diff --git a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx index 0f78af54ed..90624165a5 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx @@ -404,10 +404,8 @@ describe("useCreationWorkspace", () => { }); persistedPreferences[getModeKey(getProjectScopeId(TEST_PROJECT_PATH))] = "plan"; - // Set model preference for the project scope (read by getSendOptionsFromStorage) persistedPreferences[getModelKey(getProjectScopeId(TEST_PROJECT_PATH))] = "gpt-4"; - // Thinking level is now stored per-model, not per-workspace/project - persistedPreferences[getThinkingLevelKey("gpt-4")] = "high"; + persistedPreferences[getThinkingLevelKey(getProjectScopeId(TEST_PROJECT_PATH))] = "high"; draftSettingsState = createDraftSettingsHarness({ runtimeMode: "ssh", @@ -461,14 +459,15 @@ describe("useCreationWorkspace", () => { expect(onWorkspaceCreated.mock.calls[0][0]).toEqual(TEST_METADATA); const projectModeKey = getModeKey(getProjectScopeId(TEST_PROJECT_PATH)); + const projectThinkingKey = getThinkingLevelKey(getProjectScopeId(TEST_PROJECT_PATH)); expect(readPersistedStateCalls).toContainEqual([projectModeKey, null]); - // Thinking level is now read per-model (gpt-4), not per-project - expect(readPersistedStateCalls).toContainEqual([getThinkingLevelKey("gpt-4"), "off"]); + expect(readPersistedStateCalls).toContainEqual([projectThinkingKey, null]); const modeKey = getModeKey(TEST_WORKSPACE_ID); + const thinkingKey = getThinkingLevelKey(TEST_WORKSPACE_ID); const pendingInputKey = getInputKey(getPendingScopeId(TEST_PROJECT_PATH)); expect(updatePersistedStateCalls).toContainEqual([modeKey, "plan"]); - // Note: Thinking level is now per-model, not per-workspace, so no sync to workspace + expect(updatePersistedStateCalls).toContainEqual([thinkingKey, "high"]); expect(updatePersistedStateCalls).toContainEqual([pendingInputKey, ""]); }); diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index 408ae56d25..778825fb2d 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -12,12 +12,14 @@ import { getModeKey, getPendingScopeId, getProjectScopeId, + getThinkingLevelKey, } from "@/common/constants/storage"; import type { Toast } from "@/browser/components/ChatInputToast"; import { createErrorToast } from "@/browser/components/ChatInputToasts"; import { useAPI } from "@/browser/contexts/API"; import type { ImagePart } from "@/common/orpc/types"; import { useWorkspaceName, type WorkspaceNameState } from "@/browser/hooks/useWorkspaceName"; +import type { ThinkingLevel } from "@/common/types/thinking"; interface UseCreationWorkspaceOptions { projectPath: string; @@ -30,7 +32,6 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void const projectScopeId = getProjectScopeId(projectPath); // Sync model from project scope to workspace scope - // This ensures the model used for creation is persisted for future resumes const projectModel = readPersistedState(getModelKey(projectScopeId), null); if (projectModel) { updatePersistedState(getModelKey(workspaceId), projectModel); @@ -41,8 +42,14 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void updatePersistedState(getModeKey(workspaceId), projectMode); } - // Note: Thinking level is now stored per-model, not per-workspace, - // so no syncing needed here + // Use the project's last thinking level to seed the new workspace + const projectThinking = readPersistedState( + getThinkingLevelKey(projectScopeId), + null + ); + if (projectThinking) { + updatePersistedState(getThinkingLevelKey(workspaceId), projectThinking); + } } interface UseCreationWorkspaceReturn { diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 3f07b2f5ce..4b0eb1b528 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -1,18 +1,12 @@ import type { ReactNode } from "react"; -import React, { createContext, useContext, useEffect, useState, useCallback } from "react"; +import React, { createContext, useContext } from "react"; import type { ThinkingLevel } from "@/common/types/thinking"; -import { - usePersistedState, - readPersistedState, - updatePersistedState, -} from "@/browser/hooks/usePersistedState"; +import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { getThinkingLevelKey, getProjectScopeId, - getModelKey, GLOBAL_SCOPE_ID, } from "@/common/constants/storage"; -import { getDefaultModel } from "@/browser/hooks/useModelLRU"; interface ThinkingContextType { thinkingLevel: ThinkingLevel; @@ -22,73 +16,23 @@ interface ThinkingContextType { const ThinkingContext = createContext(undefined); interface ThinkingProviderProps { - workspaceId?: string; // Workspace-scoped storage for model selection - projectPath?: string; // Project-scoped storage for model selection (fallback) + workspaceId?: string; // Workspace-scoped storage (highest priority) + projectPath?: string; // Project-scoped storage (fallback if no workspaceId) children: ReactNode; } -/** - * ThinkingProvider manages thinking level state per model. - * - * The thinking level is stored per model (e.g., "thinkingLevel:claude-sonnet-4-20250514") - * so users can set different levels for different models and have them remembered. - * - * When the selected model changes, the thinking level is loaded from that model's storage. - */ export const ThinkingProvider: React.FC = ({ workspaceId, projectPath, children, }) => { - // Derive model storage scope (workspace or project) - const modelScopeId = - workspaceId ?? (projectPath ? getProjectScopeId(projectPath) : GLOBAL_SCOPE_ID); - const modelKey = getModelKey(modelScopeId); - - // Listen for model changes in this scope - const [selectedModel] = usePersistedState(modelKey, null, { listener: true }); - const currentModel = selectedModel ?? getDefaultModel(); - - // Local state for thinking level (managed per model) - const [thinkingLevel, setThinkingLevelState] = useState(() => { - return readPersistedState(getThinkingLevelKey(currentModel), "off"); + // Priority: workspace-scoped > project-scoped > global + const scopeId = workspaceId ?? (projectPath ? getProjectScopeId(projectPath) : GLOBAL_SCOPE_ID); + const key = getThinkingLevelKey(scopeId); + const [thinkingLevel, setThinkingLevel] = usePersistedState(key, "off", { + listener: true, }); - // When model changes, load that model's thinking level - useEffect(() => { - const modelThinkingKey = getThinkingLevelKey(currentModel); - const modelThinkingLevel = readPersistedState(modelThinkingKey, "off"); - setThinkingLevelState(modelThinkingLevel); - }, [currentModel]); - - // Listen for storage events (from command palette or other sources) - useEffect(() => { - const modelThinkingKey = getThinkingLevelKey(currentModel); - - const handleStorage = (e: StorageEvent) => { - if (e.key === modelThinkingKey && e.newValue) { - try { - const parsed = JSON.parse(e.newValue) as ThinkingLevel; - setThinkingLevelState(parsed); - } catch { - // Invalid JSON, ignore - } - } - }; - - window.addEventListener("storage", handleStorage); - return () => window.removeEventListener("storage", handleStorage); - }, [currentModel]); - - // Save thinking level to current model's storage - const setThinkingLevel = useCallback( - (level: ThinkingLevel) => { - setThinkingLevelState(level); - updatePersistedState(getThinkingLevelKey(currentModel), level); - }, - [currentModel] - ); - return ( {children} diff --git a/src/browser/utils/messages/sendOptions.ts b/src/browser/utils/messages/sendOptions.ts index 6e604bcd54..e8bfb07c7b 100644 --- a/src/browser/utils/messages/sendOptions.ts +++ b/src/browser/utils/messages/sendOptions.ts @@ -40,9 +40,9 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio // Read model preference (workspace-specific), fallback to LRU default const model = readPersistedState(getModelKey(workspaceId), getDefaultModel()); - // Read thinking level (per-model) + // Read thinking level (workspace-specific) const thinkingLevel = readPersistedState( - getThinkingLevelKey(model), + getThinkingLevelKey(workspaceId), WORKSPACE_DEFAULTS.thinkingLevel ); diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 72253727cc..c9164d73a8 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -49,15 +49,10 @@ export const SELECTED_WORKSPACE_KEY = "selectedWorkspace"; export const EXPANDED_PROJECTS_KEY = "expandedProjects"; /** - * Helper to create a thinking level storage key for a model. - * Format: "thinkingLevel:{modelName}" - * - * Thinking level is now persisted per-model so users can set different - * levels for different models and have them remembered when switching. - */ -export function getThinkingLevelKey(modelName: string): string { - return `thinkingLevel:${modelName}`; -} + * Helper to create a thinking level storage key for a scope (workspace or project). + * Format: "thinkingLevel:{scopeId}" + */ +export const getThinkingLevelKey = (scopeId: string): string => `thinkingLevel:${scopeId}`; /** * Get the localStorage key for the user's preferred model for a workspace @@ -222,6 +217,7 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> getModelKey, getInputKey, getModeKey, + getThinkingLevelKey, getAutoRetryKey, getRetryStateKey, getReviewExpandStateKey, @@ -229,7 +225,7 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> getReviewSearchStateKey, getAutoCompactionEnabledKey, getStatusUrlKey, - // Note: getAutoCompactionThresholdKey and getThinkingLevelKey are per-model, not per-workspace + // Note: getAutoCompactionThresholdKey is per-model, not per-workspace ]; /**