diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index ace134566e..d2373a5c43 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -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 diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index 7b93b5c14a..dfa8d6b4c6 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -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 } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1a5092a375..3b8e2fa41b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -636,6 +636,7 @@ export class Task extends EventEmitter 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) diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index e7b574c490..dea48a4eaf 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -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([]) const [showBatchDeleteDialog, setShowBatchDeleteDialog] = useState(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) => {

{t("history:history")}

+ {showHierarchical && ( + <> + + + + + + + + )} + + + {
{/* Select all control in selection mode */} - {isSelectionMode && tasks.length > 0 && ( + {isSelectionMode && displayTasks.length > 0 && (
0 && selectedTaskIds.length === tasks.length} + checked={displayTasks.length > 0 && selectedTaskIds.length === displayTasks.length} onCheckedChange={(checked) => toggleSelectAll(checked === true)} variant="description" /> - {selectedTaskIds.length === tasks.length + {selectedTaskIds.length === displayTasks.length ? t("history:deselectAll") : t("history:selectAll")} {t("history:selectedItems", { selected: selectedTaskIds.length, - total: tasks.length, + total: displayTasks.length, })}
@@ -225,7 +256,7 @@ 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 && (
- {t("history:selectedItems", { selected: selectedTaskIds.length, total: tasks.length })} + {t("history:selectedItems", { selected: selectedTaskIds.length, total: displayTasks.length })}
+ )} + + {/* Spacer for items without children in hierarchical view */} + {isHierarchical && !hasChildren &&
} + {/* Selection checkbox - only in full variant */} {!isCompact && isSelectionMode && (
{ + 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(["some-other-id"]) // Non-empty set without task1 + const flattened = flattenTaskHierarchy(hierarchy, expandedIds) + + expect(flattened).toHaveLength(1) + expect(flattened[0].id).toBe("task1") + }) + }) +}) diff --git a/webview-ui/src/components/history/useTaskHierarchy.ts b/webview-ui/src/components/history/useTaskHierarchy.ts new file mode 100644 index 0000000000..79dd2facbf --- /dev/null +++ b/webview-ui/src/components/history/useTaskHierarchy.ts @@ -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() + 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 = 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>(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() + 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" diff --git a/webview-ui/src/i18n/locales/en/history.json b/webview-ui/src/i18n/locales/en/history.json index 8d00433170..b90445f6ba 100644 --- a/webview-ui/src/i18n/locales/en/history.json +++ b/webview-ui/src/i18n/locales/en/history.json @@ -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" }