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
145 changes: 142 additions & 3 deletions .storybook/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* Creates a client that matches the AppRouter interface with configurable mock data.
*/
import type { APIClient } from "@/browser/contexts/API";
import type { AgentDefinitionDescriptor, AgentDefinitionPackage } from "@/common/types/agentDefinition";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import type { ProjectConfig } from "@/node/config";
import type {
Expand All @@ -21,6 +22,11 @@ import {
type SubagentAiDefaults,
type TaskSettings,
} from "@/common/types/tasks";
import {
normalizeModeAiDefaults,
type ModeAiDefaults,
} from "@/common/types/modeAiDefaults";
import { normalizeAgentAiDefaults, type AgentAiDefaults } from "@/common/types/agentAiDefaults";
import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue";
import { isWorkspaceArchived } from "@/common/utils/archive";

Expand Down Expand Up @@ -57,6 +63,12 @@ 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 unified AI defaults for agents (plan/exec/compact + subagents) */
agentAiDefaults?: AgentAiDefaults;
/** Agent definitions to expose via agents.list */
agentDefinitions?: AgentDefinitionDescriptor[];
/** 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,7 +152,10 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
mcpOverrides = new Map(),
mcpTestResults = new Map(),
taskSettings: initialTaskSettings,
modeAiDefaults: initialModeAiDefaults,
subagentAiDefaults: initialSubagentAiDefaults,
agentAiDefaults: initialAgentAiDefaults,
agentDefinitions: initialAgentDefinitions,
} = options;

// Feature flags
Expand All @@ -158,8 +173,78 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
};

const workspaceMap = new Map(workspaces.map((w) => [w.id, w]));

const agentDefinitions: AgentDefinitionDescriptor[] =
initialAgentDefinitions ??
([
{
id: "plan",
scope: "built-in",
name: "Plan",
description: "Create a plan before coding",
uiSelectable: true,
subagentRunnable: false,
policyBase: "plan",
},
{
id: "exec",
scope: "built-in",
name: "Exec",
description: "Implement changes in the repository",
uiSelectable: true,
subagentRunnable: true,
policyBase: "exec",
},
{
id: "compact",
scope: "built-in",
name: "Compact",
description: "History compaction (internal)",
uiSelectable: false,
subagentRunnable: false,
policyBase: "compact",
},
{
id: "explore",
scope: "built-in",
name: "Explore",
description: "Read-only repository exploration",
uiSelectable: false,
subagentRunnable: true,
policyBase: "exec",
},
] satisfies AgentDefinitionDescriptor[]);

let taskSettings = normalizeTaskSettings(initialTaskSettings ?? DEFAULT_TASK_SETTINGS);
let subagentAiDefaults = normalizeSubagentAiDefaults(initialSubagentAiDefaults ?? {});

let agentAiDefaults = normalizeAgentAiDefaults(
initialAgentAiDefaults ??
({
...(initialSubagentAiDefaults ?? {}),
...(initialModeAiDefaults ?? {}),
} as const)
);

const deriveModeAiDefaults = () =>
normalizeModeAiDefaults({
plan: agentAiDefaults.plan,
exec: agentAiDefaults.exec,
compact: agentAiDefaults.compact,
});

const deriveSubagentAiDefaults = () => {
const raw: Record<string, unknown> = {};
for (const [agentId, entry] of Object.entries(agentAiDefaults)) {
if (agentId === "plan" || agentId === "exec" || agentId === "compact") {
continue;
}
raw[agentId] = entry;
}
return normalizeSubagentAiDefaults(raw);
};

let modeAiDefaults = deriveModeAiDefaults();
let subagentAiDefaults = deriveSubagentAiDefaults();

const mockStats: ChatStats = {
consumers: [],
Expand Down Expand Up @@ -193,15 +278,69 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
setSshHost: async () => undefined,
},
config: {
getConfig: async () => ({ taskSettings, subagentAiDefaults }),
saveConfig: async (input: { taskSettings: unknown; subagentAiDefaults?: unknown }) => {
getConfig: async () => ({ taskSettings, agentAiDefaults, subagentAiDefaults, modeAiDefaults }),
saveConfig: async (input: {
taskSettings: unknown;
agentAiDefaults?: unknown;
subagentAiDefaults?: unknown;
}) => {
taskSettings = normalizeTaskSettings(input.taskSettings);

if (input.agentAiDefaults !== undefined) {
agentAiDefaults = normalizeAgentAiDefaults(input.agentAiDefaults);
modeAiDefaults = deriveModeAiDefaults();
subagentAiDefaults = deriveSubagentAiDefaults();
}

if (input.subagentAiDefaults !== undefined) {
subagentAiDefaults = normalizeSubagentAiDefaults(input.subagentAiDefaults);

const nextAgentAiDefaults: Record<string, unknown> = { ...agentAiDefaults };
for (const [agentType, entry] of Object.entries(subagentAiDefaults)) {
nextAgentAiDefaults[agentType] = entry;
}

agentAiDefaults = normalizeAgentAiDefaults(nextAgentAiDefaults);
modeAiDefaults = deriveModeAiDefaults();
}

return undefined;
},
updateAgentAiDefaults: async (input: { agentAiDefaults: unknown }) => {
agentAiDefaults = normalizeAgentAiDefaults(input.agentAiDefaults);
modeAiDefaults = deriveModeAiDefaults();
subagentAiDefaults = deriveSubagentAiDefaults();
return undefined;
},
updateModeAiDefaults: async (input: { modeAiDefaults: unknown }) => {
modeAiDefaults = normalizeModeAiDefaults(input.modeAiDefaults);
agentAiDefaults = normalizeAgentAiDefaults({ ...agentAiDefaults, ...modeAiDefaults });
modeAiDefaults = deriveModeAiDefaults();
subagentAiDefaults = deriveSubagentAiDefaults();
return undefined;
},
},
agents: {
list: async (_input: { workspaceId: string }) => agentDefinitions,
get: async (input: { workspaceId: string; agentId: string }) => {
const descriptor =
agentDefinitions.find((agent) => agent.id === input.agentId) ?? agentDefinitions[0];

return {
id: descriptor.id,
scope: descriptor.scope,
frontmatter: {
name: descriptor.name,
description: descriptor.description,
ui: { selectable: descriptor.uiSelectable },
subagent: { runnable: descriptor.subagentRunnable },
ai: descriptor.aiDefaults,
policy: { base: descriptor.policyBase, tools: descriptor.toolFilter },
},
body: "",
} satisfies AgentDefinitionPackage;
},
},
providers: {
list: async () => providersList,
getConfig: async () => providersConfig,
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 @@ -843,6 +844,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
71 changes: 71 additions & 0 deletions src/browser/components/AgentSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from "react";

import { useAgent } from "@/browser/contexts/AgentContext";
import {
HelpIndicator,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/browser/components/ui/tooltip";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/browser/components/ui/select";
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
import { cn } from "@/common/lib/utils";

interface AgentSelectorProps {
className?: string;
}

const AgentHelpTooltip: React.FC = () => (
<Tooltip>
<TooltipTrigger asChild>
<HelpIndicator>?</HelpIndicator>
</TooltipTrigger>
<TooltipContent align="center" className="max-w-80 whitespace-normal">
Selects an agent definition (system prompt + tool policy).
<br />
<br />
Toggle Plan/Exec with: {formatKeybind(KEYBINDS.TOGGLE_MODE)}
</TooltipContent>
</Tooltip>
);

export const AgentSelector: React.FC<AgentSelectorProps> = (props) => {
const { agentId, setAgentId, agents, loaded } = useAgent();

const selectable = agents.filter((entry) => entry.uiSelectable);

const options =
selectable.length > 0
? selectable
: [
{ id: "exec", name: "Exec" },
{ id: "plan", name: "Plan" },
];

const selectedLabel =
options.find((option) => option.id === agentId)?.name ?? (loaded ? agentId : "Agent");

return (
<div className={cn("flex items-center gap-1.5", props.className)}>
<Select value={agentId} onValueChange={(next) => setAgentId(next)}>
<SelectTrigger className="h-6 w-[120px] px-2 text-[11px]">
<SelectValue>{selectedLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
<AgentHelpTooltip />
</div>
);
};
Loading
Loading