mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
5d17f56db7
commit
7db4bfef5a
9 changed files with 687 additions and 77 deletions
|
|
@ -38,6 +38,7 @@ const HistoryPreview = () => {
|
|||
group={group}
|
||||
variant="compact"
|
||||
onToggleExpand={() => toggleExpand(group.parent.id)}
|
||||
onToggleSubtaskExpand={toggleExpand}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
213
webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx
Normal file
213
webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue