Skip to content
Closed
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
55 changes: 13 additions & 42 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,7 @@ import { LeftSidebar } from "./components/LeftSidebar";
import { ProjectCreateModal } from "./components/ProjectCreateModal";
import { AIView } from "./components/AIView";
import { ErrorBoundary } from "./components/ErrorBoundary";
import {
usePersistedState,
updatePersistedState,
readPersistedState,
} from "./hooks/usePersistedState";
import { usePersistedState, readPersistedState } from "./hooks/usePersistedState";
import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds";
import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering";
import { useResumeManager } from "./hooks/useResumeManager";
Expand All @@ -34,18 +30,15 @@ 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 {
getThinkingLevelByModelKey,
getThinkingLevelKey,
getModelKey,
} from "@/common/constants/storage";
import { getModelKey } from "@/common/constants/storage";
import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels";
import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy";
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
import type { BranchListResult } from "@/common/orpc/types";
import { useTelemetry } from "./hooks/useTelemetry";
import { getRuntimeTypeForTelemetry } from "@/common/telemetry";
import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation";
import { persistedSettingsStore } from "@/browser/stores/PersistedSettingsStore";
import { useAPI } from "@/browser/contexts/API";
import { AuthTokenModal } from "@/browser/components/AuthTokenModal";

Expand Down Expand Up @@ -89,6 +82,10 @@ function AppInner() {
);
const { api, status, error, authenticate } = useAPI();

useEffect(() => {
persistedSettingsStore.init(api);
}, [api]);

const {
projects,
removeProject,
Expand Down Expand Up @@ -307,24 +304,8 @@ function AppInner() {
return "off";
}

const scopedKey = getThinkingLevelKey(workspaceId);
const scoped = readPersistedState<ThinkingLevel | undefined>(scopedKey, undefined);
if (scoped !== undefined) {
return THINKING_LEVELS.includes(scoped) ? scoped : "off";
}

// Migration: fall back to legacy per-model thinking and seed the workspace-scoped key.
const model = getModelForWorkspace(workspaceId);
const legacy = readPersistedState<ThinkingLevel | undefined>(
getThinkingLevelByModelKey(model),
undefined
);
if (legacy !== undefined && THINKING_LEVELS.includes(legacy)) {
updatePersistedState(scopedKey, legacy);
return legacy;
}

return "off";
return persistedSettingsStore.getThinkingLevelForModel(model);
},
[getModelForWorkspace]
);
Expand All @@ -338,20 +319,10 @@ function AppInner() {
const normalized = THINKING_LEVELS.includes(level) ? level : "off";
const model = getModelForWorkspace(workspaceId);
const effective = enforceThinkingPolicy(model, normalized);
const key = getThinkingLevelKey(workspaceId);

// Use the utility function which handles localStorage and event dispatch
// ThinkingProvider will pick this up via its listener
updatePersistedState(key, effective);

// Persist to backend so the palette change follows the workspace across devices.
if (api) {
api.workspace
.updateAISettings({ workspaceId, aiSettings: { model, thinkingLevel: effective } })
.catch(() => {
// Best-effort only.
});
}

persistedSettingsStore.setAIThinkingLevel(model, effective).catch(() => {
// Best-effort only.
});

// Dispatch toast notification event for UI feedback
if (typeof window !== "undefined") {
Expand All @@ -362,7 +333,7 @@ function AppInner() {
);
}
},
[api, getModelForWorkspace]
[getModelForWorkspace]
);

const registerParamsRef = useRef<BuildSourcesParams | null>(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,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 @@ -538,9 +537,8 @@ describe("useCreationWorkspace", () => {
await waitFor(() => expect(getHook().toast?.message).toBe("backend exploded"));
await waitFor(() => expect(getHook().isSending).toBe(false));

// Side effect: send-options reader may migrate thinking level into the project scope.
const thinkingKey = getThinkingLevelKey(getProjectScopeId(TEST_PROJECT_PATH));
expect(updatePersistedStateCalls).toEqual([[thinkingKey, "off"]]);
// No side-effect writes expected (thinking level is per-model, not workspace-scoped).
expect(updatePersistedStateCalls).toEqual([]);
});
});

Expand Down
10 changes: 1 addition & 9 deletions src/browser/components/ChatInput/useCreationWorkspace.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import type { RuntimeConfig, RuntimeMode } from "@/common/types/runtime";
import type { ThinkingLevel } from "@/common/types/thinking";
import type { UIMode } from "@/common/types/mode";
import { parseRuntimeString } from "@/browser/utils/chatCommands";
import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings";
Expand All @@ -12,7 +11,6 @@ import {
getInputImagesKey,
getModelKey,
getModeKey,
getThinkingLevelKey,
getPendingScopeId,
getProjectScopeId,
} from "@/common/constants/storage";
Expand Down Expand Up @@ -47,13 +45,7 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void
updatePersistedState(getModeKey(workspaceId), projectMode);
}

