Skip to content

Commit 9ac6327

Browse files
committed
🤖 feat: implement sub-workspaces/subagents system
Add comprehensive infrastructure for spawning child agent tasks from parent workspaces. This enables parallel agent workflows with dedicated presets (Research, Explore), tool policies, and automatic lifecycle management. Core additions: - TaskService: orchestrates task creation, execution, and cleanup - Task/agent_report tools: spawn subagents and receive reports - Agent presets: Research (web search) and Explore (codebase navigation) - Nested sidebar: visual hierarchy for parent/child workspaces Config extensions: - taskSettings: global limits (maxParallelAgentTasks, maxTaskNestingDepth) - taskState: per-workspace state tracking with parent chain - getWorkspaceNestingDepth(), countRunningAgentTasks() helpers UI changes: - WorkspaceListItem: indented display with purple accent for agent tasks - sortWithNesting(): depth-first traversal preserving parent→child ordering Files added: - src/common/constants/agentPresets.ts - src/common/orpc/schemas/task.ts - src/common/types/task.ts - src/node/services/taskService.ts - src/node/services/tools/task.ts - src/node/services/tools/agent_report.ts Change-Id: I9331e8235fb79c7efe104c8a60564682a1641e56 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 3445f7b commit 9ac6327

File tree

21 files changed

+1438
-39
lines changed

21 files changed

+1438
-39
lines changed

src/browser/components/LeftSidebar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from "react";
22
import { cn } from "@/common/lib/utils";
3-
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
3+
import type { WorkspaceWithNesting } from "@/browser/utils/ui/workspaceFiltering";
44
import ProjectSidebar from "./ProjectSidebar";
55
import { TitleBar } from "./TitleBar";
66

@@ -9,7 +9,7 @@ interface LeftSidebarProps {
99
onToggleUnread: (workspaceId: string) => void;
1010
collapsed: boolean;
1111
onToggleCollapsed: () => void;
12-
sortedWorkspacesByProject: Map<string, FrontendWorkspaceMetadata[]>;
12+
sortedWorkspacesByProject: Map<string, WorkspaceWithNesting[]>;
1313
workspaceRecency: Record<string, number>;
1414
}
1515

src/browser/components/ProjectSidebar.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useState, useEffect, useCallback } from "react";
22
import { cn } from "@/common/lib/utils";
3-
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
3+
import type { WorkspaceWithNesting } from "@/browser/utils/ui/workspaceFiltering";
44
import { usePersistedState } from "@/browser/hooks/usePersistedState";
55
import { EXPANDED_PROJECTS_KEY } from "@/common/constants/storage";
66
import { DndProvider } from "react-dnd";
@@ -179,7 +179,7 @@ interface ProjectSidebarProps {
179179
onToggleUnread: (workspaceId: string) => void;
180180
collapsed: boolean;
181181
onToggleCollapsed: () => void;
182-
sortedWorkspacesByProject: Map<string, FrontendWorkspaceMetadata[]>;
182+
sortedWorkspacesByProject: Map<string, WorkspaceWithNesting[]>;
183183
workspaceRecency: Record<string, number>;
184184
}
185185

@@ -613,7 +613,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
613613
workspaceRecency
614614
);
615615

616-
const renderWorkspace = (metadata: FrontendWorkspaceMetadata) => (
616+
const renderWorkspace = (metadata: WorkspaceWithNesting) => (
617617
<WorkspaceListItem
618618
key={metadata.id}
619619
metadata={metadata}
@@ -622,6 +622,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
622622
isSelected={selectedWorkspace?.workspaceId === metadata.id}
623623
isDeleting={deletingWorkspaceIds.has(metadata.id)}
624624
lastReadTimestamp={lastReadTimestamps[metadata.id] ?? 0}
625+
nestingDepth={metadata.nestingDepth}
625626
onSelectWorkspace={handleSelectWorkspace}
626627
onRemoveWorkspace={handleRemoveWorkspace}
627628
onToggleUnread={_onToggleUnread}

src/browser/components/WorkspaceListItem.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export interface WorkspaceListItemProps {
2525
isDeleting?: boolean;
2626
/** @deprecated No longer used since status dot was removed, kept for API compatibility */
2727
lastReadTimestamp?: number;
28+
/** Nesting depth for agent task workspaces (0 = top-level, 1+ = nested) */
29+
nestingDepth?: number;
2830
// Event handlers
2931
onSelectWorkspace: (selection: WorkspaceSelection) => void;
3032
onRemoveWorkspace: (workspaceId: string, button: HTMLElement) => Promise<void>;
@@ -39,6 +41,7 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
3941
isSelected,
4042
isDeleting,
4143
lastReadTimestamp: _lastReadTimestamp,
44+
nestingDepth = 0,
4245
onSelectWorkspace,
4346
onRemoveWorkspace,
4447
onToggleUnread: _onToggleUnread,
@@ -102,17 +105,24 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
102105
const { canInterrupt, awaitingUserQuestion } = useWorkspaceSidebarState(workspaceId);
103106
const isWorking = canInterrupt && !awaitingUserQuestion;
104107

108+
// Calculate left padding based on nesting depth (base 9px + 16px per level)
109+
const leftPadding = 9 + nestingDepth * 16;
110+
const isAgentTask = Boolean(metadata.parentWorkspaceId);
111+
105112
return (
106113
<React.Fragment>
107114
<div
108115
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",
116+
"py-1.5 pr-2 border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative flex gap-2",
110117
isDisabled
111118
? "cursor-default opacity-70"
112119
: "cursor-pointer hover:bg-hover [&:hover_button]:opacity-100",
113120
isSelected && !isDisabled && "bg-hover border-l-blue-400",
114-
isDeleting && "pointer-events-none"
121+
isDeleting && "pointer-events-none",
122+
// Nested agent tasks get a subtle visual indicator
123+
isAgentTask && "border-l-purple-400/30"
115124
)}
125+
style={{ paddingLeft: `${leftPadding}px` }}
116126
onClick={() => {
117127
if (isDisabled) return;
118128
onSelectWorkspace({

src/browser/hooks/useSortedWorkspacesByProject.ts

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,82 @@ import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
44
import { useWorkspaceRecency } from "@/browser/stores/WorkspaceStore";
55
import { useStableReference, compareMaps } from "@/browser/hooks/useStableReference";
66

7+
/** Workspace metadata extended with computed nesting depth */
8+
export interface WorkspaceWithNesting extends FrontendWorkspaceMetadata {
9+
/** Nesting depth (0 = top-level, 1 = direct child, etc.) */
10+
nestingDepth: number;
11+
}
12+
13+
/**
14+
* Sort workspaces so children appear immediately after their parent.
15+
* Maintains recency order within each level.
16+
*/
17+
function sortWithNesting(
18+
metadataList: FrontendWorkspaceMetadata[],
19+
workspaceRecency: Record<string, number>
20+
): WorkspaceWithNesting[] {
21+
// Build parent→children map
22+
const childrenByParent = new Map<string, FrontendWorkspaceMetadata[]>();
23+
const topLevel: FrontendWorkspaceMetadata[] = [];
24+
25+
for (const ws of metadataList) {
26+
const parentId = ws.parentWorkspaceId;
27+
if (parentId) {
28+
const siblings = childrenByParent.get(parentId) ?? [];
29+
siblings.push(ws);
30+
childrenByParent.set(parentId, siblings);
31+
} else {
32+
topLevel.push(ws);
33+
}
34+
}
35+
36+
// Sort by recency (most recent first)
37+
const sortByRecency = (a: FrontendWorkspaceMetadata, b: FrontendWorkspaceMetadata) => {
38+
const aTs = workspaceRecency[a.id] ?? 0;
39+
const bTs = workspaceRecency[b.id] ?? 0;
40+
return bTs - aTs;
41+
};
42+
43+
topLevel.sort(sortByRecency);
44+
for (const children of childrenByParent.values()) {
45+
children.sort(sortByRecency);
46+
}
47+
48+
// Flatten: parent, then children recursively
49+
const result: WorkspaceWithNesting[] = [];
50+
51+
const visit = (ws: FrontendWorkspaceMetadata, depth: number) => {
52+
result.push({ ...ws, nestingDepth: depth });
53+
const children = childrenByParent.get(ws.id) ?? [];
54+
for (const child of children) {
55+
visit(child, depth + 1);
56+
}
57+
};
58+
59+
for (const ws of topLevel) {
60+
visit(ws, 0);
61+
}
62+
63+
return result;
64+
}
65+
766
export function useSortedWorkspacesByProject() {
867
const { projects } = useProjectContext();
968
const { workspaceMetadata } = useWorkspaceContext();
1069
const workspaceRecency = useWorkspaceRecency();
1170

1271
return useStableReference(
1372
() => {
14-
const result = new Map<string, FrontendWorkspaceMetadata[]>();
73+
const result = new Map<string, WorkspaceWithNesting[]>();
1574
for (const [projectPath, config] of projects) {
1675
const metadataList = config.workspaces
1776
.map((ws) => (ws.id ? workspaceMetadata.get(ws.id) : undefined))
1877
.filter((meta): meta is FrontendWorkspaceMetadata => Boolean(meta));
1978

20-
metadataList.sort((a, b) => {
21-
const aTimestamp = workspaceRecency[a.id] ?? 0;
22-
const bTimestamp = workspaceRecency[b.id] ?? 0;
23-
return bTimestamp - aTimestamp;
24-
});
79+
// Sort with nesting: parents first, children indented below
80+
const sorted = sortWithNesting(metadataList, workspaceRecency);
2581

26-
result.set(projectPath, metadataList);
82+
result.set(projectPath, sorted);
2783
}
2884
return result;
2985
},
@@ -37,7 +93,11 @@ export function useSortedWorkspacesByProject() {
3793
if (!other) {
3894
return false;
3995
}
40-
return metadata.id === other.id && metadata.name === other.name;
96+
return (
97+
metadata.id === other.id &&
98+
metadata.name === other.name &&
99+
metadata.nestingDepth === other.nestingDepth
100+
);
41101
});
42102
}),
43103
[projects, workspaceMetadata, workspaceRecency]

src/browser/utils/ui/workspaceFiltering.ts

Lines changed: 79 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,83 @@ export type AgeThresholdDays = (typeof AGE_THRESHOLDS_DAYS)[number];
1010

1111
const DAY_MS = 24 * 60 * 60 * 1000;
1212

13+
/** Workspace metadata extended with computed nesting depth */
14+
export interface WorkspaceWithNesting extends FrontendWorkspaceMetadata {
15+
/** Nesting depth (0 = top-level, 1 = direct child, etc.) */
16+
nestingDepth: number;
17+
}
18+
19+
/**
20+
* Sort workspaces so children appear immediately after their parent.
21+
* Maintains recency order within each level.
22+
*/
23+
function sortWithNesting(
24+
metadataList: FrontendWorkspaceMetadata[],
25+
workspaceRecency: Record<string, number>
26+
): WorkspaceWithNesting[] {
27+
// Build parent→children map
28+
const childrenByParent = new Map<string, FrontendWorkspaceMetadata[]>();
29+
const topLevel: FrontendWorkspaceMetadata[] = [];
30+
31+
for (const ws of metadataList) {
32+
const parentId = ws.parentWorkspaceId;
33+
if (parentId) {
34+
const siblings = childrenByParent.get(parentId) ?? [];
35+
siblings.push(ws);
36+
childrenByParent.set(parentId, siblings);
37+
} else {
38+
topLevel.push(ws);
39+
}
40+
}
41+
42+
// Sort by recency (most recent first)
43+
const sortByRecency = (a: FrontendWorkspaceMetadata, b: FrontendWorkspaceMetadata) => {
44+
const aTs = workspaceRecency[a.id] ?? 0;
45+
const bTs = workspaceRecency[b.id] ?? 0;
46+
return bTs - aTs;
47+
};
48+
49+
topLevel.sort(sortByRecency);
50+
for (const children of childrenByParent.values()) {
51+
children.sort(sortByRecency);
52+
}
53+
54+
// Flatten: parent, then children recursively
55+
const result: WorkspaceWithNesting[] = [];
56+
57+
const visit = (ws: FrontendWorkspaceMetadata, depth: number) => {
58+
result.push({ ...ws, nestingDepth: depth });
59+
const children = childrenByParent.get(ws.id) ?? [];
60+
for (const child of children) {
61+
visit(child, depth + 1);
62+
}
63+
};
64+
65+
for (const ws of topLevel) {
66+
visit(ws, 0);
67+
}
68+
69+
return result;
70+
}
71+
1372
/**
1473
* Build a map of project paths to sorted workspace metadata lists.
1574
* Includes both persisted workspaces (from config) and pending workspaces
1675
* (status: "creating") that haven't been saved yet.
1776
*
18-
* Workspaces are sorted by recency (most recent first).
77+
* Workspaces are sorted by recency (most recent first), with child workspaces
78+
* (agent tasks) appearing directly below their parent with indentation.
1979
*/
2080
export function buildSortedWorkspacesByProject(
2181
projects: Map<string, ProjectConfig>,
2282
workspaceMetadata: Map<string, FrontendWorkspaceMetadata>,
2383
workspaceRecency: Record<string, number>
24-
): Map<string, FrontendWorkspaceMetadata[]> {
25-
const result = new Map<string, FrontendWorkspaceMetadata[]>();
84+
): Map<string, WorkspaceWithNesting[]> {
85+
const result = new Map<string, WorkspaceWithNesting[]>();
2686
const includedIds = new Set<string>();
2787

28-
// First pass: include workspaces from persisted config
88+
// First pass: collect workspaces from persisted config
89+
const collectedByProject = new Map<string, FrontendWorkspaceMetadata[]>();
2990
for (const [projectPath, config] of projects) {
3091
const metadataList: FrontendWorkspaceMetadata[] = [];
3192
for (const ws of config.workspaces) {
@@ -36,25 +97,21 @@ export function buildSortedWorkspacesByProject(
3697
includedIds.add(ws.id);
3798
}
3899
}
39-
result.set(projectPath, metadataList);
100+
collectedByProject.set(projectPath, metadataList);
40101
}
41102

42103
// Second pass: add pending workspaces (status: "creating") not yet in config
43104
for (const [id, metadata] of workspaceMetadata) {
44105
if (metadata.status === "creating" && !includedIds.has(id)) {
45-
const projectWorkspaces = result.get(metadata.projectPath) ?? [];
106+
const projectWorkspaces = collectedByProject.get(metadata.projectPath) ?? [];
46107
projectWorkspaces.push(metadata);
47-
result.set(metadata.projectPath, projectWorkspaces);
108+
collectedByProject.set(metadata.projectPath, projectWorkspaces);
48109
}
49110
}
50111

51-
// Sort each project's workspaces by recency (sort mutates in place)
52-
for (const metadataList of result.values()) {
53-
metadataList.sort((a, b) => {
54-
const aTimestamp = workspaceRecency[a.id] ?? 0;
55-
const bTimestamp = workspaceRecency[b.id] ?? 0;
56-
return bTimestamp - aTimestamp;
57-
});
112+
// Sort with nesting for each project
113+
for (const [projectPath, metadataList] of collectedByProject) {
114+
result.set(projectPath, sortWithNesting(metadataList, workspaceRecency));
58115
}
59116

60117
return result;
@@ -76,28 +133,28 @@ export function formatDaysThreshold(days: number): string {
76133
* - buckets[1]: older than 7 days but newer than 30 days
77134
* - buckets[2]: older than 30 days
78135
*/
79-
export interface AgePartitionResult {
80-
recent: FrontendWorkspaceMetadata[];
81-
buckets: FrontendWorkspaceMetadata[][];
136+
export interface AgePartitionResult<T extends FrontendWorkspaceMetadata = WorkspaceWithNesting> {
137+
recent: T[];
138+
buckets: T[][];
82139
}
83140

84141
/**
85142
* Partition workspaces into age-based buckets.
86143
* Always shows at least one workspace in the recent section (the most recent one).
87144
*/
88-
export function partitionWorkspacesByAge(
89-
workspaces: FrontendWorkspaceMetadata[],
145+
export function partitionWorkspacesByAge<T extends FrontendWorkspaceMetadata>(
146+
workspaces: T[],
90147
workspaceRecency: Record<string, number>
91-
): AgePartitionResult {
148+
): AgePartitionResult<T> {
92149
if (workspaces.length === 0) {
93150
return { recent: [], buckets: AGE_THRESHOLDS_DAYS.map(() => []) };
94151
}
95152

96153
const now = Date.now();
97154
const thresholdMs = AGE_THRESHOLDS_DAYS.map((d) => d * DAY_MS);
98155

99-
const recent: FrontendWorkspaceMetadata[] = [];
100-
const buckets: FrontendWorkspaceMetadata[][] = AGE_THRESHOLDS_DAYS.map(() => []);
156+
const recent: T[] = [];
157+
const buckets: T[][] = AGE_THRESHOLDS_DAYS.map(() => []);
101158

102159
for (const workspace of workspaces) {
103160
const recencyTimestamp = workspaceRecency[workspace.id] ?? 0;

src/browser/utils/workspace.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
2+
import type { WorkspaceWithNesting } from "@/browser/utils/ui/workspaceFiltering";
23

34
/**
45
* Generate a comparison key for workspace sidebar display.
@@ -7,11 +8,16 @@ import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
78
* IMPORTANT: If you add a field to WorkspaceMetadata that affects how
89
* workspaces appear in the sidebar, add it here to ensure UI updates.
910
*/
10-
export function getWorkspaceSidebarKey(meta: FrontendWorkspaceMetadata): string {
11+
export function getWorkspaceSidebarKey(
12+
meta: FrontendWorkspaceMetadata | WorkspaceWithNesting
13+
): string {
14+
const nestingDepth = "nestingDepth" in meta ? meta.nestingDepth : 0;
1115
return [
1216
meta.id,
1317
meta.name,
1418
meta.title ?? "", // Display title (falls back to name in UI)
1519
meta.status ?? "", // Working/idle status indicator
20+
meta.parentWorkspaceId ?? "", // Parent ID for agent task workspaces
21+
String(nestingDepth), // Nesting depth for indentation
1622
].join("|");
1723
}

0 commit comments

Comments
 (0)