mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-07 02:58:15 +00:00
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:
parent
5d91bf8a0a
commit
6ee6c06d1a
4 changed files with 266 additions and 19 deletions
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue