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)
This commit is contained in:
Roo Code 2026-05-12 03:02:28 +00:00
parent 5d91bf8a0a
commit 6ee6c06d1a
4 changed files with 266 additions and 19 deletions

View file

@ -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
<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",
"group flex items-center gap-1 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",
@ -75,6 +82,19 @@ const TaskNodeRow = memo(({ node, depth, currentTaskId, modeMap }: TaskNodeRowPr
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}>
{/* Expand/collapse toggle for nodes with children */}
{hasChildren ? (
<button
className="shrink-0 p-0 bg-transparent border-none cursor-pointer text-vscode-descriptionForeground hover:text-vscode-foreground flex items-center"
onClick={toggleNodeExpanded}
data-testid={`task-node-toggle-${item.id}`}
aria-label={isNodeExpanded ? "Collapse" : "Expand"}>
{isNodeExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</button>
) : (
<span className="shrink-0 size-3" />
)}
{/* Mode icon/indicator */}
<span
className={cn(
@ -109,16 +129,20 @@ const TaskNodeRow = memo(({ node, depth, currentTaskId, modeMap }: TaskNodeRowPr
</span>
</div>
{/* Render children */}
{children.map((child) => (
<TaskNodeRow
key={child.item.id}
node={child}
depth={depth + 1}
currentTaskId={currentTaskId}
modeMap={modeMap}
/>
))}
{/* Render children (collapsible) */}
{hasChildren && isNodeExpanded && (
<div data-testid={`task-node-children-${item.id}`}>
{children.map((child) => (
<TaskNodeRow
key={child.item.id}
node={child}
depth={depth + 1}
currentTaskId={currentTaskId}
modeMap={modeMap}
/>
))}
</div>
)}
</div>
)
})
@ -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 ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
<GitBranch className="size-3.5" />
<span>Task Delegation</span>
<span>
Task Delegation ({taskCount} {taskCount === 1 ? "task" : "tasks"})
</span>
</button>
{/* Tree content */}

View file

@ -92,7 +92,7 @@ describe("TaskDashboard", () => {
render(<TaskDashboard />)
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(<TaskDashboard />)
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(<TaskDashboard />)
// 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(<TaskDashboard />)
// 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()
})
})

View file

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

View file

@ -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) }
}
/**