Skip to content

Commit d85a13a

Browse files
committed
🤖 feat: subagent tasks and tools
Change-Id: I9537333f59526443b0c679071fc5b0c251bcde6c Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent beaf779 commit d85a13a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+6114
-69
lines changed

.storybook/mocks/orpc.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ import type {
1313
} from "@/common/orpc/types";
1414
import type { ChatStats } from "@/common/types/chatStats";
1515
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
16+
import {
17+
DEFAULT_TASK_SETTINGS,
18+
normalizeSubagentAiDefaults,
19+
normalizeTaskSettings,
20+
type SubagentAiDefaults,
21+
type TaskSettings,
22+
} from "@/common/types/tasks";
1623
import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue";
1724

1825
/** Session usage data structure matching SessionUsageFileSchema */
@@ -46,6 +53,10 @@ export interface MockSessionUsage {
4653
export interface MockORPCClientOptions {
4754
projects?: Map<string, ProjectConfig>;
4855
workspaces?: FrontendWorkspaceMetadata[];
56+
/** Initial task settings for config.getConfig (e.g., Settings → Tasks section) */
57+
taskSettings?: Partial<TaskSettings>;
58+
/** Initial per-subagent AI defaults for config.getConfig (e.g., Settings → Tasks section) */
59+
subagentAiDefaults?: SubagentAiDefaults;
4960
/** Per-workspace chat callback. Return messages to emit, or use the callback for streaming. */
5061
onChat?: (workspaceId: string, emit: (msg: WorkspaceChatMessage) => void) => (() => void) | void;
5162
/** Mock for executeBash per workspace */
@@ -123,6 +134,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
123134
mcpServers = new Map(),
124135
mcpOverrides = new Map(),
125136
mcpTestResults = new Map(),
137+
taskSettings: initialTaskSettings,
138+
subagentAiDefaults: initialSubagentAiDefaults,
126139
} = options;
127140

128141
// Feature flags
@@ -140,6 +153,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
140153
};
141154

142155
const workspaceMap = new Map(workspaces.map((w) => [w.id, w]));
156+
let taskSettings = normalizeTaskSettings(initialTaskSettings ?? DEFAULT_TASK_SETTINGS);
157+
let subagentAiDefaults = normalizeSubagentAiDefaults(initialSubagentAiDefaults ?? {});
143158

