diff --git a/webview-ui/src/components/chat/task-dashboard/TaskDashboard.tsx b/webview-ui/src/components/chat/task-dashboard/TaskDashboard.tsx index d245daa4fa..b06f21cbbe 100644 --- a/webview-ui/src/components/chat/task-dashboard/TaskDashboard.tsx +++ b/webview-ui/src/components/chat/task-dashboard/TaskDashboard.tsx @@ -37,6 +37,8 @@ interface TaskNodeRowProps { */ const TaskNodeRow = memo(({ node, depth, currentTaskId, modeMap }: TaskNodeRowProps) => { const { item, children } = node + const hasChildren = children.length > 0 + const [isNodeExpanded, setIsNodeExpanded] = useState(true) const isCurrentTask = item.id === currentTaskId const modeConfig = item.mode ? modeMap.get(item.mode) : undefined const modeName = modeConfig?.name ?? item.mode ?? "Unknown" @@ -57,6 +59,11 @@ const TaskNodeRow = memo(({ node, depth, currentTaskId, modeMap }: TaskNodeRowPr [handleClick], ) + const toggleNodeExpanded = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + setIsNodeExpanded((prev) => !prev) + }, []) + // Truncate task description for display const taskSummary = item.task.length > 60 ? item.task.slice(0, 57) + "..." : item.task @@ -64,7 +71,7 @@ const TaskNodeRow = memo(({ node, depth, currentTaskId, modeMap }: TaskNodeRowPr
+ {/* Expand/collapse toggle for nodes with children */} + {hasChildren ? ( + + ) : ( + + )} + {/* Mode icon/indicator */}
- {/* Render children */} - {children.map((child) => ( - - ))} + {/* Render children (collapsible) */} + {hasChildren && isNodeExpanded && ( +
+ {children.map((child) => ( + + ))} +
+ )}
) }) @@ -134,7 +158,7 @@ TaskNodeRow.displayName = "TaskNodeRow" */ const TaskDashboard = () => { const { taskHistory, currentTaskItem, currentTaskId, customModes } = useExtensionState() - const { rootNode, hasDelegationHierarchy } = useTaskTree(taskHistory, currentTaskItem) + const { rootNode, hasDelegationHierarchy, taskCount } = useTaskTree(taskHistory, currentTaskItem) const [isExpanded, setIsExpanded] = useState(true) // Build a mode lookup map @@ -171,7 +195,9 @@ const TaskDashboard = () => { data-testid="task-dashboard-toggle"> {isExpanded ? : } - Task Delegation + + Task Delegation ({taskCount} {taskCount === 1 ? "task" : "tasks"}) + {/* Tree content */} diff --git a/webview-ui/src/components/chat/task-dashboard/__tests__/TaskDashboard.spec.tsx b/webview-ui/src/components/chat/task-dashboard/__tests__/TaskDashboard.spec.tsx index 117d9eca46..6c55ba7b29 100644 --- a/webview-ui/src/components/chat/task-dashboard/__tests__/TaskDashboard.spec.tsx +++ b/webview-ui/src/components/chat/task-dashboard/__tests__/TaskDashboard.spec.tsx @@ -92,7 +92,7 @@ describe("TaskDashboard", () => { render() expect(screen.getByTestId("task-dashboard")).toBeTruthy() - expect(screen.getByText("Task Delegation")).toBeTruthy() + expect(screen.getByText("Task Delegation (2 tasks)")).toBeTruthy() }) it("displays mode names for each task node", () => { @@ -257,4 +257,114 @@ describe("TaskDashboard", () => { const button = activeNode.querySelector("[role='button']") expect(button?.className).toContain("activeSelection") }) + + it("displays task count in the header", () => { + const parent = makeItem({ + id: "parent-1", + task: "Root task", + mode: "orchestrator", + status: "delegated", + childIds: ["child-1", "child-2"], + }) + const child1 = makeItem({ + id: "child-1", + task: "Child task 1", + mode: "code", + status: "completed", + rootTaskId: "parent-1", + parentTaskId: "parent-1", + }) + const child2 = makeItem({ + id: "child-2", + task: "Child task 2", + mode: "debug", + status: "active", + rootTaskId: "parent-1", + parentTaskId: "parent-1", + }) + mockState = { + taskHistory: [parent, child1, child2], + currentTaskItem: child2, + currentTaskId: "child-2", + customModes: [], + } + + render() + + expect(screen.getByText("Task Delegation (3 tasks)")).toBeTruthy() + }) + + it("shows expand/collapse toggle on nodes with children", () => { + const parent = makeItem({ + id: "parent-1", + task: "Root task", + mode: "orchestrator", + status: "delegated", + childIds: ["child-1"], + }) + const child = makeItem({ + id: "child-1", + task: "Child task", + mode: "code", + status: "active", + rootTaskId: "parent-1", + parentTaskId: "parent-1", + }) + mockState = { + taskHistory: [parent, child], + currentTaskItem: child, + currentTaskId: "child-1", + customModes: [], + } + + render() + + // Parent has children so it should have a toggle button + expect(screen.getByTestId("task-node-toggle-parent-1")).toBeTruthy() + + // Child has no children so it should NOT have a toggle button + expect(screen.queryByTestId("task-node-toggle-child-1")).toBeNull() + }) + + it("collapses and expands tree node children", () => { + const parent = makeItem({ + id: "parent-1", + task: "Root task", + mode: "orchestrator", + status: "delegated", + childIds: ["child-1"], + }) + const child = makeItem({ + id: "child-1", + task: "Child task", + mode: "code", + status: "active", + rootTaskId: "parent-1", + parentTaskId: "parent-1", + }) + mockState = { + taskHistory: [parent, child], + currentTaskItem: child, + currentTaskId: "child-1", + customModes: [], + } + + render() + + // Children should be visible by default + expect(screen.getByTestId("task-node-children-parent-1")).toBeTruthy() + expect(screen.getByTestId("task-node-child-1")).toBeTruthy() + + // Click the toggle to collapse + fireEvent.click(screen.getByTestId("task-node-toggle-parent-1")) + + // Children container should be hidden + expect(screen.queryByTestId("task-node-children-parent-1")).toBeNull() + + // Click again to expand + fireEvent.click(screen.getByTestId("task-node-toggle-parent-1")) + + // Children should be visible again + expect(screen.getByTestId("task-node-children-parent-1")).toBeTruthy() + }) }) diff --git a/webview-ui/src/components/chat/task-dashboard/__tests__/useTaskTree.spec.ts b/webview-ui/src/components/chat/task-dashboard/__tests__/useTaskTree.spec.ts index f0d47d071e..eaa12aec30 100644 --- a/webview-ui/src/components/chat/task-dashboard/__tests__/useTaskTree.spec.ts +++ b/webview-ui/src/components/chat/task-dashboard/__tests__/useTaskTree.spec.ts @@ -1,5 +1,5 @@ import type { HistoryItem } from "@roo-code/types" -import { buildTaskTree } from "../useTaskTree" +import { buildTaskTree, countTreeNodes } from "../useTaskTree" function makeItem(overrides: Partial & { id: string }): HistoryItem { return { @@ -18,6 +18,7 @@ describe("buildTaskTree", () => { const result = buildTaskTree([], undefined) expect(result.rootNode).toBeNull() expect(result.hasDelegationHierarchy).toBe(false) + expect(result.taskCount).toBe(0) }) it("returns null when current task has no delegation hierarchy", () => { @@ -25,6 +26,7 @@ describe("buildTaskTree", () => { const result = buildTaskTree([item], item) expect(result.rootNode).toBeNull() expect(result.hasDelegationHierarchy).toBe(false) + expect(result.taskCount).toBe(0) }) it("builds a simple parent-child tree", () => { @@ -52,6 +54,7 @@ describe("buildTaskTree", () => { expect(result.rootNode!.item.id).toBe("parent-1") expect(result.rootNode!.children).toHaveLength(1) expect(result.rootNode!.children[0].item.id).toBe("child-1") + expect(result.taskCount).toBe(2) }) it("builds a tree when current task is the root", () => { @@ -214,6 +217,75 @@ describe("buildTaskTree", () => { expect(result.rootNode!.children[0].item.id).toBe("child") }) + it("returns correct taskCount for deep trees", () => { + const root = makeItem({ + id: "root", + task: "Root task", + mode: "orchestrator", + status: "delegated", + childIds: ["mid"], + }) + const mid = makeItem({ + id: "mid", + task: "Middle task", + mode: "architect", + status: "delegated", + rootTaskId: "root", + parentTaskId: "root", + childIds: ["leaf"], + }) + const leaf = makeItem({ + id: "leaf", + task: "Leaf task", + mode: "code", + status: "active", + rootTaskId: "root", + parentTaskId: "mid", + }) + const history = [root, mid, leaf] + + const result = buildTaskTree(history, leaf) + expect(result.taskCount).toBe(3) + }) + + it("returns correct taskCount for multiple children", () => { + const root = makeItem({ + id: "root", + task: "Root task", + mode: "orchestrator", + status: "delegated", + childIds: ["child-a", "child-b", "child-c"], + }) + const childA = makeItem({ + id: "child-a", + task: "Task A", + mode: "code", + status: "completed", + rootTaskId: "root", + parentTaskId: "root", + }) + const childB = makeItem({ + id: "child-b", + task: "Task B", + mode: "debug", + status: "completed", + rootTaskId: "root", + parentTaskId: "root", + }) + const childC = makeItem({ + id: "child-c", + task: "Task C", + mode: "code", + status: "active", + rootTaskId: "root", + parentTaskId: "root", + }) + const history = [root, childA, childB, childC] + + const result = buildTaskTree(history, childC) + expect(result.taskCount).toBe(4) + }) + it("handles missing child items gracefully", () => { const root = makeItem({ id: "root", @@ -237,3 +309,28 @@ describe("buildTaskTree", () => { expect(result.rootNode!.children[0].item.id).toBe("existing-child") }) }) + +describe("countTreeNodes", () => { + it("returns 0 for null", () => { + expect(countTreeNodes(null)).toBe(0) + }) + + it("returns 1 for a single node", () => { + const node = { item: makeItem({ id: "single" }), children: [] } + expect(countTreeNodes(node)).toBe(1) + }) + + it("counts all nodes in a tree", () => { + const node = { + item: makeItem({ id: "root" }), + children: [ + { + item: makeItem({ id: "child-1" }), + children: [{ item: makeItem({ id: "grandchild" }), children: [] }], + }, + { item: makeItem({ id: "child-2" }), children: [] }, + ], + } + expect(countTreeNodes(node)).toBe(4) + }) +}) diff --git a/webview-ui/src/components/chat/task-dashboard/useTaskTree.ts b/webview-ui/src/components/chat/task-dashboard/useTaskTree.ts index af9edd9469..41379d509f 100644 --- a/webview-ui/src/components/chat/task-dashboard/useTaskTree.ts +++ b/webview-ui/src/components/chat/task-dashboard/useTaskTree.ts @@ -19,6 +19,20 @@ export interface TaskTreeResult { rootNode: TaskTreeNode | null /** Whether the current task is part of a delegation hierarchy */ hasDelegationHierarchy: boolean + /** Total number of tasks in the delegation tree */ + taskCount: number +} + +/** + * Count the total number of nodes in a task tree. + */ +export function countTreeNodes(node: TaskTreeNode | null): number { + if (!node) return 0 + let count = 1 + for (const child of node.children) { + count += countTreeNodes(child) + } + return count } /** @@ -31,7 +45,7 @@ export interface TaskTreeResult { */ export function buildTaskTree(taskHistory: HistoryItem[], currentTaskItem?: HistoryItem): TaskTreeResult { if (!currentTaskItem) { - return { rootNode: null, hasDelegationHierarchy: false } + return { rootNode: null, hasDelegationHierarchy: false, taskCount: 0 } } // Determine the root task ID for the current session. @@ -44,7 +58,7 @@ export function buildTaskTree(taskHistory: HistoryItem[], currentTaskItem?: Hist // Need at least 2 tasks for a delegation hierarchy if (sessionTasks.length < 2) { - return { rootNode: null, hasDelegationHierarchy: false } + return { rootNode: null, hasDelegationHierarchy: false, taskCount: 0 } } // Build lookup by id @@ -76,11 +90,11 @@ export function buildTaskTree(taskHistory: HistoryItem[], currentTaskItem?: Hist const rootItem = taskMap.get(rootId) if (!rootItem) { - return { rootNode: null, hasDelegationHierarchy: false } + return { rootNode: null, hasDelegationHierarchy: false, taskCount: 0 } } const rootNode = buildNode(rootItem, new Set()) - return { rootNode, hasDelegationHierarchy: true } + return { rootNode, hasDelegationHierarchy: true, taskCount: countTreeNodes(rootNode) } } /**