feat: display per-subtask cost breakdown in todos list

- Add SubtaskDetail interface with id, name, tokens, cost, status fields
- Add buildSubtaskDetails() function to aggregate task costs
- Pass childDetails in taskWithAggregatedCosts message
- Integrate subtask cost display into TodoListDisplay component
- Add fuzzy matching to match todo items with subtask details
- Display token count and cost next to matching todo items
- Add 16 tests for TodoListDisplay with subtask cost functionality

Related to: #5376
This commit is contained in:
Toray Altas 2026-01-15 22:35:05 -05:00
parent f48ea389df
commit 774a8f9471
9 changed files with 830 additions and 6 deletions

View file

@ -188,6 +188,14 @@ export interface ExtensionMessage {
totalCost: number
ownCost: number
childrenCost: number
childDetails?: {
id: string
name: string
tokens: number
cost: number
status: "active" | "completed" | "delegated"
hasNestedChildren: boolean
}[]
}
historyItem?: HistoryItem
}

View file

@ -47,7 +47,12 @@ import {
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
getModelId,
} from "@roo-code/types"
import { aggregateTaskCostsRecursive, type AggregatedCosts } from "./aggregateTaskCosts"
import {
aggregateTaskCostsRecursive,
buildSubtaskDetails,
type AggregatedCosts,
type SubtaskDetail,
} from "./aggregateTaskCosts"
import { TelemetryService } from "@roo-code/telemetry"
import { CloudService, BridgeOrchestrator, getRooCodeApiUrl } from "@roo-code/cloud"
@ -1717,7 +1722,24 @@ export class ClineProvider
return result.historyItem
})
return { historyItem, aggregatedCosts }
// Build subtask details if there are children
let childDetails: SubtaskDetail[] | undefined
if (aggregatedCosts.childBreakdown && Object.keys(aggregatedCosts.childBreakdown).length > 0) {
childDetails = await buildSubtaskDetails(aggregatedCosts.childBreakdown, async (id: string) => {
const result = await this.getTaskWithId(id)
return result.historyItem
})
}
return {
historyItem,
aggregatedCosts: {
totalCost: aggregatedCosts.totalCost,
ownCost: aggregatedCosts.ownCost,
childrenCost: aggregatedCosts.childrenCost,
childDetails,
},
}
}
async showTaskWithId(id: string) {

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { aggregateTaskCostsRecursive } from "../aggregateTaskCosts.js"
import { aggregateTaskCostsRecursive, buildSubtaskDetails } from "../aggregateTaskCosts.js"
import type { HistoryItem } from "@roo-code/types"
import type { AggregatedCosts } from "../aggregateTaskCosts.js"
describe("aggregateTaskCostsRecursive", () => {
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
@ -324,3 +325,211 @@ describe("aggregateTaskCostsRecursive", () => {
expect(result.totalCost).toBe(2.0)
})
})
describe("buildSubtaskDetails", () => {
it("should build subtask details from child breakdown", async () => {
const childBreakdown: { [childId: string]: AggregatedCosts } = {
"child-1": {
ownCost: 0.5,
childrenCost: 0,
totalCost: 0.5,
},
"child-2": {
ownCost: 0.3,
childrenCost: 0.2,
totalCost: 0.5,
},
}
const mockHistory: Record<string, HistoryItem> = {
"child-1": {
id: "child-1",
task: "First subtask",
tokensIn: 100,
tokensOut: 50,
status: "completed",
} as unknown as HistoryItem,
"child-2": {
id: "child-2",
task: "Second subtask with nested children",
tokensIn: 200,
tokensOut: 100,
status: "active",
} as unknown as HistoryItem,
}
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
const result = await buildSubtaskDetails(childBreakdown, getTaskHistory)
expect(result).toHaveLength(2)
const child1 = result.find((d) => d.id === "child-1")
expect(child1).toBeDefined()
expect(child1!.name).toBe("First subtask")
expect(child1!.tokens).toBe(150) // 100 + 50
expect(child1!.cost).toBe(0.5)
expect(child1!.status).toBe("completed")
expect(child1!.hasNestedChildren).toBe(false)
const child2 = result.find((d) => d.id === "child-2")
expect(child2).toBeDefined()
expect(child2!.name).toBe("Second subtask with nested children")
expect(child2!.tokens).toBe(300) // 200 + 100
expect(child2!.cost).toBe(0.5)
expect(child2!.status).toBe("active")
expect(child2!.hasNestedChildren).toBe(true) // childrenCost > 0
})
it("should truncate long task names to 50 characters", async () => {
const longTaskName =
"This is a very long task name that exceeds fifty characters and should be truncated with ellipsis"
const childBreakdown: { [childId: string]: AggregatedCosts } = {
"child-1": {
ownCost: 1.0,
childrenCost: 0,
totalCost: 1.0,
},
}
const mockHistory: Record<string, HistoryItem> = {
"child-1": {
id: "child-1",
task: longTaskName,
tokensIn: 100,
tokensOut: 50,
status: "completed",
} as unknown as HistoryItem,
}
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
const result = await buildSubtaskDetails(childBreakdown, getTaskHistory)
expect(result).toHaveLength(1)
expect(result[0].name).toBe("This is a very long task name that exceeds fift...")
expect(result[0].name.length).toBe(50)
})
it("should not truncate task names at or under 50 characters", async () => {
const exactlyFiftyChars = "12345678901234567890123456789012345678901234567890" // exactly 50 chars
const childBreakdown: { [childId: string]: AggregatedCosts } = {
"child-1": {
ownCost: 1.0,
childrenCost: 0,
totalCost: 1.0,
},
}
const mockHistory: Record<string, HistoryItem> = {
"child-1": {
id: "child-1",
task: exactlyFiftyChars,
tokensIn: 100,
tokensOut: 50,
status: "completed",
} as unknown as HistoryItem,
}
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
const result = await buildSubtaskDetails(childBreakdown, getTaskHistory)
expect(result[0].name).toBe(exactlyFiftyChars)
expect(result[0].name.length).toBe(50)
})
it("should skip children with missing history", async () => {
const childBreakdown: { [childId: string]: AggregatedCosts } = {
"child-1": {
ownCost: 0.5,
childrenCost: 0,
totalCost: 0.5,
},
"missing-child": {
ownCost: 0.3,
childrenCost: 0,
totalCost: 0.3,
},
}
const mockHistory: Record<string, HistoryItem> = {
"child-1": {
id: "child-1",
task: "Existing subtask",
tokensIn: 100,
tokensOut: 50,
status: "completed",
} as unknown as HistoryItem,
// missing-child has no history
}
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
const result = await buildSubtaskDetails(childBreakdown, getTaskHistory)
expect(result).toHaveLength(1)
expect(result[0].id).toBe("child-1")
})
it("should handle empty child breakdown", async () => {
const childBreakdown: { [childId: string]: AggregatedCosts } = {}
const getTaskHistory = vi.fn(async () => undefined)
const result = await buildSubtaskDetails(childBreakdown, getTaskHistory)
expect(result).toHaveLength(0)
})
it("should default status to completed when undefined", async () => {
const childBreakdown: { [childId: string]: AggregatedCosts } = {
"child-1": {
ownCost: 0.5,
childrenCost: 0,
totalCost: 0.5,
},
}
const mockHistory: Record<string, HistoryItem> = {
"child-1": {
id: "child-1",
task: "Subtask without status",
tokensIn: 100,
tokensOut: 50,
// status is undefined
} as unknown as HistoryItem,
}
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
const result = await buildSubtaskDetails(childBreakdown, getTaskHistory)
expect(result[0].status).toBe("completed")
})
it("should handle undefined token values", async () => {
const childBreakdown: { [childId: string]: AggregatedCosts } = {
"child-1": {
ownCost: 0.5,
childrenCost: 0,
totalCost: 0.5,
},
}
const mockHistory: Record<string, HistoryItem> = {
"child-1": {
id: "child-1",
task: "Subtask without tokens",
// tokensIn and tokensOut are undefined
status: "completed",
} as unknown as HistoryItem,
}
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
const result = await buildSubtaskDetails(childBreakdown, getTaskHistory)
expect(result[0].tokens).toBe(0)
})
})

View file

@ -1,5 +1,17 @@
import type { HistoryItem } from "@roo-code/types"
/**
* Detailed information about a subtask for UI display
*/
export interface SubtaskDetail {
id: string // Task ID
name: string // First 50 chars of task description
tokens: number // tokensIn + tokensOut
cost: number // Aggregated total cost
status: "active" | "completed" | "delegated"
hasNestedChildren: boolean // Has its own subtasks
}
export interface AggregatedCosts {
ownCost: number // This task's own API costs
childrenCost: number // Sum of all direct children costs (recursive)
@ -8,6 +20,7 @@ export interface AggregatedCosts {
// Optional detailed breakdown
[childId: string]: AggregatedCosts
}
childDetails?: SubtaskDetail[] // Detailed subtask info for UI display
}
/**
@ -63,3 +76,39 @@ export async function aggregateTaskCostsRecursive(
return result
}
/**
* Truncate a task name to a maximum length, adding ellipsis if needed
*/
function truncateTaskName(task: string, maxLength: number): string {
if (task.length <= maxLength) return task
return task.substring(0, maxLength - 3) + "..."
}
/**
* Build subtask details from child breakdown and history items
* for displaying in the UI's expandable subtask list
*/
export async function buildSubtaskDetails(
childBreakdown: { [childId: string]: AggregatedCosts },
getTaskHistory: (id: string) => Promise<HistoryItem | undefined>,
): Promise<SubtaskDetail[]> {
const details: SubtaskDetail[] = []
for (const [childId, costs] of Object.entries(childBreakdown)) {
const history = await getTaskHistory(childId)
if (history) {
details.push({
id: childId,
name: truncateTaskName(history.task, 50),
tokens: (history.tokensIn || 0) + (history.tokensOut || 0),
cost: costs.totalCost,
status: history.status || "completed",
hasNestedChildren: costs.childrenCost > 0,
})
}
}
return details
}

View file

@ -49,6 +49,19 @@ import DismissibleUpsell from "../common/DismissibleUpsell"
import { useCloudUpsell } from "@src/hooks/useCloudUpsell"
import { Cloud } from "lucide-react"
/**
* Detailed information about a subtask for UI display.
* Matches the SubtaskDetail interface from backend aggregateTaskCosts.ts
*/
interface SubtaskDetail {
id: string // Task ID
name: string // First 50 chars of task description
tokens: number // tokensIn + tokensOut
cost: number // Aggregated total cost
status: "active" | "completed" | "delegated"
hasNestedChildren: boolean // Has its own subtasks
}
export interface ChatViewProps {
isHidden: boolean
showAnnouncement: boolean
@ -174,6 +187,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
totalCost: number
ownCost: number
childrenCost: number
childDetails?: SubtaskDetail[]
}
>
>(new Map())
@ -1490,6 +1504,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
})
: undefined
}
subtaskDetails={
currentTaskItem?.id && aggregatedCostsMap.has(currentTaskItem.id)
? aggregatedCostsMap.get(currentTaskItem.id)!.childDetails
: undefined
}
contextTokens={apiMetrics.contextTokens}
buttonsDisabled={sendingDisabled}
handleCondenseContext={handleCondenseContext}