144159
const mockStats: ChatStats = {
145160
consumers: [],
@@ -172,6 +187,16 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
172187
getSshHost: async () => null,
173188
setSshHost: async () => undefined,
174189
},
190+
config: {
191+
getConfig: async () => ({ taskSettings, subagentAiDefaults }),
192+
saveConfig: async (input: { taskSettings: unknown; subagentAiDefaults?: unknown }) => {
193+
taskSettings = normalizeTaskSettings(input.taskSettings);
194+
if (input.subagentAiDefaults !== undefined) {
195+
subagentAiDefaults = normalizeSubagentAiDefaults(input.subagentAiDefaults);
196+
}
197+
return undefined;
198+
},
199+
},
175200
providers: {
176201
list: async () => providersList,
177202
getConfig: async () => providersConfig,

src/browser/components/ProjectSidebar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
partitionWorkspacesByAge,
1818
formatDaysThreshold,
1919
AGE_THRESHOLDS_DAYS,
20+
computeWorkspaceDepthMap,
2021
} from "@/browser/utils/ui/workspaceFiltering";
2122
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
2223
import SecretsModal from "./SecretsModal";
@@ -608,6 +609,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
608609
{(() => {
609610
const allWorkspaces =
610611
sortedWorkspacesByProject.get(projectPath) ?? [];
612+
const depthByWorkspaceId = computeWorkspaceDepthMap(allWorkspaces);
611613
const { recent, buckets } = partitionWorkspacesByAge(
612614
allWorkspaces,
613615
workspaceRecency
@@ -625,6 +627,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
625627
onSelectWorkspace={handleSelectWorkspace}
626628
onRemoveWorkspace={handleRemoveWorkspace}
627629
onToggleUnread={_onToggleUnread}
630+
depth={depthByWorkspaceId[metadata.id] ?? 0}
628631
/>
629632
);
630633

src/browser/components/Settings/SettingsModal.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import React from "react";
2-
import { Settings, Key, Cpu, X, Briefcase, FlaskConical } from "lucide-react";
2+
import { Settings, Key, Cpu, X, Briefcase, FlaskConical, Bot } from "lucide-react";
33
import { useSettings } from "@/browser/contexts/SettingsContext";
44
import { Dialog, DialogContent, DialogTitle, VisuallyHidden } from "@/browser/components/ui/dialog";
55
import { GeneralSection } from "./sections/GeneralSection";
6+
import { TasksSection } from "./sections/TasksSection";
67
import { ProvidersSection } from "./sections/ProvidersSection";
78
import { ModelsSection } from "./sections/ModelsSection";
89
import { Button } from "@/browser/components/ui/button";
@@ -17,6 +18,12 @@ const SECTIONS: SettingsSection[] = [
1718
icon: <Settings className="h-4 w-4" />,
1819
component: GeneralSection,
1920
},
21+
{
22+
id: "tasks",
23+
label: "Agents",
24+
icon: <Bot className="h-4 w-4" />,
25+
component: TasksSection,
26+
},
2027
{
2128
id: "providers",
2229
label: "Providers",
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import React, { useEffect, useRef, useState } from "react";
2+
import { useAPI } from "@/browser/contexts/API";
3+
import { Input } from "@/browser/components/ui/input";
4+
import {
5+
DEFAULT_TASK_SETTINGS,
6+
TASK_SETTINGS_LIMITS,
7+
normalizeTaskSettings,
8+
type TaskSettings,
9+
type SubagentAiDefaults,
10+
} from "@/common/types/tasks";
11+
import { BUILT_IN_SUBAGENTS } from "@/common/constants/agents";
12+
import type { ThinkingLevel } from "@/common/types/thinking";
13+
import { useModelsFromSettings } from "@/browser/hooks/useModelsFromSettings";
14+
import {
15+
Select,
16+
SelectContent,
17+
SelectItem,
18+
SelectTrigger,
19+
SelectValue,
20+
} from "@/browser/components/ui/select";
21+
22+
export function TasksSection() {
23+
const { api } = useAPI();
24+
const [taskSettings, setTaskSettings] = useState<TaskSettings>(DEFAULT_TASK_SETTINGS);
25+
const [subagentAiDefaults, setSubagentAiDefaults] = useState<SubagentAiDefaults>({});
26+
const [loaded, setLoaded] = useState(false);
27+
const [loadFailed, setLoadFailed] = useState(false);
28+
const [saveError, setSaveError] = useState<string | null>(null);
29+
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
30+
const savingRef = useRef(false);
31+
32+
const { models } = useModelsFromSettings();
33+
34+
useEffect(() => {
35+
if (!api) return;
36+
37+
setLoaded(false);
38+
setLoadFailed(false);
39+
setSaveError(null);
40+
41+
void api.config
42+
.getConfig()
43+
.then((cfg) => {
44+
setTaskSettings(normalizeTaskSettings(cfg.taskSettings));
45+
setSubagentAiDefaults(cfg.subagentAiDefaults ?? {});
46+
setLoadFailed(false);
47+
setLoaded(true);
48+
})
49+
.catch((error: unknown) => {
50+
setSaveError(error instanceof Error ? error.message : String(error));
51+
setLoadFailed(true);
52+
setLoaded(true);
53+
});
54+
}, [api]);
55+
56+
useEffect(() => {
57+
if (!api) return;
58+
if (!loaded) return;
59+
if (loadFailed) return;
60+
if (savingRef.current) return;
61+
62+
if (saveTimerRef.current) {
63+
clearTimeout(saveTimerRef.current);
64+
saveTimerRef.current = null;
65+
}
66+
67+
saveTimerRef.current = setTimeout(() => {
68+
savingRef.current = true;
69+
void api.config
70+
.saveConfig({ taskSettings, subagentAiDefaults })
71+
.catch((error: unknown) => {
72+
setSaveError(error instanceof Error ? error.message : String(error));
73+
})
74+
.finally(() => {
75+
savingRef.current = false;
76+
});
77+
}, 400);
78+
79+
return () => {
80+
if (saveTimerRef.current) {
81+
clearTimeout(saveTimerRef.current);
82+
saveTimerRef.current = null;
83+
}
84+
};
85+
}, [api, loaded, loadFailed, subagentAiDefaults, taskSettings]);
86+
87+
const setMaxParallelAgentTasks = (rawValue: string) => {
88+
const parsed = Number(rawValue);
89+
setTaskSettings((prev) => normalizeTaskSettings({ ...prev, maxParallelAgentTasks: parsed }));
90+
};
91+
92+
const setMaxTaskNestingDepth = (rawValue: string) => {
93+
const parsed = Number(rawValue);
94+
setTaskSettings((prev) => normalizeTaskSettings({ ...prev, maxTaskNestingDepth: parsed }));
95+
};
96+
97+
const INHERIT = "__inherit__";
98+
99+
const setSubagentModel = (agentType: string, value: string) => {
100+
setSubagentAiDefaults((prev) => {
101+
const next = { ...prev };
102+
const existing = next[agentType] ?? {};
103+
const updated = { ...existing };
104+
105+
if (value === INHERIT) {
106+
delete updated.modelString;
107+
} else {
108+
updated.modelString = value;
109+
}
110+
111+
if (!updated.modelString && !updated.thinkingLevel) {
112+
delete next[agentType];
113+
} else {
114+
next[agentType] = updated;
115+
}
116+
117+
return next;
118+
});
119+
};
120+
121+
const setSubagentThinking = (agentType: string, value: string) => {
122+
setSubagentAiDefaults((prev) => {
123+
const next = { ...prev };
124+
const existing = next[agentType] ?? {};
125+
const updated = { ...existing };
126+
127+
if (value === INHERIT) {
128+
delete updated.thinkingLevel;
129+
} else {
130+
updated.thinkingLevel = value as ThinkingLevel;
131+
}
132+
133+
if (!updated.modelString && !updated.thinkingLevel) {
134+
delete next[agentType];
135+
} else {
136+
next[agentType] = updated;
137+
}
138+
139+
return next;
140+
});
141+
};
142+
143+
return (
144+
<div className="space-y-6">
145+
<div>
146+
<h3 className="text-foreground mb-4 text-sm font-medium">Agents</h3>
147+
<div className="space-y-4">
148+
<div className="flex items-center justify-between gap-4">
149+
<div className="flex-1">
150+
<div className="text-foreground text-sm">Max Parallel Agent Tasks</div>
151+
<div className="text-muted text-xs">
152+
Default {TASK_SETTINGS_LIMITS.maxParallelAgentTasks.default}, range{" "}
153+
{TASK_SETTINGS_LIMITS.maxParallelAgentTasks.min}
154+
{TASK_SETTINGS_LIMITS.maxParallelAgentTasks.max}
155+
</div>
156+
</div>
157+
<Input
158+
type="number"
159+
value={taskSettings.maxParallelAgentTasks}
160+
min={TASK_SETTINGS_LIMITS.maxParallelAgentTasks.min}
161+
max={TASK_SETTINGS_LIMITS.maxParallelAgentTasks.max}
162+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
163+
setMaxParallelAgentTasks(e.target.value)
164+
}
165+
className="border-border-medium bg-background-secondary h-9 w-28"
166+
/>
167+
</div>
168+
169+
<div className="flex items-center justify-between gap-4">
170+
<div className="flex-1">
171+
<div className="text-foreground text-sm">Max Task Nesting Depth</div>
172+
<div className="text-muted text-xs">
173+
Default {TASK_SETTINGS_LIMITS.maxTaskNestingDepth.default}, range{" "}
174+
{TASK_SETTINGS_LIMITS.maxTaskNestingDepth.min}
175+
{TASK_SETTINGS_LIMITS.maxTaskNestingDepth.max}
176+
</div>
177+
</div>
178+
<Input
179+
type="number"
180+
value={taskSettings.maxTaskNestingDepth}
181+
min={TASK_SETTINGS_LIMITS.maxTaskNestingDepth.min}
182+
max={TASK_SETTINGS_LIMITS.maxTaskNestingDepth.max}
183+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
184+
setMaxTaskNestingDepth(e.target.value)
185+
}
186+
className="border-border-medium bg-background-secondary h-9 w-28"
187+
/>
188+
</div>
189+
</div>
190+
191+
{saveError && <div className="text-danger-light mt-4 text-xs">{saveError}</div>}
192+
</div>
193+
194+
<div>
195+
<h3 className="text-foreground mb-4 text-sm font-medium">Sub-agents</h3>
196+
<div className="space-y-4">
197+
{BUILT_IN_SUBAGENTS.map((preset) => {
198+
const agentType = preset.agentType;
199+
const entry = subagentAiDefaults[agentType];
200+
const modelValue = entry?.modelString ?? INHERIT;
201+
const thinkingValue = entry?.thinkingLevel ?? INHERIT;
202+
203+
return (
204+
<div
205+
key={agentType}
206+
className="border-border-medium bg-background-secondary rounded-md border p-3"
207+
>
208+
<div className="text-foreground text-sm font-medium">{preset.label}</div>
209+
210+
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
211+
<div className="space-y-1">
212+
<div className="text-muted text-xs">Model</div>
213+
<Select
214+
value={modelValue}
215+
onValueChange={(value) => setSubagentModel(agentType, value)}
216+
>
217+
<SelectTrigger className="border-border-medium bg-modal-bg h-9">
218+
<SelectValue />
219+
</SelectTrigger>
220+
<SelectContent>
221+
<SelectItem value={INHERIT}>Inherit</SelectItem>
222+
{models.map((model) => (
223+
<SelectItem key={model} value={model}>
224+
{model}
225+
</SelectItem>
226+
))}
227+
</SelectContent>
228+
</Select>
229+
</div>
230+
231+
<div className="space-y-1">
232+
<div className="text-muted text-xs">Reasoning</div>
233+
<Select
234+
value={thinkingValue}
235+
onValueChange={(value) => setSubagentThinking(agentType, value)}
236+
>
237+
<SelectTrigger className="border-border-medium bg-modal-bg h-9">
238+
<SelectValue />
239+
</SelectTrigger>
240+
<SelectContent>
241+
<SelectItem value={INHERIT}>Inherit</SelectItem>
242+
{(["off", "low", "medium", "high", "xhigh"] as const).map((level) => (
243+
<SelectItem key={level} value={level}>
244+
{level}
245+
</SelectItem>
246+
))}
247+
</SelectContent>
248+
</Select>
249+
</div>
250+
</div>
251+
</div>
252+
);
253+
})}
254+
</div>
255+
</div>
256+
</div>
257+
);
258+
}

0 commit comments

Comments
 (0)