const projectThinkingLevel = readPersistedState<ThinkingLevel | null>(
getThinkingLevelKey(projectScopeId),
null
);
if (projectThinkingLevel !== null) {
updatePersistedState(getThinkingLevelKey(workspaceId), projectThinkingLevel);
}
// Note: thinking level is per-model (global), no workspace sync needed
}

interface UseCreationWorkspaceReturn {
Expand Down
2 changes: 1 addition & 1 deletion src/browser/components/ThinkingSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export const ThinkingSliderComponent: React.FC<ThinkingControlProps> = ({ modelS
</div>
</TooltipTrigger>
<TooltipContent align="center">
Thinking: {formatKeybind(KEYBINDS.TOGGLE_THINKING)} to cycle. Saved per workspace.
Thinking: {formatKeybind(KEYBINDS.TOGGLE_THINKING)} to cycle. Saved per model.
</TooltipContent>
</Tooltip>
);
Expand Down
38 changes: 21 additions & 17 deletions src/browser/contexts/ThinkingContext.test.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { GlobalWindow } from "happy-dom";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import React from "react";
import { ThinkingProvider } from "./ThinkingContext";
import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel";
import {
getModelKey,
getThinkingLevelByModelKey,
getThinkingLevelKey,
} from "@/common/constants/storage";
import { getModelKey, getThinkingLevelByModelKey } from "@/common/constants/storage";
import { updatePersistedState } from "@/browser/hooks/usePersistedState";

// Mock useAPI to avoid requiring APIProvider in tests
void mock.module("@/browser/contexts/API", () => ({
useAPI: () => ({ api: null, status: "disconnected" as const, error: null }),
}));

// Setup basic DOM environment for testing-library
const dom = new GlobalWindow();
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
Expand Down Expand Up @@ -53,7 +54,8 @@ describe("ThinkingContext", () => {
const workspaceId = "ws-1";

updatePersistedState(getModelKey(workspaceId), "openai:gpt-5.2");
updatePersistedState(getThinkingLevelKey(workspaceId), "high");
updatePersistedState(getThinkingLevelByModelKey("openai:gpt-5.2"), "high");
updatePersistedState(getThinkingLevelByModelKey("anthropic:claude-3.5"), "low");

let unmounts = 0;

Expand Down Expand Up @@ -82,18 +84,19 @@ describe("ThinkingContext", () => {
updatePersistedState(getModelKey(workspaceId), "anthropic:claude-3.5");
});

// Thinking is workspace-scoped (not per-model), so switching models should not change it.
// Thinking is per-model, so switching models changes the thinking level.
await waitFor(() => {
expect(view.getByTestId("child").textContent).toBe("high");
expect(view.getByTestId("child").textContent).toBe("low");
});

expect(unmounts).toBe(0);
});
test("migrates legacy per-model thinking to the workspace-scoped key", async () => {

test("thinking level is per-model", async () => {
const workspaceId = "ws-1";

updatePersistedState(getModelKey(workspaceId), "openai:gpt-5.2");
updatePersistedState(getThinkingLevelByModelKey("openai:gpt-5.2"), "low");
updatePersistedState(getThinkingLevelByModelKey("openai:gpt-5.2"), "high");

const view = render(
<ThinkingProvider workspaceId={workspaceId}>
Expand All @@ -102,21 +105,22 @@ describe("ThinkingContext", () => {
);

await waitFor(() => {
expect(view.getByTestId("thinking").textContent).toBe("low:ws-1");
expect(view.getByTestId("thinking").textContent).toBe("high:ws-1");
});

// Migration should have populated the new workspace-scoped key.
const persisted = window.localStorage.getItem(getThinkingLevelKey(workspaceId));
// The per-model key should be used.
const persisted = window.localStorage.getItem(getThinkingLevelByModelKey("openai:gpt-5.2"));
expect(persisted).toBeTruthy();
expect(JSON.parse(persisted!)).toBe("low");
expect(JSON.parse(persisted!)).toBe("high");

// Switching models should not change the workspace-scoped value.
// Switching models should change thinking level to the new model's value.
act(() => {
updatePersistedState(getModelKey(workspaceId), "anthropic:claude-3.5");
});

// New model has no saved thinking level, should default to "off".
await waitFor(() => {
expect(view.getByTestId("thinking").textContent).toBe("low:ws-1");
expect(view.getByTestId("thinking").textContent).toBe("off:ws-1");
});
});
});
103 changes: 40 additions & 63 deletions src/browser/contexts/ThinkingContext.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
import type { ReactNode } from "react";
import React, { createContext, useContext, useEffect, useMemo, useCallback } from "react";
import React, {
createContext,
useContext,
useMemo,
useCallback,
useEffect,
useSyncExternalStore,
} from "react";
import type { ThinkingLevel } from "@/common/types/thinking";
import {
readPersistedState,
updatePersistedState,
usePersistedState,
} from "@/browser/hooks/usePersistedState";
import {
getModelKey,
getProjectScopeId,
getThinkingLevelByModelKey,
getThinkingLevelKey,
GLOBAL_SCOPE_ID,
} from "@/common/constants/storage";
import { usePersistedState } from "@/browser/hooks/usePersistedState";
import { getModelKey, getProjectScopeId, GLOBAL_SCOPE_ID } from "@/common/constants/storage";
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels";
import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy";
import { useAPI } from "@/browser/contexts/API";
import { persistedSettingsStore } from "@/browser/stores/PersistedSettingsStore";

interface ThinkingContextType {
thinkingLevel: ThinkingLevel;
Expand All @@ -26,73 +23,53 @@ 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;
}

function getScopeId(workspaceId: string | undefined, projectPath: string | undefined): string {
return workspaceId ?? (projectPath ? getProjectScopeId(projectPath) : GLOBAL_SCOPE_ID);
}

function getCanonicalModelForScope(scopeId: string, fallbackModel: string): string {
const rawModel = readPersistedState<string>(getModelKey(scopeId), fallbackModel);
return migrateGatewayModel(rawModel || fallbackModel);
}

export const ThinkingProvider: React.FC<ThinkingProviderProps> = (props) => {
const { api } = useAPI();
const defaultModel = getDefaultModel();
const scopeId = getScopeId(props.workspaceId, props.projectPath);
const thinkingKey = getThinkingLevelKey(scopeId);

// Workspace-scoped thinking. (No longer per-model.)
const [thinkingLevel, setThinkingLevelInternal] = usePersistedState<ThinkingLevel>(
thinkingKey,
"off",
{ listener: true }
useEffect(() => {
persistedSettingsStore.init(api);
}, [api]);

// Subscribe to persisted settings so we update thinking when the backend changes.
const persistedSnapshot = useSyncExternalStore(
(callback) => persistedSettingsStore.subscribe(callback),
() => persistedSettingsStore.getSnapshot(),
() => persistedSettingsStore.getSnapshot()
);

// One-time migration: if the new workspace-scoped key is missing, seed from the legacy per-model key.
useEffect(() => {
const existing = readPersistedState<ThinkingLevel | undefined>(thinkingKey, undefined);
if (existing !== undefined) {
return;
}
const defaultModel = getDefaultModel();
const scopeId = getScopeId(props.workspaceId, props.projectPath);

// Subscribe to model changes so we update thinking level when model changes.
const [rawModel] = usePersistedState<string>(getModelKey(scopeId), defaultModel, {
listener: true,
});

const model = getCanonicalModelForScope(scopeId, defaultModel);
const legacyKey = getThinkingLevelByModelKey(model);
const legacy = readPersistedState<ThinkingLevel | undefined>(legacyKey, undefined);
if (legacy === undefined) {
return;
}
const model = useMemo(
() => migrateGatewayModel(rawModel || defaultModel),
[rawModel, defaultModel]
);

const effective = enforceThinkingPolicy(model, legacy);
updatePersistedState(thinkingKey, effective);
}, [defaultModel, scopeId, thinkingKey]);
const thinkingLevel =
persistedSnapshot.settings.ai?.thinkingLevelByModel?.[model] ??
persistedSettingsStore.getThinkingLevelForModel(model);

const setThinkingLevel = useCallback(
(level: ThinkingLevel) => {
const model = getCanonicalModelForScope(scopeId, defaultModel);
const effective = enforceThinkingPolicy(model, level);

setThinkingLevelInternal(effective);

// Workspace variant: persist to backend so settings follow the workspace across devices.
if (!props.workspaceId || !api) {
return;
}

api.workspace
.updateAISettings({
workspaceId: props.workspaceId,
aiSettings: { model, thinkingLevel: effective },
})
.catch(() => {
// Best-effort only. If offline or backend is old, the next sendMessage will persist.
});
persistedSettingsStore.setAIThinkingLevel(model, level).catch(() => {
// Best-effort. Store will heal on next refresh.
});
},
[api, defaultModel, props.workspaceId, scopeId, setThinkingLevelInternal]
[model]
);

// Memoize context value to prevent unnecessary re-renders of consumers.
Expand Down
Loading