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
This commit is contained in:
Roo Code 2025-09-17 20:13:51 +00:00
parent 7b1e3a0ee5
commit 40b74b9991
8 changed files with 1049 additions and 16 deletions

86
locales/en/chat.json generated Normal file
View file

@ -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"
}
}
}

39
locales/en/history.json generated Normal file
View file

@ -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)"
}

View file

@ -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<AggregatedCostDisplayProps> = ({ 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<string, HistoryItem[]>()
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 (
<div className={cn("text-sm", className)}>
<div className="flex items-center gap-2">
<StandardTooltip
content={
<div className="space-y-1">
<div className="font-semibold">{t("chat:task.aggregatedCost")}</div>
<div>{t("chat:task.aggregatedCostDescription")}</div>
</div>
}>
<div
className="flex items-center gap-1 cursor-pointer hover:text-vscode-foreground/90"
onClick={() => setIsExpanded(!isExpanded)}>
<Calculator size={14} className="opacity-70" />
<span className="font-medium">${aggregatedCost.toFixed(2)}</span>
<span className="text-xs text-vscode-descriptionForeground">
({t("chat:task.withSubtasks")})
</span>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</div>
</StandardTooltip>
</div>
{isExpanded && (
<div className="mt-2 ml-2 border-l-2 border-vscode-panel-border pl-3 space-y-1">
{breakdown.map((item) => (
<div
key={item.id}
className={cn(
"flex justify-between items-center text-xs",
item.isParent
? "font-semibold text-vscode-foreground"
: "text-vscode-descriptionForeground",
)}
style={{ paddingLeft: `${item.depth * 12}px` }}>
<div className="flex-1 truncate pr-2" title={item.task}>
{item.isParent && (
<span className="mr-1 text-vscode-textLink-foreground">
{t("chat:task.parent")}:
</span>
)}
{item.task.length > 50 ? `${item.task.substring(0, 50)}...` : item.task}
</div>
<div className="shrink-0 font-mono">${item.cost.toFixed(2)}</div>
</div>
))}
<div className="border-t border-vscode-panel-border pt-1 mt-2">
<div className="flex justify-between items-center text-xs font-semibold">
<span>{t("chat:task.total")}</span>
<span className="font-mono">${aggregatedCost.toFixed(2)}</span>
</div>
</div>
</div>
)}
</div>
)
}

View file

@ -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 = ({
</tr>
)}
{/* Aggregated cost for orchestrator tasks */}
{currentTaskItem && isTopLevelOrchestrator(currentTaskItem, mode) && (
<tr>
<th className="font-bold text-left align-top w-1 whitespace-nowrap pl-1 pr-3 h-[24px]">
{t("chat:task.aggregatedCost")}
</th>
<td className="align-top">
<AggregatedCostDisplay currentTask={currentTaskItem} />
</td>
</tr>
)}
{/* Size display */}
{!!currentTaskItem?.size && currentTaskItem.size > 0 && (
<tr>

View file

@ -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<HierarchicalTaskItemProps> = ({
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 (
<div className="relative">
{/* Indentation guide lines */}
{depth > 0 && (
<div
className="absolute left-0 top-0 bottom-0 border-l border-vscode-panel-border/30"
style={{ left: `${(depth - 1) * 24 + 12}px` }}
/>
)}
<div
className={cn("relative flex items-start gap-1", depth > 0 && "ml-6")}
style={{ paddingLeft: depth > 0 ? `${(depth - 1) * 24}px` : 0 }}>
{/* Expand/Collapse button for parent tasks */}
{hasChildren && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="shrink-0 p-0.5 mt-3 hover:bg-vscode-list-hoverBackground rounded"
aria-label={isExpanded ? t("history:collapse") : t("history:expand")}>
{isExpanded ? (
<ChevronDown size={16} className="text-vscode-foreground/60" />
) : (
<ChevronRight size={16} className="text-vscode-foreground/60" />
)}
</button>
)}
{/* Spacer for leaf nodes */}
{!hasChildren && depth > 0 && <div className="w-5 shrink-0" />}
<div className="flex-1 min-w-0">
{/* Task item with aggregated cost indicator */}
<div className="relative">
<TaskItem
item={node.task}
variant={variant}
showWorkspace={showWorkspace}
isSelectionMode={isSelectionMode}
isSelected={isSelected}
onToggleSelection={onToggleSelection}
onDelete={onDelete}
className="m-2"
/>
{/* Aggregated cost badge for orchestrator parents */}
{isOrchestrator && hasChildren && node.aggregatedCost > (node.task.totalCost || 0) && (
<div className="absolute top-2 right-2">
<StandardTooltip
content={
<div className="space-y-1">
<div className="font-semibold">{t("history:aggregatedCost")}</div>
<div>
{t("history:aggregatedCostDescription", {
total: node.aggregatedCost.toFixed(2),
count: node.descendantCount,
})}
</div>
</div>
}>
<div className="flex items-center gap-1 px-2 py-0.5 bg-vscode-badge-background text-vscode-badge-foreground rounded text-xs">
<Calculator size={12} />
<span>${node.aggregatedCost.toFixed(2)}</span>
<span className="text-[10px] opacity-70">(+{node.descendantCount})</span>
</div>
</StandardTooltip>
</div>
)}
{/* Hierarchy indicator */}
{hasChildren && (
<div className="absolute top-2 left-2">
<StandardTooltip content={t("history:hasSubtasks", { count: node.descendantCount })}>
<FolderTree size={14} className="text-vscode-textLink-foreground opacity-60" />
</StandardTooltip>
</div>
)}
</div>
</div>
</div>
{/* Render children recursively */}
{isExpanded && hasChildren && (
<div className="relative">
{node.children.map((child) => (
<HierarchicalTaskItem
key={child.task.id}
node={child}
variant={variant}
showWorkspace={showWorkspace}
isSelectionMode={isSelectionMode}
isSelected={isSelected}
onToggleSelection={onToggleSelection}
onDelete={onDelete}
depth={depth + 1}
searchQuery={_searchQuery}
/>
))}
</div>
)}
</div>
)
}

View file

@ -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<string[]>([])
const [showBatchDeleteDialog, setShowBatchDeleteDialog] = useState<boolean>(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 (
<Tab>
<TabHeader className="flex flex-col gap-2">
@ -194,6 +244,17 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
</SelectItem>
</SelectContent>
</Select>
<StandardTooltip content={t("history:toggleHierarchy")}>
<Button
variant="secondary"
size="sm"
onClick={() => setShowHierarchy(!showHierarchy)}
className="px-2">
<span
className={`codicon ${showHierarchy ? "codicon-list-tree" : "codicon-list-flat"}`}
/>
</Button>
</StandardTooltip>
</div>
{/* Select all control in selection mode */}
@ -225,7 +286,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
<TabContent className="px-2 py-0">
<Virtuoso
className="flex-1 overflow-y-scroll"
data={tasks}
data={flattenedItems as any}
data-testid="virtuoso-container"
initialTopMostItemIndex={0}
components={{
@ -233,19 +294,45 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
<div {...props} ref={ref} data-testid="virtuoso-item-list" />
)),
}}
itemContent={(_index, item) => (
<TaskItem
key={item.id}
item={item}
variant="full"
showWorkspace={showAllWorkspaces}
isSelectionMode={isSelectionMode}
isSelected={selectedTaskIds.includes(item.id)}
onToggleSelection={toggleTaskSelection}
onDelete={setDeleteTaskId}
className="m-2"
/>
)}
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 (
<HierarchicalTaskItem
key={node.task.id}
node={node}
variant="full"
showWorkspace={showAllWorkspaces}
isSelectionMode={isSelectionMode}
isSelected={selectedTaskIds.includes(node.task.id)}
onToggleSelection={toggleTaskSelection}
onDelete={setDeleteTaskId}
searchQuery={searchQuery}
/>
)
}
return null
} else {
// Flat view
const node = item as TaskTreeNode
return (
<TaskItem
key={node.task.id}
item={node.task}
variant="full"
showWorkspace={showAllWorkspaces}
isSelectionMode={isSelectionMode}
isSelected={selectedTaskIds.includes(node.task.id)}
onToggleSelection={toggleTaskSelection}
onDelete={setDeleteTaskId}
className="m-2"
/>
)
}
}}
/>
</TabContent>

View file

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

View file

@ -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<string, TaskTreeNode>()
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<string>()
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)
}