feat(history): render nested subtasks as recursive tree (#11299)

* feat(history): render nested subtasks as recursive tree

* fix(lockfile): resolve missing ai-sdk provider entry

* fix: address review feedback — dedupe countAll, increase SubtaskRow max-h

- HistoryView: replace local countAll with imported countAllSubtasks from types.ts
- SubtaskRow: increase nested children max-h from 500px to 2000px to match TaskGroupItem
This commit is contained in:
Hannes Rudolph 2026-02-07 21:39:45 -07:00 committed by GitHub
parent 5d17f56db7
commit 7db4bfef5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 687 additions and 77 deletions

View file

@ -38,6 +38,7 @@ const HistoryPreview = () => {
group={group}
variant="compact"
onToggleExpand={() => toggleExpand(group.parent.id)}
onToggleSubtaskExpand={toggleExpand}
/>
))}
</>

View file

@ -21,6 +21,7 @@ import { useAppTranslation } from "@/i18n/TranslationContext"
import { Tab, TabContent, TabHeader } from "../common/Tab"
import { useTaskSearch } from "./useTaskSearch"
import { useGroupedTasks } from "./useGroupedTasks"
import { countAllSubtasks } from "./types"
import TaskItem from "./TaskItem"
import TaskGroupItem from "./TaskGroupItem"
@ -52,11 +53,11 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([])
const [showBatchDeleteDialog, setShowBatchDeleteDialog] = useState<boolean>(false)
// Get subtask count for a task
// Get subtask count for a task (recursive total)
const getSubtaskCount = useMemo(() => {
const countMap = new Map<string, number>()
for (const group of groups) {
countMap.set(group.parent.id, group.subtasks.length)
countMap.set(group.parent.id, countAllSubtasks(group.subtasks))
}
return (taskId: string) => countMap.get(taskId) || 0
}, [groups])
@ -300,6 +301,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
onToggleSelection={toggleTaskSelection}
onDelete={handleDelete}
onToggleExpand={() => toggleExpand(group.parent.id)}
onToggleSubtaskExpand={toggleExpand}
className="m-2"
/>
)}

View file

@ -2,46 +2,87 @@ import { memo } from "react"
import { ArrowRight } from "lucide-react"
import { vscode } from "@/utils/vscode"
import { cn } from "@/lib/utils"
import type { DisplayHistoryItem } from "./types"
import type { SubtaskTreeNode } from "./types"
import { countAllSubtasks } from "./types"
import { StandardTooltip } from "../ui"
import SubtaskCollapsibleRow from "./SubtaskCollapsibleRow"
interface SubtaskRowProps {
/** The subtask to display */
item: DisplayHistoryItem
/** The subtask tree node to display */
node: SubtaskTreeNode
/** Nesting depth (1 = direct child of parent group) */
depth: number
/** Callback when expand/collapse is toggled for a node */
onToggleExpand: (taskId: string) => void
/** Optional className for styling */
className?: string
}
/**
* Displays an individual subtask row when the parent's subtask list is expanded.
* Shows the task name and token/cost info in an indented format.
* Displays a subtask row with recursive nesting support.
* Leaf nodes render just the task row. Nodes with children show
* a collapsible section that can be expanded to reveal nested subtasks.
*/
const SubtaskRow = ({ item, className }: SubtaskRowProps) => {
const SubtaskRow = ({ node, depth, onToggleExpand, className }: SubtaskRowProps) => {
const { item, children, isExpanded } = node
const hasChildren = children.length > 0
const handleClick = () => {
vscode.postMessage({ type: "showTaskWithId", text: item.id })
}
return (
<div
data-testid={`subtask-row-${item.id}`}
className={cn(
"group flex items-center justify-between gap-2 pl-1 pr-4 py-1 ml-6 cursor-pointer",
"text-vscode-foreground/60 hover:text-vscode-foreground transition-colors",
className,
<div data-testid={`subtask-row-${item.id}`} className={className}>
{/* Task row with depth indentation */}
<div
className={cn(
"group flex items-center justify-between gap-2 pr-4 py-1 cursor-pointer",
"text-vscode-foreground/60 hover:text-vscode-foreground transition-colors",
)}
style={{ paddingLeft: `${depth * 16}px` }}
onClick={handleClick}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
handleClick()
}
}}>
<StandardTooltip content={item.task} delay={600}>
<span className="text-sm line-clamp-1">{item.task}</span>
</StandardTooltip>
<ArrowRight className="size-3 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</div>
{/* Nested subtask collapsible section */}
{hasChildren && (
<div style={{ paddingLeft: `${depth * 16}px` }}>
<SubtaskCollapsibleRow
count={countAllSubtasks(children)}
isExpanded={isExpanded}
onToggle={() => onToggleExpand(item.id)}
/>
</div>
)}
{/* Expanded nested subtasks */}
{hasChildren && (
<div
className={cn(
"overflow-clip transition-all duration-300",
isExpanded ? "max-h-[2000px]" : "max-h-0",
)}>
{children.map((child) => (
<SubtaskRow
key={child.item.id}
node={child}
depth={depth + 1}
onToggleExpand={onToggleExpand}
/>
))}
</div>
)}
onClick={handleClick}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
handleClick()
}
}}>
<StandardTooltip content={item.task} delay={600}>
<span className="text-sm line-clamp-1">{item.task}</span>
</StandardTooltip>
<ArrowRight className="size-3 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</div>
)
}

View file

@ -1,6 +1,7 @@
import { memo } from "react"
import { cn } from "@/lib/utils"
import type { TaskGroup } from "./types"
import { countAllSubtasks } from "./types"
import TaskItem from "./TaskItem"
import SubtaskCollapsibleRow from "./SubtaskCollapsibleRow"
import SubtaskRow from "./SubtaskRow"
@ -20,15 +21,17 @@ interface TaskGroupItemProps {
onToggleSelection?: (taskId: string, isSelected: boolean) => void
/** Callback when delete is requested */
onDelete?: (taskId: string) => void
/** Callback when expand/collapse is toggled */
/** Callback when the parent group expand/collapse is toggled */
onToggleExpand: () => void
/** Callback when a nested subtask node expand/collapse is toggled */
onToggleSubtaskExpand: (taskId: string) => void
/** Optional className for styling */
className?: string
}
/**
* Renders a task group consisting of a parent task and its collapsible subtask list.
* When expanded, shows individual subtask rows.
* Renders a task group consisting of a parent task and its collapsible subtask tree.
* When expanded, shows recursively nested subtask rows.
*/
const TaskGroupItem = ({
group,
@ -39,10 +42,12 @@ const TaskGroupItem = ({
onToggleSelection,
onDelete,
onToggleExpand,
onToggleSubtaskExpand,
className,
}: TaskGroupItemProps) => {
const { parent, subtasks, isExpanded } = group
const hasSubtasks = subtasks.length > 0
const totalSubtaskCount = hasSubtasks ? countAllSubtasks(subtasks) : 0
return (
<div
@ -63,21 +68,21 @@ const TaskGroupItem = ({
hasSubtasks={hasSubtasks}
/>
{/* Subtask collapsible row */}
{/* Subtask collapsible row — shows total recursive count */}
{hasSubtasks && (
<SubtaskCollapsibleRow count={subtasks.length} isExpanded={isExpanded} onToggle={onToggleExpand} />
<SubtaskCollapsibleRow count={totalSubtaskCount} isExpanded={isExpanded} onToggle={onToggleExpand} />
)}
{/* Expanded subtasks */}
{/* Expanded subtask tree */}
{hasSubtasks && (
<div
data-testid="subtask-list"
className={cn(
"overflow-clip transition-all duration-500",
isExpanded ? "max-h-100 pb-2" : "max-h-0",
isExpanded ? "max-h-[2000px] pb-2" : "max-h-0",
)}>
{subtasks.map((subtask) => (
<SubtaskRow key={subtask.id} item={subtask} />
{subtasks.map((node) => (
<SubtaskRow key={node.item.id} node={node} depth={1} onToggleExpand={onToggleSubtaskExpand} />
))}
</div>
)}

View file

@ -0,0 +1,213 @@
import { render, screen, fireEvent } from "@/utils/test-utils"
import { vscode } from "@src/utils/vscode"
import SubtaskRow from "../SubtaskRow"
import type { SubtaskTreeNode, DisplayHistoryItem } from "../types"
vi.mock("@src/utils/vscode")
vi.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === "history:subtasks" && options?.count !== undefined) {
return `${options.count} Subtask${options.count === 1 ? "" : "s"}`
}
if (key === "history:collapseSubtasks") return "Collapse subtasks"
if (key === "history:expandSubtasks") return "Expand subtasks"
return key
},
}),
}))
const createMockDisplayItem = (overrides: Partial<DisplayHistoryItem> = {}): DisplayHistoryItem => ({
id: "task-1",
number: 1,
task: "Test task",
ts: Date.now(),
tokensIn: 100,
tokensOut: 50,
totalCost: 0.01,
workspace: "/workspace/project",
...overrides,
})
const createMockNode = (
itemOverrides: Partial<DisplayHistoryItem> = {},
children: SubtaskTreeNode[] = [],
isExpanded = false,
): SubtaskTreeNode => ({
item: createMockDisplayItem(itemOverrides),
children,
isExpanded,
})
describe("SubtaskRow", () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe("leaf node rendering", () => {
it("renders leaf node with correct text", () => {
const node = createMockNode({ id: "leaf-1", task: "Leaf task content" })
render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
expect(screen.getByText("Leaf task content")).toBeInTheDocument()
})
it("renders with correct depth indentation", () => {
const node = createMockNode({ id: "leaf-1", task: "Indented task" })
render(<SubtaskRow node={node} depth={2} onToggleExpand={vi.fn()} />)
const row = screen.getByTestId("subtask-row-leaf-1")
// The clickable row inside should have paddingLeft = depth * 16 = 32px
const clickableRow = row.querySelector("[role='button']")
expect(clickableRow).toHaveStyle({ paddingLeft: "32px" })
})
it("does not render collapsible row for leaf node", () => {
const node = createMockNode({ id: "leaf-1", task: "Leaf only" })
render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
expect(screen.queryByTestId("subtask-collapsible-row")).not.toBeInTheDocument()
})
})
describe("node with children", () => {
it("renders collapsible row with correct child count", () => {
const node = createMockNode(
{ id: "parent-1", task: "Parent task" },
[
createMockNode({ id: "child-1", task: "Child 1" }),
createMockNode({ id: "child-2", task: "Child 2" }),
],
false,
)
render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
expect(screen.getByText("2 Subtasks")).toBeInTheDocument()
expect(screen.getByTestId("subtask-collapsible-row")).toBeInTheDocument()
})
it("renders nested children count including grandchildren", () => {
const node = createMockNode(
{ id: "parent-1", task: "Parent task" },
[
createMockNode({ id: "child-1", task: "Child 1" }, [
createMockNode({ id: "grandchild-1", task: "Grandchild 1" }),
]),
],
false,
)
render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
// countAllSubtasks counts child-1 (1) + grandchild-1 (1) = 2
expect(screen.getByText("2 Subtasks")).toBeInTheDocument()
})
})
describe("click behavior", () => {
it("sends showTaskWithId message when task row is clicked", () => {
const node = createMockNode({ id: "task-42", task: "Clickable task" })
render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
const row = screen.getByRole("button")
fireEvent.click(row)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "showTaskWithId",
text: "task-42",
})
})
it("calls onToggleExpand with correct task ID when collapsible row is clicked", () => {
const onToggleExpand = vi.fn()
const node = createMockNode(
{ id: "expandable-1", task: "Expandable task" },
[createMockNode({ id: "child-1", task: "Child" })],
false,
)
render(<SubtaskRow node={node} depth={1} onToggleExpand={onToggleExpand} />)
const collapsibleRow = screen.getByTestId("subtask-collapsible-row")
fireEvent.click(collapsibleRow)
expect(onToggleExpand).toHaveBeenCalledWith("expandable-1")
})
})
describe("expand/collapse behavior", () => {
it("renders child SubtaskRow components when expanded", () => {
const node = createMockNode(
{ id: "parent-1", task: "Parent" },
[
createMockNode({ id: "child-1", task: "Child 1" }),
createMockNode({ id: "child-2", task: "Child 2" }),
],
true, // expanded
)
render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
expect(screen.getByTestId("subtask-row-child-1")).toBeInTheDocument()
expect(screen.getByTestId("subtask-row-child-2")).toBeInTheDocument()
expect(screen.getByText("Child 1")).toBeInTheDocument()
expect(screen.getByText("Child 2")).toBeInTheDocument()
})
it("uses max-h-0 for collapsed node with children", () => {
const node = createMockNode(
{ id: "parent-1", task: "Parent" },
[createMockNode({ id: "child-1", task: "Child 1" })],
false, // collapsed
)
const { container } = render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
// The children wrapper div should have max-h-0 when collapsed
const childrenWrapper = container.querySelector(".max-h-0")
expect(childrenWrapper).toBeInTheDocument()
})
it("does not use max-h-0 when node is expanded", () => {
const node = createMockNode(
{ id: "parent-1", task: "Parent" },
[createMockNode({ id: "child-1", task: "Child 1" })],
true, // expanded
)
const { container } = render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
// The children wrapper should NOT have max-h-0 when expanded
const collapsedWrapper = container.querySelector(".max-h-0")
expect(collapsedWrapper).not.toBeInTheDocument()
})
it("renders deeply nested recursive structure when all levels expanded", () => {
const node = createMockNode(
{ id: "root", task: "Root" },
[
createMockNode(
{ id: "child", task: "Child" },
[createMockNode({ id: "grandchild", task: "Grandchild" })],
true, // child expanded
),
],
true, // root expanded
)
render(<SubtaskRow node={node} depth={1} onToggleExpand={vi.fn()} />)
expect(screen.getByTestId("subtask-row-root")).toBeInTheDocument()
expect(screen.getByTestId("subtask-row-child")).toBeInTheDocument()
expect(screen.getByTestId("subtask-row-grandchild")).toBeInTheDocument()
expect(screen.getByText("Grandchild")).toBeInTheDocument()
})
})
})

View file

@ -1,7 +1,7 @@
import { render, screen, fireEvent } from "@/utils/test-utils"
import TaskGroupItem from "../TaskGroupItem"
import type { TaskGroup, DisplayHistoryItem } from "../types"
import type { TaskGroup, DisplayHistoryItem, SubtaskTreeNode } from "../types"
vi.mock("@src/utils/vscode")
vi.mock("@src/i18n/TranslationContext", () => ({
@ -34,6 +34,16 @@ const createMockDisplayHistoryItem = (overrides: Partial<DisplayHistoryItem> = {
...overrides,
})
const createMockSubtaskNode = (
itemOverrides: Partial<DisplayHistoryItem> = {},
children: SubtaskTreeNode[] = [],
isExpanded = false,
): SubtaskTreeNode => ({
item: createMockDisplayHistoryItem(itemOverrides),
children,
isExpanded,
})
const createMockGroup = (overrides: Partial<TaskGroup> = {}): TaskGroup => ({
parent: createMockDisplayHistoryItem({ id: "parent-1", task: "Parent task" }),
subtasks: [],
@ -55,7 +65,9 @@ describe("TaskGroupItem", () => {
}),
})
render(<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} />)
render(
<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
)
expect(screen.getByText("Test parent task content")).toBeInTheDocument()
})
@ -65,7 +77,9 @@ describe("TaskGroupItem", () => {
parent: createMockDisplayHistoryItem({ id: "my-parent-id" }),
})
render(<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} />)
render(
<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
)
expect(screen.getByTestId("task-group-my-parent-id")).toBeInTheDocument()
})
@ -75,23 +89,27 @@ describe("TaskGroupItem", () => {
it("shows correct subtask count", () => {
const group = createMockGroup({
subtasks: [
createMockDisplayHistoryItem({ id: "child-1", task: "Child 1" }),
createMockDisplayHistoryItem({ id: "child-2", task: "Child 2" }),
createMockDisplayHistoryItem({ id: "child-3", task: "Child 3" }),
createMockSubtaskNode({ id: "child-1", task: "Child 1" }),
createMockSubtaskNode({ id: "child-2", task: "Child 2" }),
createMockSubtaskNode({ id: "child-3", task: "Child 3" }),
],
})
render(<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} />)
render(
<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
)
expect(screen.getByText("3 Subtasks")).toBeInTheDocument()
})
it("shows singular subtask text for single subtask", () => {
const group = createMockGroup({
subtasks: [createMockDisplayHistoryItem({ id: "child-1", task: "Child 1" })],
subtasks: [createMockSubtaskNode({ id: "child-1", task: "Child 1" })],
})
render(<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} />)
render(
<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
)
expect(screen.getByText("1 Subtask")).toBeInTheDocument()
})
@ -99,20 +117,48 @@ describe("TaskGroupItem", () => {
it("does not show subtask row when no subtasks", () => {
const group = createMockGroup({ subtasks: [] })
render(<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} />)
render(
<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
)
expect(screen.queryByTestId("subtask-collapsible-row")).not.toBeInTheDocument()
})
it("renders correct total subtask count with nested children", () => {
const group = createMockGroup({
subtasks: [
createMockSubtaskNode({ id: "child-1", task: "Child 1" }, [
createMockSubtaskNode({ id: "grandchild-1", task: "Grandchild 1" }),
createMockSubtaskNode({ id: "grandchild-2", task: "Grandchild 2" }),
]),
createMockSubtaskNode({ id: "child-2", task: "Child 2" }),
],
})
render(
<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
)
// 2 direct children + 2 grandchildren = 4 total
expect(screen.getByText("4 Subtasks")).toBeInTheDocument()
})
})
describe("expand/collapse behavior", () => {
it("calls onToggleExpand when chevron row is clicked", () => {
const onToggleExpand = vi.fn()
const group = createMockGroup({
subtasks: [createMockDisplayHistoryItem({ id: "child-1", task: "Child 1" })],
subtasks: [createMockSubtaskNode({ id: "child-1", task: "Child 1" })],
})
render(<TaskGroupItem group={group} variant="full" onToggleExpand={onToggleExpand} />)
render(
<TaskGroupItem
group={group}
variant="full"
onToggleExpand={onToggleExpand}
onToggleSubtaskExpand={vi.fn()}
/>,
)
const collapsibleRow = screen.getByTestId("subtask-collapsible-row")
fireEvent.click(collapsibleRow)
@ -124,12 +170,14 @@ describe("TaskGroupItem", () => {
const group = createMockGroup({
isExpanded: true,
subtasks: [
createMockDisplayHistoryItem({ id: "child-1", task: "Subtask content 1" }),
createMockDisplayHistoryItem({ id: "child-2", task: "Subtask content 2" }),
createMockSubtaskNode({ id: "child-1", task: "Subtask content 1" }),
createMockSubtaskNode({ id: "child-2", task: "Subtask content 2" }),
],
})
render(<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} />)
render(
<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
)
expect(screen.getByTestId("subtask-list")).toBeInTheDocument()
expect(screen.getByText("Subtask content 1")).toBeInTheDocument()
@ -139,16 +187,39 @@ describe("TaskGroupItem", () => {
it("hides subtasks when collapsed", () => {
const group = createMockGroup({
isExpanded: false,
subtasks: [createMockDisplayHistoryItem({ id: "child-1", task: "Subtask content" })],
subtasks: [createMockSubtaskNode({ id: "child-1", task: "Subtask content" })],
})
render(<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} />)
render(
<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
)
// The subtask-list element is present but collapsed via CSS (max-h-0)
const subtaskList = screen.queryByTestId("subtask-list")
expect(subtaskList).toBeInTheDocument()
expect(subtaskList).toHaveClass("max-h-0")
})
it("renders nested subtask when a node has children and is expanded", () => {
const group = createMockGroup({
isExpanded: true,
subtasks: [
createMockSubtaskNode(
{ id: "child-1", task: "Parent subtask" },
[createMockSubtaskNode({ id: "grandchild-1", task: "Nested subtask" })],
true, // child-1 is expanded
),
],
})
render(
<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
)
expect(screen.getByText("Parent subtask")).toBeInTheDocument()
expect(screen.getByText("Nested subtask")).toBeInTheDocument()
expect(screen.getByTestId("subtask-row-grandchild-1")).toBeInTheDocument()
})
})
describe("selection mode", () => {
@ -166,6 +237,7 @@ describe("TaskGroupItem", () => {
isSelected={false}
onToggleSelection={onToggleSelection}
onToggleExpand={vi.fn()}
onToggleSubtaskExpand={vi.fn()}
/>,
)
@ -188,6 +260,7 @@ describe("TaskGroupItem", () => {
isSelected={true}
onToggleSelection={vi.fn()}
onToggleExpand={vi.fn()}
onToggleSubtaskExpand={vi.fn()}
/>,
)
@ -201,7 +274,14 @@ describe("TaskGroupItem", () => {
it("passes compact variant to TaskItem", () => {
const group = createMockGroup()
render(<TaskGroupItem group={group} variant="compact" onToggleExpand={vi.fn()} />)
render(
<TaskGroupItem
group={group}
variant="compact"
onToggleExpand={vi.fn()}
onToggleSubtaskExpand={vi.fn()}
/>,
)
// TaskItem should be rendered with compact styling
const taskItem = screen.getByTestId("task-item-parent-1")
@ -211,7 +291,9 @@ describe("TaskGroupItem", () => {
it("passes full variant to TaskItem", () => {
const group = createMockGroup()
render(<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} />)
render(
<TaskGroupItem group={group} variant="full" onToggleExpand={vi.fn()} onToggleSubtaskExpand={vi.fn()} />,
)
const taskItem = screen.getByTestId("task-item-parent-1")
expect(taskItem).toBeInTheDocument()
@ -225,7 +307,15 @@ describe("TaskGroupItem", () => {
parent: createMockDisplayHistoryItem({ id: "parent-1", task: "Parent task" }),
})
render(<TaskGroupItem group={group} variant="full" onDelete={onDelete} onToggleExpand={vi.fn()} />)
render(
<TaskGroupItem
group={group}
variant="full"
onDelete={onDelete}
onToggleExpand={vi.fn()}
onToggleSubtaskExpand={vi.fn()}
/>,
)
// Delete button uses "delete-task-button" as testid
const deleteButton = screen.getByTestId("delete-task-button")
@ -244,7 +334,15 @@ describe("TaskGroupItem", () => {
}),
})
render(<TaskGroupItem group={group} variant="full" showWorkspace={true} onToggleExpand={vi.fn()} />)
render(
<TaskGroupItem
group={group}
variant="full"
showWorkspace={true}
onToggleExpand={vi.fn()}
onToggleSubtaskExpand={vi.fn()}
/>,
)
// Workspace should be displayed in TaskItem
const taskItem = screen.getByTestId("task-item-parent-1")
@ -258,7 +356,15 @@ describe("TaskGroupItem", () => {
it("applies custom className to container", () => {
const group = createMockGroup()
render(<TaskGroupItem group={group} variant="full" className="custom-class" onToggleExpand={vi.fn()} />)
render(
<TaskGroupItem
group={group}
variant="full"
className="custom-class"
onToggleExpand={vi.fn()}
onToggleSubtaskExpand={vi.fn()}
/>,
)
const container = screen.getByTestId("task-group-parent-1")
expect(container).toHaveClass("custom-class")

View file

@ -2,7 +2,8 @@ import { renderHook, act } from "@/utils/test-utils"
import type { HistoryItem } from "@roo-code/types"
import { useGroupedTasks } from "../useGroupedTasks"
import { useGroupedTasks, buildSubtree } from "../useGroupedTasks"
import { countAllSubtasks } from "../types"
const createMockTask = (overrides: Partial<HistoryItem> = {}): HistoryItem => ({
id: "task-1",
@ -42,8 +43,8 @@ describe("useGroupedTasks", () => {
expect(result.current.groups).toHaveLength(1)
expect(result.current.groups[0].parent.id).toBe("parent-1")
expect(result.current.groups[0].subtasks).toHaveLength(2)
expect(result.current.groups[0].subtasks[0].id).toBe("child-2") // Newest first
expect(result.current.groups[0].subtasks[1].id).toBe("child-1")
expect(result.current.groups[0].subtasks[0].item.id).toBe("child-2") // Newest first
expect(result.current.groups[0].subtasks[1].item.id).toBe("child-1")
})
it("handles tasks with no children", () => {
@ -121,7 +122,7 @@ describe("useGroupedTasks", () => {
expect(result.current.isSearchMode).toBe(false)
})
it("handles deeply nested tasks (grandchildren treated as children of their direct parent)", () => {
it("handles deeply nested tasks with recursive tree structure", () => {
const rootTask = createMockTask({
id: "root-1",
task: "Root task",
@ -146,10 +147,12 @@ describe("useGroupedTasks", () => {
expect(result.current.groups).toHaveLength(1)
expect(result.current.groups[0].parent.id).toBe("root-1")
expect(result.current.groups[0].subtasks).toHaveLength(1)
expect(result.current.groups[0].subtasks[0].id).toBe("child-1")
expect(result.current.groups[0].subtasks[0].item.id).toBe("child-1")
// Note: grandchild is a child of child-1, not root-1
// The current implementation only shows direct children in subtasks
// Grandchild is nested inside child's children
expect(result.current.groups[0].subtasks[0].children).toHaveLength(1)
expect(result.current.groups[0].subtasks[0].children[0].item.id).toBe("grandchild-1")
expect(result.current.groups[0].subtasks[0].children[0].children).toHaveLength(0)
})
})
@ -395,3 +398,199 @@ describe("useGroupedTasks", () => {
})
})
})
describe("buildSubtree", () => {
it("builds a leaf node with no children", () => {
const task = createMockTask({ id: "task-1", task: "Leaf task" })
const childrenMap = new Map<string, HistoryItem[]>()
const node = buildSubtree(task, childrenMap, new Set<string>())
expect(node.item.id).toBe("task-1")
expect(node.children).toHaveLength(0)
expect(node.isExpanded).toBe(false)
})
it("builds a node with direct children sorted newest first", () => {
const parent = createMockTask({ id: "parent-1", task: "Parent" })
const child1 = createMockTask({
id: "child-1",
task: "Child 1",
parentTaskId: "parent-1",
ts: new Date("2024-01-15T12:00:00").getTime(),
})
const child2 = createMockTask({
id: "child-2",
task: "Child 2",
parentTaskId: "parent-1",
ts: new Date("2024-01-15T14:00:00").getTime(),
})
const childrenMap = new Map<string, HistoryItem[]>()
childrenMap.set("parent-1", [child1, child2])
const node = buildSubtree(parent, childrenMap, new Set<string>())
expect(node.item.id).toBe("parent-1")
expect(node.children).toHaveLength(2)
expect(node.children[0].item.id).toBe("child-2") // Newest first
expect(node.children[1].item.id).toBe("child-1")
expect(node.isExpanded).toBe(false)
expect(node.children[0].isExpanded).toBe(false)
expect(node.children[1].isExpanded).toBe(false)
})
it("builds a deeply nested tree recursively", () => {
const root = createMockTask({ id: "root", task: "Root" })
const child = createMockTask({
id: "child",
task: "Child",
parentTaskId: "root",
ts: new Date("2024-01-15T13:00:00").getTime(),
})
const grandchild = createMockTask({
id: "grandchild",
task: "Grandchild",
parentTaskId: "child",
ts: new Date("2024-01-15T14:00:00").getTime(),
})
const greatGrandchild = createMockTask({
id: "great-grandchild",
task: "Great Grandchild",
parentTaskId: "grandchild",
ts: new Date("2024-01-15T15:00:00").getTime(),
})
const childrenMap = new Map<string, HistoryItem[]>()
childrenMap.set("root", [child])
childrenMap.set("child", [grandchild])
childrenMap.set("grandchild", [greatGrandchild])
const node = buildSubtree(root, childrenMap, new Set<string>())
expect(node.item.id).toBe("root")
expect(node.children).toHaveLength(1)
expect(node.children[0].item.id).toBe("child")
expect(node.children[0].children).toHaveLength(1)
expect(node.children[0].children[0].item.id).toBe("grandchild")
expect(node.children[0].children[0].children).toHaveLength(1)
expect(node.children[0].children[0].children[0].item.id).toBe("great-grandchild")
expect(node.children[0].children[0].children[0].children).toHaveLength(0)
})
it("does not mutate the original childrenMap arrays", () => {
const parent = createMockTask({ id: "parent-1", task: "Parent" })
const child1 = createMockTask({
id: "child-1",
task: "Child 1",
parentTaskId: "parent-1",
ts: new Date("2024-01-15T12:00:00").getTime(),
})
const child2 = createMockTask({
id: "child-2",
task: "Child 2",
parentTaskId: "parent-1",
ts: new Date("2024-01-15T14:00:00").getTime(),
})
const originalChildren = [child1, child2]
const childrenMap = new Map<string, HistoryItem[]>()
childrenMap.set("parent-1", originalChildren)
buildSubtree(parent, childrenMap, new Set<string>())
// Original array should not be mutated (sort is on a slice)
expect(originalChildren[0].id).toBe("child-1")
expect(originalChildren[1].id).toBe("child-2")
})
it("sets isExpanded: true when task ID is in expandedIds", () => {
const parent = createMockTask({ id: "parent-1", task: "Parent" })
const child = createMockTask({
id: "child-1",
task: "Child",
parentTaskId: "parent-1",
ts: new Date("2024-01-15T13:00:00").getTime(),
})
const childrenMap = new Map<string, HistoryItem[]>()
childrenMap.set("parent-1", [child])
const expandedIds = new Set<string>(["parent-1"])
const node = buildSubtree(parent, childrenMap, expandedIds)
expect(node.isExpanded).toBe(true)
expect(node.children[0].isExpanded).toBe(false)
})
it("propagates isExpanded correctly through deeply nested tree", () => {
const root = createMockTask({ id: "root", task: "Root" })
const child = createMockTask({
id: "child",
task: "Child",
parentTaskId: "root",
ts: new Date("2024-01-15T13:00:00").getTime(),
})
const grandchild = createMockTask({
id: "grandchild",
task: "Grandchild",
parentTaskId: "child",
ts: new Date("2024-01-15T14:00:00").getTime(),
})
const greatGrandchild = createMockTask({
id: "great-grandchild",
task: "Great Grandchild",
parentTaskId: "grandchild",
ts: new Date("2024-01-15T15:00:00").getTime(),
})
const childrenMap = new Map<string, HistoryItem[]>()
childrenMap.set("root", [child])
childrenMap.set("child", [grandchild])
childrenMap.set("grandchild", [greatGrandchild])
// Expand root and grandchild, but NOT child
const expandedIds = new Set<string>(["root", "grandchild"])
const node = buildSubtree(root, childrenMap, expandedIds)
expect(node.isExpanded).toBe(true)
expect(node.children[0].isExpanded).toBe(false) // child not expanded
expect(node.children[0].children[0].isExpanded).toBe(true) // grandchild expanded
expect(node.children[0].children[0].children[0].isExpanded).toBe(false) // great-grandchild not expanded
})
})
describe("countAllSubtasks", () => {
it("returns 0 for empty array", () => {
expect(countAllSubtasks([])).toBe(0)
})
it("returns count of items in flat list (no grandchildren)", () => {
const nodes = [
{ item: createMockTask({ id: "a" }), children: [], isExpanded: false },
{ item: createMockTask({ id: "b" }), children: [], isExpanded: false },
{ item: createMockTask({ id: "c" }), children: [], isExpanded: false },
]
expect(countAllSubtasks(nodes)).toBe(3)
})
it("returns total count at all nesting levels", () => {
const nodes = [
{
item: createMockTask({ id: "a" }),
children: [
{
item: createMockTask({ id: "a1" }),
children: [{ item: createMockTask({ id: "a1i" }), children: [], isExpanded: false }],
isExpanded: false,
},
{ item: createMockTask({ id: "a2" }), children: [], isExpanded: false },
],
isExpanded: false,
},
{ item: createMockTask({ id: "b" }), children: [], isExpanded: false },
]
// a (1) + a1 (1) + a1i (1) + a2 (1) + b (1) = 5
expect(countAllSubtasks(nodes)).toBe(5)
})
})

View file

@ -11,13 +11,36 @@ export interface DisplayHistoryItem extends HistoryItem {
}
/**
* A group of tasks consisting of a parent task and its subtasks
* A node in the subtask tree, representing a task and its recursively nested children.
*/
export interface SubtaskTreeNode {
/** The task at this tree node */
item: DisplayHistoryItem
/** Recursively nested child subtasks */
children: SubtaskTreeNode[]
/** Whether this node's children are expanded in the UI */
isExpanded: boolean
}
/**
* Recursively counts all subtasks in a tree of SubtaskTreeNodes.
*/
export function countAllSubtasks(nodes: SubtaskTreeNode[]): number {
let count = 0
for (const node of nodes) {
count += 1 + countAllSubtasks(node.children)
}
return count
}
/**
* A group of tasks consisting of a parent task and its nested subtask tree
*/
export interface TaskGroup {
/** The parent task */
parent: DisplayHistoryItem
/** List of direct subtasks */
subtasks: DisplayHistoryItem[]
/** Tree of subtasks (supports arbitrary nesting depth) */
subtasks: SubtaskTreeNode[]
/** Whether the subtask list is expanded */
isExpanded: boolean
}

View file

@ -1,6 +1,29 @@
import { useState, useMemo, useCallback } from "react"
import type { HistoryItem } from "@roo-code/types"
import type { DisplayHistoryItem, TaskGroup, GroupedTasksResult } from "./types"
import type { DisplayHistoryItem, SubtaskTreeNode, TaskGroup, GroupedTasksResult } from "./types"
/**
* Recursively builds a subtask tree node for the given task.
* Pure function exported for independent testing.
*
* @param task - The task to build a tree node for
* @param childrenMap - Map of parentId direct children
* @param expandedIds - Set of task IDs whose children are currently expanded
* @returns A SubtaskTreeNode with recursively built children sorted by ts (newest first)
*/
export function buildSubtree(
task: HistoryItem,
childrenMap: Map<string, HistoryItem[]>,
expandedIds: Set<string>,
): SubtaskTreeNode {
const directChildren = (childrenMap.get(task.id) || []).slice().sort((a, b) => b.ts - a.ts)
return {
item: task as DisplayHistoryItem,
children: directChildren.map((child) => buildSubtree(child, childrenMap, expandedIds)),
isExpanded: expandedIds.has(task.id),
}
}
/**
* Hook to transform a flat task list into grouped structure based on parent-child relationships.
@ -31,7 +54,7 @@ export function useGroupedTasks(tasks: HistoryItem[], searchQuery: string): Grou
return []
}
// Build children map: parentId -> children[]
// Build children map: parentId -> direct children[]
const childrenMap = new Map<string, HistoryItem[]>()
for (const task of tasks) {
@ -44,19 +67,16 @@ export function useGroupedTasks(tasks: HistoryItem[], searchQuery: string): Grou
// Identify root tasks - tasks that either:
// 1. Have no parentTaskId
// 2. Have a parentTaskId that doesn't exist in our task list
// 2. Have a parentTaskId that doesn't exist in our task list (orphans promoted to root)
const rootTasks = tasks.filter((task) => !task.parentTaskId || !taskMap.has(task.parentTaskId))
// Build groups from root tasks
// Build groups from root tasks with recursively nested subtask trees
const taskGroups: TaskGroup[] = rootTasks.map((parent) => {
// Get direct children (sorted by timestamp, newest first)
const subtasks = (childrenMap.get(parent.id) || [])
.slice()
.sort((a, b) => b.ts - a.ts) as DisplayHistoryItem[]
const directChildren = (childrenMap.get(parent.id) || []).slice().sort((a, b) => b.ts - a.ts)
return {
parent: parent as DisplayHistoryItem,
subtasks,
subtasks: directChildren.map((child) => buildSubtree(child, childrenMap, expandedIds)),
isExpanded: expandedIds.has(parent.id),
}
})