diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff809db9add..7f48e153c9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14958,7 +14958,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -22251,8 +22251,8 @@ snapshots: zhipu-ai-provider@0.2.2(zod@3.25.76): dependencies: - '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.5(zod@3.25.76) transitivePeerDependencies: - zod diff --git a/webview-ui/src/components/history/HistoryPreview.tsx b/webview-ui/src/components/history/HistoryPreview.tsx index 02464e69c0d..70467c44fba 100644 --- a/webview-ui/src/components/history/HistoryPreview.tsx +++ b/webview-ui/src/components/history/HistoryPreview.tsx @@ -38,6 +38,7 @@ const HistoryPreview = () => { group={group} variant="compact" onToggleExpand={() => toggleExpand(group.parent.id)} + onToggleSubtaskExpand={toggleExpand} /> ))} diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 88b65518812..1d6de93e64d 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/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([]) const [showBatchDeleteDialog, setShowBatchDeleteDialog] = useState(false) - // Get subtask count for a task + // Get subtask count for a task (recursive total) const getSubtaskCount = useMemo(() => { const countMap = new Map() 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" /> )} diff --git a/webview-ui/src/components/history/SubtaskRow.tsx b/webview-ui/src/components/history/SubtaskRow.tsx index dec227ebc89..0089e1f81db 100644 --- a/webview-ui/src/components/history/SubtaskRow.tsx +++ b/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 ( -
+ {/* Task row with depth indentation */} +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + handleClick() + } + }}> + + {item.task} + + +
+ + {/* Nested subtask collapsible section */} + {hasChildren && ( +
+ onToggleExpand(item.id)} + /> +
+ )} + + {/* Expanded nested subtasks */} + {hasChildren && ( +
+ {children.map((child) => ( + + ))} +
)} - onClick={handleClick} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - handleClick() - } - }}> - - {item.task} - -
) } diff --git a/webview-ui/src/components/history/TaskGroupItem.tsx b/webview-ui/src/components/history/TaskGroupItem.tsx index 6bf2e1a9572..45b8293f016 100644 --- a/webview-ui/src/components/history/TaskGroupItem.tsx +++ b/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 (
- {/* Subtask collapsible row */} + {/* Subtask collapsible row — shows total recursive count */} {hasSubtasks && ( - + )} - {/* Expanded subtasks */} + {/* Expanded subtask tree */} {hasSubtasks && (
- {subtasks.map((subtask) => ( - + {subtasks.map((node) => ( + ))}
)} diff --git a/webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx b/webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx new file mode 100644 index 00000000000..6337b9f1fa2 --- /dev/null +++ b/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) => { + 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 => ({ + 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 = {}, + 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() + + expect(screen.getByText("Leaf task content")).toBeInTheDocument() + }) + + it("renders with correct depth indentation", () => { + const node = createMockNode({ id: "leaf-1", task: "Indented task" }) + + render() + + 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() + + 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() + + 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() + + // 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() + + 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() + + 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() + + 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() + + // 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() + + // 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() + + 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() + }) + }) +}) diff --git a/webview-ui/src/components/history/__tests__/TaskGroupItem.spec.tsx b/webview-ui/src/components/history/__tests__/TaskGroupItem.spec.tsx index ff40963a87a..b04fac6b543 100644 --- a/webview-ui/src/components/history/__tests__/TaskGroupItem.spec.tsx +++ b/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 = { ...overrides, }) +const createMockSubtaskNode = ( + itemOverrides: Partial = {}, + children: SubtaskTreeNode[] = [], + isExpanded = false, +): SubtaskTreeNode => ({ + item: createMockDisplayHistoryItem(itemOverrides), + children, + isExpanded, +}) + const createMockGroup = (overrides: Partial = {}): TaskGroup => ({ parent: createMockDisplayHistoryItem({ id: "parent-1", task: "Parent task" }), subtasks: [], @@ -55,7 +65,9 @@ describe("TaskGroupItem", () => { }), }) - render() + render( + , + ) expect(screen.getByText("Test parent task content")).toBeInTheDocument() }) @@ -65,7 +77,9 @@ describe("TaskGroupItem", () => { parent: createMockDisplayHistoryItem({ id: "my-parent-id" }), }) - render() + render( + , + ) 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() + render( + , + ) 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() + render( + , + ) 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() + render( + , + ) 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( + , + ) + + // 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() + render( + , + ) 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() + render( + , + ) 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() + render( + , + ) // 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( + , + ) + + 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() + render( + , + ) // 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() + render( + , + ) 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() + render( + , + ) // Delete button uses "delete-task-button" as testid const deleteButton = screen.getByTestId("delete-task-button") @@ -244,7 +334,15 @@ describe("TaskGroupItem", () => { }), }) - render() + render( + , + ) // 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() + render( + , + ) const container = screen.getByTestId("task-group-parent-1") expect(container).toHaveClass("custom-class") diff --git a/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts b/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts index 4f280e72d40..8873695c62a 100644 --- a/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts +++ b/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 => ({ 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() + + const node = buildSubtree(task, childrenMap, new Set()) + + 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() + childrenMap.set("parent-1", [child1, child2]) + + const node = buildSubtree(parent, childrenMap, new Set()) + + 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() + childrenMap.set("root", [child]) + childrenMap.set("child", [grandchild]) + childrenMap.set("grandchild", [greatGrandchild]) + + const node = buildSubtree(root, childrenMap, new Set()) + + 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() + childrenMap.set("parent-1", originalChildren) + + buildSubtree(parent, childrenMap, new Set()) + + // 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() + childrenMap.set("parent-1", [child]) + + const expandedIds = new Set(["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() + childrenMap.set("root", [child]) + childrenMap.set("child", [grandchild]) + childrenMap.set("grandchild", [greatGrandchild]) + + // Expand root and grandchild, but NOT child + const expandedIds = new Set(["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) + }) +}) diff --git a/webview-ui/src/components/history/types.ts b/webview-ui/src/components/history/types.ts index a12dfbce630..0de5e430812 100644 --- a/webview-ui/src/components/history/types.ts +++ b/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 } diff --git a/webview-ui/src/components/history/useGroupedTasks.ts b/webview-ui/src/components/history/useGroupedTasks.ts index 9d7085881eb..d3f3d4e9531 100644 --- a/webview-ui/src/components/history/useGroupedTasks.ts +++ b/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, + expandedIds: Set, +): 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() 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), } })