Skip to content

Commit 02c578d

Browse files
committed
feat: add durable agent task subworkspaces
Change-Id: Ie2360ab64b5cc1ea6313bb345110f0d130473e5b Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 389a452 commit 02c578d

25 files changed

+1593
-29
lines changed

src/browser/components/ProjectSidebar.tsx

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,16 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
224224
);
225225

226226
// Wrapper to close sidebar on mobile after adding workspace
227+
228+
const workspaceMetadataById = (() => {
229+
const map = new Map<string, FrontendWorkspaceMetadata>();
230+
for (const list of sortedWorkspacesByProject.values()) {
231+
for (const metadata of list) {
232+
map.set(metadata.id, metadata);
233+
}
234+
}
235+
return map;
236+
})();
227237
const handleAddWorkspace = useCallback(
228238
(projectPath: string) => {
229239
onAddWorkspace(projectPath);
@@ -613,20 +623,39 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
613623
workspaceRecency
614624
);
615625

616-
const renderWorkspace = (metadata: FrontendWorkspaceMetadata) => (
617-
<WorkspaceListItem
618-
key={metadata.id}
619-
metadata={metadata}
620-
projectPath={projectPath}
621-
projectName={projectName}
622-
isSelected={selectedWorkspace?.workspaceId === metadata.id}
623-
isDeleting={deletingWorkspaceIds.has(metadata.id)}
624-
lastReadTimestamp={lastReadTimestamps[metadata.id] ?? 0}
625-
onSelectWorkspace={handleSelectWorkspace}
626-
onRemoveWorkspace={handleRemoveWorkspace}
627-
onToggleUnread={_onToggleUnread}
628-
/>
629-
);
626+
const renderWorkspace = (metadata: FrontendWorkspaceMetadata) => {
627+
let indentLevel = 0;
628+
let current: FrontendWorkspaceMetadata | undefined = metadata;
629+
const seen = new Set<string>([metadata.id]);
630+
631+
while (current?.parentWorkspaceId) {
632+
const parentMetadata = workspaceMetadataById.get(
633+
current.parentWorkspaceId
634+
);
635+
if (!parentMetadata || seen.has(parentMetadata.id)) {
636+
break;
637+
}
638+
indentLevel += 1;
639+
seen.add(parentMetadata.id);
640+
current = parentMetadata;
641+
}
642+
643+
return (
644+
<WorkspaceListItem
645+
key={metadata.id}
646+
metadata={metadata}
647+
projectPath={projectPath}
648+
projectName={projectName}
649+
isSelected={selectedWorkspace?.workspaceId === metadata.id}
650+
indentLevel={indentLevel}
651+
isDeleting={deletingWorkspaceIds.has(metadata.id)}
652+
lastReadTimestamp={lastReadTimestamps[metadata.id] ?? 0}
653+
onSelectWorkspace={handleSelectWorkspace}
654+
onRemoveWorkspace={handleRemoveWorkspace}
655+
onToggleUnread={_onToggleUnread}
656+
/>
657+
);
658+
};
630659

631660
// Find the next tier with workspaces (skip empty tiers)
632661
const findNextNonEmptyTier = (startIndex: number): number => {

src/browser/components/Settings/SettingsModal.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import React from "react";
2-
import { Settings, Key, Cpu, X, Briefcase, FlaskConical } from "lucide-react";
2+
import { Settings, Key, Cpu, X, Briefcase, FlaskConical, ListTodo } 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";
66
import { ProvidersSection } from "./sections/ProvidersSection";
7+
import { TasksSection } from "./sections/TasksSection";
78
import { ModelsSection } from "./sections/ModelsSection";
89
import { Button } from "@/browser/components/ui/button";
910
import { ProjectSettingsSection } from "./sections/ProjectSettingsSection";
@@ -35,6 +36,12 @@ const SECTIONS: SettingsSection[] = [
3536
icon: <Cpu className="h-4 w-4" />,
3637
component: ModelsSection,
3738
},
39+
{
40+
id: "tasks",
41+
label: "Tasks",
42+
icon: <ListTodo className="h-4 w-4" />,
43+
component: TasksSection,
44+
},
3845
{
3946
id: "experiments",
4047
label: "Experiments",
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import React from "react";
2+
import { useAPI } from "@/browser/contexts/API";
3+
import { Button } from "@/browser/components/ui/button";
4+
import { Input } from "@/browser/components/ui/input";
5+
6+
export function TasksSection() {
7+
const { api } = useAPI();
8+
9+
const [maxParallelAgentTasks, setMaxParallelAgentTasks] = React.useState<number>(3);
10+
const [maxTaskNestingDepth, setMaxTaskNestingDepth] = React.useState<number>(3);
11+
const [isSaving, setIsSaving] = React.useState(false);
12+
const [isLoading, setIsLoading] = React.useState(true);
13+
14+
React.useEffect(() => {
15+
let cancelled = false;
16+
17+
if (!api) {
18+
setIsLoading(false);
19+
return () => {
20+
cancelled = true;
21+
};
22+
}
23+
24+
void (async () => {
25+
try {
26+
const settings = await api.tasks.getTaskSettings();
27+
if (cancelled) {
28+
return;
29+
}
30+
31+
setMaxParallelAgentTasks(settings.maxParallelAgentTasks);
32+
setMaxTaskNestingDepth(settings.maxTaskNestingDepth);
33+
} finally {
34+
if (!cancelled) {
35+
setIsLoading(false);
36+
}
37+
}
38+
})();
39+
40+
return () => {
41+
cancelled = true;
42+
};
43+
}, [api]);
44+
45+
const onSave = React.useCallback(async () => {
46+
if (!api) {
47+
return;
48+
}
49+
50+
setIsSaving(true);
51+
try {
52+
await api.tasks.setTaskSettings({
53+
maxParallelAgentTasks,
54+
maxTaskNestingDepth,
55+
});
56+
} finally {
57+
setIsSaving(false);
58+
}
59+
}, [api, maxParallelAgentTasks, maxTaskNestingDepth]);
60+
61+
return (
62+
<div className="space-y-4">
63+
<div className="space-y-2">
64+
<h3 className="text-sm font-medium">Agent task limits</h3>
65+
<p className="text-muted-foreground text-sm">
66+
Control how many subagent workspaces can run at once and how deep nesting is allowed.
67+
</p>
68+
</div>
69+
70+
<div className="space-y-3">
71+
<div className="flex items-center gap-3">
72+
<div className="flex-1">
73+
<label className="text-sm">Max parallel subagents</label>
74+
<Input
75+
type="number"
76+
min={1}
77+
max={10}
78+
value={maxParallelAgentTasks}
79+
disabled={isLoading}
80+
onChange={(e) => setMaxParallelAgentTasks(Number(e.target.value))}
81+
/>
82+
</div>
83+
</div>
84+
85+
<div className="flex items-center gap-3">
86+
<div className="flex-1">
87+
<label className="text-sm">Max nesting depth</label>
88+
<Input
89+
type="number"
90+
min={1}
91+
max={5}
92+
value={maxTaskNestingDepth}
93+
disabled={isLoading}
94+
onChange={(e) => setMaxTaskNestingDepth(Number(e.target.value))}
95+
/>
96+
</div>
97+
</div>
98+
99+
<Button onClick={() => void onSave()} disabled={isLoading || isSaving}>
100+
{isSaving ? "Saving..." : "Save"}
101+
</Button>
102+
</div>
103+
</div>
104+
);
105+
}

src/browser/components/WorkspaceListItem.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface WorkspaceListItemProps {
2121
metadata: FrontendWorkspaceMetadata;
2222
projectPath: string;
2323
projectName: string;
24+
indentLevel?: number;
2425
isSelected: boolean;
2526
isDeleting?: boolean;
2627
/** @deprecated No longer used since status dot was removed, kept for API compatibility */
@@ -36,6 +37,7 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
3637
metadata,
3738
projectPath,
3839
projectName,
40+
indentLevel = 0,
3941
isSelected,
4042
isDeleting,
4143
lastReadTimestamp: _lastReadTimestamp,
@@ -46,6 +48,7 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
4648
// Destructure metadata for convenience
4749
const { id: workspaceId, namedWorkspacePath, status } = metadata;
4850
const isCreating = status === "creating";
51+
const paddingLeft = 9 + indentLevel * 16;
4952
const isDisabled = isCreating || isDeleting;
5053
const gitStatus = useGitStatus(workspaceId);
5154

@@ -106,13 +109,14 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
106109
<React.Fragment>
107110
<div
108111
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",
112+
"py-1.5 pr-2 border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative flex gap-2",
110113
isDisabled
111114
? "cursor-default opacity-70"
112115
: "cursor-pointer hover:bg-hover [&:hover_button]:opacity-100",
113116
isSelected && !isDisabled && "bg-hover border-l-blue-400",
114117
isDeleting && "pointer-events-none"
115118
)}
119+
style={{ paddingLeft }}
116120
onClick={() => {
117121
if (isDisabled) return;
118122
onSelectWorkspace({

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

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,26 @@ describe("partitionWorkspacesByAge", () => {
163163
expect(buckets[2]).toHaveLength(1);
164164
expect(buckets[2][0].id).toBe("bucket2");
165165
});
166+
167+
it("uses the parent workspace's recency for child workspaces", () => {
168+
const parent = createWorkspace("parent");
169+
const child: FrontendWorkspaceMetadata = {
170+
...createWorkspace("child"),
171+
parentWorkspaceId: "parent",
172+
};
173+
174+
const workspaces = [parent, child];
175+
176+
const workspaceRecency = {
177+
parent: now - 1 * ONE_DAY_MS, // recent
178+
child: now - 60 * ONE_DAY_MS, // old (but should inherit parent's)
179+
};
180+
181+
const { recent, buckets } = partitionWorkspacesByAge(workspaces, workspaceRecency);
182+
183+
expect(recent.map((w) => w.id)).toEqual(["parent", "child"]);
184+
expect(getAllOld(buckets)).toHaveLength(0);
185+
});
166186
});
167187

168188
describe("formatDaysThreshold", () => {
@@ -272,10 +292,45 @@ describe("buildSortedWorkspacesByProject", () => {
272292
};
273293

274294
const result = buildSortedWorkspacesByProject(projects, metadata, recency);
275-
276295
expect(result.get("/project/a")?.map((w) => w.id)).toEqual(["ws2", "ws3", "ws1"]);
277296
});
278297

298+
it("nests child workspaces directly under their parent", () => {
299+
const now = Date.now();
300+
const projects = new Map<string, ProjectConfig>([
301+
[
302+
"/project/a",
303+
{
304+
workspaces: [
305+
{ path: "/a/parent", id: "parent" },
306+
{ path: "/a/child", id: "child" },
307+
],
308+
},
309+
],
310+
]);
311+
312+
const parent = createWorkspace("parent", "/project/a");
313+
const child: FrontendWorkspaceMetadata = {
314+
...createWorkspace("child", "/project/a"),
315+
parentWorkspaceId: "parent",
316+
};
317+
318+
const metadata = new Map<string, FrontendWorkspaceMetadata>([
319+
["parent", parent],
320+
["child", child],
321+
]);
322+
323+
// Child is more recent, but should still render under its parent.
324+
const recency = {
325+
parent: now - 2 * 60 * 60 * 1000,
326+
child: now - 1 * 60 * 60 * 1000,
327+
};
328+
329+
const result = buildSortedWorkspacesByProject(projects, metadata, recency);
330+
331+
expect(result.get("/project/a")?.map((w) => w.id)).toEqual(["parent", "child"]);
332+
});
333+
279334
it("should not duplicate workspaces that exist in both config and have creating status", () => {
280335
// Edge case: workspace was saved to config but still has status: "creating"
281336
// (this shouldn't happen in practice but tests defensive coding)

0 commit comments

Comments
 (0)