View file

@ -0,0 +1,123 @@
import { memo, useState } from "react"
import { useTranslation } from "react-i18next"
import { ChevronRight, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatLargeNumber } from "@/utils/format"
export interface SubtaskDetail {
id: string
name: string
tokens: number
cost: number
status: "active" | "completed" | "delegated"
hasNestedChildren: boolean
}
export interface SubtaskCostListProps {
subtasks: SubtaskDetail[]
onSubtaskClick?: (subtaskId: string) => void
}
interface SubtaskRowProps {
subtask: SubtaskDetail
isLast: boolean
onClick?: () => void
t: (key: string, options?: Record<string, unknown>) => string
}
const statusColors: Record<SubtaskDetail["status"], string> = {
active: "bg-vscode-testing-iconQueued",
completed: "bg-vscode-testing-iconPassed",
delegated: "bg-vscode-testing-iconSkipped",
}
const SubtaskRow = memo(({ subtask, isLast, onClick, t }: SubtaskRowProps) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-2 w-full text-left py-1 px-2 rounded",
"text-xs text-vscode-descriptionForeground",
"hover:bg-vscode-list-hoverBackground transition-colors",
)}>
{/* Tree indicator */}
<span className="text-vscode-descriptionForeground opacity-50 font-mono">{isLast ? "└─" : "├─"}</span>
{/* Status indicator */}
<span
className={cn("size-2 rounded-full shrink-0", statusColors[subtask.status])}
title={t(`subtasks.status.${subtask.status}`)}
/>
{/* Subtask name */}
<span className="flex-1 truncate" title={subtask.name}>
&quot;{subtask.name}&quot;
</span>
{/* Nested indicator */}
{subtask.hasNestedChildren && (
<span className="text-vscode-descriptionForeground opacity-50" title={t("subtasks.hasNestedChildren")}>
+
</span>
)}
{/* Token count */}
<span className="text-vscode-descriptionForeground opacity-70 tabular-nums">
{formatLargeNumber(subtask.tokens)}
</span>
{/* Cost */}
<span className="min-w-[60px] text-right tabular-nums">${subtask.cost.toFixed(2)}</span>
</button>
)
})
SubtaskRow.displayName = "SubtaskRow"
export const SubtaskCostList = memo(({ subtasks, onSubtaskClick }: SubtaskCostListProps) => {
const { t } = useTranslation("chat")
const [isExpanded, setIsExpanded] = useState(false)
if (!subtasks || subtasks.length === 0) {
return null
}
return (
<div className="mt-2 border-t border-vscode-sideBar-background pt-2">
{/* Collapsible Header */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
"flex items-center gap-1 w-full text-left",
"text-sm text-vscode-descriptionForeground",
"hover:text-vscode-foreground transition-colors",
)}
aria-expanded={isExpanded}>
{isExpanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
<span>{t("subtasks.count", { count: subtasks.length })}</span>
</button>
{/* Expanded Subtask List */}
{isExpanded && (
<div className="mt-2 ml-2 space-y-1">
{subtasks.map((subtask, index) => (
<SubtaskRow
key={subtask.id}
subtask={subtask}
isLast={index === subtasks.length - 1}
onClick={() => onSubtaskClick?.(subtask.id)}
t={t}
/>
))}
</div>
)}
</div>
)
})
SubtaskCostList.displayName = "SubtaskCostList"
export default SubtaskCostList

View file

@ -34,6 +34,7 @@ import { ContextWindowProgress } from "./ContextWindowProgress"
import { Mention } from "./Mention"
import { TodoListDisplay } from "./TodoListDisplay"
import { LucideIconButton } from "./LucideIconButton"
import type { SubtaskDetail } from "./SubtaskCostList"
export interface TaskHeaderProps {
task: ClineMessage
@ -45,6 +46,7 @@ export interface TaskHeaderProps {
aggregatedCost?: number
hasSubtasks?: boolean
costBreakdown?: string
subtaskDetails?: SubtaskDetail[]
contextTokens: number
buttonsDisabled: boolean
handleCondenseContext: (taskId: string) => void
@ -61,6 +63,7 @@ const TaskHeader = ({
aggregatedCost,
hasSubtasks,
costBreakdown,
subtaskDetails,
contextTokens,
buttonsDisabled,
handleCondenseContext,
@ -472,7 +475,15 @@ const TaskHeader = ({
</>
)}
{/* Todo list - always shown at bottom when todos exist */}
{hasTodos && <TodoListDisplay todos={todos ?? (task as any)?.tool?.todos ?? []} />}
{hasTodos && (
<TodoListDisplay
todos={todos ?? (task as any)?.tool?.todos ?? []}
subtaskDetails={subtaskDetails}
onSubtaskClick={(subtaskId) => {
vscode.postMessage({ type: "showTaskWithId", text: subtaskId })
}}
/>
)}
</div>
<CloudUpsellDialog open={isOpen} onOpenChange={closeUpsell} onConnect={handleConnect} />
</div>

View file

@ -3,6 +3,10 @@ import { t } from "i18next"
import { ArrowRight, Check, ListChecks, SquareDashed } from "lucide-react"
import { useState, useRef, useMemo, useEffect } from "react"
import { formatLargeNumber } from "@src/utils/format"
import type { SubtaskDetail } from "./SubtaskCostList"
type TodoStatus = "completed" | "in_progress" | "pending"
function getTodoIcon(status: TodoStatus | null) {
@ -16,7 +20,58 @@ function getTodoIcon(status: TodoStatus | null) {
}
}
export function TodoListDisplay({ todos }: { todos: any[] }) {
/**
* Normalizes a string for comparison by:
* - Converting to lowercase
* - Removing extra whitespace
* - Trimming quotes
* - Stripping common task prefixes (Subtask N:, ## Task:, Task N:)
* - Removing trailing ellipsis from truncated strings
*/
function normalizeForComparison(str: string): string {
return (
str
.toLowerCase()
.replace(/\s+/g, " ")
.trim()
.replace(/^["']|["']$/g, "")
// Strip common task prefixes: "Subtask N:", "## Task:", "Task N:", etc.
.replace(/^(subtask\s*\d*\s*:|##\s*task\s*:|task\s*\d*\s*:)\s*/i, "")
// Remove trailing ellipsis from truncated strings
.replace(/\.{3}$/, "")
.trim()
)
}
/**
* Match a todo content string to a subtask detail using fuzzy matching.
* Returns the matching SubtaskDetail if found, undefined otherwise.
*/
function findMatchingSubtask(todoContent: string, subtaskDetails: SubtaskDetail[]): SubtaskDetail | undefined {
const normalizedTodo = normalizeForComparison(todoContent)
// Try exact match first
const exactMatch = subtaskDetails.find((s) => normalizeForComparison(s.name) === normalizedTodo)
if (exactMatch) {
return exactMatch
}
// Try partial match - check if one contains the other
const partialMatch = subtaskDetails.find((s) => {
const normalizedSubtask = normalizeForComparison(s.name)
return normalizedTodo.includes(normalizedSubtask) || normalizedSubtask.includes(normalizedTodo)
})
return partialMatch
}
export interface TodoListDisplayProps {
todos: any[]
subtaskDetails?: SubtaskDetail[]
onSubtaskClick?: (subtaskId: string) => void
}
export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoListDisplayProps) {
const [isCollapsed, setIsCollapsed] = useState(true)
const ulRef = useRef<HTMLUListElement>(null)
const itemRefs = useRef<(HTMLLIElement | null)[]>([])
@ -82,6 +137,11 @@ export function TodoListDisplay({ todos }: { todos: any[] }) {
<ul ref={ulRef} className="list-none max-h-[300px] overflow-y-auto mt-2 -mb-1 pb-0 px-2 cursor-default">
{todos.map((todo: any, idx: number) => {
const icon = getTodoIcon(todo.status as TodoStatus)
const matchingSubtask = subtaskDetails
? findMatchingSubtask(todo.content, subtaskDetails)
: undefined
const isClickable = matchingSubtask && onSubtaskClick
return (
<li
key={todo.id || todo.content}
@ -92,7 +152,22 @@ export function TodoListDisplay({ todos }: { todos: any[] }) {
todo.status !== "in_progress" && todo.status !== "completed" && "opacity-60",
)}>
{icon}
<span>{todo.content}</span>
<span
className={cn("flex-1", isClickable && "cursor-pointer hover:underline")}
onClick={isClickable ? () => onSubtaskClick(matchingSubtask.id) : undefined}>
{todo.content}
</span>
{/* Token count and cost display */}
{matchingSubtask && (
<span className="flex items-center gap-2 text-xs text-vscode-descriptionForeground shrink-0">
<span className="tabular-nums opacity-70">
{formatLargeNumber(matchingSubtask.tokens)}
</span>
<span className="tabular-nums min-w-[45px] text-right">
${matchingSubtask.cost.toFixed(2)}
</span>
</span>
)}
</li>
)
})}

View file

@ -0,0 +1,308 @@
import { describe, it, expect, vi } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import { TodoListDisplay } from "../TodoListDisplay"
import type { SubtaskDetail } from "../SubtaskCostList"
// Mock i18next
vi.mock("i18next", () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === "chat:todo.complete") return `${options?.total} to-dos done`
if (key === "chat:todo.partial") return `${options?.completed} of ${options?.total} to-dos done`
return key
},
}))
// Mock format utility
vi.mock("@src/utils/format", () => ({
formatLargeNumber: (num: number) => {
if (num >= 1e3) return `${(num / 1e3).toFixed(1)}k`
return num.toString()
},
}))
describe("TodoListDisplay", () => {
const baseTodos = [
{ id: "1", content: "Task 1: Change background colour", status: "completed" },
{ id: "2", content: "Task 2: Add timestamp to bottom", status: "completed" },
{ id: "3", content: "Task 3: Pending task", status: "pending" },
]
const subtaskDetails: SubtaskDetail[] = [
{
id: "subtask-1",
name: "Task 1: Change background colour",
tokens: 95400,
cost: 0.22,
status: "completed",
hasNestedChildren: false,
},
{
id: "subtask-2",
name: "Task 2: Add timestamp to bottom",
tokens: 95000,
cost: 0.24,
status: "completed",
hasNestedChildren: false,
},
]
describe("basic rendering", () => {
it("should render nothing when todos is empty", () => {
const { container } = render(<TodoListDisplay todos={[]} />)
expect(container.firstChild).toBeNull()
})
it("should render collapsed view by default", () => {
render(<TodoListDisplay todos={baseTodos} />)
// Should show the first incomplete task in collapsed view
expect(screen.getByText("Task 3: Pending task")).toBeInTheDocument()
})
it("should expand when header is clicked", () => {
render(<TodoListDisplay todos={baseTodos} />)
const header = screen.getByText("Task 3: Pending task")
fireEvent.click(header)
// After expanding, should show all tasks
expect(screen.getByText("Task 1: Change background colour")).toBeInTheDocument()
expect(screen.getByText("Task 2: Add timestamp to bottom")).toBeInTheDocument()
expect(screen.getByText("Task 3: Pending task")).toBeInTheDocument()
})
it("should show completion count when all tasks are complete", () => {
const completedTodos = [
{ id: "1", content: "Task 1", status: "completed" },
{ id: "2", content: "Task 2", status: "completed" },
]
render(<TodoListDisplay todos={completedTodos} />)
expect(screen.getByText("2 to-dos done")).toBeInTheDocument()
})
})
describe("subtask cost display", () => {
it("should display tokens and cost when subtaskDetails are provided and match", () => {
render(<TodoListDisplay todos={baseTodos} subtaskDetails={subtaskDetails} />)
// Expand to see the items
const header = screen.getByText("Task 3: Pending task")
fireEvent.click(header)
// Check for formatted token counts
expect(screen.getByText("95.4k")).toBeInTheDocument()
expect(screen.getByText("95.0k")).toBeInTheDocument()
// Check for costs
expect(screen.getByText("$0.22")).toBeInTheDocument()
expect(screen.getByText("$0.24")).toBeInTheDocument()
})
it("should not display tokens/cost for unmatched todos", () => {
render(<TodoListDisplay todos={baseTodos} subtaskDetails={subtaskDetails} />)
// Expand to see the items
const header = screen.getByText("Task 3: Pending task")
fireEvent.click(header)
// The pending task has no matching subtask, should not show cost
const listItems = screen.getAllByRole("listitem")
const pendingItem = listItems.find((item) => item.textContent?.includes("Task 3: Pending task"))
expect(pendingItem).toBeDefined()
expect(pendingItem?.textContent).not.toContain("$")
})
it("should not display tokens/cost when subtaskDetails is undefined", () => {
render(<TodoListDisplay todos={baseTodos} />)
// Expand to see the items
const header = screen.getByText("Task 3: Pending task")
fireEvent.click(header)
// No cost should be displayed
expect(screen.queryByText("$0.22")).not.toBeInTheDocument()
expect(screen.queryByText("$0.24")).not.toBeInTheDocument()
})
it("should not display tokens/cost when subtaskDetails is empty array", () => {
render(<TodoListDisplay todos={baseTodos} subtaskDetails={[]} />)
// Expand to see the items
const header = screen.getByText("Task 3: Pending task")
fireEvent.click(header)
// No cost should be displayed
expect(screen.queryByText("$0.22")).not.toBeInTheDocument()
})
})
describe("fuzzy matching", () => {
it("should match todos with slightly different names (partial match)", () => {
const todosWithSlightlyDifferentNames = [
{ id: "1", content: "Change background colour", status: "completed" }, // Missing "Task 1:" prefix
]
const subtaskWithFullName: SubtaskDetail[] = [
{
id: "subtask-1",
name: "Change background colour", // Exact partial match
tokens: 50000,
cost: 0.15,
status: "completed",
hasNestedChildren: false,
},
]
render(<TodoListDisplay todos={todosWithSlightlyDifferentNames} subtaskDetails={subtaskWithFullName} />)
// Expand
const header = screen.getByText("1 to-dos done")
fireEvent.click(header)
// Should find the match
expect(screen.getByText("$0.15")).toBeInTheDocument()
})
it("should handle case-insensitive matching", () => {
const todosLowercase = [{ id: "1", content: "change background colour", status: "completed" }]
const subtaskUppercase: SubtaskDetail[] = [
{
id: "subtask-1",
name: "Change Background Colour",
tokens: 50000,
cost: 0.15,
status: "completed",
hasNestedChildren: false,
},
]
render(<TodoListDisplay todos={todosLowercase} subtaskDetails={subtaskUppercase} />)
// Expand
const header = screen.getByText("1 to-dos done")
fireEvent.click(header)
// Should find the match despite case difference
expect(screen.getByText("$0.15")).toBeInTheDocument()
})
it("should match when todo has 'Subtask N:' prefix and subtask has '## Task:' prefix", () => {
const todosWithSubtaskPrefix = [
{ id: "1", content: "Subtask 1: Change background colour to light purple", status: "completed" },
]
const subtaskWithMarkdownPrefix: SubtaskDetail[] = [
{
id: "subtask-1",
name: "## Task: Change Background Colour to Light Purp...",
tokens: 95400,
cost: 0.22,
status: "completed",
hasNestedChildren: false,
},
]
render(<TodoListDisplay todos={todosWithSubtaskPrefix} subtaskDetails={subtaskWithMarkdownPrefix} />)
// Expand
const header = screen.getByText("1 to-dos done")
fireEvent.click(header)
// Should find the match despite different prefixes
expect(screen.getByText("$0.22")).toBeInTheDocument()
})
it("should match when subtask name is truncated with ellipsis", () => {
const todos = [{ id: "1", content: "Task 1: Add timestamp to the bottom of the page", status: "completed" }]
const subtaskWithTruncation: SubtaskDetail[] = [
{
id: "subtask-1",
name: "## Task: Add Timestamp to the Bottom of the Pag...",
tokens: 95000,
cost: 0.24,
status: "completed",
hasNestedChildren: false,
},
]
render(<TodoListDisplay todos={todos} subtaskDetails={subtaskWithTruncation} />)
// Expand
const header = screen.getByText("1 to-dos done")
fireEvent.click(header)
// Should find the match despite truncation
expect(screen.getByText("$0.24")).toBeInTheDocument()
})
it("should strip 'Subtask N:' prefix from todo content", () => {
const todosWithNumberedPrefix = [
{ id: "1", content: "Subtask 2: Do something important", status: "completed" },
]
const subtaskWithoutPrefix: SubtaskDetail[] = [
{
id: "subtask-1",
name: "Do something important",
tokens: 50000,
cost: 0.15,
status: "completed",
hasNestedChildren: false,
},
]
render(<TodoListDisplay todos={todosWithNumberedPrefix} subtaskDetails={subtaskWithoutPrefix} />)
// Expand
const header = screen.getByText("1 to-dos done")
fireEvent.click(header)
// Should find the match after stripping prefix
expect(screen.getByText("$0.15")).toBeInTheDocument()
})
})
describe("click handler", () => {
it("should call onSubtaskClick when a matched todo is clicked", () => {
const onSubtaskClick = vi.fn()
render(
<TodoListDisplay todos={baseTodos} subtaskDetails={subtaskDetails} onSubtaskClick={onSubtaskClick} />,
)
// Expand
const header = screen.getByText("Task 3: Pending task")
fireEvent.click(header)
// Click on first matched todo
const task1 = screen.getByText("Task 1: Change background colour")
fireEvent.click(task1)
expect(onSubtaskClick).toHaveBeenCalledWith("subtask-1")
})
it("should not call onSubtaskClick when an unmatched todo is clicked", () => {
const onSubtaskClick = vi.fn()
render(
<TodoListDisplay todos={baseTodos} subtaskDetails={subtaskDetails} onSubtaskClick={onSubtaskClick} />,
)
// Expand
const header = screen.getByText("Task 3: Pending task")
fireEvent.click(header)
// Click on unmatched todo
const task3 = screen.getByText("Task 3: Pending task")
fireEvent.click(task3)
expect(onSubtaskClick).not.toHaveBeenCalled()
})
it("should not be clickable when onSubtaskClick is not provided", () => {
render(<TodoListDisplay todos={baseTodos} subtaskDetails={subtaskDetails} />)
// Expand
const header = screen.getByText("Task 3: Pending task")
fireEvent.click(header)
// Task should be present but not have hover:underline class behavior
const task1 = screen.getByText("Task 1: Change background colour")
expect(task1.className).not.toContain("cursor-pointer")
})
})
})