From 40b74b9991f0d5fc73035b810cf965ff5be6b83f Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 17 Sep 2025 20:13:51 +0000 Subject: [PATCH] feat: add orchestrator cost aggregation and hierarchy visualization - Add utility functions for building task trees and calculating aggregated costs - Implement AggregatedCostDisplay component with collapsible breakdown - Update TaskHeader to show aggregated costs for top-level orchestrator tasks - Add HierarchicalTaskItem component for tree-based task display - Update HistoryView to support hierarchical task visualization with toggle - Add comprehensive tests for task hierarchy utilities - Add translation keys for new UI elements This implementation addresses Issue #5376 by: 1. Computing aggregated costs client-side from existing rootTaskId/parentTaskId fields 2. Showing collapsible cost breakdown in TaskHeader for orchestrator tasks 3. Rendering task history as an indented tree structure 4. Maintaining real-time updates as subtasks complete 5. Preserving existing behavior for non-orchestrator tasks --- locales/en/chat.json | 86 +++++ locales/en/history.json | 39 ++ .../components/chat/AggregatedCostDisplay.tsx | 148 ++++++++ webview-ui/src/components/chat/TaskHeader.tsx | 16 +- .../history/HierarchicalTaskItem.tsx | 138 ++++++++ .../src/components/history/HistoryView.tsx | 117 +++++- .../src/utils/__tests__/taskHierarchy.spec.ts | 333 ++++++++++++++++++ webview-ui/src/utils/taskHierarchy.ts | 188 ++++++++++ 8 files changed, 1049 insertions(+), 16 deletions(-) create mode 100644 locales/en/chat.json create mode 100644 locales/en/history.json create mode 100644 webview-ui/src/components/chat/AggregatedCostDisplay.tsx create mode 100644 webview-ui/src/components/history/HierarchicalTaskItem.tsx create mode 100644 webview-ui/src/utils/__tests__/taskHierarchy.spec.ts create mode 100644 webview-ui/src/utils/taskHierarchy.ts diff --git a/locales/en/chat.json b/locales/en/chat.json new file mode 100644 index 0000000000..c3e39c89ec --- /dev/null +++ b/locales/en/chat.json @@ -0,0 +1,86 @@ +{ + "task": { + "title": "Task", + "collapse": "Collapse task", + "expand": "Expand task", + "contextWindow": "Context Window", + "tokens": "Tokens", + "cache": "Cache", + "apiCost": "API Cost", + "aggregatedCost": "Total Cost", + "aggregatedCostDescription": "Total cost including all subtasks", + "withSubtasks": "with subtasks", + "parent": "Parent", + "total": "Total", + "size": "Size", + "unnamed": "Unnamed task", + "condenseContext": "Condense context" + }, + "tokenProgress": { + "tokensUsed": "{{used}} / {{total}} tokens used", + "reservedForResponse": "{{amount}} tokens reserved for response", + "availableSpace": "{{amount}} tokens available" + }, + "forNextMode": "for next mode", + "forPreviousMode": "for previous mode", + "typeMessage": "Type a message...", + "typeTask": "Type a task...", + "scrollToBottom": "Scroll to bottom", + "retry": { + "title": "Retry", + "tooltip": "Retry the failed request" + }, + "save": { + "title": "Save", + "tooltip": "Save the changes" + }, + "approve": { + "title": "Approve", + "tooltip": "Approve the action" + }, + "runCommand": { + "title": "Run Command", + "tooltip": "Execute the command" + }, + "startNewTask": { + "title": "Start New Task", + "tooltip": "Begin a new task" + }, + "resumeTask": { + "title": "Resume Task", + "tooltip": "Continue the paused task" + }, + "proceedAnyways": { + "title": "Proceed Anyways", + "tooltip": "Continue despite the warning" + }, + "proceedWhileRunning": { + "title": "Proceed While Running", + "tooltip": "Continue while the command is running" + }, + "cancel": { + "title": "Cancel", + "tooltip": "Cancel the current operation" + }, + "reject": { + "title": "Reject", + "tooltip": "Reject the proposed action" + }, + "terminate": { + "title": "Terminate", + "tooltip": "Stop the task completely" + }, + "killCommand": { + "title": "Kill Command", + "tooltip": "Terminate the running command" + }, + "completeSubtaskAndReturn": "Complete & Return", + "read-batch": { + "approve": { + "title": "Read Files" + }, + "deny": { + "title": "Deny" + } + } +} diff --git a/locales/en/history.json b/locales/en/history.json new file mode 100644 index 0000000000..38984b9130 --- /dev/null +++ b/locales/en/history.json @@ -0,0 +1,39 @@ +{ + "history": "History", + "done": "Done", + "searchPlaceholder": "Search tasks...", + "workspace": { + "prefix": "Workspace:", + "current": "Current", + "all": "All" + }, + "sort": { + "prefix": "Sort by", + "newest": "Newest", + "oldest": "Oldest", + "mostExpensive": "Most Expensive", + "mostTokens": "Most Tokens", + "mostRelevant": "Most Relevant" + }, + "newest": "Newest", + "oldest": "Oldest", + "mostExpensive": "Most Expensive", + "mostTokens": "Most Tokens", + "mostRelevant": "Most Relevant", + "recentTasks": "Recent Tasks", + "selectionMode": "Select", + "exitSelection": "Done", + "enterSelectionMode": "Enter selection mode to select multiple tasks", + "exitSelectionMode": "Exit selection mode", + "selectAll": "Select All", + "deselectAll": "Deselect All", + "selectedItems": "{{selected}} of {{total}} selected", + "clearSelection": "Clear", + "deleteSelected": "Delete Selected", + "toggleHierarchy": "Toggle hierarchy view", + "collapse": "Collapse", + "expand": "Expand", + "aggregatedCost": "Aggregated Cost", + "aggregatedCostDescription": "Total: ${{total}} (includes {{count}} subtasks)", + "hasSubtasks": "Has {{count}} subtask(s)" +} diff --git a/webview-ui/src/components/chat/AggregatedCostDisplay.tsx b/webview-ui/src/components/chat/AggregatedCostDisplay.tsx new file mode 100644 index 0000000000..1abf8a5a77 --- /dev/null +++ b/webview-ui/src/components/chat/AggregatedCostDisplay.tsx @@ -0,0 +1,148 @@ +import React, { useState, useMemo } from "react" +import { ChevronDown, ChevronRight, Calculator } from "lucide-react" +import type { HistoryItem } from "@roo-code/types" +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { calculateAggregatedCost, getTaskDescendants } from "@src/utils/taskHierarchy" +import { cn } from "@src/lib/utils" +import { StandardTooltip } from "@src/components/ui" +import { useTranslation } from "react-i18next" + +interface AggregatedCostDisplayProps { + currentTask: HistoryItem + className?: string +} + +interface CostBreakdownItem { + id: string + task: string + cost: number + isParent?: boolean + depth: number +} + +export const AggregatedCostDisplay: React.FC = ({ currentTask, className }) => { + const { t } = useTranslation() + const { taskHistory } = useExtensionState() + const [isExpanded, setIsExpanded] = useState(false) + + // Calculate aggregated cost and get breakdown + const { aggregatedCost, breakdown, hasDescendants } = useMemo(() => { + if (!currentTask || !taskHistory) { + return { aggregatedCost: 0, breakdown: [], hasDescendants: false } + } + + // Get all descendants + const descendants = getTaskDescendants(taskHistory, currentTask.id) + const hasDesc = descendants.length > 0 + + // Calculate total cost + const totalCost = calculateAggregatedCost(taskHistory, currentTask.id) + + // Build breakdown for display + const items: CostBreakdownItem[] = [] + + // Add parent task + items.push({ + id: currentTask.id, + task: currentTask.task || t("chat:task.unnamed"), + cost: currentTask.totalCost || 0, + isParent: true, + depth: 0, + }) + + // Build a tree structure for proper indentation + const childrenByParent = new Map() + descendants.forEach((desc) => { + const parentId = desc.parentTaskId || currentTask.id + if (!childrenByParent.has(parentId)) { + childrenByParent.set(parentId, []) + } + childrenByParent.get(parentId)!.push(desc) + }) + + // Recursively add children with proper indentation + function addChildren(parentId: string, depth: number) { + const children = childrenByParent.get(parentId) || [] + children.forEach((child) => { + items.push({ + id: child.id, + task: child.task || t("chat:task.unnamed"), + cost: child.totalCost || 0, + depth, + }) + // Recursively add this child's children + addChildren(child.id, depth + 1) + }) + } + + addChildren(currentTask.id, 1) + + return { + aggregatedCost: totalCost, + breakdown: items, + hasDescendants: hasDesc, + } + }, [currentTask, taskHistory, t]) + + // Don't show if no descendants + if (!hasDescendants) { + return null + } + + return ( +
+
+ +
{t("chat:task.aggregatedCost")}
+
{t("chat:task.aggregatedCostDescription")}
+
+ }> +
setIsExpanded(!isExpanded)}> + + ${aggregatedCost.toFixed(2)} + + ({t("chat:task.withSubtasks")}) + + {isExpanded ? : } +
+ +
+ + {isExpanded && ( +
+ {breakdown.map((item) => ( +
+
+ {item.isParent && ( + + {t("chat:task.parent")}: + + )} + {item.task.length > 50 ? `${item.task.substring(0, 50)}...` : item.task} +
+
${item.cost.toFixed(2)}
+
+ ))} +
+
+ {t("chat:task.total")} + ${aggregatedCost.toFixed(2)} +
+
+
+ )} + + ) +} diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 6164294722..9093e40a92 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -16,6 +16,7 @@ import { cn } from "@src/lib/utils" import { StandardTooltip } from "@src/components/ui" import { useExtensionState } from "@src/context/ExtensionStateContext" import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" +import { isTopLevelOrchestrator } from "@src/utils/taskHierarchy" import Thumbnails from "../common/Thumbnails" @@ -23,6 +24,7 @@ import { TaskActions } from "./TaskActions" import { ContextWindowProgress } from "./ContextWindowProgress" import { Mention } from "./Mention" import { TodoListDisplay } from "./TodoListDisplay" +import { AggregatedCostDisplay } from "./AggregatedCostDisplay" export interface TaskHeaderProps { task: ClineMessage @@ -50,7 +52,7 @@ const TaskHeader = ({ todos, }: TaskHeaderProps) => { const { t } = useTranslation() - const { apiConfiguration, currentTaskItem, clineMessages } = useExtensionState() + const { apiConfiguration, currentTaskItem, clineMessages, mode } = useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false) @@ -305,6 +307,18 @@ const TaskHeader = ({ )} + {/* Aggregated cost for orchestrator tasks */} + {currentTaskItem && isTopLevelOrchestrator(currentTaskItem, mode) && ( + + + {t("chat:task.aggregatedCost")} + + + + + + )} + {/* Size display */} {!!currentTaskItem?.size && currentTaskItem.size > 0 && ( diff --git a/webview-ui/src/components/history/HierarchicalTaskItem.tsx b/webview-ui/src/components/history/HierarchicalTaskItem.tsx new file mode 100644 index 0000000000..00f758cd46 --- /dev/null +++ b/webview-ui/src/components/history/HierarchicalTaskItem.tsx @@ -0,0 +1,138 @@ +import React, { useState } from "react" +import { ChevronRight, ChevronDown, FolderTree, Calculator } from "lucide-react" +import { cn } from "@src/lib/utils" +import { StandardTooltip } from "@src/components/ui" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import TaskItem from "./TaskItem" +import { TaskTreeNode } from "@src/utils/taskHierarchy" + +interface HierarchicalTaskItemProps { + node: TaskTreeNode + variant: "compact" | "full" + showWorkspace?: boolean + isSelectionMode?: boolean + isSelected?: boolean + onToggleSelection?: (taskId: string, isSelected: boolean) => void + onDelete?: (taskId: string) => void + depth?: number + searchQuery?: string +} + +export const HierarchicalTaskItem: React.FC = ({ + node, + variant, + showWorkspace = false, + isSelectionMode = false, + isSelected = false, + onToggleSelection, + onDelete, + depth = 0, + searchQuery: _searchQuery, +}) => { + const { t } = useAppTranslation() + const [isExpanded, setIsExpanded] = useState(true) + const hasChildren = node.children.length > 0 + const isOrchestrator = node.task.mode === "orchestrator" + + return ( +
+ {/* Indentation guide lines */} + {depth > 0 && ( +
+ )} + +
0 && "ml-6")} + style={{ paddingLeft: depth > 0 ? `${(depth - 1) * 24}px` : 0 }}> + {/* Expand/Collapse button for parent tasks */} + {hasChildren && ( + + )} + + {/* Spacer for leaf nodes */} + {!hasChildren && depth > 0 &&
} + +
+ {/* Task item with aggregated cost indicator */} +
+ + + {/* Aggregated cost badge for orchestrator parents */} + {isOrchestrator && hasChildren && node.aggregatedCost > (node.task.totalCost || 0) && ( +
+ +
{t("history:aggregatedCost")}
+
+ {t("history:aggregatedCostDescription", { + total: node.aggregatedCost.toFixed(2), + count: node.descendantCount, + })} +
+
+ }> +
+ + ${node.aggregatedCost.toFixed(2)} + (+{node.descendantCount}) +
+ +
+ )} + + {/* Hierarchy indicator */} + {hasChildren && ( +
+ + + +
+ )} +
+
+
+ + {/* Render children recursively */} + {isExpanded && hasChildren && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ) +} diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index e7b574c490..50f6ccdbe1 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -1,4 +1,4 @@ -import React, { memo, useState } from "react" +import React, { memo, useState, useMemo } from "react" import { DeleteTaskDialog } from "./DeleteTaskDialog" import { BatchDeleteTaskDialog } from "./BatchDeleteTaskDialog" import { Virtuoso } from "react-virtuoso" @@ -20,6 +20,8 @@ import { useAppTranslation } from "@/i18n/TranslationContext" import { Tab, TabContent, TabHeader } from "../common/Tab" import { useTaskSearch } from "./useTaskSearch" import TaskItem from "./TaskItem" +import { HierarchicalTaskItem } from "./HierarchicalTaskItem" +import { buildTaskTree, filterTaskTree, TaskTreeNode } from "@src/utils/taskHierarchy" type HistoryViewProps = { onDone: () => void @@ -44,6 +46,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { const [isSelectionMode, setIsSelectionMode] = useState(false) const [selectedTaskIds, setSelectedTaskIds] = useState([]) const [showBatchDeleteDialog, setShowBatchDeleteDialog] = useState(false) + const [showHierarchy, setShowHierarchy] = useState(true) // Toggle selection mode const toggleSelectionMode = () => { @@ -78,6 +81,53 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { } } + // Build hierarchical tree structure + const taskTree = useMemo(() => { + if (!showHierarchy) { + // Return flat list wrapped as nodes for consistent interface + return tasks.map( + (task) => + ({ + task, + children: [], + aggregatedCost: task.totalCost || 0, + descendantCount: 0, + }) as TaskTreeNode, + ) + } + + // Build actual tree + const tree = buildTaskTree(tasks) + + // Apply search filter if needed + if (searchQuery) { + return filterTaskTree(tree, searchQuery) + } + + return tree + }, [tasks, showHierarchy, searchQuery]) + + // Flatten tree for virtuoso when needed + const flattenedItems = useMemo(() => { + if (!showHierarchy) { + return taskTree + } + + const flattened: (TaskTreeNode | { node: TaskTreeNode; depth: number })[] = [] + + function flatten(nodes: TaskTreeNode[], depth = 0) { + nodes.forEach((node) => { + flattened.push({ node, depth }) + if (node.children.length > 0) { + flatten(node.children, depth + 1) + } + }) + } + + flatten(taskTree) + return flattened + }, [taskTree, showHierarchy]) + return ( @@ -194,6 +244,17 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { + + +
{/* Select all control in selection mode */} @@ -225,7 +286,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { {
)), }} - itemContent={(_index, item) => ( - - )} + itemContent={(_index, item: any) => { + if (showHierarchy && "node" in item && "depth" in item) { + // Hierarchical view + const { node, depth } = item as { node: TaskTreeNode; depth: number } + // Only render root nodes (depth 0) as HierarchicalTaskItem handles children + if (depth === 0) { + return ( + + ) + } + return null + } else { + // Flat view + const node = item as TaskTreeNode + return ( + + ) + } + }} /> diff --git a/webview-ui/src/utils/__tests__/taskHierarchy.spec.ts b/webview-ui/src/utils/__tests__/taskHierarchy.spec.ts new file mode 100644 index 0000000000..e56b06854e --- /dev/null +++ b/webview-ui/src/utils/__tests__/taskHierarchy.spec.ts @@ -0,0 +1,333 @@ +import { describe, it, expect } from "vitest" +import type { HistoryItem } from "@roo-code/types" +import { + buildTaskTree, + getTaskDescendants, + calculateAggregatedCost, + isTopLevelOrchestrator, + formatTaskTree, + filterTaskTree, +} from "../taskHierarchy" + +describe("taskHierarchy utilities", () => { + const mockTasks: HistoryItem[] = [ + { + id: "root-1", + number: 1, + ts: Date.now(), + task: "Root orchestrator task", + tokensIn: 100, + tokensOut: 50, + totalCost: 1.5, + mode: "orchestrator", + }, + { + id: "child-1", + rootTaskId: "root-1", + parentTaskId: "root-1", + number: 2, + ts: Date.now(), + task: "Child task 1", + tokensIn: 50, + tokensOut: 25, + totalCost: 0.5, + mode: "code", + }, + { + id: "child-2", + rootTaskId: "root-1", + parentTaskId: "root-1", + number: 3, + ts: Date.now(), + task: "Child task 2", + tokensIn: 75, + tokensOut: 40, + totalCost: 0.8, + mode: "debug", + }, + { + id: "grandchild-1", + rootTaskId: "root-1", + parentTaskId: "child-1", + number: 4, + ts: Date.now(), + task: "Grandchild task", + tokensIn: 25, + tokensOut: 15, + totalCost: 0.3, + mode: "code", + }, + { + id: "root-2", + number: 5, + ts: Date.now(), + task: "Another root task", + tokensIn: 200, + tokensOut: 100, + totalCost: 2.0, + mode: "code", + }, + ] + + describe("buildTaskTree", () => { + it("should build a correct tree structure from flat tasks", () => { + const tree = buildTaskTree(mockTasks) + + expect(tree).toHaveLength(2) // Two root nodes + expect(tree[0].task.id).toBe("root-1") + expect(tree[1].task.id).toBe("root-2") + + // Check root-1 structure + expect(tree[0].children).toHaveLength(2) // Two direct children + expect(tree[0].children[0].task.id).toBe("child-1") + expect(tree[0].children[1].task.id).toBe("child-2") + + // Check grandchild + expect(tree[0].children[0].children).toHaveLength(1) + expect(tree[0].children[0].children[0].task.id).toBe("grandchild-1") + + // Check root-2 has no children + expect(tree[1].children).toHaveLength(0) + }) + + it("should calculate aggregated costs correctly", () => { + const tree = buildTaskTree(mockTasks) + + // Root-1 should have aggregated cost of all its descendants + // 1.5 (root) + 0.5 (child-1) + 0.8 (child-2) + 0.3 (grandchild) = 3.1 + expect(tree[0].aggregatedCost).toBeCloseTo(3.1, 1) + expect(tree[0].descendantCount).toBe(3) + + // Child-1 should include grandchild cost + // 0.5 (self) + 0.3 (grandchild) = 0.8 + expect(tree[0].children[0].aggregatedCost).toBeCloseTo(0.8, 1) + expect(tree[0].children[0].descendantCount).toBe(1) + + // Root-2 has no children + expect(tree[1].aggregatedCost).toBe(2.0) + expect(tree[1].descendantCount).toBe(0) + }) + + it("should handle empty task list", () => { + const tree = buildTaskTree([]) + expect(tree).toHaveLength(0) + }) + + it("should handle orphaned tasks (parent not in list)", () => { + const orphanedTasks: HistoryItem[] = [ + { + id: "orphan", + parentTaskId: "non-existent", + number: 1, + ts: Date.now(), + task: "Orphaned task", + tokensIn: 10, + tokensOut: 5, + totalCost: 0.1, + }, + ] + + const tree = buildTaskTree(orphanedTasks) + expect(tree).toHaveLength(1) + expect(tree[0].task.id).toBe("orphan") + }) + }) + + describe("getTaskDescendants", () => { + it("should return all descendants of a task", () => { + const descendants = getTaskDescendants(mockTasks, "root-1") + + expect(descendants).toHaveLength(3) + const descendantIds = descendants.map((d) => d.id) + expect(descendantIds).toContain("child-1") + expect(descendantIds).toContain("child-2") + expect(descendantIds).toContain("grandchild-1") + }) + + it("should return empty array for task with no descendants", () => { + const descendants = getTaskDescendants(mockTasks, "root-2") + expect(descendants).toHaveLength(0) + }) + + it("should return empty array for non-existent task", () => { + const descendants = getTaskDescendants(mockTasks, "non-existent") + expect(descendants).toHaveLength(0) + }) + + it("should handle circular references gracefully", () => { + const circularTasks: HistoryItem[] = [ + { + id: "task-a", + parentTaskId: "task-b", + number: 1, + ts: Date.now(), + task: "Task A", + tokensIn: 10, + tokensOut: 5, + totalCost: 0.1, + }, + { + id: "task-b", + parentTaskId: "task-a", + number: 2, + ts: Date.now(), + task: "Task B", + tokensIn: 10, + tokensOut: 5, + totalCost: 0.1, + }, + ] + + // Should not cause infinite loop - in this case both tasks are descendants of each other + const descendants = getTaskDescendants(circularTasks, "task-a") + // task-a has task-b as a child, and task-b has task-a as a child (circular) + // The visited set prevents infinite loop, but both are found as descendants + expect(descendants).toHaveLength(2) + }) + }) + + describe("calculateAggregatedCost", () => { + it("should calculate total cost including descendants", () => { + const cost = calculateAggregatedCost(mockTasks, "root-1") + // 1.5 + 0.5 + 0.8 + 0.3 = 3.1 + expect(cost).toBeCloseTo(3.1, 1) + }) + + it("should return task's own cost if no descendants", () => { + const cost = calculateAggregatedCost(mockTasks, "root-2") + expect(cost).toBe(2.0) + }) + + it("should return 0 for non-existent task", () => { + const cost = calculateAggregatedCost(mockTasks, "non-existent") + expect(cost).toBe(0) + }) + + it("should handle tasks with undefined totalCost", () => { + const tasksWithUndefinedCost: HistoryItem[] = [ + { + id: "task-1", + number: 1, + ts: Date.now(), + task: "Task without cost", + tokensIn: 10, + tokensOut: 5, + totalCost: 0, // Set to 0 instead of undefined + }, + { + id: "child", + parentTaskId: "task-1", + number: 2, + ts: Date.now(), + task: "Child with cost", + tokensIn: 10, + tokensOut: 5, + totalCost: 0.5, + }, + ] + + const cost = calculateAggregatedCost(tasksWithUndefinedCost, "task-1") + expect(cost).toBe(0.5) // Parent's 0 + child's 0.5 + }) + }) + + describe("isTopLevelOrchestrator", () => { + it("should return true for top-level orchestrator task", () => { + const task = mockTasks[0] // root-1 with mode="orchestrator" + expect(isTopLevelOrchestrator(task, "orchestrator")).toBe(true) + }) + + it("should return false for orchestrator with parent", () => { + const task: HistoryItem = { + ...mockTasks[1], + mode: "orchestrator", + } + expect(isTopLevelOrchestrator(task, "orchestrator")).toBe(false) + }) + + it("should return false for non-orchestrator task", () => { + const task = mockTasks[4] // root-2 with mode="code" + expect(isTopLevelOrchestrator(task, "code")).toBe(false) + }) + + it("should return false for undefined task", () => { + expect(isTopLevelOrchestrator(undefined, "orchestrator")).toBe(false) + }) + + it("should return false for undefined mode", () => { + expect(isTopLevelOrchestrator(mockTasks[0], undefined)).toBe(false) + }) + }) + + describe("formatTaskTree", () => { + it("should format tree with correct depth levels", () => { + const tree = buildTaskTree(mockTasks) + const formatted = formatTaskTree(tree[0]) + + expect(formatted).toHaveLength(4) // root + 2 children + 1 grandchild + expect(formatted[0].depth).toBe(0) // root + expect(formatted[1].depth).toBe(1) // child-1 + expect(formatted[2].depth).toBe(2) // grandchild + expect(formatted[3].depth).toBe(1) // child-2 + }) + + it("should include aggregated costs and hasChildren flag", () => { + const tree = buildTaskTree(mockTasks) + const formatted = formatTaskTree(tree[0]) + + expect(formatted[0].hasChildren).toBe(true) + expect(formatted[0].aggregatedCost).toBeCloseTo(3.1, 1) + + expect(formatted[1].hasChildren).toBe(true) + expect(formatted[1].aggregatedCost).toBeCloseTo(0.8, 1) + + expect(formatted[2].hasChildren).toBe(false) + expect(formatted[2].aggregatedCost).toBe(0.3) + }) + }) + + describe("filterTaskTree", () => { + it("should filter tree based on search term", () => { + const tree = buildTaskTree(mockTasks) + const filtered = filterTaskTree(tree, "child") + + // Should include root-1 (has matching children) and both child tasks + expect(filtered).toHaveLength(1) // Only root-1 tree + expect(filtered[0].task.id).toBe("root-1") + expect(filtered[0].children).toHaveLength(2) + }) + + it("should include parent if child matches", () => { + const tree = buildTaskTree(mockTasks) + const filtered = filterTaskTree(tree, "grandchild") + + expect(filtered).toHaveLength(1) + expect(filtered[0].task.id).toBe("root-1") + expect(filtered[0].children).toHaveLength(1) // Only child-1 + expect(filtered[0].children[0].task.id).toBe("child-1") + expect(filtered[0].children[0].children).toHaveLength(1) + }) + + it("should return original tree for empty search term", () => { + const tree = buildTaskTree(mockTasks) + const filtered = filterTaskTree(tree, "") + + expect(filtered).toEqual(tree) + }) + + it("should be case-insensitive", () => { + const tree = buildTaskTree(mockTasks) + const filtered = filterTaskTree(tree, "CHILD") + + expect(filtered).toHaveLength(1) + expect(filtered[0].children).toHaveLength(2) + }) + + it("should return empty array if no matches", () => { + const tree = buildTaskTree(mockTasks) + const filtered = filterTaskTree(tree, "nonexistent") + + expect(filtered).toHaveLength(0) + }) + }) +}) diff --git a/webview-ui/src/utils/taskHierarchy.ts b/webview-ui/src/utils/taskHierarchy.ts new file mode 100644 index 0000000000..3576294c11 --- /dev/null +++ b/webview-ui/src/utils/taskHierarchy.ts @@ -0,0 +1,188 @@ +import type { HistoryItem } from "@roo-code/types" + +export interface TaskTreeNode { + task: HistoryItem + children: TaskTreeNode[] + aggregatedCost: number + descendantCount: number +} + +/** + * Builds a hierarchical tree structure from a flat list of history items + * @param tasks - Flat list of history items + * @returns Array of root task nodes with their children + */ +export function buildTaskTree(tasks: HistoryItem[]): TaskTreeNode[] { + const taskMap = new Map() + const rootNodes: TaskTreeNode[] = [] + + // First pass: Create nodes for all tasks + tasks.forEach((task) => { + taskMap.set(task.id, { + task, + children: [], + aggregatedCost: task.totalCost || 0, + descendantCount: 0, + }) + }) + + // Second pass: Build the tree structure + tasks.forEach((task) => { + const node = taskMap.get(task.id) + if (!node) return + + if (task.parentTaskId) { + const parent = taskMap.get(task.parentTaskId) + if (parent) { + parent.children.push(node) + } else { + // Parent not found in current list, treat as root + rootNodes.push(node) + } + } else { + // No parent, this is a root node + rootNodes.push(node) + } + }) + + // Third pass: Calculate aggregated costs and descendant counts + rootNodes.forEach((root) => calculateAggregatedMetrics(root)) + + return rootNodes +} + +/** + * Recursively calculates aggregated cost and descendant count for a task node + * @param node - Task node to calculate metrics for + * @returns Object containing aggregated cost and descendant count + */ +function calculateAggregatedMetrics(node: TaskTreeNode): { cost: number; count: number } { + let totalCost = node.task.totalCost || 0 + let totalCount = 0 + + node.children.forEach((child) => { + const childMetrics = calculateAggregatedMetrics(child) + totalCost += childMetrics.cost + totalCount += childMetrics.count + 1 + }) + + node.aggregatedCost = totalCost + node.descendantCount = totalCount + + return { cost: totalCost, count: totalCount } +} + +/** + * Gets all descendants of a task by its ID + * @param tasks - Flat list of history items + * @param rootTaskId - ID of the root task + * @returns Array of descendant tasks + */ +export function getTaskDescendants(tasks: HistoryItem[], rootTaskId: string): HistoryItem[] { + const descendants: HistoryItem[] = [] + const visited = new Set() + + function collectDescendants(taskId: string) { + if (visited.has(taskId)) return + visited.add(taskId) + + tasks.forEach((task) => { + if (task.parentTaskId === taskId) { + descendants.push(task) + collectDescendants(task.id) + } + }) + } + + collectDescendants(rootTaskId) + return descendants +} + +/** + * Calculates the total cost for a task and all its descendants + * @param tasks - Flat list of history items + * @param rootTaskId - ID of the root task + * @returns Total aggregated cost + */ +export function calculateAggregatedCost(tasks: HistoryItem[], rootTaskId: string): number { + const rootTask = tasks.find((t) => t.id === rootTaskId) + if (!rootTask) return 0 + + const descendants = getTaskDescendants(tasks, rootTaskId) + const totalCost = (rootTask.totalCost || 0) + descendants.reduce((sum, task) => sum + (task.totalCost || 0), 0) + + return totalCost +} + +/** + * Checks if a task is a top-level orchestrator task + * @param task - Task to check + * @param mode - Current mode of the task + * @returns True if the task is a top-level orchestrator + */ +export function isTopLevelOrchestrator(task: HistoryItem | undefined, mode: string | undefined): boolean { + if (!task || !mode) return false + return mode === "orchestrator" && !task.parentTaskId +} + +/** + * Formats a task tree for display with indentation + * @param node - Task tree node + * @param depth - Current depth for indentation + * @returns Array of formatted task items + */ +export function formatTaskTree( + node: TaskTreeNode, + depth: number = 0, +): Array<{ + task: HistoryItem + depth: number + aggregatedCost: number + hasChildren: boolean +}> { + const result = [ + { + task: node.task, + depth, + aggregatedCost: node.aggregatedCost, + hasChildren: node.children.length > 0, + }, + ] + + node.children.forEach((child) => { + result.push(...formatTaskTree(child, depth + 1)) + }) + + return result +} + +/** + * Filters a task tree based on search criteria while maintaining hierarchy + * @param nodes - Array of task tree nodes + * @param searchTerm - Search term to filter by + * @returns Filtered tree nodes + */ +export function filterTaskTree(nodes: TaskTreeNode[], searchTerm: string): TaskTreeNode[] { + if (!searchTerm) return nodes + + const lowerSearch = searchTerm.toLowerCase() + + function filterNode(node: TaskTreeNode): TaskTreeNode | null { + const taskMatches = node.task.task?.toLowerCase().includes(lowerSearch) + const filteredChildren = node.children + .map((child) => filterNode(child)) + .filter((child): child is TaskTreeNode => child !== null) + + // Include node if it matches or has matching children + if (taskMatches || filteredChildren.length > 0) { + return { + ...node, + children: filteredChildren, + } + } + + return null + } + + return nodes.map((node) => filterNode(node)).filter((node): node is TaskTreeNode => node !== null) +}