feat: implement hierarchical task execution history with HTA-inspired visualization

- Add parentId field to HistoryItem type for parent-child relationships
- Update Task class to track and persist parent task relationships
- Create useTaskHierarchy hook with tree building and flattening utilities
- Update HistoryView with tree/flat view toggle and expand/collapse controls
- Enhance TaskItem with hierarchical display including indentation and chevrons
- Add comprehensive tests for hierarchical data structure utilities
- Add translation keys for new UI elements

This implementation allows users to visualize task relationships in a tree structure,
making it easier to understand complex orchestrated workflows and trace task lineage.
This commit is contained in:
Roo Code 2025-08-29 14:14:48 +00:00
parent 1d46bd1bbc
commit b733d5e205
8 changed files with 451 additions and 9 deletions

View file

@ -17,6 +17,7 @@ export const historyItemSchema = z.object({
size: z.number().optional(),
workspace: z.string().optional(),
mode: z.string().optional(),
parentId: z.string().optional(),
})
export type HistoryItem = z.infer<typeof historyItemSchema>

View file

@ -19,6 +19,7 @@ export type TaskMetadataOptions = {
globalStoragePath: string
workspace: string
mode?: string
parentId?: string
}
export async function taskMetadata({
@ -28,6 +29,7 @@ export async function taskMetadata({
globalStoragePath,
workspace,
mode,
parentId,
}: TaskMetadataOptions) {
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
@ -95,6 +97,7 @@ export async function taskMetadata({
size: taskDirSize,
workspace,
mode,
parentId,
}
return { historyItem, tokenUsage }

View file

@ -636,6 +636,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
globalStoragePath: this.globalStoragePath,
workspace: this.cwd,
mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode
parentId: this.parentTask?.taskId,
})
this.emit(RooCodeEventName.TaskTokenUsageUpdated, this.taskId, tokenUsage)

View file

@ -20,6 +20,7 @@ import { useAppTranslation } from "@/i18n/TranslationContext"
import { Tab, TabContent, TabHeader } from "../common/Tab"
import { useTaskSearch } from "./useTaskSearch"
import TaskItem from "./TaskItem"
import { useTaskHierarchy } from "./useTaskHierarchy"
type HistoryViewProps = {
onDone: () => void
@ -44,6 +45,13 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
const [isSelectionMode, setIsSelectionMode] = useState(false)
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([])
const [showBatchDeleteDialog, setShowBatchDeleteDialog] = useState<boolean>(false)
const [showHierarchical, setShowHierarchical] = useState(true)
// Use the hierarchical task hook
const { flattenedTasks, expandedIds, toggleExpanded, expandAll, collapseAll } = useTaskHierarchy(tasks)
// Use either flat or hierarchical tasks based on toggle
const displayTasks = showHierarchical ? flattenedTasks : tasks
// Toggle selection mode
const toggleSelectionMode = () => {
@ -65,7 +73,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
// Toggle select all tasks
const toggleSelectAll = (selectAll: boolean) => {
if (selectAll) {
setSelectedTaskIds(tasks.map((task) => task.id))
setSelectedTaskIds(displayTasks.map((task) => task.id))
} else {
setSelectedTaskIds([])
}
@ -84,6 +92,29 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
<div className="flex justify-between items-center">
<h3 className="text-vscode-foreground m-0">{t("history:history")}</h3>
<div className="flex gap-2">
{showHierarchical && (
<>
<StandardTooltip content={t("history:expandAll")}>
<Button variant="secondary" onClick={expandAll}>
<span className="codicon codicon-expand-all" />
</Button>
</StandardTooltip>
<StandardTooltip content={t("history:collapseAll")}>
<Button variant="secondary" onClick={collapseAll}>
<span className="codicon codicon-collapse-all" />
</Button>
</StandardTooltip>
</>
)}
<StandardTooltip
content={showHierarchical ? t("history:showFlat") : t("history:showHierarchical")}>
<Button variant="secondary" onClick={() => setShowHierarchical(!showHierarchical)}>
<span
className={`codicon ${showHierarchical ? "codicon-list-flat" : "codicon-list-tree"} mr-1`}
/>
{showHierarchical ? t("history:flatView") : t("history:treeView")}
</Button>
</StandardTooltip>
<StandardTooltip
content={
isSelectionMode
@ -197,23 +228,23 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
</div>
{/* Select all control in selection mode */}
{isSelectionMode && tasks.length > 0 && (
{isSelectionMode && displayTasks.length > 0 && (
<div className="flex items-center py-1">
<div className="flex items-center gap-2">
<Checkbox
checked={tasks.length > 0 && selectedTaskIds.length === tasks.length}
checked={displayTasks.length > 0 && selectedTaskIds.length === displayTasks.length}
onCheckedChange={(checked) => toggleSelectAll(checked === true)}
variant="description"
/>
<span className="text-vscode-foreground">
{selectedTaskIds.length === tasks.length
{selectedTaskIds.length === displayTasks.length
? t("history:deselectAll")
: t("history:selectAll")}
</span>
<span className="ml-auto text-vscode-descriptionForeground text-xs">
{t("history:selectedItems", {
selected: selectedTaskIds.length,
total: tasks.length,
total: displayTasks.length,
})}
</span>
</div>
@ -225,7 +256,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
<TabContent className="px-2 py-0">
<Virtuoso
className="flex-1 overflow-y-scroll"
data={tasks}
data={displayTasks}
data-testid="virtuoso-container"
initialTopMostItemIndex={0}
components={{
@ -244,6 +275,11 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
onToggleSelection={toggleTaskSelection}
onDelete={setDeleteTaskId}
className="m-2"
isHierarchical={showHierarchical}
level={showHierarchical ? (item as any).level || 0 : 0}
hasChildren={showHierarchical && (item as any).children?.length > 0}
isExpanded={showHierarchical && expandedIds.has(item.id)}
onToggleExpanded={showHierarchical ? () => toggleExpanded(item.id) : undefined}
/>
)}
/>
@ -253,7 +289,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
{isSelectionMode && selectedTaskIds.length > 0 && (
<div className="fixed bottom-0 left-0 right-2 bg-vscode-editor-background border-t border-vscode-panel-border p-2 flex justify-between items-center">
<div className="text-vscode-foreground">
{t("history:selectedItems", { selected: selectedTaskIds.length, total: tasks.length })}
{t("history:selectedItems", { selected: selectedTaskIds.length, total: displayTasks.length })}
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setSelectedTaskIds([])}>

View file

@ -9,6 +9,8 @@ import TaskItemFooter from "./TaskItemFooter"
interface DisplayHistoryItem extends HistoryItem {
highlight?: string
level?: number
children?: DisplayHistoryItem[]
}
interface TaskItemProps {
@ -20,6 +22,11 @@ interface TaskItemProps {
onToggleSelection?: (taskId: string, isSelected: boolean) => void
onDelete?: (taskId: string) => void
className?: string
isHierarchical?: boolean
level?: number
hasChildren?: boolean
isExpanded?: boolean
onToggleExpanded?: () => void
}
const TaskItem = ({
@ -31,15 +38,27 @@ const TaskItem = ({
onToggleSelection,
onDelete,
className,
isHierarchical = false,
level = 0,
hasChildren = false,
isExpanded = false,
onToggleExpanded,
}: TaskItemProps) => {
const handleClick = () => {
if (isSelectionMode && onToggleSelection) {
onToggleSelection(item.id, !isSelected)
} else {
} else if (!isHierarchical || !hasChildren) {
vscode.postMessage({ type: "showTaskWithId", text: item.id })
}
}
const handleExpandClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (onToggleExpanded) {
onToggleExpanded()
}
}
const isCompact = variant === "compact"
return (
@ -50,8 +69,28 @@ const TaskItem = ({
"cursor-pointer group bg-vscode-editor-background rounded relative overflow-hidden border border-transparent hover:bg-vscode-list-hoverBackground transition-colors",
className,
)}
style={isHierarchical ? { marginLeft: `${level * 24}px` } : undefined}
onClick={handleClick}>
<div className={(!isCompact && isSelectionMode ? "pl-3 pb-3" : "pl-4") + " flex gap-3 px-3 pt-3 pb-1"}>
{/* Expand/collapse button for hierarchical view */}
{isHierarchical && hasChildren && (
<button
className="flex items-center justify-center w-5 h-5 mt-1 hover:bg-vscode-list-hoverBackground rounded"
onClick={handleExpandClick}
aria-label={isExpanded ? "Collapse" : "Expand"}>
<span
className={cn(
"codicon",
isExpanded ? "codicon-chevron-down" : "codicon-chevron-right",
"text-xs",
)}
/>
</button>
)}
{/* Spacer for items without children in hierarchical view */}
{isHierarchical && !hasChildren && <div className="w-5" />}
{/* Selection checkbox - only in full variant */}
{!isCompact && isSelectionMode && (
<div

View file

@ -0,0 +1,214 @@
import { describe, it, expect } from "vitest"
import { buildTaskHierarchy, flattenTaskHierarchy } from "../useTaskHierarchy"
import type { HistoryItem } from "@roo-code/types"
describe("useTaskHierarchy", () => {
describe("buildTaskHierarchy", () => {
it("should build a hierarchical structure from flat tasks", () => {
const tasks: HistoryItem[] = [
{
id: "task1",
number: 1,
ts: 1000,
task: "Parent Task 1",
tokensIn: 100,
tokensOut: 200,
totalCost: 0.01,
},
{
id: "task2",
number: 2,
ts: 2000,
task: "Child Task 1",
tokensIn: 50,
tokensOut: 100,
totalCost: 0.005,
parentId: "task1",
},
{
id: "task3",
number: 3,
ts: 3000,
task: "Parent Task 2",
tokensIn: 150,
tokensOut: 250,
totalCost: 0.015,
},
{
id: "task4",
number: 4,
ts: 4000,
task: "Child Task 2",
tokensIn: 75,
tokensOut: 125,
totalCost: 0.007,
parentId: "task1",
},
]
const hierarchy = buildTaskHierarchy(tasks)
// Should have 2 root tasks
expect(hierarchy).toHaveLength(2)
// First root task should be task3 (newer timestamp)
expect(hierarchy[0].id).toBe("task3")
expect(hierarchy[0].children).toHaveLength(0)
expect(hierarchy[0].level).toBe(0)
// Second root task should be task1 (older timestamp)
expect(hierarchy[1].id).toBe("task1")
expect(hierarchy[1].children).toHaveLength(2)
expect(hierarchy[1].level).toBe(0)
// Children of task1 should be sorted by timestamp (newest first)
expect(hierarchy[1].children[0].id).toBe("task4")
expect(hierarchy[1].children[0].level).toBe(1)
expect(hierarchy[1].children[1].id).toBe("task2")
expect(hierarchy[1].children[1].level).toBe(1)
})
it("should handle tasks with no parent-child relationships", () => {
const tasks: HistoryItem[] = [
{
id: "task1",
number: 1,
ts: 1000,
task: "Task 1",
tokensIn: 100,
tokensOut: 200,
totalCost: 0.01,
},
{
id: "task2",
number: 2,
ts: 2000,
task: "Task 2",
tokensIn: 50,
tokensOut: 100,
totalCost: 0.005,
},
]
const hierarchy = buildTaskHierarchy(tasks)
expect(hierarchy).toHaveLength(2)
expect(hierarchy[0].id).toBe("task2") // Newer first
expect(hierarchy[1].id).toBe("task1")
expect(hierarchy[0].children).toHaveLength(0)
expect(hierarchy[1].children).toHaveLength(0)
})
it("should handle nested hierarchies", () => {
const tasks: HistoryItem[] = [
{
id: "task1",
number: 1,
ts: 1000,
task: "Root Task",
tokensIn: 100,
tokensOut: 200,
totalCost: 0.01,
},
{
id: "task2",
number: 2,
ts: 2000,
task: "Child Task",
tokensIn: 50,
tokensOut: 100,
totalCost: 0.005,
parentId: "task1",
},
{
id: "task3",
number: 3,
ts: 3000,
task: "Grandchild Task",
tokensIn: 25,
tokensOut: 50,
totalCost: 0.002,
parentId: "task2",
},
]
const hierarchy = buildTaskHierarchy(tasks)
expect(hierarchy).toHaveLength(1)
expect(hierarchy[0].id).toBe("task1")
expect(hierarchy[0].children).toHaveLength(1)
expect(hierarchy[0].children[0].id).toBe("task2")
expect(hierarchy[0].children[0].level).toBe(1)
expect(hierarchy[0].children[0].children).toHaveLength(1)
expect(hierarchy[0].children[0].children[0].id).toBe("task3")
expect(hierarchy[0].children[0].children[0].level).toBe(2)
})
})
describe("flattenTaskHierarchy", () => {
it("should flatten hierarchical tasks with all expanded", () => {
const tasks: HistoryItem[] = [
{
id: "task1",
number: 1,
ts: 1000,
task: "Parent Task",
tokensIn: 100,
tokensOut: 200,
totalCost: 0.01,
},
{
id: "task2",
number: 2,
ts: 2000,
task: "Child Task",
tokensIn: 50,
tokensOut: 100,
totalCost: 0.005,
parentId: "task1",
},
]
const hierarchy = buildTaskHierarchy(tasks)
const expandedIds = new Set(["task1"])
const flattened = flattenTaskHierarchy(hierarchy, expandedIds)
expect(flattened).toHaveLength(2)
expect(flattened[0].id).toBe("task1")
expect(flattened[1].id).toBe("task2")
})
it("should hide children when parent is collapsed", () => {
const tasks: HistoryItem[] = [
{
id: "task1",
number: 1,
ts: 1000,
task: "Parent Task",
tokensIn: 100,
tokensOut: 200,
totalCost: 0.01,
},
{
id: "task2",
number: 2,
ts: 2000,
task: "Child Task",
tokensIn: 50,
tokensOut: 100,
totalCost: 0.005,
parentId: "task1",
},
]
const hierarchy = buildTaskHierarchy(tasks)
// When expandedIds is empty (size === 0), all items are expanded by default
// To test collapsed state, we need to pass a non-empty set that doesn't include task1
const expandedIds = new Set<string>(["some-other-id"]) // Non-empty set without task1
const flattened = flattenTaskHierarchy(hierarchy, expandedIds)
expect(flattened).toHaveLength(1)
expect(flattened[0].id).toBe("task1")
})
})
})

View file

@ -0,0 +1,142 @@
import { useMemo } from "react"
import type { HistoryItem } from "@roo-code/types"
export interface HierarchicalHistoryItem extends HistoryItem {
children: HierarchicalHistoryItem[]
level: number
isExpanded?: boolean
highlight?: string
}
/**
* Build a hierarchical tree structure from flat task history
*/
export function buildTaskHierarchy(tasks: HistoryItem[]): HierarchicalHistoryItem[] {
const taskMap = new Map<string, HierarchicalHistoryItem>()
const rootTasks: HierarchicalHistoryItem[] = []
// First pass: Create hierarchical items for all tasks
tasks.forEach((task) => {
const hierarchicalTask: HierarchicalHistoryItem = {
...task,
children: [],
level: 0,
isExpanded: true, // Default to expanded
}
taskMap.set(task.id, hierarchicalTask)
})
// Second pass: Build the tree structure
tasks.forEach((task) => {
const hierarchicalTask = taskMap.get(task.id)!
if (task.parentId && taskMap.has(task.parentId)) {
// This is a child task
const parent = taskMap.get(task.parentId)!
parent.children.push(hierarchicalTask)
hierarchicalTask.level = parent.level + 1
} else {
// This is a root task
rootTasks.push(hierarchicalTask)
}
})
// Sort tasks by timestamp (newest first) at each level
const sortByTimestamp = (a: HierarchicalHistoryItem, b: HierarchicalHistoryItem) => b.ts - a.ts
const sortRecursively = (items: HierarchicalHistoryItem[]) => {
items.sort(sortByTimestamp)
items.forEach((item) => {
if (item.children.length > 0) {
sortRecursively(item.children)
}
})
}
sortRecursively(rootTasks)
return rootTasks
}
/**
* Flatten a hierarchical task structure for display
*/
export function flattenTaskHierarchy(
tasks: HierarchicalHistoryItem[],
expandedIds: Set<string> = new Set(),
): HierarchicalHistoryItem[] {
const result: HierarchicalHistoryItem[] = []
const traverse = (items: HierarchicalHistoryItem[]) => {
items.forEach((item) => {
result.push(item)
// Only include children if the parent is expanded
// Default to expanded (true) unless explicitly collapsed (not in expandedIds when expandedIds is being used)
const isExpanded = expandedIds.size === 0 ? true : expandedIds.has(item.id)
if (item.children.length > 0 && isExpanded) {
traverse(item.children)
}
})
}
traverse(tasks)
return result
}
/**
* Custom hook to manage task hierarchy state
*/
export function useTaskHierarchy(tasks: HistoryItem[]) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const hierarchicalTasks = useMemo(() => {
return buildTaskHierarchy(tasks)
}, [tasks])
const flattenedTasks = useMemo(() => {
return flattenTaskHierarchy(hierarchicalTasks, expandedIds)
}, [hierarchicalTasks, expandedIds])
const toggleExpanded = (taskId: string) => {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(taskId)) {
next.delete(taskId)
} else {
next.add(taskId)
}
return next
})
}
const expandAll = () => {
const allIds = new Set<string>()
const collectIds = (items: HierarchicalHistoryItem[]) => {
items.forEach((item) => {
if (item.children.length > 0) {
allIds.add(item.id)
collectIds(item.children)
}
})
}
collectIds(hierarchicalTasks)
setExpandedIds(allIds)
}
const collapseAll = () => {
setExpandedIds(new Set())
}
return {
hierarchicalTasks,
flattenedTasks,
expandedIds,
toggleExpanded,
expandAll,
collapseAll,
}
}
// Import useState
import { useState } from "react"

View file

@ -41,5 +41,11 @@
"mostTokens": "Most Tokens",
"mostRelevant": "Most Relevant"
},
"viewAllHistory": "View all tasks"
"viewAllHistory": "View all tasks",
"expandAll": "Expand All",
"collapseAll": "Collapse All",
"showFlat": "Show Flat View",
"showHierarchical": "Show Hierarchical View",
"flatView": "Flat View",
"treeView": "Tree View"
}