From 5d91bf8a0a6d0eb833129df4f073737cf277463e Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 02:38:42 +0000 Subject: [PATCH 1/2] feat: add task coordination dashboard for multi-agent delegation visibility Adds a collapsible Task Delegation dashboard section inside the existing Roo Code webview panel, displayed below the TaskHeader when the current task is part of a multi-task delegation hierarchy. Features: - Tree view showing parent/child delegation relationships - Mode name + status badge (Active/Delegated/Completed) per task - Active task highlighting with visual indicator - Click-to-navigate to any task in the hierarchy - Collapsible panel header - Only visible during active delegation sessions (2+ tasks) - Supports custom modes Closes #12329 --- webview-ui/src/components/chat/ChatView.tsx | 3 + .../chat/task-dashboard/TaskDashboard.tsx | 187 +++++++++++++ .../__tests__/TaskDashboard.spec.tsx | 260 ++++++++++++++++++ .../__tests__/useTaskTree.spec.ts | 239 ++++++++++++++++ .../components/chat/task-dashboard/index.ts | 3 + .../chat/task-dashboard/useTaskTree.ts | 95 +++++++ 6 files changed, 787 insertions(+) create mode 100644 webview-ui/src/components/chat/task-dashboard/TaskDashboard.tsx create mode 100644 webview-ui/src/components/chat/task-dashboard/__tests__/TaskDashboard.spec.tsx create mode 100644 webview-ui/src/components/chat/task-dashboard/__tests__/useTaskTree.spec.ts create mode 100644 webview-ui/src/components/chat/task-dashboard/index.ts create mode 100644 webview-ui/src/components/chat/task-dashboard/useTaskTree.ts diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index dd9cbbe36a7..2d58eb82bc1 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -44,6 +44,7 @@ import { CheckpointWarning } from "./CheckpointWarning" import { QueuedMessages } from "./QueuedMessages" import { WorktreeSelector } from "./WorktreeSelector" import FileChangesPanel from "./FileChangesPanel" +import { TaskDashboard } from "./task-dashboard" import DismissibleUpsell from "../common/DismissibleUpsell" import { useCloudUpsell } from "@src/hooks/useCloudUpsell" import { useScrollLifecycle } from "@src/hooks/useScrollLifecycle" @@ -1615,6 +1616,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction + + {checkpointWarning && (
diff --git a/webview-ui/src/components/chat/task-dashboard/TaskDashboard.tsx b/webview-ui/src/components/chat/task-dashboard/TaskDashboard.tsx new file mode 100644 index 00000000000..d245daa4fa0 --- /dev/null +++ b/webview-ui/src/components/chat/task-dashboard/TaskDashboard.tsx @@ -0,0 +1,187 @@ +import { memo, useState, useCallback, useMemo } from "react" +import { ChevronDown, ChevronRight, GitBranch } from "lucide-react" + +import type { ModeConfig } from "@roo-code/types" + +import { getAllModes } from "@roo/modes" + +import { cn } from "@src/lib/utils" +import { vscode } from "@src/utils/vscode" +import { useExtensionState } from "@src/context/ExtensionStateContext" + +import type { TaskTreeNode } from "./useTaskTree" +import { useTaskTree } from "./useTaskTree" + +/** + * Status badge colors for task states. + */ +const statusConfig: Record = { + active: { label: "Active", className: "bg-vscode-charts-green text-white" }, + delegated: { label: "Delegated", className: "bg-vscode-charts-blue text-white" }, + completed: { + label: "Completed", + className: "bg-vscode-descriptionForeground/30 text-vscode-descriptionForeground", + }, +} + +interface TaskNodeRowProps { + node: TaskTreeNode + depth: number + currentTaskId?: string + modeMap: Map +} + +/** + * A single row in the task tree, showing mode name, status badge, + * and active indicator. Supports click-to-navigate. + */ +const TaskNodeRow = memo(({ node, depth, currentTaskId, modeMap }: TaskNodeRowProps) => { + const { item, children } = node + const isCurrentTask = item.id === currentTaskId + const modeConfig = item.mode ? modeMap.get(item.mode) : undefined + const modeName = modeConfig?.name ?? item.mode ?? "Unknown" + const status = item.status ?? "active" + const statusInfo = statusConfig[status] ?? statusConfig.active + + const handleClick = useCallback(() => { + vscode.postMessage({ type: "showTaskWithId", text: item.id }) + }, [item.id]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + handleClick() + } + }, + [handleClick], + ) + + // Truncate task description for display + const taskSummary = item.task.length > 60 ? item.task.slice(0, 57) + "..." : item.task + + return ( +
+
+ {/* Mode icon/indicator */} + + + {/* Mode name */} + + {modeName} + + + {/* Status badge */} + + {statusInfo.label} + + + {/* Task summary (truncated) */} + + {taskSummary} + +
+ + {/* Render children */} + {children.map((child) => ( + + ))} +
+ ) +}) + +TaskNodeRow.displayName = "TaskNodeRow" + +/** + * The Task Coordination Dashboard component. + * + * Displays a collapsible tree view of the current delegation session, + * showing each task's mode, status, and delegation relationships. + * Only visible when the current task is part of a multi-task delegation hierarchy. + */ +const TaskDashboard = () => { + const { taskHistory, currentTaskItem, currentTaskId, customModes } = useExtensionState() + const { rootNode, hasDelegationHierarchy } = useTaskTree(taskHistory, currentTaskItem) + const [isExpanded, setIsExpanded] = useState(true) + + // Build a mode lookup map + const modeMap = useMemo(() => { + const allModes = getAllModes(customModes) + const map = new Map() + for (const mode of allModes) { + map.set(mode.slug, mode) + } + return map + }, [customModes]) + + const toggleExpanded = useCallback(() => { + setIsExpanded((prev) => !prev) + }, []) + + // Don't render if there's no delegation hierarchy + if (!hasDelegationHierarchy || !rootNode) { + return null + } + + return ( +
+ {/* Header */} + + + {/* Tree content */} + {isExpanded && ( +
+ +
+ )} +
+ ) +} + +export default memo(TaskDashboard) 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 new file mode 100644 index 00000000000..117d9eca468 --- /dev/null +++ b/webview-ui/src/components/chat/task-dashboard/__tests__/TaskDashboard.spec.tsx @@ -0,0 +1,260 @@ +import { render, screen, fireEvent } from "@testing-library/react" +import type { HistoryItem } from "@roo-code/types" +import TaskDashboard from "../TaskDashboard" + +// Mock the vscode API +const mockPostMessage = vi.fn() +vi.mock("@src/utils/vscode", () => ({ + vscode: { postMessage: (...args: any[]) => mockPostMessage(...args) }, +})) + +// Mock useTranslation +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +// Mock extension state +let mockState: Record = {} +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => mockState, +})) + +// Mock modes +vi.mock("@roo/modes", () => ({ + getAllModes: (customModes: any[]) => [ + { slug: "orchestrator", name: "Orchestrator" }, + { slug: "code", name: "Code" }, + { slug: "architect", name: "Architect" }, + { slug: "debug", name: "Debug" }, + ...(customModes || []), + ], +})) + +function makeItem(overrides: Partial & { id: string }): HistoryItem { + return { + ts: Date.now(), + task: "Test task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + number: 1, + ...overrides, + } +} + +describe("TaskDashboard", () => { + beforeEach(() => { + mockPostMessage.mockClear() + mockState = { + taskHistory: [], + currentTaskItem: undefined, + currentTaskId: undefined, + customModes: [], + } + }) + + it("does not render when there is no delegation hierarchy", () => { + const standalone = makeItem({ id: "standalone", task: "Simple task" }) + mockState = { + taskHistory: [standalone], + currentTaskItem: standalone, + currentTaskId: "standalone", + customModes: [], + } + + const { container } = render() + expect(container.innerHTML).toBe("") + }) + + it("renders the dashboard when delegation hierarchy exists", () => { + const parent = makeItem({ + id: "parent-1", + task: "Orchestrator task", + mode: "orchestrator", + status: "delegated", + childIds: ["child-1"], + }) + const child = makeItem({ + id: "child-1", + task: "Code task", + mode: "code", + status: "active", + rootTaskId: "parent-1", + parentTaskId: "parent-1", + }) + mockState = { + taskHistory: [parent, child], + currentTaskItem: child, + currentTaskId: "child-1", + customModes: [], + } + + render() + + expect(screen.getByTestId("task-dashboard")).toBeTruthy() + expect(screen.getByText("Task Delegation")).toBeTruthy() + }) + + it("displays mode names for each task node", () => { + 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() + + expect(screen.getByText("Orchestrator")).toBeTruthy() + expect(screen.getByText("Code")).toBeTruthy() + }) + + it("displays status badges", () => { + 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() + + expect(screen.getByText("Delegated")).toBeTruthy() + expect(screen.getByText("Active")).toBeTruthy() + }) + + it("sends showTaskWithId message on task node click", () => { + 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() + + const parentNode = screen.getByTestId("task-node-parent-1") + fireEvent.click(parentNode.querySelector("[role='button']")!) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showTaskWithId", + text: "parent-1", + }) + }) + + it("collapses and expands the dashboard", () => { + 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() + + // Should start expanded + expect(screen.getByTestId("task-dashboard-content")).toBeTruthy() + + // Click toggle to collapse + fireEvent.click(screen.getByTestId("task-dashboard-toggle")) + + // Content should be hidden + expect(screen.queryByTestId("task-dashboard-content")).toBeNull() + + // Click again to expand + fireEvent.click(screen.getByTestId("task-dashboard-toggle")) + + expect(screen.getByTestId("task-dashboard-content")).toBeTruthy() + }) + + it("highlights the currently active task", () => { + 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() + + // The active task node should have the active selection class + const activeNode = screen.getByTestId("task-node-child-1") + const button = activeNode.querySelector("[role='button']") + expect(button?.className).toContain("activeSelection") + }) +}) 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 new file mode 100644 index 00000000000..f0d47d071e2 --- /dev/null +++ b/webview-ui/src/components/chat/task-dashboard/__tests__/useTaskTree.spec.ts @@ -0,0 +1,239 @@ +import type { HistoryItem } from "@roo-code/types" +import { buildTaskTree } from "../useTaskTree" + +function makeItem(overrides: Partial & { id: string }): HistoryItem { + return { + ts: Date.now(), + task: "Test task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + number: 1, + ...overrides, + } +} + +describe("buildTaskTree", () => { + it("returns null when currentTaskItem is undefined", () => { + const result = buildTaskTree([], undefined) + expect(result.rootNode).toBeNull() + expect(result.hasDelegationHierarchy).toBe(false) + }) + + it("returns null when current task has no delegation hierarchy", () => { + const item = makeItem({ id: "standalone" }) + const result = buildTaskTree([item], item) + expect(result.rootNode).toBeNull() + expect(result.hasDelegationHierarchy).toBe(false) + }) + + it("builds a simple parent-child tree", () => { + const parent = makeItem({ + id: "parent-1", + task: "Orchestrator task", + mode: "orchestrator", + status: "delegated", + childIds: ["child-1"], + }) + const child = makeItem({ + id: "child-1", + task: "Code task", + mode: "code", + status: "active", + rootTaskId: "parent-1", + parentTaskId: "parent-1", + }) + const history = [parent, child] + + const result = buildTaskTree(history, child) + + expect(result.hasDelegationHierarchy).toBe(true) + expect(result.rootNode).not.toBeNull() + expect(result.rootNode!.item.id).toBe("parent-1") + expect(result.rootNode!.children).toHaveLength(1) + expect(result.rootNode!.children[0].item.id).toBe("child-1") + }) + + it("builds a tree when current task is the root", () => { + const parent = makeItem({ + id: "parent-1", + task: "Orchestrator task", + mode: "orchestrator", + status: "delegated", + childIds: ["child-1"], + }) + const child = makeItem({ + id: "child-1", + task: "Code task", + mode: "code", + status: "active", + rootTaskId: "parent-1", + parentTaskId: "parent-1", + }) + const history = [parent, child] + + // Current task is the root itself + const result = buildTaskTree(history, parent) + + expect(result.hasDelegationHierarchy).toBe(true) + expect(result.rootNode!.item.id).toBe("parent-1") + expect(result.rootNode!.children).toHaveLength(1) + }) + + it("builds a deep tree (parent -> child -> grandchild)", () => { + 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.hasDelegationHierarchy).toBe(true) + expect(result.rootNode!.item.id).toBe("root") + expect(result.rootNode!.children).toHaveLength(1) + expect(result.rootNode!.children[0].item.id).toBe("mid") + expect(result.rootNode!.children[0].children).toHaveLength(1) + expect(result.rootNode!.children[0].children[0].item.id).toBe("leaf") + }) + + it("builds a tree with 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.hasDelegationHierarchy).toBe(true) + expect(result.rootNode!.children).toHaveLength(3) + }) + + it("handles circular references safely", () => { + const taskA = makeItem({ + id: "a", + task: "Task A", + status: "delegated", + childIds: ["b"], + }) + const taskB = makeItem({ + id: "b", + task: "Task B", + status: "delegated", + rootTaskId: "a", + parentTaskId: "a", + childIds: ["a"], // circular reference + }) + const history = [taskA, taskB] + + // Should not throw or infinite loop + const result = buildTaskTree(history, taskB) + + expect(result.hasDelegationHierarchy).toBe(true) + expect(result.rootNode!.item.id).toBe("a") + }) + + it("excludes tasks from other sessions", () => { + const root = makeItem({ + id: "root", + task: "Root task", + childIds: ["child"], + }) + const child = makeItem({ + id: "child", + task: "Child task", + rootTaskId: "root", + parentTaskId: "root", + }) + const otherRoot = makeItem({ + id: "other-root", + task: "Other session", + childIds: ["other-child"], + }) + const otherChild = makeItem({ + id: "other-child", + task: "Other child", + rootTaskId: "other-root", + parentTaskId: "other-root", + }) + const history = [root, child, otherRoot, otherChild] + + const result = buildTaskTree(history, child) + + expect(result.hasDelegationHierarchy).toBe(true) + expect(result.rootNode!.item.id).toBe("root") + expect(result.rootNode!.children).toHaveLength(1) + expect(result.rootNode!.children[0].item.id).toBe("child") + }) + + it("handles missing child items gracefully", () => { + const root = makeItem({ + id: "root", + task: "Root task", + childIds: ["existing-child", "missing-child"], + }) + const child = makeItem({ + id: "existing-child", + task: "Existing child", + rootTaskId: "root", + parentTaskId: "root", + }) + // "missing-child" is not in the history + const history = [root, child] + + const result = buildTaskTree(history, child) + + expect(result.hasDelegationHierarchy).toBe(true) + // Only the existing child should appear + expect(result.rootNode!.children).toHaveLength(1) + expect(result.rootNode!.children[0].item.id).toBe("existing-child") + }) +}) diff --git a/webview-ui/src/components/chat/task-dashboard/index.ts b/webview-ui/src/components/chat/task-dashboard/index.ts new file mode 100644 index 00000000000..d69211552b1 --- /dev/null +++ b/webview-ui/src/components/chat/task-dashboard/index.ts @@ -0,0 +1,3 @@ +export { default as TaskDashboard } from "./TaskDashboard" +export { useTaskTree, buildTaskTree } from "./useTaskTree" +export type { TaskTreeNode, TaskTreeResult } from "./useTaskTree" diff --git a/webview-ui/src/components/chat/task-dashboard/useTaskTree.ts b/webview-ui/src/components/chat/task-dashboard/useTaskTree.ts new file mode 100644 index 00000000000..af9edd94699 --- /dev/null +++ b/webview-ui/src/components/chat/task-dashboard/useTaskTree.ts @@ -0,0 +1,95 @@ +import { useMemo } from "react" +import type { HistoryItem } from "@roo-code/types" + +/** + * A node in the task delegation tree. + */ +export interface TaskTreeNode { + /** The history item for this task */ + item: HistoryItem + /** Child tasks that were delegated from this task */ + children: TaskTreeNode[] +} + +/** + * Result from the useTaskTree hook. + */ +export interface TaskTreeResult { + /** The root node of the task tree (null if no delegation hierarchy exists) */ + rootNode: TaskTreeNode | null + /** Whether the current task is part of a delegation hierarchy */ + hasDelegationHierarchy: boolean +} + +/** + * Given the full taskHistory and the current task item, build a tree + * of tasks belonging to the current delegation session. + * + * A "session" is identified by the rootTaskId: the top-level orchestrator + * task that started the delegation chain. All tasks sharing the same + * rootTaskId (or whose id IS the rootTaskId) belong to the same session. + */ +export function buildTaskTree(taskHistory: HistoryItem[], currentTaskItem?: HistoryItem): TaskTreeResult { + if (!currentTaskItem) { + return { rootNode: null, hasDelegationHierarchy: false } + } + + // Determine the root task ID for the current session. + // If the current task has a rootTaskId, use that. Otherwise, + // if the current task itself has children, it IS the root. + const rootId = currentTaskItem.rootTaskId ?? currentTaskItem.id + + // Collect all tasks belonging to this session + const sessionTasks = taskHistory.filter((item) => item.id === rootId || item.rootTaskId === rootId) + + // Need at least 2 tasks for a delegation hierarchy + if (sessionTasks.length < 2) { + return { rootNode: null, hasDelegationHierarchy: false } + } + + // Build lookup by id + const taskMap = new Map() + for (const task of sessionTasks) { + taskMap.set(task.id, task) + } + + // Build tree nodes recursively + const buildNode = (item: HistoryItem, visited: Set): TaskTreeNode => { + // Prevent circular references + if (visited.has(item.id)) { + return { item, children: [] } + } + visited.add(item.id) + + const children: TaskTreeNode[] = [] + if (item.childIds) { + for (const childId of item.childIds) { + const childItem = taskMap.get(childId) + if (childItem) { + children.push(buildNode(childItem, visited)) + } + } + } + + return { item, children } + } + + const rootItem = taskMap.get(rootId) + if (!rootItem) { + return { rootNode: null, hasDelegationHierarchy: false } + } + + const rootNode = buildNode(rootItem, new Set()) + return { rootNode, hasDelegationHierarchy: true } +} + +/** + * Hook that builds a task delegation tree for the current session. + * + * @param taskHistory - Full task history from extension state + * @param currentTaskItem - The currently active task's history item + * @returns The delegation tree for the current session + */ +export function useTaskTree(taskHistory: HistoryItem[], currentTaskItem?: HistoryItem): TaskTreeResult { + return useMemo(() => buildTaskTree(taskHistory, currentTaskItem), [taskHistory, currentTaskItem]) +} From 6ee6c06d1a8c7974267b6ba804630ba65a04c830 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 May 2026 03:02:28 +0000 Subject: [PATCH 2/2] feat: add task count in header and expand/collapse controls on tree nodes - Section header now shows task count (e.g. "Task Delegation (3 tasks)") - Tree nodes with children have expand/collapse toggle buttons - Added countTreeNodes helper and taskCount to useTaskTree - Added 8 new tests covering both features (24 total pass) --- .../chat/task-dashboard/TaskDashboard.tsx | 52 ++++++-- .../__tests__/TaskDashboard.spec.tsx | 112 +++++++++++++++++- .../__tests__/useTaskTree.spec.ts | 99 +++++++++++++++- .../chat/task-dashboard/useTaskTree.ts | 22 +++- 4 files changed, 266 insertions(+), 19 deletions(-) diff --git a/webview-ui/src/components/chat/task-dashboard/TaskDashboard.tsx b/webview-ui/src/components/chat/task-dashboard/TaskDashboard.tsx index d245daa4fa0..b06f21cbbee 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 117d9eca468..6c55ba7b294 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 f0d47d071e2..eaa12aec309 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 af9edd94699..41379d509f6 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) } } /**