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
This commit is contained in:
Roo Code 2026-05-12 02:38:42 +00:00
parent 22d845cecb
commit 5d91bf8a0a
6 changed files with 787 additions and 0 deletions

View file

@ -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<ChatViewRef, ChatViewPro
todos={latestTodos}
/>
<TaskDashboard />
{checkpointWarning && (
<div className="px-3">
<CheckpointWarning warning={checkpointWarning} />

View file

@ -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<string, { label: string; className: string }> = {
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<string, ModeConfig>
}
/**
* 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 (
<div data-testid={`task-node-${item.id}`}>
<div
className={cn(
"group flex items-center gap-2 py-1.5 px-2 cursor-pointer rounded-sm transition-colors",
"hover:bg-vscode-list-hoverBackground",
isCurrentTask &&
"bg-vscode-list-activeSelectionBackground/20 border-l-2 border-vscode-charts-green",
!isCurrentTask && "border-l-2 border-transparent",
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={handleClick}
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}>
{/* Mode icon/indicator */}
<span
className={cn(
"shrink-0 size-2 rounded-full",
status === "active" && "bg-vscode-charts-green",
status === "delegated" && "bg-vscode-charts-blue",
status === "completed" && "bg-vscode-descriptionForeground/50",
)}
/>
{/* Mode name */}
<span
className={cn(
"text-xs font-medium shrink-0",
isCurrentTask ? "text-vscode-foreground" : "text-vscode-descriptionForeground",
)}>
{modeName}
</span>
{/* Status badge */}
<span
className={cn(
"text-[10px] px-1.5 py-0.5 rounded-full leading-none shrink-0",
statusInfo.className,
)}>
{statusInfo.label}
</span>
{/* Task summary (truncated) */}
<span className="text-xs text-vscode-descriptionForeground truncate min-w-0" title={item.task}>
{taskSummary}
</span>
</div>
{/* Render children */}
{children.map((child) => (
<TaskNodeRow
key={child.item.id}
node={child}
depth={depth + 1}
currentTaskId={currentTaskId}
modeMap={modeMap}
/>
))}
</div>
)
})
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<string, ModeConfig>()
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 (
<div
data-testid="task-dashboard"
className="border-b border-vscode-panel-border bg-vscode-sideBar-background/50">
{/* Header */}
<button
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-xs font-medium",
"text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-colors cursor-pointer select-none",
)}
onClick={toggleExpanded}
data-testid="task-dashboard-toggle">
{isExpanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
<GitBranch className="size-3.5" />
<span>Task Delegation</span>
</button>
{/* Tree content */}
{isExpanded && (
<div className="pb-2" data-testid="task-dashboard-content">
<TaskNodeRow node={rootNode} depth={0} currentTaskId={currentTaskId} modeMap={modeMap} />
</div>
)}
</div>
)
}
export default memo(TaskDashboard)

View file

@ -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<string, any> = {}
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<HistoryItem> & { 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(<TaskDashboard />)
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(<TaskDashboard />)
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(<TaskDashboard />)
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(<TaskDashboard />)
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(<TaskDashboard />)
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(<TaskDashboard />)
// 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(<TaskDashboard />)
// 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")
})
})

View file

@ -0,0 +1,239 @@
import type { HistoryItem } from "@roo-code/types"
import { buildTaskTree } from "../useTaskTree"
function makeItem(overrides: Partial<HistoryItem> & { 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")
})
})

View file

@ -0,0 +1,3 @@
export { default as TaskDashboard } from "./TaskDashboard"
export { useTaskTree, buildTaskTree } from "./useTaskTree"
export type { TaskTreeNode, TaskTreeResult } from "./useTaskTree"

View file

@ -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<string, HistoryItem>()
for (const task of sessionTasks) {
taskMap.set(task.id, task)
}
// Build tree nodes recursively
const buildNode = (item: HistoryItem, visited: Set<string>): 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])
}