mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
22d845cecb
commit
5d91bf8a0a
6 changed files with 787 additions and 0 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
187
webview-ui/src/components/chat/task-dashboard/TaskDashboard.tsx
Normal file
187
webview-ui/src/components/chat/task-dashboard/TaskDashboard.tsx
Normal 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)
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
3
webview-ui/src/components/chat/task-dashboard/index.ts
Normal file
3
webview-ui/src/components/chat/task-dashboard/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as TaskDashboard } from "./TaskDashboard"
|
||||
export { useTaskTree, buildTaskTree } from "./useTaskTree"
|
||||
export type { TaskTreeNode, TaskTreeResult } from "./useTaskTree"
|
||||
95
webview-ui/src/components/chat/task-dashboard/useTaskTree.ts
Normal file
95
webview-ui/src/components/chat/task-dashboard/useTaskTree.ts
Normal 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])
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue