diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 20874b4c3..2712c916b 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"; @@ -267,18 +271,12 @@ function AppInner() { return "off"; } - if (typeof window === "undefined" || !window.localStorage) { - return "off"; - } - try { - const key = getThinkingLevelKey(workspaceId); - const stored = window.localStorage.getItem(key); - if (!stored || stored === "undefined") { - return "off"; - } - const parsed = JSON.parse(stored) as ThinkingLevel; - return THINKING_LEVELS.includes(parsed) ? parsed : "off"; + 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"; @@ -293,11 +291,8 @@ function AppInner() { 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); - // Dispatch toast notification event for UI feedback if (typeof window !== "undefined") { window.dispatchEvent( new CustomEvent(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, { diff --git a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx index c1d0eb4e7..90624165a 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx @@ -404,9 +404,8 @@ 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"; + persistedPreferences[getThinkingLevelKey(getProjectScopeId(TEST_PROJECT_PATH))] = "high"; draftSettingsState = createDraftSettingsHarness({ runtimeMode: "ssh", diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index d169d9001..778825fb2 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"; @@ -20,6 +19,7 @@ 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; @@ -32,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); @@ -43,6 +42,7 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void updatePersistedState(getModeKey(workspaceId), projectMode); } + // Use the project's last thinking level to seed the new workspace const projectThinking = readPersistedState( getThinkingLevelKey(projectScopeId), null diff --git a/src/browser/components/ThinkingSlider.tsx b/src/browser/components/ThinkingSlider.tsx index d05124c09..3057eee38 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 { getLastThinkingByModelKey } 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,13 +142,8 @@ 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 - if (newLevel !== "off") { - const lastThinkingKey = getLastThinkingByModelKey(modelString); - updatePersistedState(lastThinkingKey, newLevel as ThinkingLevelOn); - } }; // Cycle through allowed thinking levels diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 613e92760..4b0eb1b52 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -29,11 +29,9 @@ export const ThinkingProvider: React.FC = ({ // 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 - ); + const [thinkingLevel, setThinkingLevel] = usePersistedState(key, "off", { + listener: true, + }); return ( diff --git a/src/browser/hooks/useAIViewKeybinds.ts b/src/browser/hooks/useAIViewKeybinds.ts index 62bbbe7b5..03d708af6 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 { getLastThinkingByModelKey, getModelKey } from "@/common/constants/storage"; -import { updatePersistedState, readPersistedState } 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"; @@ -106,30 +105,18 @@ 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); - if (allowed.length === 1) { + if (allowed.length <= 1) { return; // No toggle for single-option policies } - 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") - const activeLevel: ThinkingLevelOn = currentWorkspaceThinking; - updatePersistedState(lastThinkingKey, activeLevel); - setThinkingLevel("off"); - } else { - // Thinking is currently OFF - restore the last level used for this model - const lastUsedThinkingForModel = readPersistedState( - lastThinkingKey, - DEFAULT_THINKING_LEVEL - ); - setThinkingLevel(lastUsedThinkingForModel); - } + // 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 5987a6be4..c9164d73a 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -49,10 +49,10 @@ 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 scope (workspace or project). + * Format: "thinkingLevel:{scopeId}" */ -export const getThinkingLevelKey = (workspaceId: string): string => `thinkingLevel:${workspaceId}`; +export const getThinkingLevelKey = (scopeId: string): string => `thinkingLevel:${scopeId}`; /** * Get the localStorage key for the user's preferred model for a workspace @@ -83,15 +83,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. @@ -246,7 +237,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) {