mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
7b1e3a0ee5
commit
40b74b9991
8 changed files with 1049 additions and 16 deletions
86
locales/en/chat.json
generated
Normal file
86
locales/en/chat.json
generated
Normal 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
39
locales/en/history.json
generated
Normal 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)"
|
||||
}
|
||||
148
webview-ui/src/components/chat/AggregatedCostDisplay.tsx
Normal file
148
webview-ui/src/components/chat/AggregatedCostDisplay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
138
webview-ui/src/components/history/HierarchicalTaskItem.tsx
Normal file
138
webview-ui/src/components/history/HierarchicalTaskItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
333
webview-ui/src/utils/__tests__/taskHierarchy.spec.ts
Normal file
333
webview-ui/src/utils/__tests__/taskHierarchy.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
188
webview-ui/src/utils/taskHierarchy.ts
Normal file
188
webview-ui/src/utils/taskHierarchy.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue