Răsfoiți Sursa

feat(history): render nested subtasks as recursive tree (#11299)

* feat(history): render nested subtasks as recursive tree

* fix(lockfile): resolve missing ai-sdk provider entry

* fix: address review feedback — dedupe countAll, increase SubtaskRow max-h

- HistoryView: replace local countAll with imported countAllSubtasks from types.ts
- SubtaskRow: increase nested children max-h from 500px to 2000px to match TaskGroupItem
Hannes Rudolph 6 zile în urmă
părinte
comite
7db4bfef5a

+ 1 - 0
webview-ui/src/components/history/HistoryPreview.tsx

@@ -38,6 +38,7 @@ const HistoryPreview = () => {
 							group={group}
 							variant="compact"
 							onToggleExpand={() => toggleExpand(group.parent.id)}
+							onToggleSubtaskExpand={toggleExpand}
 						/>
 					))}
 				</>

+ 4 - 2
webview-ui/src/components/history/HistoryView.tsx

@@ -21,6 +21,7 @@ import { useAppTranslation } from "@/i18n/TranslationContext"
 import { Tab, TabContent, TabHeader } from "../common/Tab"
 import { useTaskSearch } from "./useTaskSearch"
 import { useGroupedTasks } from "./useGroupedTasks"
+import { countAllSubtasks } from "./types"
 import TaskItem from "./TaskItem"
 import TaskGroupItem from "./TaskGroupItem"
 
@@ -52,11 +53,11 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 	const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([])
 	const [showBatchDeleteDialog, setShowBatchDeleteDialog] = useState<boolean>(false)
 
-	// Get subtask count for a task
+	// Get subtask count for a task (recursive total)
 	const getSubtaskCount = useMemo(() => {
 		const countMap = new Map<string, number>()
 		for (const group of groups) {
-			countMap.set(group.parent.id, group.subtasks.length)
+			countMap.set(group.parent.id, countAllSubtasks(group.subtasks))
 		}
 		return (taskId: string) => countMap.get(taskId) || 0
 	}, [groups])
@@ -300,6 +301,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 								onToggleSelection={toggleTaskSelection}
 								onDelete={handleDelete}
 								onToggleExpand={() => toggleExpand(group.parent.id)}
+								onToggleSubtaskExpand={toggleExpand}
 								className="m-2"
 							/>
 						)}

+ 66 - 25
webview-ui/src/components/history/SubtaskRow.tsx

@@ -2,46 +2,87 @@ import { memo } from "react"
 import { ArrowRight } from "lucide-react"
 import { vscode } from "@/utils/vscode"
 import { cn } from "@/lib/utils"
-import type { DisplayHistoryItem } from "./types"
+import type { SubtaskTreeNode } from "./types"
+import { countAllSubtasks } from "./types"
 import { StandardTooltip } from "../ui"
+import SubtaskCollapsibleRow from "./SubtaskCollapsibleRow"
 
 interface SubtaskRowProps {
-	/** The subtask to display */
-	item: DisplayHistoryItem
+	/** The subtask tree node to display */
+	node: SubtaskTreeNode
+	/** Nesting depth (1 = direct child of parent group) */
+	depth: number
+	/** Callback when expand/collapse is toggled for a node */
+	onToggleExpand: (taskId: string) => void
 	/** Optional className for styling */
 	className?: string
 }
 
 /**
- * Displays an individual subtask row when the parent's subtask list is expanded.
- * Shows the task name and token/cost info in an indented format.
+ * Displays a subtask row with recursive nesting support.
+ * Leaf nodes render just the task row. Nodes with children show
+ * a collapsible section that can be expanded to reveal nested subtasks.
  */
