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
47 changes: 39 additions & 8 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,13 @@ 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, getModelKey } from "@/common/constants/storage";
import {
getThinkingLevelByModelKey,
getThinkingLevelKey,
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";
Expand All @@ -52,7 +57,7 @@ import { TooltipProvider } from "./components/ui/tooltip";
import { ExperimentsProvider } from "./contexts/ExperimentsContext";
import { getWorkspaceSidebarKey } from "./utils/workspace";

const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"];
const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high", "xhigh"];

function isStorybookIframe(): boolean {
return typeof window !== "undefined" && window.location.pathname.endsWith("iframe.html");
Expand Down Expand Up @@ -293,9 +298,25 @@ function AppInner() {
if (!workspaceId) {
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 level = readPersistedState<ThinkingLevel>(getThinkingLevelByModelKey(model), "off");
return THINKING_LEVELS.includes(level) ? level : "off";
const legacy = readPersistedState<ThinkingLevel | undefined>(
getThinkingLevelByModelKey(model),
undefined
);
if (legacy !== undefined && THINKING_LEVELS.includes(legacy)) {
updatePersistedState(scopedKey, legacy);
return legacy;
}

return "off";
},
[getModelForWorkspace]
);
Expand All @@ -308,22 +329,32 @@ function AppInner() {

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

// Dispatch toast notification event for UI feedback
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, {
detail: { workspaceId, level: normalized },
detail: { workspaceId, level: effective },
})
);
}
},
[getModelForWorkspace]
[api, getModelForWorkspace]
);

