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
14 changes: 13 additions & 1 deletion .storybook/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import {
type SubagentAiDefaults,
type TaskSettings,
} from "@/common/types/tasks";
import {
normalizeModeAiDefaults,
type ModeAiDefaults,
} from "@/common/types/modeAiDefaults";
import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue";
import { isWorkspaceArchived } from "@/common/utils/archive";

Expand Down Expand Up @@ -57,6 +61,8 @@ export interface MockORPCClientOptions {
workspaces?: FrontendWorkspaceMetadata[];
/** Initial task settings for config.getConfig (e.g., Settings → Tasks section) */
taskSettings?: Partial<TaskSettings>;
/** Initial mode AI defaults for config.getConfig (e.g., Settings → Modes section) */
modeAiDefaults?: ModeAiDefaults;
/** Initial per-subagent AI defaults for config.getConfig (e.g., Settings → Tasks section) */
subagentAiDefaults?: SubagentAiDefaults;
/** Per-workspace chat callback. Return messages to emit, or use the callback for streaming. */
Expand Down Expand Up @@ -140,6 +146,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
mcpOverrides = new Map(),
mcpTestResults = new Map(),
taskSettings: initialTaskSettings,
modeAiDefaults: initialModeAiDefaults,
subagentAiDefaults: initialSubagentAiDefaults,
} = options;

Expand All @@ -158,6 +165,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
};

const workspaceMap = new Map(workspaces.map((w) => [w.id, w]));
let modeAiDefaults = normalizeModeAiDefaults(initialModeAiDefaults ?? {});
let taskSettings = normalizeTaskSettings(initialTaskSettings ?? DEFAULT_TASK_SETTINGS);
let subagentAiDefaults = normalizeSubagentAiDefaults(initialSubagentAiDefaults ?? {});

