Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 10 additions & 15 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<ThinkingLevel>(
getThinkingLevelKey(workspaceId),
"off"
);
return THINKING_LEVELS.includes(storedLevel) ? storedLevel : "off";
} catch (error) {
console.warn("Failed to read thinking level", error);
return "off";
Expand All @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/browser/components/ChatInput/useCreationWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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<string | null>(getModelKey(projectScopeId), null);
if (projectModel) {
updatePersistedState(getModelKey(workspaceId), projectModel);
Expand All @@ -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<ThinkingLevel | null>(
getThinkingLevelKey(projectScopeId),
null
Expand Down
11 changes: 2 additions & 9 deletions src/browser/components/ThinkingSlider.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -144,13 +142,8 @@ export const ThinkingSliderComponent: React.FC<ThinkingControlProps> = ({ 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
Expand Down
8 changes: 3 additions & 5 deletions src/browser/contexts/ThinkingContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,9 @@ export const ThinkingProvider: React.FC<ThinkingProviderProps> = ({
// Priority: workspace-scoped > project-scoped > global
const scopeId = workspaceId ?? (projectPath ? getProjectScopeId(projectPath) : GLOBAL_SCOPE_ID);
const key = getThinkingLevelKey(scopeId);
const [thinkingLevel, setThinkingLevel] = usePersistedState<ThinkingLevel>(
key,
"off",
{ listener: true } // Listen for changes from command palette and other sources
);
const [thinkingLevel, setThinkingLevel] = usePersistedState<ThinkingLevel>(key, "off", {
listener: true,
});

return (
<ThinkingContext.Provider value={{ thinkingLevel, setThinkingLevel }}>
Expand Down
31 changes: 9 additions & 22 deletions src/browser/hooks/useAIViewKeybinds.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -106,30 +105,18 @@ export function useAIViewKeybinds({
const selectedModel = readPersistedState<string | null>(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<ThinkingLevelOn>(
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;
}

Expand Down
17 changes: 4 additions & 13 deletions src/common/constants/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
Loading