const registerParamsRef = useRef<BuildSourcesParams | null>(null);
Expand Down
27 changes: 24 additions & 3 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import { useMode } from "@/browser/contexts/ModeContext";
import { ThinkingSliderComponent } from "../ThinkingSlider";
import { ModelSettings } from "../ModelSettings";
import { useAPI } from "@/browser/contexts/API";
import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel";
import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels";
import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy";
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
import {
getModelKey,
Expand Down Expand Up @@ -133,6 +136,8 @@ export type { ChatInputProps, ChatInputAPI };
const ChatInputInner: React.FC<ChatInputProps> = (props) => {
const { api } = useAPI();
const { variant } = props;
const [thinkingLevel] = useThinkingLevel();
const workspaceId = variant === "workspace" ? props.workspaceId : null;

// Extract workspace-specific props with defaults
const disabled = props.disabled ?? false;
Expand Down Expand Up @@ -333,10 +338,26 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {

const setPreferredModel = useCallback(
(model: string) => {
ensureModelInSettings(model); // Ensure model exists in Settings
updatePersistedState(storageKeys.modelKey, model); // Update workspace or project-specific
const canonicalModel = migrateGatewayModel(model);
ensureModelInSettings(canonicalModel); // Ensure model exists in Settings
updatePersistedState(storageKeys.modelKey, canonicalModel); // Update workspace or project-specific

// Workspace variant: persist to backend for cross-device consistency.
if (!api || variant !== "workspace" || !workspaceId) {
return;
}

const effectiveThinkingLevel = enforceThinkingPolicy(canonicalModel, thinkingLevel);
api.workspace
.updateAISettings({
workspaceId,
aiSettings: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel },
})
.catch(() => {
// Best-effort only. If offline or backend is old, sendMessage will persist.
});
},
[storageKeys.modelKey, ensureModelInSettings]
[api, storageKeys.modelKey, ensureModelInSettings, thinkingLevel, variant, workspaceId]
);
const deferredModel = useDeferredValue(preferredModel);
const deferredInput = useDeferredValue(input);
Expand Down
38 changes: 34 additions & 4 deletions src/browser/components/ChatInput/useCreationWorkspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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 @@ -83,11 +84,18 @@ type ListBranchesArgs = Parameters<APIClient["projects"]["listBranches"]>[0];
type WorkspaceSendMessageArgs = Parameters<APIClient["workspace"]["sendMessage"]>[0];
type WorkspaceSendMessageResult = Awaited<ReturnType<APIClient["workspace"]["sendMessage"]>>;
type WorkspaceCreateArgs = Parameters<APIClient["workspace"]["create"]>[0];
type WorkspaceUpdateAISettingsArgs = Parameters<APIClient["workspace"]["updateAISettings"]>[0];
type WorkspaceUpdateAISettingsResult = Awaited<
ReturnType<APIClient["workspace"]["updateAISettings"]>
>;
type WorkspaceCreateResult = Awaited<ReturnType<APIClient["workspace"]["create"]>>;
type NameGenerationArgs = Parameters<APIClient["nameGeneration"]["generate"]>[0];
type NameGenerationResult = Awaited<ReturnType<APIClient["nameGeneration"]["generate"]>>;
type MockOrpcProjectsClient = Pick<APIClient["projects"], "listBranches">;
type MockOrpcWorkspaceClient = Pick<APIClient["workspace"], "sendMessage" | "create">;
type MockOrpcWorkspaceClient = Pick<
APIClient["workspace"],
"sendMessage" | "create" | "updateAISettings"
>;
type MockOrpcNameGenerationClient = Pick<APIClient["nameGeneration"], "generate">;
type WindowWithApi = Window & typeof globalThis;
type WindowApi = WindowWithApi["api"];
Expand All @@ -114,6 +122,9 @@ interface SetupWindowOptions {
sendMessage?: ReturnType<
typeof mock<(args: WorkspaceSendMessageArgs) => Promise<WorkspaceSendMessageResult>>
>;
updateAISettings?: ReturnType<
typeof mock<(args: WorkspaceUpdateAISettingsArgs) => Promise<WorkspaceUpdateAISettingsResult>>
>;
create?: ReturnType<typeof mock<(args: WorkspaceCreateArgs) => Promise<WorkspaceCreateResult>>>;
nameGeneration?: ReturnType<
typeof mock<(args: NameGenerationArgs) => Promise<NameGenerationResult>>
Expand All @@ -124,6 +135,7 @@ const setupWindow = ({
listBranches,
sendMessage,
create,
updateAISettings,
nameGeneration,
}: SetupWindowOptions = {}) => {
const listBranchesMock =
Expand Down Expand Up @@ -157,6 +169,15 @@ const setupWindow = ({
} as WorkspaceCreateResult);
});

const updateAISettingsMock =
updateAISettings ??
mock<(args: WorkspaceUpdateAISettingsArgs) => Promise<WorkspaceUpdateAISettingsResult>>(() => {
return Promise.resolve({
success: true,
data: undefined,
} as WorkspaceUpdateAISettingsResult);
});

const nameGenerationMock =
nameGeneration ??
mock<(args: NameGenerationArgs) => Promise<NameGenerationResult>>(() => {
Expand All @@ -176,6 +197,7 @@ const setupWindow = ({
workspace: {
sendMessage: (input: WorkspaceSendMessageArgs) => sendMessageMock(input),
create: (input: WorkspaceCreateArgs) => createMock(input),
updateAISettings: (input: WorkspaceUpdateAISettingsArgs) => updateAISettingsMock(input),
},
nameGeneration: {
generate: (input: NameGenerationArgs) => nameGenerationMock(input),
Expand Down Expand Up @@ -213,6 +235,7 @@ const setupWindow = ({
workspace: {
list: rejectNotImplemented("workspace.list"),
create: (args: WorkspaceCreateArgs) => createMock(args),
updateAISettings: (args: WorkspaceUpdateAISettingsArgs) => updateAISettingsMock(args),
remove: rejectNotImplemented("workspace.remove"),
rename: rejectNotImplemented("workspace.rename"),
fork: rejectNotImplemented("workspace.fork"),
Expand Down Expand Up @@ -278,7 +301,11 @@ const setupWindow = ({

return {
projectsApi: { listBranches: listBranchesMock },
workspaceApi: { sendMessage: sendMessageMock, create: createMock },
workspaceApi: {
sendMessage: sendMessageMock,
create: createMock,
updateAISettings: updateAISettingsMock,
},
nameGenerationApi: { generate: nameGenerationMock },
};
};
Expand Down Expand Up @@ -466,7 +493,7 @@ describe("useCreationWorkspace", () => {
const pendingInputKey = getInputKey(pendingScopeId);
const pendingImagesKey = getInputImagesKey(pendingScopeId);
expect(updatePersistedStateCalls).toContainEqual([modeKey, "plan"]);
// Note: thinking level is no longer synced per-workspace, it's stored per-model globally
// Thinking is workspace-scoped, but this test doesn't set a project-scoped thinking preference.
expect(updatePersistedStateCalls).toContainEqual([pendingInputKey, ""]);
expect(updatePersistedStateCalls).toContainEqual([pendingImagesKey, undefined]);
});
Expand Down Expand Up @@ -510,7 +537,10 @@ describe("useCreationWorkspace", () => {
expect(onWorkspaceCreated.mock.calls.length).toBe(0);
await waitFor(() => expect(getHook().toast?.message).toBe("backend exploded"));
await waitFor(() => expect(getHook().isSending).toBe(false));
expect(updatePersistedStateCalls).toEqual([]);

// 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"]]);
});
});

Expand Down
26 changes: 24 additions & 2 deletions src/browser/components/ChatInput/useCreationWorkspace.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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 @@ -11,6 +12,7 @@ import {
getInputImagesKey,
getModelKey,
getModeKey,
getThinkingLevelKey,
getPendingScopeId,
getProjectScopeId,
} from "@/common/constants/storage";
Expand Down Expand Up @@ -45,8 +47,13 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void
updatePersistedState(getModeKey(workspaceId), projectMode);
}

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

interface UseCreationWorkspaceReturn {
Expand Down Expand Up @@ -196,6 +203,19 @@ export function useCreationWorkspace({

const { metadata } = createResult;

// Best-effort: persist the initial AI settings to the backend immediately so this workspace
// is portable across devices even before the first stream starts.
api.workspace
.updateAISettings({
workspaceId: metadata.id,
aiSettings: {
model: settings.model,
thinkingLevel: settings.thinkingLevel,
},
})
.catch(() => {
// Ignore (offline / older backend). sendMessage will persist as a fallback.
});
// Sync preferences immediately (before switching)
syncCreationPreferences(projectPath, metadata.id);
if (projectPath) {
Expand Down Expand Up @@ -239,6 +259,8 @@ export function useCreationWorkspace({
projectScopeId,
onWorkspaceCreated,
getRuntimeString,
settings.model,
settings.thinkingLevel,
settings.trunkBranch,
waitForGeneration,
]
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 model.
Thinking: {formatKeybind(KEYBINDS.TOGGLE_THINKING)} to cycle. Saved per workspace.
</TooltipContent>
</Tooltip>
);
Expand Down
29 changes: 17 additions & 12 deletions src/browser/contexts/ThinkingContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ 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 } from "@/common/constants/storage";
import {
getModelKey,
getThinkingLevelByModelKey,
getThinkingLevelKey,
} from "@/common/constants/storage";
import { updatePersistedState } from "@/browser/hooks/usePersistedState";

// Setup basic DOM environment for testing-library
Expand Down Expand Up @@ -49,8 +53,7 @@ describe("ThinkingContext", () => {
const workspaceId = "ws-1";

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

let unmounts = 0;

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

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

expect(unmounts).toBe(0);
});
test("switching models restores the per-model thinking level", async () => {
test("migrates legacy per-model thinking to the workspace-scoped key", async () => {
const workspaceId = "ws-1";

// Model A
updatePersistedState(getModelKey(workspaceId), "openai:gpt-5.2");
updatePersistedState(getThinkingLevelByModelKey("openai:gpt-5.2"), "high");

// Model B
updatePersistedState(getThinkingLevelByModelKey("anthropic:claude-3.5"), "low");
updatePersistedState(getThinkingLevelByModelKey("openai:gpt-5.2"), "low");

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

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

// Change model -> should restore that model's stored thinking level
// Migration should have populated the new workspace-scoped key.
const persisted = window.localStorage.getItem(getThinkingLevelKey(workspaceId));
expect(persisted).toBeTruthy();
expect(JSON.parse(persisted!)).toBe("low");

// Switching models should not change the workspace-scoped value.
act(() => {
updatePersistedState(getModelKey(workspaceId), "anthropic:claude-3.5");
});
Expand Down
Loading