Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ Avoid mock-heavy tests that verify implementation details rather than behavior.
## Component State & Storage

- Parent components own localStorage interactions; children announce intent only.
- Use `usePersistedState`/`readPersistedState`/`updatePersistedState` helpers—never call `localStorage` directly.
- **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.
- 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))`.
- When multiple components need the same persisted value, use `usePersistedState` with identical keys and `{ listener: true }` for automatic cross-component sync.
- Avoid destructuring props in function signatures; access via `props.field` to keep rename-friendly code.
Expand Down
86 changes: 47 additions & 39 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 All @@ -30,7 +34,9 @@ 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 { getThinkingLevelByModelKey, getModelKey } from "@/common/constants/storage";
import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels";
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
import type { BranchListResult } from "@/common/orpc/types";
import { useTelemetry } from "./hooks/useTelemetry";
import { getRuntimeTypeForTelemetry } from "@/common/telemetry";
Expand Down Expand Up @@ -262,50 +268,52 @@ function AppInner() {
close: closeCommandPalette,
} = useCommandRegistry();

const getThinkingLevelForWorkspace = useCallback((workspaceId: string): ThinkingLevel => {
if (!workspaceId) {
return "off";
}

if (typeof window === "undefined" || !window.localStorage) {
return "off";
}
/**
* Get model for a workspace, returning canonical format.
*/
const getModelForWorkspace = useCallback((workspaceId: string): string => {
const defaultModel = getDefaultModel();
const rawModel = readPersistedState<string>(getModelKey(workspaceId), defaultModel);
return migrateGatewayModel(rawModel || defaultModel);
}, []);

try {
const key = getThinkingLevelKey(workspaceId);
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 model = getModelForWorkspace(workspaceId);
const level = readPersistedState<ThinkingLevel>(getThinkingLevelByModelKey(model), "off");
return THINKING_LEVELS.includes(level) ? level : "off";
},
[getModelForWorkspace]
);

const setThinkingLevelFromPalette = useCallback((workspaceId: string, level: ThinkingLevel) => {
if (!workspaceId) {
return;
}
const setThinkingLevelFromPalette = useCallback(
(workspaceId: string, level: ThinkingLevel) => {
if (!workspaceId) {
return;
}

const normalized = THINKING_LEVELS.includes(level) ? level : "off";
const key = getThinkingLevelKey(workspaceId);
const normalized = THINKING_LEVELS.includes(level) ? level : "off";
const model = getModelForWorkspace(workspaceId);
const key = getThinkingLevelByModelKey(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 },
})
);
}
},
[getModelForWorkspace]
);

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
getModeKey,
getPendingScopeId,
getProjectScopeId,
getThinkingLevelKey,
} from "@/common/constants/storage";
import type { SendMessageError as _SendMessageError } from "@/common/types/errors";
import type { WorkspaceChatMessage } from "@/common/orpc/types";
Expand Down Expand Up @@ -404,7 +403,6 @@ 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";

Expand Down Expand Up @@ -460,15 +458,12 @@ 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]);

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 no longer synced per-workspace, it's stored per-model globally
expect(updatePersistedStateCalls).toContainEqual([pendingInputKey, ""]);
});

Expand Down
11 changes: 2 additions & 9 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 @@ -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";
Expand Down Expand Up @@ -47,13 +45,8 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void
updatePersistedState(getModeKey(workspaceId), projectMode);
}

const projectThinking = readPersistedState<ThinkingLevel | null>(
getThinkingLevelKey(projectScopeId),
null
);
if (projectThinking) {
updatePersistedState(getThinkingLevelKey(workspaceId), projectThinking);
}
// Note: thinking level is stored per-model globally, not per-workspace,
// so no sync is needed here
}

interface UseCreationWorkspaceReturn {
Expand Down
12 changes: 2 additions & 10 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 { Tooltip, TooltipTrigger, TooltipContent } from "./ui/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 @@ -147,12 +145,6 @@ export const ThinkingSliderComponent: React.FC<ThinkingControlProps> = ({ modelS

const handleThinkingLevelChange = (newLevel: ThinkingLevel) => {
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 Expand Up @@ -207,7 +199,7 @@ export const ThinkingSliderComponent: React.FC<ThinkingControlProps> = ({ modelS
</div>
</TooltipTrigger>
<TooltipContent align="center">
Thinking: {formatKeybind(KEYBINDS.TOGGLE_THINKING)} to toggle. Click level to cycle.
Thinking: {formatKeybind(KEYBINDS.TOGGLE_THINKING)} to cycle. Saved per model.
</TooltipContent>
</Tooltip>
);
Expand Down
42 changes: 31 additions & 11 deletions src/browser/contexts/ThinkingContext.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import type { ReactNode } from "react";
import React, { createContext, useContext } from "react";
import type { ThinkingLevel } from "@/common/types/thinking";
import { usePersistedState } from "@/browser/hooks/usePersistedState";
import {
getThinkingLevelKey,
getProjectScopeId,
GLOBAL_SCOPE_ID,
} from "@/common/constants/storage";
import { usePersistedState, readPersistedState } from "@/browser/hooks/usePersistedState";
import { getThinkingLevelByModelKey, getModelKey } from "@/common/constants/storage";
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels";

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

interface ThinkingProviderProps {
workspaceId?: string; // Workspace-scoped storage (highest priority)
projectPath?: string; // Project-scoped storage (fallback if no workspaceId)
workspaceId?: string; // For existing workspaces
projectPath?: string; // For workspace creation (uses project-scoped model key)
children: ReactNode;
}

/**
* Reads the current model from localStorage for the given scope.
* Returns canonical model format (after gateway migration).
*/
function getScopedModel(workspaceId?: string, projectPath?: string): string {
const defaultModel = getDefaultModel();
// Use workspace-scoped model key if available, otherwise project-scoped
const modelKey = workspaceId
? getModelKey(workspaceId)
: projectPath
? getModelKey(`__project__/${projectPath}`)
: null;

if (!modelKey) {
return defaultModel;
}

const rawModel = readPersistedState<string>(modelKey, defaultModel);
// Normalize to canonical format (e.g., strip legacy gateway prefix)
return migrateGatewayModel(rawModel || defaultModel);
}

export const ThinkingProvider: React.FC<ThinkingProviderProps> = ({
workspaceId,
projectPath,
children,
}) => {
// Priority: workspace-scoped > project-scoped > global
const scopeId = workspaceId ?? (projectPath ? getProjectScopeId(projectPath) : GLOBAL_SCOPE_ID);
const key = getThinkingLevelKey(scopeId);
// Read current model from localStorage (non-reactive, re-reads on each render)
const modelString = getScopedModel(workspaceId, projectPath);
const key = getThinkingLevelByModelKey(modelString);
const [thinkingLevel, setThinkingLevel] = usePersistedState<ThinkingLevel>(
key,
"off",
Expand Down
40 changes: 12 additions & 28 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/useModelsFromSettings";
import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator";
Expand Down Expand Up @@ -32,7 +31,7 @@ interface UseAIViewKeybindsParams {
* Manages keyboard shortcuts for AIView:
* - Esc (non-vim) or Ctrl+C (vim): Interrupt stream (always, regardless of selection)
* - Ctrl+I: Focus chat input
* - Ctrl+Shift+T: Toggle thinking level
* - Ctrl+Shift+T: Cycle thinking level through allowed values for current model
* - Ctrl+G: Jump to bottom
* - Ctrl+T: Open terminal
* - Ctrl+Shift+E: Open in editor
Expand Down Expand Up @@ -99,40 +98,25 @@ export function useAIViewKeybinds({
return;
}

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

// Get selected model from localStorage (what user sees in UI)
// Fall back to message history model, then to the Settings default model
// This matches the same logic as useSendMessageOptions
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.
// Get allowed levels for this model
const allowed = getThinkingPolicyForModel(modelToUse);
if (allowed.length === 1) {
return; // No toggle for single-option policies
if (allowed.length <= 1) {
return; // No cycling 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 to the next allowed level
const currentIndex = allowed.indexOf(currentWorkspaceThinking);
const nextIndex = (currentIndex + 1) % allowed.length;
setThinkingLevel(allowed[nextIndex]);
return;
}

Expand Down
6 changes: 3 additions & 3 deletions src/browser/utils/messages/sendOptions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getModelKey, getThinkingLevelKey, getModeKey } from "@/common/constants/storage";
import { getModelKey, getThinkingLevelByModelKey, getModeKey } from "@/common/constants/storage";
import { modeToToolPolicy } from "@/common/utils/ui/modeUtils";
import { readPersistedState } from "@/browser/hooks/usePersistedState";
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
Expand Down Expand Up @@ -47,9 +47,9 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio
// Transform to gateway format if gateway is enabled for this model
const model = toGatewayModel(baseModel);

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

Expand Down
Loading