diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index f3116141f0..1448c08c80 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -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 } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 33fa12ca78..4f09371cb4 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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) { diff --git a/src/core/webview/__tests__/aggregateTaskCosts.spec.ts b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts index ffb35f5e48..eeffcebf47 100644 --- a/src/core/webview/__tests__/aggregateTaskCosts.spec.ts +++ b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts @@ -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 @@ -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 = { + "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 = { + "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 = { + "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 = { + "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 = { + "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 = { + "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) + }) +}) diff --git a/src/core/webview/aggregateTaskCosts.ts b/src/core/webview/aggregateTaskCosts.ts index 3100b2a65e..f85d7176a9 100644 --- a/src/core/webview/aggregateTaskCosts.ts +++ b/src/core/webview/aggregateTaskCosts.ts @@ -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, +): Promise { + 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 +} diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 81f6cbebf6..ab232a072d 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -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 >(new Map()) @@ -1490,6 +1504,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction void +} + +interface SubtaskRowProps { + subtask: SubtaskDetail + isLast: boolean + onClick?: () => void + t: (key: string, options?: Record) => string +} + +const statusColors: Record = { + active: "bg-vscode-testing-iconQueued", + completed: "bg-vscode-testing-iconPassed", + delegated: "bg-vscode-testing-iconSkipped", +} + +const SubtaskRow = memo(({ subtask, isLast, onClick, t }: SubtaskRowProps) => { + return ( + + ) +}) + +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 ( +
+ {/* Collapsible Header */} + + + {/* Expanded Subtask List */} + {isExpanded && ( +
+ {subtasks.map((subtask, index) => ( + onSubtaskClick?.(subtask.id)} + t={t} + /> + ))} +
+ )} +
+ ) +}) + +SubtaskCostList.displayName = "SubtaskCostList" + +export default SubtaskCostList diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 5dca11b963..133e6ff4ef 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -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 && } + {hasTodos && ( + { + vscode.postMessage({ type: "showTaskWithId", text: subtaskId }) + }} + /> + )} diff --git a/webview-ui/src/components/chat/TodoListDisplay.tsx b/webview-ui/src/components/chat/TodoListDisplay.tsx index f2dbbc4d80..06d8185ef8 100644 --- a/webview-ui/src/components/chat/TodoListDisplay.tsx +++ b/webview-ui/src/components/chat/TodoListDisplay.tsx @@ -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(null) const itemRefs = useRef<(HTMLLIElement | null)[]>([]) @@ -82,6 +137,11 @@ export function TodoListDisplay({ todos }: { todos: any[] }) {
    {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 (
  • {icon} - {todo.content} + onSubtaskClick(matchingSubtask.id) : undefined}> + {todo.content} + + {/* Token count and cost display */} + {matchingSubtask && ( + + + {formatLargeNumber(matchingSubtask.tokens)} + + + ${matchingSubtask.cost.toFixed(2)} + + + )}
  • ) })} diff --git a/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx b/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx new file mode 100644 index 0000000000..be84b9d28f --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx @@ -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) => { + 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() + expect(container.firstChild).toBeNull() + }) + + it("should render collapsed view by default", () => { + render() + // 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() + 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() + 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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( + , + ) + + // 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( + , + ) + + // 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() + + // 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") + }) + }) +})