Skip to content

Commit c2be176

Browse files
committed
🤖 feat: sub-workspaces as subagents
Signed-off-by: Thomas Kosiewski <tk@coder.com> --- _Generated with `codex cli` • Model: `gpt-5.2` • Thinking: `xhigh`_ <!-- mux-attribution: model=gpt-5.2 thinking=xhigh --> Change-Id: I8b9f052ecf68308e29944aa1cdbe5b8eafff4479
1 parent 6dea256 commit c2be176

34 files changed

+2569
-18
lines changed

‎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: "Tasks",
24+
icon: <Bot className="h-4 w-4" />,
25+
component: TasksSection,
26+
},
2027
{
2128
id: "providers",
2229
label: "Providers",
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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+
} from "@/common/types/tasks";
10+
11+
export function TasksSection() {
12+
const { api } = useAPI();
13+
const [settings, setSettings] = useState<TaskSettings>(DEFAULT_TASK_SETTINGS);
14+
const [loaded, setLoaded] = useState(false);
15+
const [saveError, setSaveError] = useState<string | null>(null);
16+
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
17+
const savingRef = useRef(false);
18+
19+
useEffect(() => {
20+
if (!api) return;
21+
22+
setLoaded(false);
23+
setSaveError(null);
24+
25+
void api.config
26+
.getConfig()
27+
.then((cfg) => {
28+
setSettings(normalizeTaskSettings(cfg.taskSettings));
29+
setLoaded(true);
30+
})
31+
.catch((error: unknown) => {
32+
setSaveError(error instanceof Error ? error.message : String(error));
33+
setLoaded(true);
34+
});
35+
}, [api]);
36+
37+
useEffect(() => {
38+
if (!api) return;
39+
if (!loaded) return;
40+
if (savingRef.current) return;
41+
42+
if (saveTimerRef.current) {
43+
clearTimeout(saveTimerRef.current);
44+
saveTimerRef.current = null;
45+
}
46+
47+
saveTimerRef.current = setTimeout(() => {
48+
savingRef.current = true;
49+
void api.config
50+
.saveConfig({ taskSettings: settings })
51+
.catch((error: unknown) => {
52+
setSaveError(error instanceof Error ? error.message : String(error));
53+
})
54+
.finally(() => {
55+
savingRef.current = false;
56+
});
57+
}, 400);
58+
59+
return () => {
60+
if (saveTimerRef.current) {
61+
clearTimeout(saveTimerRef.current);
62+
saveTimerRef.current = null;
63+
}
64+
};
65+
}, [api, loaded, settings]);
66+
67+
const setMaxParallelAgentTasks = (rawValue: string) => {
68+
const parsed = Number(rawValue);
69+
setSettings((prev) =>
70+
normalizeTaskSettings({ ...prev, maxParallelAgentTasks: parsed })
71+
);
72+
};
73+
74+
const setMaxTaskNestingDepth = (rawValue: string) => {
75+
const parsed = Number(rawValue);
76+
setSettings((prev) => normalizeTaskSettings({ ...prev, maxTaskNestingDepth: parsed }));
77+
};
78+
79+
return (
80+
<div className="space-y-6">
81+
<div>
82+
<h3 className="text-foreground mb-4 text-sm font-medium">Tasks</h3>
83+
<div className="space-y-4">
84+
<div className="flex items-center justify-between gap-4">
85+
<div className="flex-1">
86+
<div className="text-foreground text-sm">Max Parallel Agent Tasks</div>
87+
<div className="text-muted text-xs">
88+
Default {TASK_SETTINGS_LIMITS.maxParallelAgentTasks.default}, range{" "}
89+
{TASK_SETTINGS_LIMITS.maxParallelAgentTasks.min}–
90+
{TASK_SETTINGS_LIMITS.maxParallelAgentTasks.max}
91+
</div>
92+
</div>
93+
<Input
94+
type="number"
95+
value={settings.maxParallelAgentTasks}
96+
min={TASK_SETTINGS_LIMITS.maxParallelAgentTasks.min}
97+
max={TASK_SETTINGS_LIMITS.maxParallelAgentTasks.max}
98+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
99+
setMaxParallelAgentTasks(e.target.value)
100+
}
101+
className="border-border-medium bg-background-secondary h-9 w-28"
102+
/>
103+
</div>
104+
105+
<div className="flex items-center justify-between gap-4">
106+
<div className="flex-1">
107+
<div className="text-foreground text-sm">Max Task Nesting Depth</div>
108+
<div className="text-muted text-xs">
109+
Default {TASK_SETTINGS_LIMITS.maxTaskNestingDepth.default}, range{" "}
110+
{TASK_SETTINGS_LIMITS.maxTaskNestingDepth.min}–
111+
{TASK_SETTINGS_LIMITS.maxTaskNestingDepth.max}
112+
</div>
113+
</div>
114+
<Input
115+
type="number"
116+
value={settings.maxTaskNestingDepth}
117+
min={TASK_SETTINGS_LIMITS.maxTaskNestingDepth.min}
118+
max={TASK_SETTINGS_LIMITS.maxTaskNestingDepth.max}
119+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
120+
setMaxTaskNestingDepth(e.target.value)
121+
}
122+
className="border-border-medium bg-background-secondary h-9 w-28"
123+
/>
124+
</div>
125+
</div>
126+
127+
{saveError && <div className="text-danger-light mt-4 text-xs">{saveError}</div>}
128+
</div>
129+
</div>
130+
);
131+
}
132+

‎src/browser/components/WorkspaceListItem.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface WorkspaceListItemProps {
2323
projectName: string;
2424
isSelected: boolean;
2525
isDeleting?: boolean;
26+
depth?: number;
2627
/** @deprecated No longer used since status dot was removed, kept for API compatibility */
2728
lastReadTimestamp?: number;
2829
// Event handlers
@@ -38,6 +39,7 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
3839
projectName,
3940
isSelected,
4041
isDeleting,
42+
depth,
4143
lastReadTimestamp: _lastReadTimestamp,
4244
onSelectWorkspace,
4345
onRemoveWorkspace,
@@ -101,18 +103,21 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
101103

102104
const { canInterrupt, awaitingUserQuestion } = useWorkspaceSidebarState(workspaceId);
103105
const isWorking = canInterrupt && !awaitingUserQuestion;
106+
const safeDepth = typeof depth === "number" && Number.isFinite(depth) ? Math.max(0, depth) : 0;
107+
const paddingLeft = 9 + Math.min(32, safeDepth) * 12;
104108

105109
return (
106110
<React.Fragment>
107111
<div
108112
className={cn(
109-
"py-1.5 pl-[9px] pr-2 border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative flex gap-2",
113+
"py-1.5 pr-2 border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative flex gap-2",
110114
isDisabled
111115
? "cursor-default opacity-70"
112116
: "cursor-pointer hover:bg-hover [&:hover_button]:opacity-100",
113117
isSelected && !isDisabled && "bg-hover border-l-blue-400",
114118
isDeleting && "pointer-events-none"
115119
)}
120+
style={{ paddingLeft }}
116121
onClick={() => {
117122
if (isDisabled) return;
118123
onSelectWorkspace({

‎src/browser/utils/ui/workspaceFiltering.test.ts‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,8 @@ describe("buildSortedWorkspacesByProject", () => {
180180
const createWorkspace = (
181181
id: string,
182182
projectPath: string,
183-
status?: "creating"
183+
status?: "creating",
184+
parentWorkspaceId?: string
184185
): FrontendWorkspaceMetadata => ({
185186
id,
186187
name: `workspace-${id}`,
@@ -189,6 +190,7 @@ describe("buildSortedWorkspacesByProject", () => {
189190
namedWorkspacePath: `${projectPath}/workspace-${id}`,
190191
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
191192
status,
193+
parentWorkspaceId,
192194
});
193195

194196
it("should include workspaces from persisted config", () => {
@@ -276,6 +278,41 @@ describe("buildSortedWorkspacesByProject", () => {
276278
expect(result.get("/project/a")?.map((w) => w.id)).toEqual(["ws2", "ws3", "ws1"]);
277279
});
278280

281+
it("should flatten child workspaces directly under their parent", () => {
282+
const now = Date.now();
283+
const projects = new Map<string, ProjectConfig>([
284+
[
285+
"/project/a",
286+
{
287+
workspaces: [
288+
{ path: "/a/root", id: "root" },
289+
{ path: "/a/child1", id: "child1" },
290+
{ path: "/a/child2", id: "child2" },
291+
{ path: "/a/grand", id: "grand" },
292+
],
293+
},
294+
],
295+
]);
296+
297+
const metadata = new Map<string, FrontendWorkspaceMetadata>([
298+
["root", createWorkspace("root", "/project/a")],
299+
["child1", createWorkspace("child1", "/project/a", undefined, "root")],
300+
["child2", createWorkspace("child2", "/project/a", undefined, "root")],
301+
["grand", createWorkspace("grand", "/project/a", undefined, "child1")],
302+
]);
303+
304+
// Child workspaces are more recent than the parent, but should still render below it.
305+
const recency = {
306+
child1: now - 1000,
307+
child2: now - 2000,
308+
grand: now - 3000,
309+
root: now - 4000,
310+
};
311+
312+
const result = buildSortedWorkspacesByProject(projects, metadata, recency);
313+
expect(result.get("/project/a")?.map((w) => w.id)).toEqual(["root", "child1", "grand", "child2"]);
314+
});
315+
279316
it("should not duplicate workspaces that exist in both config and have creating status", () => {
280317
// Edge case: workspace was saved to config but still has status: "creating"
281318
// (this shouldn't happen in practice but tests defensive coding)

‎src/browser/utils/ui/workspaceFiltering.ts‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,105 @@
11
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
22
import type { ProjectConfig } from "@/common/types/project";
33

4+
export function flattenWorkspaceTree(
5+
workspaces: FrontendWorkspaceMetadata[]
6+
): FrontendWorkspaceMetadata[] {
7+
if (workspaces.length === 0) return [];
8+
9+
const byId = new Map<string, FrontendWorkspaceMetadata>();
10+
for (const workspace of workspaces) {
11+
byId.set(workspace.id, workspace);
12+
}
13+
14+
const childrenByParent = new Map<string, FrontendWorkspaceMetadata[]>();
15+
const roots: FrontendWorkspaceMetadata[] = [];
16+
17+
// Preserve input order for both roots and siblings by iterating in-order.
18+
for (const workspace of workspaces) {
19+
const parentId = workspace.parentWorkspaceId;
20+
if (parentId && byId.has(parentId)) {
21+
const children = childrenByParent.get(parentId) ?? [];
22+
children.push(workspace);
23+
childrenByParent.set(parentId, children);
24+
} else {
25+
roots.push(workspace);
26+
}
27+
}
28+
29+
const result: FrontendWorkspaceMetadata[] = [];
30+
const visited = new Set<string>();
31+
32+
const visit = (workspace: FrontendWorkspaceMetadata, depth: number) => {
33+
if (visited.has(workspace.id)) return;
34+
visited.add(workspace.id);
35+
36+
// Cap depth defensively to avoid pathological cycles/graphs.
37+
if (depth > 32) {
38+
result.push(workspace);
39+
return;
40+
}
41+
42+
result.push(workspace);
43+
const children = childrenByParent.get(workspace.id);
44+
if (children) {
45+
for (const child of children) {
46+
visit(child, depth + 1);
47+
}
48+
}
49+
};
50+
51+
for (const root of roots) {
52+
visit(root, 0);
53+
}
54+
55+
// Fallback: ensure we include any remaining nodes (cycles, missing parents, etc.).
56+
for (const workspace of workspaces) {
57+
if (!visited.has(workspace.id)) {
58+
visit(workspace, 0);
59+
}
60+
}
61+
62+
return result;
63+
}
64+
65+
export function computeWorkspaceDepthMap(
66+
workspaces: FrontendWorkspaceMetadata[]
67+
): Record<string, number> {
68+
const byId = new Map<string, FrontendWorkspaceMetadata>();
69+
for (const workspace of workspaces) {
70+
byId.set(workspace.id, workspace);
71+
}
72+
73+
const depths = new Map<string, number>();
74+
const visiting = new Set<string>();
75+
76+
const computeDepth = (workspaceId: string): number => {
77+
const existing = depths.get(workspaceId);
78+
if (existing !== undefined) return existing;
79+
80+
if (visiting.has(workspaceId)) {
81+
// Cycle detected - treat as root.
82+
return 0;
83+
}
84+
85+
visiting.add(workspaceId);
86+
const workspace = byId.get(workspaceId);
87+
const parentId = workspace?.parentWorkspaceId;
88+
const depth =
89+
parentId && byId.has(parentId) ? Math.min(computeDepth(parentId) + 1, 32) : 0;
90+
visiting.delete(workspaceId);
91+
92+
depths.set(workspaceId, depth);
93+
return depth;
94+
};
95+
96+
for (const workspace of workspaces) {
97+
computeDepth(workspace.id);
98+
}
99+
100+
return Object.fromEntries(depths);
101+
}
102+
4103
/**
5104
* Age thresholds for workspace filtering, in ascending order.
6105
* Each tier hides workspaces older than the specified duration.
@@ -57,6 +156,11 @@ export function buildSortedWorkspacesByProject(
57156
});
58157
}
59158

159+
// Ensure child workspaces appear directly below their parents.
160+
for (const [projectPath, metadataList] of result) {
161+
result.set(projectPath, flattenWorkspaceTree(metadataList));
162+
}
163+
60164
return result;
61165
}
62166

0 commit comments

Comments
 (0)