diff --git a/packages/types/src/todo.ts b/packages/types/src/todo.ts index 4e874e17505..0530f920542 100644 --- a/packages/types/src/todo.ts +++ b/packages/types/src/todo.ts @@ -14,6 +14,10 @@ export const todoItemSchema = z.object({ id: z.string(), content: z.string(), status: todoStatusSchema, + // Optional fields for subtask tracking + subtaskId: z.string().optional(), // ID of the linked subtask (child task) for direct cost/token attribution + tokens: z.number().optional(), // Total tokens (in + out) for linked subtask + cost: z.number().optional(), // Total cost for linked subtask }) export type TodoItem = z.infer diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index f3116141f04..1448c08c809 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/tools/UpdateTodoListTool.ts b/src/core/tools/UpdateTodoListTool.ts index f8b3653b9a3..cb2c37cb217 100644 --- a/src/core/tools/UpdateTodoListTool.ts +++ b/src/core/tools/UpdateTodoListTool.ts @@ -26,6 +26,12 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { const { pushToolResult, handleError, askApproval, toolProtocol } = callbacks try { + // Pull the previous todo list so we can preserve metadata fields across update_todo_list calls. + // Prefer the in-memory task.todoList when available; otherwise fall back to the latest todo list + // stored in the conversation history. + const previousTodos = + getTodoListForTask(task) ?? (getLatestTodo(task.clineMessages) as unknown as TodoItem[]) + const todosRaw = params.todos let todos: TodoItem[] @@ -39,6 +45,10 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { return } + // Preserve metadata (subtaskId/tokens/cost) for todos whose content matches an existing todo. + // Matching is by exact content string; duplicates are matched in order. + const todosWithPreservedMetadata = preserveTodoMetadata(todos, previousTodos) + const { valid, error } = validateTodos(todos) if (!valid) { task.consecutiveMistakeCount++ @@ -48,10 +58,13 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { return } - let normalizedTodos: TodoItem[] = todos.map((t) => ({ + let normalizedTodos: TodoItem[] = todosWithPreservedMetadata.map((t) => ({ id: t.id, content: t.content, status: normalizeStatus(t.status), + subtaskId: t.subtaskId, + tokens: t.tokens, + cost: t.cost, })) const approvalMsg = JSON.stringify({ @@ -70,6 +83,11 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { approvedTodoList !== undefined && JSON.stringify(normalizedTodos) !== JSON.stringify(approvedTodoList) if (isTodoListChanged) { normalizedTodos = approvedTodoList ?? [] + + // If the user-edited todo list dropped metadata fields, re-apply metadata preservation against + // the previous list (and keep any explicitly provided metadata in the edited list). + normalizedTodos = preserveTodoMetadata(normalizedTodos, previousTodos) + task.say( "user_edit_todos", JSON.stringify({ @@ -94,6 +112,7 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { override async handlePartial(task: Task, block: ToolUse<"update_todo_list">): Promise { const todosRaw = block.params.todos + const previousTodos = getTodoListForTask(task) ?? (getLatestTodo(task.clineMessages) as unknown as TodoItem[]) // Parse the markdown checklist to maintain consistent format with execute() let todos: TodoItem[] @@ -104,6 +123,8 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { todos = [] } + todos = preserveTodoMetadata(todos, previousTodos) + const approvalMsg = JSON.stringify({ tool: "updateTodoList", todos: todos, @@ -181,6 +202,30 @@ function normalizeStatus(status: string | undefined): TodoStatus { return "pending" } +function preserveTodoMetadata(nextTodos: TodoItem[], previousTodos: TodoItem[]): TodoItem[] { + // Build content -> queue mapping so duplicates are matched in order. + const previousByContent = new Map() + for (const prev of previousTodos ?? []) { + if (!prev || typeof prev.content !== "string") continue + const list = previousByContent.get(prev.content) + if (list) list.push(prev) + else previousByContent.set(prev.content, [prev]) + } + + return (nextTodos ?? []).map((next) => { + const candidates = previousByContent.get(next.content) + const matchedPrev = candidates?.shift() + if (!matchedPrev) return next + + return { + ...next, + subtaskId: next.subtaskId ?? matchedPrev.subtaskId, + tokens: next.tokens ?? matchedPrev.tokens, + cost: next.cost ?? matchedPrev.cost, + } + }) +} + export function parseMarkdownChecklist(md: string): TodoItem[] { if (typeof md !== "string") return [] const lines = md diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 33fa12ca78c..9acb26211aa 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" @@ -99,6 +104,7 @@ import { webviewMessageHandler } from "./webviewMessageHandler" import type { ClineMessage, TodoItem } from "@roo-code/types" import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" +import { getLatestTodo } from "../../shared/todo" import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" @@ -1717,7 +1723,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) { @@ -3177,6 +3200,69 @@ export class ClineProvider initialStatus: "active", }) + // 4.5) Direct todo-subtask linking: set todo.subtaskId = childTaskId at delegation-time + // Persist by appending an updateTodoList message to the parent's message history. + try { + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + const parentMessages = await readTaskMessages({ taskId: parentTaskId, globalStoragePath }) + const todos = getLatestTodo(parentMessages) as unknown as TodoItem[] + + const inProgress = todos.filter((t) => t?.status === "in_progress") + const pending = todos.filter((t) => t?.status === "pending") + + // Deterministic selection rule (in_progress > pending): pick the first matching item + // in the list order, even if multiple candidates exist. + const chosen: TodoItem | undefined = inProgress[0] ?? pending[0] + if (!chosen) { + this.log( + `[delegateParentAndOpenChild] Not linking subtask ${child.taskId}: no in_progress or pending todos found`, + ) + } else { + // Log ambiguity (but still link deterministically). + if (inProgress.length > 1) { + this.log( + `[delegateParentAndOpenChild] Multiple in_progress todos (${inProgress.length}); linking first to subtask ${child.taskId}`, + ) + } else if (pending.length > 1 && inProgress.length === 0) { + this.log( + `[delegateParentAndOpenChild] Multiple pending todos (${pending.length}); linking first to subtask ${child.taskId}`, + ) + } + } + + if (chosen) { + if (chosen.subtaskId && chosen.subtaskId !== child.taskId) { + this.log( + `[delegateParentAndOpenChild] Overwriting existing todo.subtaskId '${chosen.subtaskId}' -> '${child.taskId}'`, + ) + } + chosen.subtaskId = child.taskId + + await saveTaskMessages({ + messages: [ + ...parentMessages, + { + ts: Date.now(), + type: "say", + say: "user_edit_todos", + text: JSON.stringify({ + tool: "updateTodoList", + todos, + }), + }, + ], + taskId: parentTaskId, + globalStoragePath, + }) + } + } catch (error) { + this.log( + `[delegateParentAndOpenChild] Failed to persist delegation-time todo link (non-fatal): ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + // 5) Persist parent delegation metadata try { const { historyItem } = await this.getTaskWithId(parentTaskId) @@ -3218,6 +3304,19 @@ export class ClineProvider const { parentTaskId, childTaskId, completionResultSummary } = params const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + // 0) Load child task history to capture tokens/cost for write-back. + let childHistoryItem: HistoryItem | undefined + try { + const { historyItem } = await this.getTaskWithId(childTaskId) + childHistoryItem = historyItem + } catch (error) { + this.log( + `[reopenParentFromDelegation] Failed to load child history for ${childTaskId} (non-fatal): ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + // 1) Load parent from history and current persisted messages const { historyItem } = await this.getTaskWithId(parentTaskId) @@ -3255,6 +3354,40 @@ export class ClineProvider ts, } parentClineMessages.push(subtaskUiMessage) + + // 2.5) Persist provider completion write-back: update parent's todo item with tokens/cost. + try { + const todos = getLatestTodo(parentClineMessages) as unknown as TodoItem[] + if (Array.isArray(todos) && todos.length > 0) { + const linkedTodo = todos.find((t) => t?.subtaskId === childTaskId) + if (!linkedTodo) { + this.log( + `[reopenParentFromDelegation] No todo found with subtaskId === ${childTaskId}; skipping cost write-back`, + ) + } else { + linkedTodo.tokens = (childHistoryItem?.tokensIn || 0) + (childHistoryItem?.tokensOut || 0) + linkedTodo.cost = childHistoryItem?.totalCost || 0 + + parentClineMessages.push({ + ts: Date.now(), + type: "say", + say: "user_edit_todos", + text: JSON.stringify({ + tool: "updateTodoList", + todos, + }), + }) + } + } + } catch (error) { + this.log( + `[reopenParentFromDelegation] Failed to write back todo cost/tokens (non-fatal): ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + + // Persist injected UI records (subtask_result + optional todo write-back) await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath }) // Find the tool_use_id from the last assistant message's new_task tool_use @@ -3333,7 +3466,7 @@ export class ClineProvider // 3) Update child metadata to "completed" status try { - const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) + const childHistory = childHistoryItem ?? (await this.getTaskWithId(childTaskId)).historyItem await this.updateTaskHistory({ ...childHistory, status: "completed", diff --git a/src/core/webview/__tests__/aggregateTaskCosts.spec.ts b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts index ffb35f5e48c..eeffcebf477 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 3100b2a65e7..f85d7176a95 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 81f6cbebf66..ab232a072d2 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 5dca11b9634..133a38a35f2 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -21,6 +21,7 @@ import { getModelMaxOutputTokens } from "@roo/api" import { findLastIndex } from "@roo/array" import { formatLargeNumber } from "@src/utils/format" +import { getTaskHeaderCostTooltipData } from "@src/utils/taskCostBreakdown" import { cn } from "@src/lib/utils" import { StandardTooltip, Button } from "@src/components/ui" import { useExtensionState } from "@src/context/ExtensionStateContext" @@ -34,6 +35,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 +47,7 @@ export interface TaskHeaderProps { aggregatedCost?: number hasSubtasks?: boolean costBreakdown?: string + subtaskDetails?: SubtaskDetail[] contextTokens: number buttonsDisabled: boolean handleCondenseContext: (taskId: string) => void @@ -61,6 +64,7 @@ const TaskHeader = ({ aggregatedCost, hasSubtasks, costBreakdown, + subtaskDetails, contextTokens, buttonsDisabled, handleCondenseContext, @@ -126,6 +130,37 @@ const TaskHeader = ({ const hasTodos = todos && Array.isArray(todos) && todos.length > 0 + const subtaskCosts = useMemo(() => { + if (!subtaskDetails || subtaskDetails.length === 0) { + return [] + } + + return subtaskDetails + .map((subtask) => subtask.cost) + .filter((cost): cost is number => typeof cost === "number" && Number.isFinite(cost)) + }, [subtaskDetails]) + + const tooltipCostData = useMemo( + () => + getTaskHeaderCostTooltipData({ + ownCost: totalCost, + aggregatedCost, + hasSubtasksProp: hasSubtasks, + costBreakdownProp: costBreakdown, + subtaskCosts, + labels: { + own: t("common:costs.own"), + subtasks: t("common:costs.subtasks"), + }, + }), + [totalCost, aggregatedCost, hasSubtasks, costBreakdown, subtaskCosts, t], + ) + + const displayTotalCost = tooltipCostData.displayTotalCost + const displayCostBreakdown = tooltipCostData.displayCostBreakdown + const shouldTreatAsHasSubtasks = tooltipCostData.hasSubtasks + const hasAnyCost = tooltipCostData.hasAnyCost + return (
{showLongRunningTaskMessage && !isTaskComplete && ( @@ -254,17 +289,19 @@ const TaskHeader = ({ {formatLargeNumber(contextTokens || 0)} / {formatLargeNumber(contextWindow)} - {!!totalCost && ( + {hasAnyCost && (
{t("chat:costs.totalWithSubtasks", { - cost: (aggregatedCost ?? totalCost).toFixed(2), + cost: displayTotalCost.toFixed(2), })}
- {costBreakdown &&
{costBreakdown}
} + {displayCostBreakdown && ( +
{displayCostBreakdown}
+ )}
) : (
{t("chat:costs.total", { cost: totalCost.toFixed(2) })}
@@ -273,8 +310,8 @@ const TaskHeader = ({ side="top" sideOffset={8}> - ${(aggregatedCost ?? totalCost).toFixed(2)} - {hasSubtasks && ( + ${displayTotalCost.toFixed(2)} + {shouldTreatAsHasSubtasks && ( * @@ -413,7 +450,7 @@ const TaskHeader = ({ )} - {!!totalCost && ( + {hasAnyCost && ( {t("chat:task.apiCost")} @@ -421,15 +458,17 @@ const TaskHeader = ({
{t("chat:costs.totalWithSubtasks", { - cost: (aggregatedCost ?? totalCost).toFixed(2), + cost: displayTotalCost.toFixed(2), })}
- {costBreakdown && ( -
{costBreakdown}
+ {displayCostBreakdown && ( +
+ {displayCostBreakdown} +
)} ) : ( @@ -441,8 +480,8 @@ const TaskHeader = ({ side="top" sideOffset={8}> - ${(aggregatedCost ?? totalCost).toFixed(2)} - {hasSubtasks && ( + ${displayTotalCost.toFixed(2)} + {shouldTreatAsHasSubtasks && ( @@ -472,7 +511,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 f2dbbc4d80d..89eb1151e86 100644 --- a/webview-ui/src/components/chat/TodoListDisplay.tsx +++ b/webview-ui/src/components/chat/TodoListDisplay.tsx @@ -3,8 +3,24 @@ 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" +interface TodoItem { + // Legacy fields + id?: string + content: string + status?: TodoStatus | string | null + + // Direct-linking/cost fields (optional for backward compatibility) + subtaskId?: string + tokens?: number + cost?: number +} + function getTodoIcon(status: TodoStatus | null) { switch (status) { case "completed": @@ -16,21 +32,27 @@ function getTodoIcon(status: TodoStatus | null) { } } -export function TodoListDisplay({ todos }: { todos: any[] }) { +export interface TodoListDisplayProps { + todos: TodoItem[] + 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)[]>([]) const scrollIndex = useMemo(() => { - const inProgressIdx = todos.findIndex((todo: any) => todo.status === "in_progress") + const inProgressIdx = todos.findIndex((todo) => todo.status === "in_progress") if (inProgressIdx !== -1) return inProgressIdx - return todos.findIndex((todo: any) => todo.status !== "completed") + return todos.findIndex((todo) => todo.status !== "completed") }, [todos]) // Find the most important todo to display when collapsed const mostImportantTodo = useMemo(() => { - const inProgress = todos.find((todo: any) => todo.status === "in_progress") + const inProgress = todos.find((todo) => todo.status === "in_progress") if (inProgress) return inProgress - return todos.find((todo: any) => todo.status !== "completed") + return todos.find((todo) => todo.status !== "completed") }, [todos]) useEffect(() => { if (isCollapsed) return @@ -49,7 +71,7 @@ export function TodoListDisplay({ todos }: { todos: any[] }) { if (!Array.isArray(todos) || todos.length === 0) return null const totalCount = todos.length - const completedCount = todos.filter((todo: any) => todo.status === "completed").length + const completedCount = todos.filter((todo) => todo.status === "completed").length const allCompleted = completedCount === totalCount && totalCount > 0 @@ -80,8 +102,17 @@ export function TodoListDisplay({ todos }: { todos: any[] }) { {/* Inline expanded list */} {!isCollapsed && (
    - {todos.map((todo: any, idx: number) => { + {todos.map((todo, idx: number) => { const icon = getTodoIcon(todo.status as TodoStatus) + const isClickable = Boolean(todo.subtaskId && onSubtaskClick) + const subtaskById = + subtaskDetails && todo.subtaskId + ? subtaskDetails.find((s) => s.id === todo.subtaskId) + : undefined + const displayTokens = todo.tokens ?? subtaskById?.tokens + const displayCost = todo.cost ?? subtaskById?.cost + const shouldShowCost = typeof displayTokens === "number" && typeof displayCost === "number" + return (
  • {icon} - {todo.content} + onSubtaskClick?.(todo.subtaskId as string) : undefined + }> + {todo.content} + + {/* Token count and cost display */} + {shouldShowCost && ( + + + {formatLargeNumber(displayTokens)} + + + ${displayCost.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 00000000000..74933bbc95c --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx @@ -0,0 +1,227 @@ +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", subtaskId: "subtask-1" }, + { id: "2", content: "Task 2: Add timestamp to bottom", status: "completed", subtaskId: "subtask-2" }, + { 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 todo.subtaskId matches", () => { + 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 todos without subtaskId", () => { + render() + + // Expand to see the items + const header = screen.getByText("Task 3: Pending task") + fireEvent.click(header) + + // The pending task has no subtaskId, 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("direct subtask linking", () => { + it("should use todo.tokens and todo.cost when provided (no subtaskDetails required)", () => { + const todosWithDirectCost = [ + { + id: "1", + content: "Task 1: Change background colour", + status: "completed", + subtaskId: "subtask-1", + tokens: 95400, + cost: 0.22, + }, + ] + render() + + // Expand + const header = screen.getByText("1 to-dos done") + fireEvent.click(header) + + expect(screen.getByText("95.4k")).toBeInTheDocument() + expect(screen.getByText("$0.22")).toBeInTheDocument() + }) + + it("should fall back to subtaskDetails by ID when todo.tokens/cost are missing", () => { + const todosMissingCostFields = [ + { + id: "1", + content: "Task 1: Change background colour", + status: "completed", + subtaskId: "subtask-1", + }, + ] + render() + + // Expand + const header = screen.getByText("1 to-dos done") + fireEvent.click(header) + + expect(screen.getByText("95.4k")).toBeInTheDocument() + expect(screen.getByText("$0.22")).toBeInTheDocument() + }) + }) + + describe("click handler", () => { + it("should call onSubtaskClick when a todo with subtaskId 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 a todo does not have subtaskId", () => { + 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") + }) + }) +}) diff --git a/webview-ui/src/utils/__tests__/taskCostBreakdown.spec.ts b/webview-ui/src/utils/__tests__/taskCostBreakdown.spec.ts new file mode 100644 index 00000000000..fe7f00771fb --- /dev/null +++ b/webview-ui/src/utils/__tests__/taskCostBreakdown.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest" + +import { computeTaskCostsIncludingSubtasks, getTaskHeaderCostTooltipData } from "@src/utils/taskCostBreakdown" + +describe("taskCostBreakdown", () => { + it("sums subtask line-item costs (micros) and produces a stable total", () => { + const result = computeTaskCostsIncludingSubtasks(0.05, [0.16, 0.13]) + + expect(result.ownCostCents).toBe(5) + expect(result.subtasksCostCents).toBe(29) + expect(result.totalCostIncludingSubtasksCents).toBe(34) + expect(result.totalCostIncludingSubtasks).toBeCloseTo(0.34, 10) + }) + + it("prefers derived subtask sum over provided breakdown/aggregatedCost when details are available", () => { + const data = getTaskHeaderCostTooltipData({ + ownCost: 0.05, + aggregatedCost: 0.09, + hasSubtasksProp: true, + costBreakdownProp: "Own: $0.05 + Subtasks: $0.09", + subtaskCosts: [0.16, 0.13], + labels: { own: "Own", subtasks: "Subtasks" }, + }) + + expect(data.displayTotalCost).toBeCloseTo(0.34, 10) + expect(data.displayCostBreakdown).toBe("Own: $0.05 + Subtasks: $0.29") + expect(data.hasSubtasks).toBe(true) + expect(data.hasAnyCost).toBe(true) + }) +}) diff --git a/webview-ui/src/utils/taskCostBreakdown.ts b/webview-ui/src/utils/taskCostBreakdown.ts new file mode 100644 index 00000000000..514246e50eb --- /dev/null +++ b/webview-ui/src/utils/taskCostBreakdown.ts @@ -0,0 +1,104 @@ +import { formatCostBreakdown } from "@src/utils/costFormatting" + +const MICROS_PER_DOLLAR = 1_000_000 +const MICROS_PER_CENT = 10_000 + +function dollarsToMicros(amount: number): number { + if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0) { + return 0 + } + return Math.round(amount * MICROS_PER_DOLLAR) +} + +function microsToDollars(micros: number): number { + return micros / MICROS_PER_DOLLAR +} + +export interface TaskCostsIncludingSubtasks { + ownCost: number + ownCostMicros: number + ownCostCents: number + + subtasksCost: number + subtasksCostMicros: number + subtasksCostCents: number + + totalCostIncludingSubtasks: number + totalCostIncludingSubtasksMicros: number + totalCostIncludingSubtasksCents: number +} + +/** + * Computes task costs using integer micros for stable aggregation. + * + * Note: we only have access to floating-dollar amounts in the webview. + * Converting to micros and summing avoids most floating point drift. + */ +export function computeTaskCostsIncludingSubtasks(ownCost: number, subtaskCosts: number[]): TaskCostsIncludingSubtasks { + const ownCostMicros = dollarsToMicros(ownCost) + const subtasksCostMicros = (subtaskCosts ?? []).reduce((sum, cost) => sum + dollarsToMicros(cost), 0) + const totalCostIncludingSubtasksMicros = ownCostMicros + subtasksCostMicros + + const ownCostCents = Math.round(ownCostMicros / MICROS_PER_CENT) + const subtasksCostCents = Math.round(subtasksCostMicros / MICROS_PER_CENT) + const totalCostIncludingSubtasksCents = Math.round(totalCostIncludingSubtasksMicros / MICROS_PER_CENT) + + return { + ownCost: microsToDollars(ownCostMicros), + ownCostMicros, + ownCostCents, + subtasksCost: microsToDollars(subtasksCostMicros), + subtasksCostMicros, + subtasksCostCents, + totalCostIncludingSubtasks: microsToDollars(totalCostIncludingSubtasksMicros), + totalCostIncludingSubtasksMicros, + totalCostIncludingSubtasksCents, + } +} + +export interface TaskHeaderCostTooltipData { + /** Total cost to display (includes subtasks when details provided). */ + displayTotalCost: number + /** Breakdown string to show in tooltip, if subtasks exist. */ + displayCostBreakdown?: string + /** Whether the UI should show the "includes subtasks" marker. */ + hasSubtasks: boolean + /** Whether there is any cost to render. */ + hasAnyCost: boolean +} + +/** + * Cost display logic for TaskHeader tooltip. + * + * When subtask details are available, this derives subtasks cost as the sum of + * subtask line-item totals (same source as the accordion list), rather than + * trusting any derived deltas. + */ +export function getTaskHeaderCostTooltipData(params: { + ownCost: number + aggregatedCost?: number + hasSubtasksProp?: boolean + costBreakdownProp?: string + subtaskCosts?: number[] + labels: { own: string; subtasks: string } +}): TaskHeaderCostTooltipData { + const { ownCost, aggregatedCost, hasSubtasksProp, costBreakdownProp, subtaskCosts, labels } = params + + const computed = computeTaskCostsIncludingSubtasks(ownCost, subtaskCosts ?? []) + const hasComputedSubtasks = computed.subtasksCostCents > 0 + const hasSubtasks = !!hasSubtasksProp || hasComputedSubtasks + + const displayTotalCost = hasComputedSubtasks ? computed.totalCostIncludingSubtasks : (aggregatedCost ?? ownCost) + const displayCostBreakdown = hasComputedSubtasks + ? formatCostBreakdown(computed.ownCost, computed.subtasksCost, labels) + : costBreakdownProp + + const hasAnyCost = typeof displayTotalCost === "number" && Number.isFinite(displayTotalCost) && displayTotalCost > 0 + + return { + displayTotalCost, + displayCostBreakdown, + hasSubtasks, + hasAnyCost, + } +}