-const SubtaskRow = ({ item, className }: SubtaskRowProps) => {
+const SubtaskRow = ({ node, depth, onToggleExpand, className }: SubtaskRowProps) => {
+	const { item, children, isExpanded } = node
+	const hasChildren = children.length > 0
+
 	const handleClick = () => {
 		vscode.postMessage({ type: "showTaskWithId", text: item.id })
 	}
 
 	return (
-		<div
-			data-testid={`subtask-row-${item.id}`}
-			className={cn(
-				"group flex items-center justify-between gap-2 pl-1 pr-4 py-1 ml-6 cursor-pointer",
-				"text-vscode-foreground/60 hover:text-vscode-foreground transition-colors",
-				className,
+		<div data-testid={`subtask-row-${item.id}`} className={className}>
+			{/* Task row with depth indentation */}
+			<div
+				className={cn(
+					"group flex items-center justify-between gap-2 pr-4 py-1 cursor-pointer",
+					"text-vscode-foreground/60 hover:text-vscode-foreground transition-colors",
+				)}
+				style={{ paddingLeft: `${depth * 16}px` }}
+				onClick={handleClick}
+				role="button"
+				tabIndex={0}
+				onKeyDown={(e) => {
+					if (e.key === "Enter" || e.key === " ") {
+						e.preventDefault()
+						handleClick()
+					}
+				}}>
+				<StandardTooltip content={item.task} delay={600}>
+					<span className="text-sm line-clamp-1">{item.task}</span>
+				</StandardTooltip>
+				<ArrowRight className="size-3 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
+			</div>
+
+			{/* Nested subtask collapsible section */}
+			{hasChildren && (
+				<div style={{ paddingLeft: `${depth * 16}px` }}>
+					<SubtaskCollapsibleRow
+						count={countAllSubtasks(children)}
+						isExpanded={isExpanded}
+						onToggle={() => onToggleExpand(item.id)}
+					/>
+				</div>
+			)}
+
+			{/* Expanded nested subtasks */}
+			{hasChildren && (
+				<div
+					className={cn(
+						"overflow-clip transition-all duration-300",
+						isExpanded ? "max-h-[2000px]" : "max-h-0",
+					)}>
+					{children.map((child) => (
+						<SubtaskRow
+							key={child.item.id}
+							node={child}
+							depth={depth + 1}
+							onToggleExpand={onToggleExpand}
+						/>
+					))}
+				</div>
 			)}
-			onClick={handleClick}
-			role="button"
-			tabIndex={0}
-			onKeyDown={(e) => {
-				if (e.key === "Enter" || e.key === " ") {
-					e.preventDefault()
-					handleClick()
-				}
-			}}>
-			<StandardTooltip content={item.task} delay={600}>
-				<span className="text-sm line-clamp-1">{item.task}</span>
-			</StandardTooltip>
-			<ArrowRight className="size-3 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
 		</div>
 	)
 }

+ 14 - 9
webview-ui/src/components/history/TaskGroupItem.tsx

@@ -1,6 +1,7 @@
 import { memo } from "react"
 import { cn } from "@/lib/utils"
 import type { TaskGroup } from "./types"
+import { countAllSubtasks } from "./types"
 import TaskItem from "./TaskItem"
 import SubtaskCollapsibleRow from "./SubtaskCollapsibleRow"
 import SubtaskRow from "./SubtaskRow"
@@ -20,15 +21,17 @@ interface TaskGroupItemProps {
 	onToggleSelection?: (taskId: string, isSelected: boolean) => void
 	/** Callback when delete is requested */
 	onDelete?: (taskId: string) => void
-	/** Callback when expand/collapse is toggled */
+	/** Callback when the parent group expand/collapse is toggled */
 	onToggleExpand: () => void
+	/** Callback when a nested subtask node expand/collapse is toggled */
+	onToggleSubtaskExpand: (taskId: string) => void
 	/** Optional className for styling */
 	className?: string
 }
 
 /**
- * Renders a task group consisting of a parent task and its collapsible subtask list.
- * When expanded, shows individual subtask rows.
+ * Renders a task group consisting of a parent task and its collapsible subtask tree.
+ * When expanded, shows recursively nested subtask rows.
  */
 const TaskGroupItem = ({
 	group,
@@ -39,10 +42,12 @@ const TaskGroupItem = ({
 	onToggleSelection,
 	onDelete,
 	onToggleExpand,
+	onToggleSubtaskExpand,
 	className,
 }: TaskGroupItemProps) => {
 	const { parent, subtasks, isExpanded } = group
 	const hasSubtasks = subtasks.length > 0
+	const totalSubtaskCount = hasSubtasks ? countAllSubtasks(subtasks) : 0
 
 	return (
 		<div
@@ -63,21 +68,21 @@ const TaskGroupItem = ({
 				hasSubtasks={hasSubtasks}
 			/>
 
-			{/* Subtask collapsible row */}
+			{/* Subtask collapsible row — shows total recursive count */}
 			{hasSubtasks && (
-				<SubtaskCollapsibleRow count={subtasks.length} isExpanded={isExpanded} onToggle={onToggleExpand} />
+				<SubtaskCollapsibleRow count={totalSubtaskCount} isExpanded={isExpanded} onToggle={onToggleExpand} />
 			)}
 
-			{/* Expanded subtasks */}
+			{/* Expanded subtask tree */}
 			{hasSubtasks && (
 				<div
 					data-testid="subtask-list"
 					className={cn(
 						"overflow-clip transition-all duration-500",
-						isExpanded ? "max-h-100 pb-2" : "max-h-0",
+						isExpanded ? "max-h-[2000px] pb-2" : "max-h-0",
 					)}>
-					{subtasks.map((subtask) => (
-						<SubtaskRow key={subtask.id} item={subtask} />
+					{subtasks.map((node) => (
+						<SubtaskRow key={node.item.id} node={node} depth={1} onToggleExpand={onToggleSubtaskExpand} />
 					))}
 				</div>
 			)}

+ 213 - 0
webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx

@@ -0,0 +1,213 @@
+import { render, screen, fireEvent } from "@/utils/test-utils"
+
+import { vscode } from "@src/utils/vscode"
+
+import SubtaskRow from "../SubtaskRow"
+import type { SubtaskTreeNode, DisplayHistoryItem } from "../types"
+
+vi.mock("@src/utils/vscode")
+vi.mock("@src/i18n/TranslationContext", () => ({
+	useAppTranslation: () => ({
+		t: (key: string, options?: Record<string, unknown>) => {
+			if (key === "history:subtasks" && options?.count !== undefined) {
+				return `${options.count} Subtask${options.count === 1 ? "" : "s"}`
+			}
+			if (key === "history:collapseSubtasks") return "Collapse subtasks"
+			if (key === "history:expandSubtasks") return "Expand subtasks"
+			return key
+		},
+	}),
+}))
+
+const createMockDisplayItem = (overrides: Partial<DisplayHistoryItem> = {}): DisplayHistoryItem => ({
+	id: "task-1",
+	number: 1,
+	task: "Test task",
+	ts: Date.now(),
+	tokensIn: 100,
+	tokensOut: 50,
+	totalCost: 0.01,
+	workspace: "/workspace/project",
+	...overrides,
+})
+
+const createMockNode = (
+	itemOverrides: Partial<DisplayHistoryItem> = {},
+	children: SubtaskTreeNode[] = [],
+	isExpanded = false,
+): SubtaskTreeNode => ({
+	item: createMockDisplayItem(itemOverrides),
+	children,
+	isExpanded,
+})
+
+describe("SubtaskRow", () => {
+	beforeEach(() => {
+		vi.clearAllMocks()
+	})
+
+	describe("leaf node rendering", () => {
+		it("renders leaf node with correct text", () => {
+			const node = createMockNode({ id: "leaf-1", task: "Leaf task content" })
+
+			render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
+
+			expect(screen.getByText("Leaf task content")).toBeInTheDocument()
+		})
+
+		it("renders with correct depth indentation", () => {
+			const node = createMockNode({ id: "leaf-1", task: "Indented task" })
+
+			render(<SubtaskRow node={node} depth={2} onToggleExpand={vi.fn()} />)
+
+			const row = screen.getByTestId("subtask-row-leaf-1")
+			// The clickable row inside should have paddingLeft = depth * 16 = 32px
+			const clickableRow = row.querySelector("[role='button']")
+			expect(clickableRow).toHaveStyle({ paddingLeft: "32px" })
+		})
+
+		it("does not render collapsible row for leaf node", () => {
+			const node = createMockNode({ id: "leaf-1", task: "Leaf only" })
+
+			render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
+
+			expect(screen.queryByTestId("subtask-collapsible-row")).not.toBeInTheDocument()
+		})
+	})
+
+	describe("node with children", () => {
+		it("renders collapsible row with correct child count", () => {
+			const node = createMockNode(
+				{ id: "parent-1", task: "Parent task" },
+				[
+					createMockNode({ id: "child-1", task: "Child 1" }),
+					createMockNode({ id: "child-2", task: "Child 2" }),
+				],
+				false,
+			)
+
+			render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
+
+			expect(screen.getByText("2 Subtasks")).toBeInTheDocument()
+			expect(screen.getByTestId("subtask-collapsible-row")).toBeInTheDocument()
+		})
+
+		it("renders nested children count including grandchildren", () => {
+			const node = createMockNode(
+				{ id: "parent-1", task: "Parent task" },
+				[
+					createMockNode({ id: "child-1", task: "Child 1" }, [
+						createMockNode({ id: "grandchild-1", task: "Grandchild 1" }),
+					]),
+				],
+				false,
+			)
+
+			render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
+
+			// countAllSubtasks counts child-1 (1) + grandchild-1 (1) = 2
+			expect(screen.getByText("2 Subtasks")).toBeInTheDocument()
+		})
+	})
+
+	describe("click behavior", () => {
+		it("sends showTaskWithId message when task row is clicked", () => {
+			const node = createMockNode({ id: "task-42", task: "Clickable task" })
+
+			render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
+
+			const row = screen.getByRole("button")
+			fireEvent.click(row)
+
+			expect(vscode.postMessage).toHaveBeenCalledWith({
+				type: "showTaskWithId",
+				text: "task-42",
+			})
+		})
+
+		it("calls onToggleExpand with correct task ID when collapsible row is clicked", () => {
+			const onToggleExpand = vi.fn()
+			const node = createMockNode(
+				{ id: "expandable-1", task: "Expandable task" },
+				[createMockNode({ id: "child-1", task: "Child" })],
+				false,
+			)
+
+			render(<SubtaskRow node={node} depth={1} onToggleExpand={onToggleExpand} />)
+
+			const collapsibleRow = screen.getByTestId("subtask-collapsible-row")
+			fireEvent.click(collapsibleRow)
+
+			expect(onToggleExpand).toHaveBeenCalledWith("expandable-1")
+		})
+	})
+
+	describe("expand/collapse behavior", () => {
+		it("renders child SubtaskRow components when expanded", () => {
+			const node = createMockNode(
+				{ id: "parent-1", task: "Parent" },
+				[
+					createMockNode({ id: "child-1", task: "Child 1" }),
+					createMockNode({ id: "child-2", task: "Child 2" }),
+				],
+				true, // expanded
+			)
+
+			render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
+
+			expect(screen.getByTestId("subtask-row-child-1")).toBeInTheDocument()
+			expect(screen.getByTestId("subtask-row-child-2")).toBeInTheDocument()
+			expect(screen.getByText("Child 1")).toBeInTheDocument()
+			expect(screen.getByText("Child 2")).toBeInTheDocument()
+		})
+
+		it("uses max-h-0 for collapsed node with children", () => {
+			const node = createMockNode(
+				{ id: "parent-1", task: "Parent" },
+				[createMockNode({ id: "child-1", task: "Child 1" })],
+				false, // collapsed
+			)
+
+			const { container } = render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
+
+			// The children wrapper div should have max-h-0 when collapsed
+			const childrenWrapper = container.querySelector(".max-h-0")
+			expect(childrenWrapper).toBeInTheDocument()
+		})
+
+		it("does not use max-h-0 when node is expanded", () => {
+			const node = createMockNode(
+				{ id: "parent-1", task: "Parent" },
+				[createMockNode({ id: "child-1", task: "Child 1" })],
+				true, // expanded
+			)
+
+			const { container } = render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
+
+			// The children wrapper should NOT have max-h-0 when expanded
+			const collapsedWrapper = container.querySelector(".max-h-0")
+			expect(collapsedWrapper).not.toBeInTheDocument()
+		})
+
+		it("renders deeply nested recursive structure when all levels expanded", () => {
+			const node = createMockNode(
+				{ id: "root", task: "Root" },
+				[
+					createMockNode(
+						{ id: "child", task: "Child" },
+						[createMockNode({ id: "grandchild", task: "Grandchild" })],
+						true, // child expanded
+					),
+				],
+				true, // root expanded
+			)
+
+			render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
+
+			expect(screen.getByTestId("subtask-row-root")).toBeInTheDocument()
+			expect(screen.getByTestId("subtask-row-child")).toBeInTheDocument()
+			expect(screen.getByTestId("subtask-row-grandchild")).toBeInTheDocument()
+			expect(screen.getByText("Grandchild")).toBeInTheDocument()
+		})
+	})
+})

+ 128 - 22
webview-ui/src/components/history/__tests__/TaskGroupItem.spec.tsx

@@ -1,7 +1,7 @@
 import { render, screen, fireEvent } from "@/utils/test-utils"
 
 import TaskGroupItem from "../TaskGroupItem"
-import type { TaskGroup, DisplayHistoryItem } from "../types"
+import type { TaskGroup, DisplayHistoryItem, SubtaskTreeNode } from "../types"
 
 vi.mock("@src/utils/vscode")
 vi.mock("@src/i18n/TranslationContext", () => ({
@@ -34,6 +34,16 @@ const createMockDisplayHistoryItem = (overrides: Partial<DisplayHistoryItem> = {
 	...overrides,
 })
 
+const createMockSubtaskNode = (
+	itemOverrides: Partial<DisplayHistoryItem> = {},
+	children: SubtaskTreeNode[] = [],
+	isExpanded = false,
+): SubtaskTreeNode => ({
+	item: createMockDisplayHistoryItem(itemOverrides),
+	children,
+	isExpanded,
+})
+
 const createMockGroup = (overrides: Partial<TaskGroup> = {}): TaskGroup => ({
 	parent: createMockDisplayHistoryItem({ id: "parent-1", task: "Parent task" }),
 	subtasks: [],
@@ -55,7 +65,9 @@ describe("TaskGroupItem", () => {
 				}),
 			})
 
-			render(<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} />)
+			render(
+				<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
+			)
 
 			expect(screen.getByText("Test parent task content")).toBeInTheDocument()
 		})
@@ -65,7 +77,9 @@ describe("TaskGroupItem", () => {
 				parent: createMockDisplayHistoryItem({ id: "my-parent-id" }),
 			})
 
-			render(<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} />)
+			render(
+				<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
+			)
 
 			expect(screen.getByTestId("task-group-my-parent-id")).toBeInTheDocument()
 		})
@@ -75,23 +89,27 @@ describe("TaskGroupItem", () => {
 		it("shows correct subtask count", () => {
 			const group = createMockGroup({
 				subtasks: [
-					createMockDisplayHistoryItem({ id: "child-1", task: "Child 1" }),
-					createMockDisplayHistoryItem({ id: "child-2", task: "Child 2" }),
-					createMockDisplayHistoryItem({ id: "child-3", task: "Child 3" }),
+					createMockSubtaskNode({ id: "child-1", task: "Child 1" }),
+					createMockSubtaskNode({ id: "child-2", task: "Child 2" }),
+					createMockSubtaskNode({ id: "child-3", task: "Child 3" }),
 				],
 			})
 
-			render(<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} />)
+			render(
+				<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
+			)
 
 			expect(screen.getByText("3 Subtasks")).toBeInTheDocument()
 		})
 
 		it("shows singular subtask text for single subtask", () => {
 			const group = createMockGroup({
-				subtasks: [createMockDisplayHistoryItem({ id: "child-1", task: "Child 1" })],
+				subtasks: [createMockSubtaskNode({ id: "child-1", task: "Child 1" })],
 			})
 
-			render(<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} />)
+			render(
+				<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
+			)
 
 			expect(screen.getByText("1 Subtask")).toBeInTheDocument()
 		})
@@ -99,20 +117,48 @@ describe("TaskGroupItem", () => {
 		it("does not show subtask row when no subtasks", () => {
 			const group = createMockGroup({ subtasks: [] })
 
-			render(<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} />)
+			render(
+				<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
+			)
 
 			expect(screen.queryByTestId("subtask-collapsible-row")).not.toBeInTheDocument()
 		})
+
+		it("renders correct total subtask count with nested children", () => {
+			const group = createMockGroup({
+				subtasks: [
+					createMockSubtaskNode({ id: "child-1", task: "Child 1" }, [
+						createMockSubtaskNode({ id: "grandchild-1", task: "Grandchild 1" }),
+						createMockSubtaskNode({ id: "grandchild-2", task: "Grandchild 2" }),
+					]),
+					createMockSubtaskNode({ id: "child-2", task: "Child 2" }),
+				],
+			})
+
+			render(
+				<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
+			)
+
+			// 2 direct children + 2 grandchildren = 4 total
+			expect(screen.getByText("4 Subtasks")).toBeInTheDocument()
+		})
 	})
 
 	describe("expand/collapse behavior", () => {
 		it("calls onToggleExpand when chevron row is clicked", () => {
 			const onToggleExpand = vi.fn()
 			const group = createMockGroup({
-				subtasks: [createMockDisplayHistoryItem({ id: "child-1", task: "Child 1" })],
+				subtasks: [createMockSubtaskNode({ id: "child-1", task: "Child 1" })],
 			})
 
-			render(<TaskGroupItem group={group} variant="full" onToggleExpand={onToggleExpand} />)
+			render(
+				<TaskGroupItem
+					group={group}
+					variant="full"
+					onToggleExpand={onToggleExpand}
+					onToggleSubtaskExpand={vi.fn()}
+				/>,
+			)
 
 			const collapsibleRow = screen.getByTestId("subtask-collapsible-row")
 			fireEvent.click(collapsibleRow)
@@ -124,12 +170,14 @@ describe("TaskGroupItem", () => {
 			const group = createMockGroup({
 				isExpanded: true,
 				subtasks: [
-					createMockDisplayHistoryItem({ id: "child-1", task: "Subtask content 1" }),
-					createMockDisplayHistoryItem({ id: "child-2", task: "Subtask content 2" }),
+					createMockSubtaskNode({ id: "child-1", task: "Subtask content 1" }),
+					createMockSubtaskNode({ id: "child-2", task: "Subtask content 2" }),
 				],
 			})
 
-			render(<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} />)
+			render(
+				<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
+			)
 
 			expect(screen.getByTestId("subtask-list")).toBeInTheDocument()
 			expect(screen.getByText("Subtask content 1")).toBeInTheDocument()
@@ -139,16 +187,39 @@ describe("TaskGroupItem", () => {
 		it("hides subtasks when collapsed", () => {
 			const group = createMockGroup({
 				isExpanded: false,
-				subtasks: [createMockDisplayHistoryItem({ id: "child-1", task: "Subtask content" })],
+				subtasks: [createMockSubtaskNode({ id: "child-1", task: "Subtask content" })],
 			})
 
-			render(<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} />)
+			render(
+				<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
+			)
 
 			// The subtask-list element is present but collapsed via CSS (max-h-0)
 			const subtaskList = screen.queryByTestId("subtask-list")
 			expect(subtaskList).toBeInTheDocument()
 			expect(subtaskList).toHaveClass("max-h-0")
 		})
+
+		it("renders nested subtask when a node has children and is expanded", () => {
+			const group = createMockGroup({
+				isExpanded: true,
+				subtasks: [
+					createMockSubtaskNode(
+						{ id: "child-1", task: "Parent subtask" },
+						[createMockSubtaskNode({ id: "grandchild-1", task: "Nested subtask" })],
+						true, // child-1 is expanded
+					),
+				],
+			})
+
+			render(
+				<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
+			)
+
+			expect(screen.getByText("Parent subtask")).toBeInTheDocument()
+			expect(screen.getByText("Nested subtask")).toBeInTheDocument()
+			expect(screen.getByTestId("subtask-row-grandchild-1")).toBeInTheDocument()
+		})
 	})
 
 	describe("selection mode", () => {
@@ -166,6 +237,7 @@ describe("TaskGroupItem", () => {
 					isSelected={false}
 					onToggleSelection={onToggleSelection}
 					onToggleExpand={vi.fn()}
+					onToggleSubtaskExpand={vi.fn()}
 				/>,
 			)
 
@@ -188,6 +260,7 @@ describe("TaskGroupItem", () => {
 					isSelected={true}
 					onToggleSelection={vi.fn()}
 					onToggleExpand={vi.fn()}
+					onToggleSubtaskExpand={vi.fn()}
 				/>,
 			)
 
@@ -201,7 +274,14 @@ describe("TaskGroupItem", () => {
 		it("passes compact variant to TaskItem", () => {
 			const group = createMockGroup()
 
-			render(<TaskGroupItem group={group} variant="compact" onToggleExpand={vi.fn()} />)
+			render(
+				<TaskGroupItem
+					group={group}
+					variant="compact"
+					onToggleExpand={vi.fn()}
+					onToggleSubtaskExpand={vi.fn()}
+				/>,
+			)
 
 			// TaskItem should be rendered with compact styling
 			const taskItem = screen.getByTestId("task-item-parent-1")
@@ -211,7 +291,9 @@ describe("TaskGroupItem", () => {
 		it("passes full variant to TaskItem", () => {
 			const group = createMockGroup()
 
-			render(<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} />)
+			render(
+				<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
+			)
 
 			const taskItem = screen.getByTestId("task-item-parent-1")
 			expect(taskItem).toBeInTheDocument()
@@ -225,7 +307,15 @@ describe("TaskGroupItem", () => {
 				parent: createMockDisplayHistoryItem({ id: "parent-1", task: "Parent task" }),
 			})
 
-			render(<TaskGroupItem group={group} variant="full" onDelete={onDelete} onToggleExpand={vi.fn()} />)
+			render(
+				<TaskGroupItem
+					group={group}
+					variant="full"
+					onDelete={onDelete}
+					onToggleExpand={vi.fn()}
+					onToggleSubtaskExpand={vi.fn()}
+				/>,
+			)
 
 			// Delete button uses "delete-task-button" as testid
 			const deleteButton = screen.getByTestId("delete-task-button")
@@ -244,7 +334,15 @@ describe("TaskGroupItem", () => {
 				}),
 			})
 
-			render(<TaskGroupItem group={group} variant="full" showWorkspace={true} onToggleExpand={vi.fn()} />)
+			render(
+				<TaskGroupItem
+					group={group}
+					variant="full"
+					showWorkspace={true}
+					onToggleExpand={vi.fn()}
+					onToggleSubtaskExpand={vi.fn()}
+				/>,
+			)
 
 			// Workspace should be displayed in TaskItem
 			const taskItem = screen.getByTestId("task-item-parent-1")
@@ -258,7 +356,15 @@ describe("TaskGroupItem", () => {
 		it("applies custom className to container", () => {
 			const group = createMockGroup()
 
-			render(<TaskGroupItem group={group} variant="full" className="custom-class" onToggleExpand={vi.fn()} />)
+			render(
+				<TaskGroupItem
+					group={group}
+					variant="full"
+					className="custom-class"
+					onToggleExpand={vi.fn()}
+					onToggleSubtaskExpand={vi.fn()}
+				/>,
+			)
 
 			const container = screen.getByTestId("task-group-parent-1")
 			expect(container).toHaveClass("custom-class")

+ 206 - 7
webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts

@@ -2,7 +2,8 @@ import { renderHook, act } from "@/utils/test-utils"
 
 import type { HistoryItem } from "@roo-code/types"
 
-import { useGroupedTasks } from "../useGroupedTasks"
+import { useGroupedTasks, buildSubtree } from "../useGroupedTasks"
+import { countAllSubtasks } from "../types"
 
 const createMockTask = (overrides: Partial<HistoryItem> = {}): HistoryItem => ({
 	id: "task-1",
@@ -42,8 +43,8 @@ describe("useGroupedTasks", () => {
 			expect(result.current.groups).toHaveLength(1)
 			expect(result.current.groups[0].parent.id).toBe("parent-1")
 			expect(result.current.groups[0].subtasks).toHaveLength(2)
-			expect(result.current.groups[0].subtasks[0].id).toBe("child-2") // Newest first
-			expect(result.current.groups[0].subtasks[1].id).toBe("child-1")
+			expect(result.current.groups[0].subtasks[0].item.id).toBe("child-2") // Newest first
+			expect(result.current.groups[0].subtasks[1].item.id).toBe("child-1")
 		})
 
 		it("handles tasks with no children", () => {
@@ -121,7 +122,7 @@ describe("useGroupedTasks", () => {
 			expect(result.current.isSearchMode).toBe(false)
 		})
 
-		it("handles deeply nested tasks (grandchildren treated as children of their direct parent)", () => {
+		it("handles deeply nested tasks with recursive tree structure", () => {
 			const rootTask = createMockTask({
 				id: "root-1",
 				task: "Root task",
@@ -146,10 +147,12 @@ describe("useGroupedTasks", () => {
 			expect(result.current.groups).toHaveLength(1)
 			expect(result.current.groups[0].parent.id).toBe("root-1")
 			expect(result.current.groups[0].subtasks).toHaveLength(1)
-			expect(result.current.groups[0].subtasks[0].id).toBe("child-1")
+			expect(result.current.groups[0].subtasks[0].item.id).toBe("child-1")
 
-			// Note: grandchild is a child of child-1, not root-1
-			// The current implementation only shows direct children in subtasks
+			// Grandchild is nested inside child's children
+			expect(result.current.groups[0].subtasks[0].children).toHaveLength(1)
+			expect(result.current.groups[0].subtasks[0].children[0].item.id).toBe("grandchild-1")
+			expect(result.current.groups[0].subtasks[0].children[0].children).toHaveLength(0)
 		})
 	})
 
@@ -395,3 +398,199 @@ describe("useGroupedTasks", () => {
 		})
 	})
 })
+
+describe("buildSubtree", () => {
+	it("builds a leaf node with no children", () => {
+		const task = createMockTask({ id: "task-1", task: "Leaf task" })
+		const childrenMap = new Map<string, HistoryItem[]>()
+
+		const node = buildSubtree(task, childrenMap, new Set<string>())
+
+		expect(node.item.id).toBe("task-1")
+		expect(node.children).toHaveLength(0)
+		expect(node.isExpanded).toBe(false)
+	})
+
+	it("builds a node with direct children sorted newest first", () => {
+		const parent = createMockTask({ id: "parent-1", task: "Parent" })
+		const child1 = createMockTask({
+			id: "child-1",
+			task: "Child 1",
+			parentTaskId: "parent-1",
+			ts: new Date("2024-01-15T12:00:00").getTime(),
+		})
+		const child2 = createMockTask({
+			id: "child-2",
+			task: "Child 2",
+			parentTaskId: "parent-1",
+			ts: new Date("2024-01-15T14:00:00").getTime(),
+		})
+
+		const childrenMap = new Map<string, HistoryItem[]>()
+		childrenMap.set("parent-1", [child1, child2])
+
+		const node = buildSubtree(parent, childrenMap, new Set<string>())
+
+		expect(node.item.id).toBe("parent-1")
+		expect(node.children).toHaveLength(2)
+		expect(node.children[0].item.id).toBe("child-2") // Newest first
+		expect(node.children[1].item.id).toBe("child-1")
+		expect(node.isExpanded).toBe(false)
+		expect(node.children[0].isExpanded).toBe(false)
+		expect(node.children[1].isExpanded).toBe(false)
+	})
+
+	it("builds a deeply nested tree recursively", () => {
+		const root = createMockTask({ id: "root", task: "Root" })
+		const child = createMockTask({
+			id: "child",
+			task: "Child",
+			parentTaskId: "root",
+			ts: new Date("2024-01-15T13:00:00").getTime(),
+		})
+		const grandchild = createMockTask({
+			id: "grandchild",
+			task: "Grandchild",
+			parentTaskId: "child",
+			ts: new Date("2024-01-15T14:00:00").getTime(),
+		})
+		const greatGrandchild = createMockTask({
+			id: "great-grandchild",
+			task: "Great Grandchild",
+			parentTaskId: "grandchild",
+			ts: new Date("2024-01-15T15:00:00").getTime(),
+		})
+
+		const childrenMap = new Map<string, HistoryItem[]>()
+		childrenMap.set("root", [child])
+		childrenMap.set("child", [grandchild])
+		childrenMap.set("grandchild", [greatGrandchild])
+
+		const node = buildSubtree(root, childrenMap, new Set<string>())
+
+		expect(node.item.id).toBe("root")
+		expect(node.children).toHaveLength(1)
+		expect(node.children[0].item.id).toBe("child")
+		expect(node.children[0].children).toHaveLength(1)
+		expect(node.children[0].children[0].item.id).toBe("grandchild")
+		expect(node.children[0].children[0].children).toHaveLength(1)
+		expect(node.children[0].children[0].children[0].item.id).toBe("great-grandchild")
+		expect(node.children[0].children[0].children[0].children).toHaveLength(0)
+	})
+
+	it("does not mutate the original childrenMap arrays", () => {
+		const parent = createMockTask({ id: "parent-1", task: "Parent" })
+		const child1 = createMockTask({
+			id: "child-1",
+			task: "Child 1",
+			parentTaskId: "parent-1",
+			ts: new Date("2024-01-15T12:00:00").getTime(),
+		})
+		const child2 = createMockTask({
+			id: "child-2",
+			task: "Child 2",
+			parentTaskId: "parent-1",
+			ts: new Date("2024-01-15T14:00:00").getTime(),
+		})
+
+		const originalChildren = [child1, child2]
+		const childrenMap = new Map<string, HistoryItem[]>()
+		childrenMap.set("parent-1", originalChildren)
+
+		buildSubtree(parent, childrenMap, new Set<string>())
+
+		// Original array should not be mutated (sort is on a slice)
+		expect(originalChildren[0].id).toBe("child-1")
+		expect(originalChildren[1].id).toBe("child-2")
+	})
+
+	it("sets isExpanded: true when task ID is in expandedIds", () => {
+		const parent = createMockTask({ id: "parent-1", task: "Parent" })
+		const child = createMockTask({
+			id: "child-1",
+			task: "Child",
+			parentTaskId: "parent-1",
+			ts: new Date("2024-01-15T13:00:00").getTime(),
+		})
+
+		const childrenMap = new Map<string, HistoryItem[]>()
+		childrenMap.set("parent-1", [child])
+
+		const expandedIds = new Set<string>(["parent-1"])
+		const node = buildSubtree(parent, childrenMap, expandedIds)
+
+		expect(node.isExpanded).toBe(true)
+		expect(node.children[0].isExpanded).toBe(false)
+	})
+
+	it("propagates isExpanded correctly through deeply nested tree", () => {
+		const root = createMockTask({ id: "root", task: "Root" })
+		const child = createMockTask({
+			id: "child",
+			task: "Child",
+			parentTaskId: "root",
+			ts: new Date("2024-01-15T13:00:00").getTime(),
+		})
+		const grandchild = createMockTask({
+			id: "grandchild",
+			task: "Grandchild",
+			parentTaskId: "child",
+			ts: new Date("2024-01-15T14:00:00").getTime(),
+		})
+		const greatGrandchild = createMockTask({
+			id: "great-grandchild",
+			task: "Great Grandchild",
+			parentTaskId: "grandchild",
+			ts: new Date("2024-01-15T15:00:00").getTime(),
+		})
+
+		const childrenMap = new Map<string, HistoryItem[]>()
+		childrenMap.set("root", [child])
+		childrenMap.set("child", [grandchild])
+		childrenMap.set("grandchild", [greatGrandchild])
+
+		// Expand root and grandchild, but NOT child
+		const expandedIds = new Set<string>(["root", "grandchild"])
+		const node = buildSubtree(root, childrenMap, expandedIds)
+
+		expect(node.isExpanded).toBe(true)
+		expect(node.children[0].isExpanded).toBe(false) // child not expanded
+		expect(node.children[0].children[0].isExpanded).toBe(true) // grandchild expanded
+		expect(node.children[0].children[0].children[0].isExpanded).toBe(false) // great-grandchild not expanded
+	})
+})
+
+describe("countAllSubtasks", () => {
+	it("returns 0 for empty array", () => {
+		expect(countAllSubtasks([])).toBe(0)
+	})
+
+	it("returns count of items in flat list (no grandchildren)", () => {
+		const nodes = [
+			{ item: createMockTask({ id: "a" }), children: [], isExpanded: false },
+			{ item: createMockTask({ id: "b" }), children: [], isExpanded: false },
+			{ item: createMockTask({ id: "c" }), children: [], isExpanded: false },
+		]
+		expect(countAllSubtasks(nodes)).toBe(3)
+	})
+
+	it("returns total count at all nesting levels", () => {
+		const nodes = [
+			{
+				item: createMockTask({ id: "a" }),
+				children: [
+					{
+						item: createMockTask({ id: "a1" }),
+						children: [{ item: createMockTask({ id: "a1i" }), children: [], isExpanded: false }],
+						isExpanded: false,
+					},
+					{ item: createMockTask({ id: "a2" }), children: [], isExpanded: false },
+				],
+				isExpanded: false,
+			},
+			{ item: createMockTask({ id: "b" }), children: [], isExpanded: false },
+		]
+		// a (1) + a1 (1) + a1i (1) + a2 (1) + b (1) = 5
+		expect(countAllSubtasks(nodes)).toBe(5)
+	})
+})

+ 26 - 3
webview-ui/src/components/history/types.ts

@@ -11,13 +11,36 @@ export interface DisplayHistoryItem extends HistoryItem {
 }
 
 /**
- * A group of tasks consisting of a parent task and its subtasks
+ * A node in the subtask tree, representing a task and its recursively nested children.
+ */
+export interface SubtaskTreeNode {
+	/** The task at this tree node */
+	item: DisplayHistoryItem
+	/** Recursively nested child subtasks */
+	children: SubtaskTreeNode[]
+	/** Whether this node's children are expanded in the UI */
+	isExpanded: boolean
+}
+
+/**
+ * Recursively counts all subtasks in a tree of SubtaskTreeNodes.
+ */
+export function countAllSubtasks(nodes: SubtaskTreeNode[]): number {
+	let count = 0
+	for (const node of nodes) {
+		count += 1 + countAllSubtasks(node.children)
+	}
+	return count
+}
+
+/**
+ * A group of tasks consisting of a parent task and its nested subtask tree
  */
 export interface TaskGroup {
 	/** The parent task */
 	parent: DisplayHistoryItem
-	/** List of direct subtasks */
-	subtasks: DisplayHistoryItem[]
+	/** Tree of subtasks (supports arbitrary nesting depth) */
+	subtasks: SubtaskTreeNode[]
 	/** Whether the subtask list is expanded */
 	isExpanded: boolean
 }

+ 29 - 9
webview-ui/src/components/history/useGroupedTasks.ts

@@ -1,6 +1,29 @@
 import { useState, useMemo, useCallback } from "react"
 import type { HistoryItem } from "@roo-code/types"
-import type { DisplayHistoryItem, TaskGroup, GroupedTasksResult } from "./types"
+import type { DisplayHistoryItem, SubtaskTreeNode, TaskGroup, GroupedTasksResult } from "./types"
+
+/**
+ * Recursively builds a subtask tree node for the given task.
+ * Pure function — exported for independent testing.
+ *
+ * @param task - The task to build a tree node for
+ * @param childrenMap - Map of parentId → direct children
+ * @param expandedIds - Set of task IDs whose children are currently expanded
+ * @returns A SubtaskTreeNode with recursively built children sorted by ts (newest first)
+ */
+export function buildSubtree(
+	task: HistoryItem,
+	childrenMap: Map<string, HistoryItem[]>,
+	expandedIds: Set<string>,
+): SubtaskTreeNode {
+	const directChildren = (childrenMap.get(task.id) || []).slice().sort((a, b) => b.ts - a.ts)
+
+	return {
+		item: task as DisplayHistoryItem,
+		children: directChildren.map((child) => buildSubtree(child, childrenMap, expandedIds)),
+		isExpanded: expandedIds.has(task.id),
+	}
+}
 
 /**
  * Hook to transform a flat task list into grouped structure based on parent-child relationships.
@@ -31,7 +54,7 @@ export function useGroupedTasks(tasks: HistoryItem[], searchQuery: string): Grou
 			return []
 		}
 
-		// Build children map: parentId -> children[]
+		// Build children map: parentId -> direct children[]
 		const childrenMap = new Map<string, HistoryItem[]>()
 
 		for (const task of tasks) {
@@ -44,19 +67,16 @@ export function useGroupedTasks(tasks: HistoryItem[], searchQuery: string): Grou
 
 		// Identify root tasks - tasks that either:
 		// 1. Have no parentTaskId
-		// 2. Have a parentTaskId that doesn't exist in our task list
+		// 2. Have a parentTaskId that doesn't exist in our task list (orphans promoted to root)
 		const rootTasks = tasks.filter((task) => !task.parentTaskId || !taskMap.has(task.parentTaskId))
 
-		// Build groups from root tasks
+		// Build groups from root tasks with recursively nested subtask trees
 		const taskGroups: TaskGroup[] = rootTasks.map((parent) => {
-			// Get direct children (sorted by timestamp, newest first)
-			const subtasks = (childrenMap.get(parent.id) || [])
-				.slice()
-				.sort((a, b) => b.ts - a.ts) as DisplayHistoryItem[]
+			const directChildren = (childrenMap.get(parent.id) || []).slice().sort((a, b) => b.ts - a.ts)
 
 			return {
 				parent: parent as DisplayHistoryItem,
-				subtasks,
+				subtasks: directChildren.map((child) => buildSubtree(child, childrenMap, expandedIds)),
 				isExpanded: expandedIds.has(parent.id),
 			}
 		})