Expand Down Expand Up @@ -193,14 +201,18 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
setSshHost: async () => undefined,
},
config: {
getConfig: async () => ({ taskSettings, subagentAiDefaults }),
getConfig: async () => ({ taskSettings, subagentAiDefaults, modeAiDefaults }),
saveConfig: async (input: { taskSettings: unknown; subagentAiDefaults?: unknown }) => {
taskSettings = normalizeTaskSettings(input.taskSettings);
if (input.subagentAiDefaults !== undefined) {
subagentAiDefaults = normalizeSubagentAiDefaults(input.subagentAiDefaults);
}
return undefined;
},
updateModeAiDefaults: async (input: { modeAiDefaults: unknown }) => {
modeAiDefaults = normalizeModeAiDefaults(input.modeAiDefaults);
return undefined;
},
},
providers: {
list: async () => providersList,
Expand Down
30 changes: 28 additions & 2 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,16 @@ import { ThemeProvider, useTheme, type ThemeMode } from "./contexts/ThemeContext
import { CommandPalette } from "./components/CommandPalette";
import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources";

import type { UIMode } from "@/common/types/mode";
import type { ThinkingLevel } from "@/common/types/thinking";
import { CUSTOM_EVENTS } from "@/common/constants/events";
import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents";
import {
getModeKey,
getModelKey,
getThinkingLevelByModelKey,
getThinkingLevelKey,
getModelKey,
getWorkspaceAISettingsByModeKey,
} from "@/common/constants/storage";
import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels";
import { enforceThinkingPolicy } from "@/common/utils/thinking/policy";
Expand Down Expand Up @@ -319,10 +322,33 @@ function AppInner() {
// ThinkingProvider will pick this up via its listener
updatePersistedState(key, effective);

type WorkspaceAISettingsByModeCache = Partial<
Record<UIMode, { model: string; thinkingLevel: ThinkingLevel }>
>;

const mode = readPersistedState<UIMode>(getModeKey(workspaceId), "exec");

updatePersistedState<WorkspaceAISettingsByModeCache>(
getWorkspaceAISettingsByModeKey(workspaceId),
(prev) => {
const record: WorkspaceAISettingsByModeCache =
prev && typeof prev === "object" ? prev : {};
return {
...record,
[mode]: { model, thinkingLevel: effective },
};
},
{}
);

// Persist to backend so the palette change follows the workspace across devices.
if (api) {
api.workspace
.updateAISettings({ workspaceId, aiSettings: { model, thinkingLevel: effective } })
.updateModeAISettings({
workspaceId,
mode,
aiSettings: { model, thinkingLevel: effective },
})
.catch(() => {
// Best-effort only.
});
Expand Down
2 changes: 2 additions & 0 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import { BashOutputCollapsedIndicator } from "./tools/BashOutputCollapsedIndicator";
import { hasInterruptedStream } from "@/browser/utils/messages/retryEligibility";
import { ThinkingProvider } from "@/browser/contexts/ThinkingContext";
import { WorkspaceModeAISync } from "@/browser/components/WorkspaceModeAISync";
import { ModeProvider } from "@/browser/contexts/ModeContext";
import { ProviderOptionsProvider } from "@/browser/contexts/ProviderOptionsContext";

Expand Down Expand Up @@ -838,6 +839,7 @@ export const AIView: React.FC<AIViewProps> = (props) => {

return (
<ModeProvider workspaceId={props.workspaceId}>
<WorkspaceModeAISync workspaceId={props.workspaceId} />
<ProviderOptionsProvider>
<ThinkingProvider workspaceId={props.workspaceId}>
<AIViewInner {...props} />
Expand Down
99 changes: 78 additions & 21 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import type { Toast } from "../ChatInputToast";
import { ChatInputToast } from "../ChatInputToast";
import { createCommandToast, createErrorToast } from "../ChatInputToasts";
import { parseCommand } from "@/browser/utils/slashCommands/parser";
import { usePersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import {
readPersistedState,
usePersistedState,
updatePersistedState,
} from "@/browser/hooks/usePersistedState";
import { useSettings } from "@/browser/contexts/SettingsContext";
import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
import { useMode } from "@/browser/contexts/ModeContext";
Expand All @@ -26,8 +30,11 @@ import { enforceThinkingPolicy } from "@/common/utils/thinking/policy";
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
import {
getModelKey,
getThinkingLevelKey,
getWorkspaceAISettingsByModeKey,
getInputKey,
getInputImagesKey,
MODE_AI_DEFAULTS_KEY,
VIM_ENABLED_KEY,
getProjectScopeId,
getPendingScopeId,
Expand Down Expand Up @@ -73,7 +80,8 @@ import {
processImageFiles,
} from "@/browser/utils/imageHandling";

import type { ThinkingLevel } from "@/common/types/thinking";
import type { ModeAiDefaults } from "@/common/types/modeAiDefaults";
import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking";
import type { MuxFrontendMetadata } from "@/common/types/message";
import { prepareUserMessageForSend } from "@/common/types/message";
import { MODEL_ABBREVIATION_EXAMPLES } from "@/common/constants/knownModels";
Expand Down Expand Up @@ -269,6 +277,14 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
defaultModel,
setDefaultModel,
} = useModelsFromSettings();

const [modeAiDefaults] = usePersistedState<ModeAiDefaults>(
MODE_AI_DEFAULTS_KEY,
{},
{
listener: true,
}
);
const commandListId = useId();
const telemetry = useTelemetry();
const [vimEnabled, setVimEnabled] = usePersistedState<boolean>(VIM_ENABLED_KEY, false, {
Expand Down Expand Up @@ -339,26 +355,49 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {

const setPreferredModel = useCallback(
(model: string) => {
type WorkspaceAISettingsByModeCache = Partial<
Record<"plan" | "exec", { model: string; thinkingLevel: ThinkingLevel }>
>;

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) {
if (variant !== "workspace" || !workspaceId) {
return;
}

const effectiveThinkingLevel = enforceThinkingPolicy(canonicalModel, thinkingLevel);

updatePersistedState<WorkspaceAISettingsByModeCache>(
getWorkspaceAISettingsByModeKey(workspaceId),
(prev) => {
const record: WorkspaceAISettingsByModeCache =
prev && typeof prev === "object" ? prev : {};
return {
...record,
[mode]: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel },
};
},
{}
);

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

api.workspace
.updateAISettings({
.updateModeAISettings({
workspaceId,
mode,
aiSettings: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel },
})
.catch(() => {
// Best-effort only. If offline or backend is old, sendMessage will persist.
});
},
[api, storageKeys.modelKey, ensureModelInSettings, thinkingLevel, variant, workspaceId]
[api, mode, storageKeys.modelKey, ensureModelInSettings, thinkingLevel, variant, workspaceId]
);
const deferredModel = useDeferredValue(preferredModel);
const deferredInput = useDeferredValue(input);
Expand Down Expand Up @@ -421,23 +460,41 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
const hasReviews = attachedReviews.length > 0;
const canSend = (hasTypedText || hasImages || hasReviews) && !disabled && !isSendInFlight;

// When entering creation mode, initialize the project-scoped model to the
// default so previous manual picks don't bleed into new creation flows.
// Only runs once per creation session (not when defaultModel changes, which
// would clobber the user's intentional model selection).
const creationModelInitialized = useRef<string | null>(null);
const creationProjectPath = variant === "creation" ? props.projectPath : "";

// Creation variant: keep the project-scoped model/thinking in sync with global per-mode defaults
// so switching Plan/Exec uses the configured defaults (and respects "inherit" semantics).
useEffect(() => {
if (variant === "creation" && defaultModel) {
// Only initialize once per project scope
if (creationModelInitialized.current !== storageKeys.modelKey) {
creationModelInitialized.current = storageKeys.modelKey;
updatePersistedState(storageKeys.modelKey, defaultModel);
}
} else if (variant !== "creation") {
// Reset when leaving creation mode so re-entering triggers initialization
creationModelInitialized.current = null;
if (variant !== "creation") {
return;
}

const scopeId = getProjectScopeId(creationProjectPath);
const modelKey = getModelKey(scopeId);
const thinkingKey = getThinkingLevelKey(scopeId);

const fallbackModel = defaultModel;

const existingModel = readPersistedState<string>(modelKey, fallbackModel);
const candidateModel = modeAiDefaults[mode]?.modelString ?? existingModel;
const resolvedModel =
typeof candidateModel === "string" && candidateModel.trim().length > 0
? candidateModel
: fallbackModel;

const existingThinking = readPersistedState<ThinkingLevel>(thinkingKey, "off");
const candidateThinking = modeAiDefaults[mode]?.thinkingLevel ?? existingThinking ?? "off";
const resolvedThinking = coerceThinkingLevel(candidateThinking) ?? "off";
const effectiveThinking = enforceThinkingPolicy(resolvedModel, resolvedThinking);

if (existingModel !== resolvedModel) {
updatePersistedState(modelKey, resolvedModel);
}

if (existingThinking !== effectiveThinking) {
updatePersistedState(thinkingKey, effectiveThinking);
}
}, [variant, defaultModel, storageKeys.modelKey]);
}, [creationProjectPath, defaultModel, mode, modeAiDefaults, variant]);

// Expose ChatInput auto-focus completion for Storybook/tests.
const chatInputSectionRef = useRef<HTMLDivElement | null>(null);
Expand Down
60 changes: 49 additions & 11 deletions src/browser/components/ChatInput/useCreationWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getModelKey,
getModeKey,
getThinkingLevelKey,
getWorkspaceAISettingsByModeKey,
getPendingScopeId,
getProjectScopeId,
} from "@/common/constants/storage";
Expand Down Expand Up @@ -54,6 +55,27 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void
if (projectThinkingLevel !== null) {
updatePersistedState(getThinkingLevelKey(workspaceId), projectThinkingLevel);
}

if (projectModel) {
const effectiveMode: UIMode = projectMode ?? "exec";
const effectiveThinking: ThinkingLevel = projectThinkingLevel ?? "off";

updatePersistedState<
Partial<Record<"plan" | "exec", { model: string; thinkingLevel: ThinkingLevel }>>
>(
getWorkspaceAISettingsByModeKey(workspaceId),
(prev) => {
const record = prev && typeof prev === "object" ? prev : {};
return {
...(record as Partial<
Record<"plan" | "exec", { model: string; thinkingLevel: ThinkingLevel }>
>),
[effectiveMode]: { model: projectModel, thinkingLevel: effectiveThinking },
};
},
{}
);
}
}

interface UseCreationWorkspaceReturn {
Expand Down Expand Up @@ -205,17 +227,32 @@ export function useCreationWorkspace({

// 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.
});
try {
api.workspace
.updateModeAISettings({
workspaceId: metadata.id,
mode: settings.mode,
aiSettings: {
model: settings.model,
thinkingLevel: settings.thinkingLevel,
},
})
.catch(() => {
// Ignore (offline / older backend). sendMessage will persist as a fallback.
});
} catch {
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 @@ -259,6 +296,7 @@ export function useCreationWorkspace({
projectScopeId,
onWorkspaceCreated,
getRuntimeString,
settings.mode,
settings.model,
settings.thinkingLevel,
settings.trunkBranch,
Expand Down
9 changes: 8 additions & 1 deletion src/browser/components/Settings/SettingsModal.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React from "react";
import { Settings, Key, Cpu, X, Briefcase, FlaskConical, Bot } from "lucide-react";
import { Settings, Key, Cpu, X, Briefcase, FlaskConical, Bot, Layers } from "lucide-react";
import { useSettings } from "@/browser/contexts/SettingsContext";
import { Dialog, DialogContent, DialogTitle, VisuallyHidden } from "@/browser/components/ui/dialog";
import { GeneralSection } from "./sections/GeneralSection";
import { TasksSection } from "./sections/TasksSection";
import { ProvidersSection } from "./sections/ProvidersSection";
import { ModesSection } from "./sections/ModesSection";
import { ModelsSection } from "./sections/ModelsSection";
import { Button } from "@/browser/components/ui/button";
import { ProjectSettingsSection } from "./sections/ProjectSettingsSection";
Expand Down Expand Up @@ -36,6 +37,12 @@ const SECTIONS: SettingsSection[] = [
icon: <Briefcase className="h-4 w-4" />,
component: ProjectSettingsSection,
},
{
id: "modes",
label: "Modes",
icon: <Layers className="h-4 w-4" />,
component: ModesSection,
},
{
id: "models",
label: "Models",
Expand Down
Loading
Loading