From 5f4b6579c2688b7dec5af01680315b700cd690ab Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Thu, 15 Jan 2026 13:34:49 -0500 Subject: [PATCH 1/7] feat: aggregate subtask costs in orchestrator Add recursive task cost aggregation and expose it through the extension host/webview so orchestrator parent tasks display cumulative costs from all subtasks. Fixes RooCodeInc/Roo-Code#5376 --- packages/core/package.json | 3 +- .../__tests__/aggregateTaskCosts.spec.ts | 494 ++++++++++++++++++ .../src/message-utils/aggregateTaskCosts.ts | 123 +++++ packages/types/src/vscode-extension-host.ts | 9 + src/core/webview/ClineProvider.ts | 15 + src/core/webview/webviewMessageHandler.ts | 38 ++ webview-ui/src/components/chat/ChatView.tsx | 77 +++ webview-ui/src/components/chat/TaskHeader.tsx | 74 ++- 8 files changed, 830 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/message-utils/__tests__/aggregateTaskCosts.spec.ts create mode 100644 packages/core/src/message-utils/aggregateTaskCosts.ts diff --git a/packages/core/package.json b/packages/core/package.json index 95c6d793b35..94cb10ad267 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,7 +6,8 @@ "exports": { ".": "./src/index.ts", "./cli": "./src/cli.ts", - "./browser": "./src/browser.ts" + "./browser": "./src/browser.ts", + "./message-utils/aggregateTaskCosts": "./src/message-utils/aggregateTaskCosts.ts" }, "scripts": { "lint": "eslint src --ext=ts --max-warnings=0", diff --git a/packages/core/src/message-utils/__tests__/aggregateTaskCosts.spec.ts b/packages/core/src/message-utils/__tests__/aggregateTaskCosts.spec.ts new file mode 100644 index 00000000000..4b562038d1b --- /dev/null +++ b/packages/core/src/message-utils/__tests__/aggregateTaskCosts.spec.ts @@ -0,0 +1,494 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { aggregateTaskCostsRecursive, getDisplayCosts } from "../aggregateTaskCosts.js" +import type { HistoryItem } from "@roo-code/types" + +describe("aggregateTaskCostsRecursive", () => { + let consoleWarnSpy: ReturnType + + beforeEach(() => { + consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + }) + + it("should calculate cost for task with no children", async () => { + const mockHistory: Record = { + "task-1": { + id: "task-1", + totalCost: 1.5, + childIds: [], + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await aggregateTaskCostsRecursive("task-1", getTaskHistory) + + expect(result.ownCost).toBe(1.5) + expect(result.childrenCost).toBe(0) + expect(result.totalCost).toBe(1.5) + expect(result.childBreakdown).toEqual({}) + }) + + it("should calculate cost for task with undefined childIds", async () => { + const mockHistory: Record = { + "task-1": { + id: "task-1", + totalCost: 2.0, + // childIds is undefined + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await aggregateTaskCostsRecursive("task-1", getTaskHistory) + + expect(result.ownCost).toBe(2.0) + expect(result.childrenCost).toBe(0) + expect(result.totalCost).toBe(2.0) + expect(result.childBreakdown).toEqual({}) + }) + + it("should aggregate parent with one child", async () => { + const mockHistory: Record = { + parent: { + id: "parent", + totalCost: 1.0, + childIds: ["child-1"], + } as unknown as HistoryItem, + "child-1": { + id: "child-1", + totalCost: 0.5, + childIds: [], + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await aggregateTaskCostsRecursive("parent", getTaskHistory) + + expect(result.ownCost).toBe(1.0) + expect(result.childrenCost).toBe(0.5) + expect(result.totalCost).toBe(1.5) + expect(result.childBreakdown).toHaveProperty("child-1") + expect(result.childBreakdown?.["child-1"].totalCost).toBe(0.5) + }) + + it("should aggregate parent with multiple children", async () => { + const mockHistory: Record = { + parent: { + id: "parent", + totalCost: 1.0, + childIds: ["child-1", "child-2", "child-3"], + } as unknown as HistoryItem, + "child-1": { + id: "child-1", + totalCost: 0.5, + childIds: [], + } as unknown as HistoryItem, + "child-2": { + id: "child-2", + totalCost: 0.75, + childIds: [], + } as unknown as HistoryItem, + "child-3": { + id: "child-3", + totalCost: 0.25, + childIds: [], + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await aggregateTaskCostsRecursive("parent", getTaskHistory) + + expect(result.ownCost).toBe(1.0) + expect(result.childrenCost).toBe(1.5) // 0.5 + 0.75 + 0.25 + expect(result.totalCost).toBe(2.5) + expect(Object.keys(result.childBreakdown || {})).toHaveLength(3) + }) + + it("should recursively aggregate multi-level hierarchy", async () => { + const mockHistory: Record = { + parent: { + id: "parent", + totalCost: 1.0, + childIds: ["child"], + } as unknown as HistoryItem, + child: { + id: "child", + totalCost: 0.5, + childIds: ["grandchild"], + } as unknown as HistoryItem, + grandchild: { + id: "grandchild", + totalCost: 0.25, + childIds: [], + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await aggregateTaskCostsRecursive("parent", getTaskHistory) + + expect(result.ownCost).toBe(1.0) + expect(result.childrenCost).toBe(0.75) // child (0.5) + grandchild (0.25) + expect(result.totalCost).toBe(1.75) + + // Verify child breakdown + expect(result.childBreakdown?.child.ownCost).toBe(0.5) + expect(result.childBreakdown?.child.childrenCost).toBe(0.25) + expect(result.childBreakdown?.child.totalCost).toBe(0.75) + + // Verify grandchild breakdown + expect(result.childBreakdown?.child.childBreakdown?.grandchild.ownCost).toBe(0.25) + expect(result.childBreakdown?.child.childBreakdown?.grandchild.childrenCost).toBe(0) + expect(result.childBreakdown?.child.childBreakdown?.grandchild.totalCost).toBe(0.25) + }) + + it("should detect and prevent circular references", async () => { + const mockHistory: Record = { + "task-a": { + id: "task-a", + totalCost: 1.0, + childIds: ["task-b"], + } as unknown as HistoryItem, + "task-b": { + id: "task-b", + totalCost: 0.5, + childIds: ["task-a"], // Circular reference back to task-a + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await aggregateTaskCostsRecursive("task-a", getTaskHistory) + + // Should still process task-b but ignore the circular reference + expect(result.ownCost).toBe(1.0) + expect(result.childrenCost).toBe(0.5) // Only task-b's own cost, circular ref returns 0 + expect(result.totalCost).toBe(1.5) + + // Verify warning was logged + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Circular reference detected: task-a")) + }) + + it("should handle missing task gracefully", async () => { + const mockHistory: Record = { + parent: { + id: "parent", + totalCost: 1.0, + childIds: ["nonexistent-child"], + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await aggregateTaskCostsRecursive("parent", getTaskHistory) + + expect(result.ownCost).toBe(1.0) + expect(result.childrenCost).toBe(0) // Missing child contributes 0 + expect(result.totalCost).toBe(1.0) + + // Verify warning was logged + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Task nonexistent-child not found")) + }) + + it("should return zero costs for completely missing task", async () => { + const mockHistory: Record = {} + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await aggregateTaskCostsRecursive("nonexistent", getTaskHistory) + + expect(result.ownCost).toBe(0) + expect(result.childrenCost).toBe(0) + expect(result.totalCost).toBe(0) + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Task nonexistent not found")) + }) + + it("should handle task with null totalCost", async () => { + const mockHistory: Record = { + "task-1": { + id: "task-1", + totalCost: null as unknown as number, // Explicitly null (invalid type in prod) + childIds: [], + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await aggregateTaskCostsRecursive("task-1", getTaskHistory) + + expect(result.ownCost).toBe(0) + expect(result.childrenCost).toBe(0) + expect(result.totalCost).toBe(0) + }) + + it("should handle task with undefined totalCost", async () => { + const mockHistory: Record = { + "task-1": { + id: "task-1", + // totalCost is undefined + childIds: [], + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await aggregateTaskCostsRecursive("task-1", getTaskHistory) + + expect(result.ownCost).toBe(0) + expect(result.childrenCost).toBe(0) + expect(result.totalCost).toBe(0) + }) + + it("should handle complex hierarchy with mixed costs", async () => { + const mockHistory: Record = { + root: { + id: "root", + totalCost: 2.5, + childIds: ["child-1", "child-2"], + } as unknown as HistoryItem, + "child-1": { + id: "child-1", + totalCost: 1.2, + childIds: ["grandchild-1", "grandchild-2"], + } as unknown as HistoryItem, + "child-2": { + id: "child-2", + totalCost: 0.8, + childIds: [], + } as unknown as HistoryItem, + "grandchild-1": { + id: "grandchild-1", + totalCost: 0.3, + childIds: [], + } as unknown as HistoryItem, + "grandchild-2": { + id: "grandchild-2", + totalCost: 0.15, + childIds: [], + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await aggregateTaskCostsRecursive("root", getTaskHistory) + + expect(result.ownCost).toBe(2.5) + // child-1: 1.2 + 0.3 + 0.15 = 1.65 + // child-2: 0.8 + // Total children: 2.45 + expect(result.childrenCost).toBe(2.45) + expect(result.totalCost).toBe(4.95) // 2.5 + 2.45 + }) + + it("should handle siblings without cross-contamination", async () => { + const mockHistory: Record = { + parent: { + id: "parent", + totalCost: 1.0, + childIds: ["sibling-1", "sibling-2"], + } as unknown as HistoryItem, + "sibling-1": { + id: "sibling-1", + totalCost: 0.5, + childIds: ["nephew"], + } as unknown as HistoryItem, + "sibling-2": { + id: "sibling-2", + totalCost: 0.3, + childIds: ["nephew"], // Same child ID as sibling-1 + } as unknown as HistoryItem, + nephew: { + id: "nephew", + totalCost: 0.1, + childIds: [], + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await aggregateTaskCostsRecursive("parent", getTaskHistory) + + // Both siblings should independently count nephew + // sibling-1: 0.5 + 0.1 = 0.6 + // sibling-2: 0.3 + 0.1 = 0.4 + // Total: 1.0 + 0.6 + 0.4 = 2.0 + expect(result.totalCost).toBe(2.0) + }) +}) + +describe("getDisplayCosts", () => { + it("should return correct format for task with no children", async () => { + const mockHistory: Record = { + "task-1": { + id: "task-1", + totalCost: 1.5, + childIds: [], + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await getDisplayCosts("task-1", getTaskHistory) + + expect(result.displayCost).toBe(1.5) + expect(result.showAggregated).toBe(false) + expect(result.breakdown).toBeUndefined() + }) + + it("should return correct format for task with undefined childIds", async () => { + const mockHistory: Record = { + "task-1": { + id: "task-1", + totalCost: 2.0, + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await getDisplayCosts("task-1", getTaskHistory) + + expect(result.displayCost).toBe(2.0) + expect(result.showAggregated).toBe(false) + expect(result.breakdown).toBeUndefined() + }) + + it("should return aggregated format for task with children", async () => { + const mockHistory: Record = { + parent: { + id: "parent", + totalCost: 1.0, + childIds: ["child-1", "child-2"], + } as unknown as HistoryItem, + "child-1": { + id: "child-1", + totalCost: 0.5, + childIds: [], + } as unknown as HistoryItem, + "child-2": { + id: "child-2", + totalCost: 0.75, + childIds: [], + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await getDisplayCosts("parent", getTaskHistory) + + expect(result.displayCost).toBe(2.25) // 1.0 + 0.5 + 0.75 + expect(result.showAggregated).toBe(true) + expect(result.breakdown).toBe("Own: $1.00 + Subtasks: $1.25") + }) + + it("should format breakdown string with correct decimal places", async () => { + const mockHistory: Record = { + parent: { + id: "parent", + totalCost: 1.234, + childIds: ["child"], + } as unknown as HistoryItem, + child: { + id: "child", + totalCost: 0.567, + childIds: [], + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await getDisplayCosts("parent", getTaskHistory) + + expect(result.displayCost).toBeCloseTo(1.801, 3) + expect(result.showAggregated).toBe(true) + expect(result.breakdown).toBe("Own: $1.23 + Subtasks: $0.57") + }) + + it("should handle nested hierarchy correctly", async () => { + const mockHistory: Record = { + root: { + id: "root", + totalCost: 2.0, + childIds: ["child"], + } as unknown as HistoryItem, + child: { + id: "child", + totalCost: 1.0, + childIds: ["grandchild"], + } as unknown as HistoryItem, + grandchild: { + id: "grandchild", + totalCost: 0.5, + childIds: [], + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await getDisplayCosts("root", getTaskHistory) + + expect(result.displayCost).toBe(3.5) // 2.0 + 1.0 + 0.5 + expect(result.showAggregated).toBe(true) + expect(result.breakdown).toBe("Own: $2.00 + Subtasks: $1.50") + }) + + it("should handle zero costs gracefully", async () => { + const mockHistory: Record = { + parent: { + id: "parent", + totalCost: 0, + childIds: ["child"], + } as unknown as HistoryItem, + child: { + id: "child", + totalCost: 0, + childIds: [], + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await getDisplayCosts("parent", getTaskHistory) + + expect(result.displayCost).toBe(0) + expect(result.showAggregated).toBe(false) // childrenCost is 0, so no aggregation shown + expect(result.breakdown).toBeUndefined() + }) + + it("should show aggregated even if own cost is zero", async () => { + const mockHistory: Record = { + parent: { + id: "parent", + totalCost: 0, + childIds: ["child"], + } as unknown as HistoryItem, + child: { + id: "child", + totalCost: 1.5, + childIds: [], + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await getDisplayCosts("parent", getTaskHistory) + + expect(result.displayCost).toBe(1.5) + expect(result.showAggregated).toBe(true) + expect(result.breakdown).toBe("Own: $0.00 + Subtasks: $1.50") + }) + + it("should handle missing task gracefully", async () => { + const mockHistory: Record = {} + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await getDisplayCosts("nonexistent", getTaskHistory) + + expect(result.displayCost).toBe(0) + expect(result.showAggregated).toBe(false) + expect(result.breakdown).toBeUndefined() + }) +}) diff --git a/packages/core/src/message-utils/aggregateTaskCosts.ts b/packages/core/src/message-utils/aggregateTaskCosts.ts new file mode 100644 index 00000000000..0096bcfdfc5 --- /dev/null +++ b/packages/core/src/message-utils/aggregateTaskCosts.ts @@ -0,0 +1,123 @@ +import type { HistoryItem } from "@roo-code/types" + +export interface AggregatedCosts { + ownCost: number // This task's own API costs + childrenCost: number // Sum of all direct children costs (recursive) + totalCost: number // ownCost + childrenCost + childBreakdown?: { + // Optional detailed breakdown + [childId: string]: AggregatedCosts + } +} + +/** + * Recursively aggregate costs for a task and all its subtasks. + * + * @param taskId - The task ID to aggregate costs for + * @param getTaskHistory - Function to load HistoryItem by task ID + * @param visited - Set to prevent circular references + * @returns Aggregated cost information + */ +export async function aggregateTaskCostsRecursive( + taskId: string, + getTaskHistory: (id: string) => Promise, + visited: Set = new Set(), +): Promise { + console.log("[Aggregated Costs][CORE] aggregateTaskCostsRecursive enter", { + taskId, + visitedSize: visited.size, + visited: Array.from(visited), + }) + // Prevent infinite loops + if (visited.has(taskId)) { + console.warn(`[aggregateTaskCostsRecursive] Circular reference detected: ${taskId}`) + console.log("[Aggregated Costs][CORE] aggregateTaskCostsRecursive circular", { taskId }) + return { ownCost: 0, childrenCost: 0, totalCost: 0 } + } + visited.add(taskId) + + // Load this task's history + const history = await getTaskHistory(taskId) + if (!history) { + console.warn(`[aggregateTaskCostsRecursive] Task ${taskId} not found`) + console.log("[Aggregated Costs][CORE] aggregateTaskCostsRecursive missing history", { taskId }) + return { ownCost: 0, childrenCost: 0, totalCost: 0 } + } + + const ownCost = history.totalCost || 0 + let childrenCost = 0 + const childBreakdown: { [childId: string]: AggregatedCosts } = {} + console.log("[Aggregated Costs][CORE] aggregateTaskCostsRecursive loaded history", { + taskId, + ownCost, + childIds: history.childIds, + }) + + // Recursively aggregate child costs + if (history.childIds && history.childIds.length > 0) { + for (const childId of history.childIds) { + console.log("[Aggregated Costs][CORE] aggregateTaskCostsRecursive recurse child", { + parentTaskId: taskId, + childId, + }) + const childAggregated = await aggregateTaskCostsRecursive( + childId, + getTaskHistory, + new Set(visited), // Create new Set to allow sibling traversal + ) + console.log("[Aggregated Costs][CORE] aggregateTaskCostsRecursive child result", { + parentTaskId: taskId, + childId, + childAggregated, + }) + childrenCost += childAggregated.totalCost + childBreakdown[childId] = childAggregated + } + } + + const result: AggregatedCosts = { + ownCost, + childrenCost, + totalCost: ownCost + childrenCost, + childBreakdown, + } + + console.log("[Aggregated Costs][CORE] aggregateTaskCostsRecursive exit", { + taskId, + ownCost: result.ownCost, + childrenCost: result.childrenCost, + totalCost: result.totalCost, + }) + + return { + ...result, + } +} + +/** + * Get aggregated costs for display, handling incomplete tasks gracefully. + */ +export async function getDisplayCosts( + taskId: string, + getTaskHistory: (id: string) => Promise, +): Promise<{ + displayCost: number + showAggregated: boolean + breakdown?: string +}> { + const aggregated = await aggregateTaskCostsRecursive(taskId, getTaskHistory) + + const hasChildren = aggregated.childrenCost > 0 + const displayCost = aggregated.totalCost + + let breakdown: string | undefined + if (hasChildren) { + breakdown = `Own: $${aggregated.ownCost.toFixed(2)} + Subtasks: $${aggregated.childrenCost.toFixed(2)}` + } + + return { + displayCost, + showAggregated: hasChildren, + breakdown, + } +} diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index bce6c993bc7..f3116141f04 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -94,6 +94,7 @@ export interface ExtensionMessage { | "claudeCodeRateLimits" | "customToolsResult" | "modes" + | "taskWithAggregatedCosts" text?: string payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any checkpointWarning?: { @@ -182,6 +183,13 @@ export interface ExtensionMessage { stepIndex?: number // For browserSessionNavigate: the target step index to display tools?: SerializedCustomToolDefinition[] // For customToolsResult modes?: { slug: string; name: string }[] // For modes response + aggregatedCosts?: { + // For taskWithAggregatedCosts response + totalCost: number + ownCost: number + childrenCost: number + } + historyItem?: HistoryItem } export type ExtensionState = Pick< @@ -498,6 +506,7 @@ export interface WebviewMessage { | "getDismissedUpsells" | "updateSettings" | "allowedCommands" + | "getTaskWithAggregatedCosts" | "deniedCommands" | "killBrowserSession" | "openBrowserSessionPanel" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index f9ac4c8fffd..5dd924e7909 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -47,6 +47,7 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, getModelId, } from "@roo-code/types" +import { aggregateTaskCostsRecursive, type AggregatedCosts } from "@roo-code/core/message-utils/aggregateTaskCosts" import { TelemetryService } from "@roo-code/telemetry" import { CloudService, BridgeOrchestrator, getRooCodeApiUrl } from "@roo-code/cloud" @@ -1705,6 +1706,20 @@ export class ClineProvider throw new Error("Task not found") } + async getTaskWithAggregatedCosts(taskId: string): Promise<{ + historyItem: HistoryItem + aggregatedCosts: AggregatedCosts + }> { + const { historyItem } = await this.getTaskWithId(taskId) + + const aggregatedCosts = await aggregateTaskCostsRecursive(taskId, async (id: string) => { + const result = await this.getTaskWithId(id) + return result.historyItem + }) + + return { historyItem, aggregatedCosts } + } + async showTaskWithId(id: string) { if (id !== this.getCurrentTask()?.taskId) { // Non-current task. diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index ddd97dffd50..de878c4ab15 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -784,6 +784,44 @@ export const webviewMessageHandler = async ( case "exportTaskWithId": provider.exportTaskWithId(message.text!) break + case "getTaskWithAggregatedCosts": { + try { + const taskId = message.text + console.log("[Aggregated Costs][EXT] getTaskWithAggregatedCosts received", { + taskId, + messageType: message.type, + }) + if (!taskId) { + throw new Error("Task ID is required") + } + const result = await provider.getTaskWithAggregatedCosts(taskId) + console.log("[Aggregated Costs][EXT] getTaskWithAggregatedCosts computed", { + taskId, + historyChildIds: result.historyItem?.childIds, + aggregatedCosts: result.aggregatedCosts, + }) + await provider.postMessageToWebview({ + type: "taskWithAggregatedCosts", + // IMPORTANT: ChatView stores aggregatedCostsMap keyed by message.text (taskId) + // so we must include it here. + text: taskId, + historyItem: result.historyItem, + aggregatedCosts: result.aggregatedCosts, + }) + console.log("[Aggregated Costs][EXT] taskWithAggregatedCosts posted to webview", { + taskId, + }) + } catch (error) { + console.error("Error getting task with aggregated costs:", error) + await provider.postMessageToWebview({ + type: "taskWithAggregatedCosts", + // Include taskId when available for correlation in UI logs. + text: message.text, + error: error instanceof Error ? error.message : String(error), + }) + } + break + } case "importSettings": { await importSettingsWithFeedback({ providerSettingsManager: provider.providerSettingsManager, diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 8f34de2cda3..88face4c346 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -166,6 +166,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null) const userRespondedRef = useRef(false) const [currentFollowUpTs, setCurrentFollowUpTs] = useState(null) + const [aggregatedCostsMap, setAggregatedCostsMap] = useState< + Map< + string, + { + totalCost: number + ownCost: number + childrenCost: number + } + > + >(new Map()) const clineAskRef = useRef(clineAsk) useEffect(() => { @@ -207,6 +217,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + const entries = Array.from(aggregatedCostsMap.entries()).map(([taskId, costs]) => ({ taskId, costs })) + console.log("[Aggregated Costs][UI] aggregatedCostsMap updated", { + size: aggregatedCostsMap.size, + entries, + }) + }, [aggregatedCostsMap]) + const isProfileDisabled = useMemo( () => !!apiConfiguration && !ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList), [apiConfiguration, organizationAllowList], @@ -467,6 +486,23 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + if (taskTs && currentTaskItem?.childIds && currentTaskItem.childIds.length > 0) { + console.log("[Aggregated Costs][UI] Requesting getTaskWithAggregatedCosts", { + taskId: currentTaskItem?.id, + childIds: currentTaskItem?.childIds, + taskTs, + }) + vscode.postMessage({ + type: "getTaskWithAggregatedCosts", + text: currentTaskItem.id, + }) + } + }, [taskTs, currentTaskItem?.id, currentTaskItem?.childIds]) + useEffect(() => { if (isHidden) { everVisibleMessagesTsRef.current.clear() @@ -889,6 +925,25 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + const newMap = new Map(prev) + newMap.set(message.text!, message.aggregatedCosts!) + console.log("[Aggregated Costs][UI] aggregatedCostsMap set", { + taskId: message.text, + costs: message.aggregatedCosts, + newSize: newMap.size, + }) + return newMap + }) + } + break } // textAreaRef.current is not explicitly required here since React // guarantees that ref will be stable across re-renders, and we're @@ -1438,6 +1493,28 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0 + ) + } + costBreakdown={ + currentTaskItem?.id && aggregatedCostsMap.has(currentTaskItem.id) + ? (() => { + const costs = aggregatedCostsMap.get(currentTaskItem.id)! + return costs.childrenCost > 0 + ? `Own: $${costs.ownCost.toFixed(2)} + Subtasks: $${costs.childrenCost.toFixed(2)}` + : undefined + })() + : undefined + } contextTokens={apiMetrics.contextTokens} buttonsDisabled={sendingDisabled} handleCondenseContext={handleCondenseContext} diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index de499b9aade..c5af4df340e 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -42,6 +42,9 @@ export interface TaskHeaderProps { cacheWrites?: number cacheReads?: number totalCost: number + aggregatedCost?: number + hasSubtasks?: boolean + costBreakdown?: string contextTokens: number buttonsDisabled: boolean handleCondenseContext: (taskId: string) => void @@ -55,6 +58,9 @@ const TaskHeader = ({ cacheWrites, cacheReads, totalCost, + aggregatedCost, + hasSubtasks, + costBreakdown, contextTokens, buttonsDisabled, handleCondenseContext, @@ -64,6 +70,16 @@ const TaskHeader = ({ const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive } = useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) + + useEffect(() => { + console.log("[Aggregated Costs][UI][TaskHeader] props", { + taskId: currentTaskItem?.id, + totalCost, + aggregatedCost, + hasSubtasks, + costBreakdown, + }) + }, [currentTaskItem?.id, totalCost, aggregatedCost, hasSubtasks, costBreakdown]) const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false) const { isOpen, openUpsell, closeUpsell, handleConnect } = useCloudUpsell({ autoOpenOnAuth: false, @@ -248,7 +264,33 @@ const TaskHeader = ({ {formatLargeNumber(contextTokens || 0)} / {formatLargeNumber(contextWindow)} - {!!totalCost && ${totalCost.toFixed(2)}} + {!!totalCost && ( + +
+ Total Cost (including subtasks): $ + {(aggregatedCost ?? totalCost).toFixed(2)} +
+ {costBreakdown &&
{costBreakdown}
} + + ) : ( +
Total Cost: ${totalCost.toFixed(2)}
+ ) + } + side="top" + sideOffset={8}> + + ${(aggregatedCost ?? totalCost).toFixed(2)} + {hasSubtasks && ( + + * + + )} + +
+ )} {showBrowserGlobe && (
e.stopPropagation()}> @@ -386,7 +428,35 @@ const TaskHeader = ({ {t("chat:task.apiCost")} - ${totalCost?.toFixed(2)} + +
+ Total Cost (including subtasks): $ + {(aggregatedCost ?? totalCost).toFixed(2)} +
+ {costBreakdown && ( +
{costBreakdown}
+ )} +
+ ) : ( +
Total Cost: ${totalCost.toFixed(2)}
+ ) + } + side="top" + sideOffset={8}> + + ${(aggregatedCost ?? totalCost).toFixed(2)} + {hasSubtasks && ( + + * + + )} + + )} From 00ad92dbc296c2c01b2919dde6ea89822b8989b0 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Thu, 15 Jan 2026 13:37:24 -0500 Subject: [PATCH 2/7] test: fix aggregateTaskCosts tests for tsc Adjust test assertions to satisfy strict undefined checks during typecheck. --- .../__tests__/aggregateTaskCosts.spec.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/core/src/message-utils/__tests__/aggregateTaskCosts.spec.ts b/packages/core/src/message-utils/__tests__/aggregateTaskCosts.spec.ts index 4b562038d1b..5a4e0461b79 100644 --- a/packages/core/src/message-utils/__tests__/aggregateTaskCosts.spec.ts +++ b/packages/core/src/message-utils/__tests__/aggregateTaskCosts.spec.ts @@ -69,7 +69,9 @@ describe("aggregateTaskCostsRecursive", () => { expect(result.childrenCost).toBe(0.5) expect(result.totalCost).toBe(1.5) expect(result.childBreakdown).toHaveProperty("child-1") - expect(result.childBreakdown?.["child-1"].totalCost).toBe(0.5) + const child1 = result.childBreakdown?.["child-1"] + expect(child1).toBeDefined() + expect(child1!.totalCost).toBe(0.5) }) it("should aggregate parent with multiple children", async () => { @@ -134,14 +136,18 @@ describe("aggregateTaskCostsRecursive", () => { expect(result.totalCost).toBe(1.75) // Verify child breakdown - expect(result.childBreakdown?.child.ownCost).toBe(0.5) - expect(result.childBreakdown?.child.childrenCost).toBe(0.25) - expect(result.childBreakdown?.child.totalCost).toBe(0.75) + const child = result.childBreakdown?.["child"] + expect(child).toBeDefined() + expect(child!.ownCost).toBe(0.5) + expect(child!.childrenCost).toBe(0.25) + expect(child!.totalCost).toBe(0.75) // Verify grandchild breakdown - expect(result.childBreakdown?.child.childBreakdown?.grandchild.ownCost).toBe(0.25) - expect(result.childBreakdown?.child.childBreakdown?.grandchild.childrenCost).toBe(0) - expect(result.childBreakdown?.child.childBreakdown?.grandchild.totalCost).toBe(0.25) + const grandchild = child!.childBreakdown?.["grandchild"] + expect(grandchild).toBeDefined() + expect(grandchild!.ownCost).toBe(0.25) + expect(grandchild!.childrenCost).toBe(0) + expect(grandchild!.totalCost).toBe(0.25) }) it("should detect and prevent circular references", async () => { From f49ef4132b4cc78d197eac88c0eb2cb5e77d3516 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Thu, 15 Jan 2026 13:52:57 -0500 Subject: [PATCH 3/7] chore: remove aggregated cost debug logs Remove temporary console logging added while developing subtask cost aggregation. --- .../src/message-utils/aggregateTaskCosts.ts | 32 +------------------ src/core/webview/webviewMessageHandler.ts | 12 ------- webview-ui/src/components/chat/ChatView.tsx | 24 +------------- webview-ui/src/components/chat/TaskHeader.tsx | 10 +----- 4 files changed, 3 insertions(+), 75 deletions(-) diff --git a/packages/core/src/message-utils/aggregateTaskCosts.ts b/packages/core/src/message-utils/aggregateTaskCosts.ts index 0096bcfdfc5..3890e285248 100644 --- a/packages/core/src/message-utils/aggregateTaskCosts.ts +++ b/packages/core/src/message-utils/aggregateTaskCosts.ts @@ -23,15 +23,9 @@ export async function aggregateTaskCostsRecursive( getTaskHistory: (id: string) => Promise, visited: Set = new Set(), ): Promise { - console.log("[Aggregated Costs][CORE] aggregateTaskCostsRecursive enter", { - taskId, - visitedSize: visited.size, - visited: Array.from(visited), - }) // Prevent infinite loops if (visited.has(taskId)) { console.warn(`[aggregateTaskCostsRecursive] Circular reference detected: ${taskId}`) - console.log("[Aggregated Costs][CORE] aggregateTaskCostsRecursive circular", { taskId }) return { ownCost: 0, childrenCost: 0, totalCost: 0 } } visited.add(taskId) @@ -40,36 +34,21 @@ export async function aggregateTaskCostsRecursive( const history = await getTaskHistory(taskId) if (!history) { console.warn(`[aggregateTaskCostsRecursive] Task ${taskId} not found`) - console.log("[Aggregated Costs][CORE] aggregateTaskCostsRecursive missing history", { taskId }) return { ownCost: 0, childrenCost: 0, totalCost: 0 } } const ownCost = history.totalCost || 0 let childrenCost = 0 const childBreakdown: { [childId: string]: AggregatedCosts } = {} - console.log("[Aggregated Costs][CORE] aggregateTaskCostsRecursive loaded history", { - taskId, - ownCost, - childIds: history.childIds, - }) // Recursively aggregate child costs if (history.childIds && history.childIds.length > 0) { for (const childId of history.childIds) { - console.log("[Aggregated Costs][CORE] aggregateTaskCostsRecursive recurse child", { - parentTaskId: taskId, - childId, - }) const childAggregated = await aggregateTaskCostsRecursive( childId, getTaskHistory, new Set(visited), // Create new Set to allow sibling traversal ) - console.log("[Aggregated Costs][CORE] aggregateTaskCostsRecursive child result", { - parentTaskId: taskId, - childId, - childAggregated, - }) childrenCost += childAggregated.totalCost childBreakdown[childId] = childAggregated } @@ -82,16 +61,7 @@ export async function aggregateTaskCostsRecursive( childBreakdown, } - console.log("[Aggregated Costs][CORE] aggregateTaskCostsRecursive exit", { - taskId, - ownCost: result.ownCost, - childrenCost: result.childrenCost, - totalCost: result.totalCost, - }) - - return { - ...result, - } + return result } /** diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index de878c4ab15..e93be3278d2 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -787,19 +787,10 @@ export const webviewMessageHandler = async ( case "getTaskWithAggregatedCosts": { try { const taskId = message.text - console.log("[Aggregated Costs][EXT] getTaskWithAggregatedCosts received", { - taskId, - messageType: message.type, - }) if (!taskId) { throw new Error("Task ID is required") } const result = await provider.getTaskWithAggregatedCosts(taskId) - console.log("[Aggregated Costs][EXT] getTaskWithAggregatedCosts computed", { - taskId, - historyChildIds: result.historyItem?.childIds, - aggregatedCosts: result.aggregatedCosts, - }) await provider.postMessageToWebview({ type: "taskWithAggregatedCosts", // IMPORTANT: ChatView stores aggregatedCostsMap keyed by message.text (taskId) @@ -808,9 +799,6 @@ export const webviewMessageHandler = async ( historyItem: result.historyItem, aggregatedCosts: result.aggregatedCosts, }) - console.log("[Aggregated Costs][EXT] taskWithAggregatedCosts posted to webview", { - taskId, - }) } catch (error) { console.error("Error getting task with aggregated costs:", error) await provider.postMessageToWebview({ diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 88face4c346..e2d2b98eabc 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -217,14 +217,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - const entries = Array.from(aggregatedCostsMap.entries()).map(([taskId, costs]) => ({ taskId, costs })) - console.log("[Aggregated Costs][UI] aggregatedCostsMap updated", { - size: aggregatedCostsMap.size, - entries, - }) - }, [aggregatedCostsMap]) + // (intentionally no debug logging) const isProfileDisabled = useMemo( () => !!apiConfiguration && !ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList), @@ -491,11 +484,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { if (taskTs && currentTaskItem?.childIds && currentTaskItem.childIds.length > 0) { - console.log("[Aggregated Costs][UI] Requesting getTaskWithAggregatedCosts", { - taskId: currentTaskItem?.id, - childIds: currentTaskItem?.childIds, - taskTs, - }) vscode.postMessage({ type: "getTaskWithAggregatedCosts", text: currentTaskItem.id, @@ -926,20 +914,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction { const newMap = new Map(prev) newMap.set(message.text!, message.aggregatedCosts!) - console.log("[Aggregated Costs][UI] aggregatedCostsMap set", { - taskId: message.text, - costs: message.aggregatedCosts, - newSize: newMap.size, - }) return newMap }) } diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index c5af4df340e..b14aa83a166 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -71,15 +71,7 @@ const TaskHeader = ({ const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) - useEffect(() => { - console.log("[Aggregated Costs][UI][TaskHeader] props", { - taskId: currentTaskItem?.id, - totalCost, - aggregatedCost, - hasSubtasks, - costBreakdown, - }) - }, [currentTaskItem?.id, totalCost, aggregatedCost, hasSubtasks, costBreakdown]) + // (intentionally no debug logging) const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false) const { isOpen, openUpsell, closeUpsell, handleConnect } = useCloudUpsell({ autoOpenOnAuth: false, From d7f4d7cfeaf984a30e1a938d4306f62bac6f4039 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Thu, 15 Jan 2026 14:23:39 -0500 Subject: [PATCH 4/7] refactor: move aggregateTaskCosts from packages/core to src/core/webview This function is specific to the VS Code extension's orchestrator UI, not shared code for CLI/browser. Moving it eliminates the need for an exports mapping in packages/core/package.json. Relates to #5376 --- packages/core/package.json | 3 +-- src/core/webview/ClineProvider.ts | 2 +- .../core/webview}/__tests__/aggregateTaskCosts.spec.ts | 0 .../message-utils => src/core/webview}/aggregateTaskCosts.ts | 0 4 files changed, 2 insertions(+), 3 deletions(-) rename {packages/core/src/message-utils => src/core/webview}/__tests__/aggregateTaskCosts.spec.ts (100%) rename {packages/core/src/message-utils => src/core/webview}/aggregateTaskCosts.ts (100%) diff --git a/packages/core/package.json b/packages/core/package.json index 94cb10ad267..95c6d793b35 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,8 +6,7 @@ "exports": { ".": "./src/index.ts", "./cli": "./src/cli.ts", - "./browser": "./src/browser.ts", - "./message-utils/aggregateTaskCosts": "./src/message-utils/aggregateTaskCosts.ts" + "./browser": "./src/browser.ts" }, "scripts": { "lint": "eslint src --ext=ts --max-warnings=0", diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5dd924e7909..33fa12ca78c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -47,7 +47,7 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, getModelId, } from "@roo-code/types" -import { aggregateTaskCostsRecursive, type AggregatedCosts } from "@roo-code/core/message-utils/aggregateTaskCosts" +import { aggregateTaskCostsRecursive, type AggregatedCosts } from "./aggregateTaskCosts" import { TelemetryService } from "@roo-code/telemetry" import { CloudService, BridgeOrchestrator, getRooCodeApiUrl } from "@roo-code/cloud" diff --git a/packages/core/src/message-utils/__tests__/aggregateTaskCosts.spec.ts b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts similarity index 100% rename from packages/core/src/message-utils/__tests__/aggregateTaskCosts.spec.ts rename to src/core/webview/__tests__/aggregateTaskCosts.spec.ts diff --git a/packages/core/src/message-utils/aggregateTaskCosts.ts b/src/core/webview/aggregateTaskCosts.ts similarity index 100% rename from packages/core/src/message-utils/aggregateTaskCosts.ts rename to src/core/webview/aggregateTaskCosts.ts From 17975eb90722e878046eb461d1b4d71b133ded3d Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Thu, 15 Jan 2026 15:04:46 -0500 Subject: [PATCH 5/7] refactor: address PR review feedback for cost aggregation Remove redundant showAggregated flag in favor of checking breakdown existence. Centralize cost breakdown string generation with i18n support and add translations to all 17 supported locales. Remove unnecessary debug comment from ChatView. --- .../__tests__/aggregateTaskCosts.spec.ts | 50 +++++++++++++++---- src/core/webview/aggregateTaskCosts.ts | 25 ++++++++-- src/i18n/locales/ca/common.json | 4 ++ src/i18n/locales/de/common.json | 4 ++ src/i18n/locales/en/common.json | 4 ++ src/i18n/locales/es/common.json | 4 ++ src/i18n/locales/fr/common.json | 4 ++ src/i18n/locales/hi/common.json | 4 ++ src/i18n/locales/id/common.json | 4 ++ src/i18n/locales/it/common.json | 4 ++ src/i18n/locales/ja/common.json | 4 ++ src/i18n/locales/ko/common.json | 4 ++ src/i18n/locales/nl/common.json | 4 ++ src/i18n/locales/pl/common.json | 4 ++ src/i18n/locales/pt-BR/common.json | 4 ++ src/i18n/locales/ru/common.json | 4 ++ src/i18n/locales/tr/common.json | 4 ++ src/i18n/locales/vi/common.json | 4 ++ src/i18n/locales/zh-CN/common.json | 4 ++ src/i18n/locales/zh-TW/common.json | 4 ++ webview-ui/src/components/chat/ChatView.tsx | 13 ++--- webview-ui/src/i18n/locales/ca/common.json | 4 ++ webview-ui/src/i18n/locales/de/common.json | 4 ++ webview-ui/src/i18n/locales/en/common.json | 4 ++ webview-ui/src/i18n/locales/es/common.json | 4 ++ webview-ui/src/i18n/locales/fr/common.json | 4 ++ webview-ui/src/i18n/locales/hi/common.json | 4 ++ webview-ui/src/i18n/locales/id/common.json | 4 ++ webview-ui/src/i18n/locales/it/common.json | 4 ++ webview-ui/src/i18n/locales/ja/common.json | 4 ++ webview-ui/src/i18n/locales/ko/common.json | 4 ++ webview-ui/src/i18n/locales/nl/common.json | 4 ++ webview-ui/src/i18n/locales/pl/common.json | 4 ++ webview-ui/src/i18n/locales/pt-BR/common.json | 4 ++ webview-ui/src/i18n/locales/ru/common.json | 4 ++ webview-ui/src/i18n/locales/tr/common.json | 4 ++ webview-ui/src/i18n/locales/vi/common.json | 4 ++ webview-ui/src/i18n/locales/zh-CN/common.json | 4 ++ webview-ui/src/i18n/locales/zh-TW/common.json | 4 ++ webview-ui/src/utils/costFormatting.ts | 33 ++++++++++++ 40 files changed, 244 insertions(+), 21 deletions(-) create mode 100644 webview-ui/src/utils/costFormatting.ts diff --git a/src/core/webview/__tests__/aggregateTaskCosts.spec.ts b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts index 5a4e0461b79..02a9c8298d7 100644 --- a/src/core/webview/__tests__/aggregateTaskCosts.spec.ts +++ b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts @@ -1,7 +1,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest" -import { aggregateTaskCostsRecursive, getDisplayCosts } from "../aggregateTaskCosts.js" +import { aggregateTaskCostsRecursive, getDisplayCosts, formatCostBreakdown } from "../aggregateTaskCosts.js" import type { HistoryItem } from "@roo-code/types" +// Mock the i18n module +vi.mock("../../../i18n", () => ({ + t: (key: string) => { + const translations: Record = { + "common:costs.own": "Own", + "common:costs.subtasks": "Subtasks", + } + return translations[key] || key + }, +})) + describe("aggregateTaskCostsRecursive", () => { let consoleWarnSpy: ReturnType @@ -340,7 +351,6 @@ describe("getDisplayCosts", () => { const result = await getDisplayCosts("task-1", getTaskHistory) expect(result.displayCost).toBe(1.5) - expect(result.showAggregated).toBe(false) expect(result.breakdown).toBeUndefined() }) @@ -357,7 +367,6 @@ describe("getDisplayCosts", () => { const result = await getDisplayCosts("task-1", getTaskHistory) expect(result.displayCost).toBe(2.0) - expect(result.showAggregated).toBe(false) expect(result.breakdown).toBeUndefined() }) @@ -385,7 +394,7 @@ describe("getDisplayCosts", () => { const result = await getDisplayCosts("parent", getTaskHistory) expect(result.displayCost).toBe(2.25) // 1.0 + 0.5 + 0.75 - expect(result.showAggregated).toBe(true) + expect(result.breakdown).toBeDefined() expect(result.breakdown).toBe("Own: $1.00 + Subtasks: $1.25") }) @@ -408,7 +417,7 @@ describe("getDisplayCosts", () => { const result = await getDisplayCosts("parent", getTaskHistory) expect(result.displayCost).toBeCloseTo(1.801, 3) - expect(result.showAggregated).toBe(true) + expect(result.breakdown).toBeDefined() expect(result.breakdown).toBe("Own: $1.23 + Subtasks: $0.57") }) @@ -436,7 +445,7 @@ describe("getDisplayCosts", () => { const result = await getDisplayCosts("root", getTaskHistory) expect(result.displayCost).toBe(3.5) // 2.0 + 1.0 + 0.5 - expect(result.showAggregated).toBe(true) + expect(result.breakdown).toBeDefined() expect(result.breakdown).toBe("Own: $2.00 + Subtasks: $1.50") }) @@ -459,11 +468,11 @@ describe("getDisplayCosts", () => { const result = await getDisplayCosts("parent", getTaskHistory) expect(result.displayCost).toBe(0) - expect(result.showAggregated).toBe(false) // childrenCost is 0, so no aggregation shown + // childrenCost is 0, so breakdown should be undefined expect(result.breakdown).toBeUndefined() }) - it("should show aggregated even if own cost is zero", async () => { + it("should show breakdown even if own cost is zero", async () => { const mockHistory: Record = { parent: { id: "parent", @@ -482,7 +491,7 @@ describe("getDisplayCosts", () => { const result = await getDisplayCosts("parent", getTaskHistory) expect(result.displayCost).toBe(1.5) - expect(result.showAggregated).toBe(true) + expect(result.breakdown).toBeDefined() expect(result.breakdown).toBe("Own: $0.00 + Subtasks: $1.50") }) @@ -494,7 +503,28 @@ describe("getDisplayCosts", () => { const result = await getDisplayCosts("nonexistent", getTaskHistory) expect(result.displayCost).toBe(0) - expect(result.showAggregated).toBe(false) expect(result.breakdown).toBeUndefined() }) }) + +describe("formatCostBreakdown", () => { + it("should format breakdown with default labels", () => { + const result = formatCostBreakdown(1.0, 0.5) + expect(result).toBe("Own: $1.00 + Subtasks: $0.50") + }) + + it("should format breakdown with custom labels", () => { + const result = formatCostBreakdown(1.0, 0.5, { own: "Self", subtasks: "Children" }) + expect(result).toBe("Self: $1.00 + Children: $0.50") + }) + + it("should handle zero costs", () => { + const result = formatCostBreakdown(0, 0) + expect(result).toBe("Own: $0.00 + Subtasks: $0.00") + }) + + it("should format with correct decimal places", () => { + const result = formatCostBreakdown(1.234, 5.678) + expect(result).toBe("Own: $1.23 + Subtasks: $5.68") + }) +}) diff --git a/src/core/webview/aggregateTaskCosts.ts b/src/core/webview/aggregateTaskCosts.ts index 3890e285248..9ccdddad7d3 100644 --- a/src/core/webview/aggregateTaskCosts.ts +++ b/src/core/webview/aggregateTaskCosts.ts @@ -1,4 +1,5 @@ import type { HistoryItem } from "@roo-code/types" +import { t } from "../../i18n" export interface AggregatedCosts { ownCost: number // This task's own API costs @@ -10,6 +11,25 @@ export interface AggregatedCosts { } } +/** + * Format a cost breakdown string for display. + * This is the centralized function for generating breakdown strings. + * + * @param ownCost - The task's own cost + * @param childrenCost - The sum of subtask costs + * @param labels - Optional custom labels for "Own" and "Subtasks" (for i18n in webview) + * @returns Formatted breakdown string like "Own: $1.00 + Subtasks: $0.50" + */ +export function formatCostBreakdown( + ownCost: number, + childrenCost: number, + labels?: { own: string; subtasks: string }, +): string { + const ownLabel = labels?.own ?? t("common:costs.own") + const subtasksLabel = labels?.subtasks ?? t("common:costs.subtasks") + return `${ownLabel}: $${ownCost.toFixed(2)} + ${subtasksLabel}: $${childrenCost.toFixed(2)}` +} + /** * Recursively aggregate costs for a task and all its subtasks. * @@ -66,13 +86,13 @@ export async function aggregateTaskCostsRecursive( /** * Get aggregated costs for display, handling incomplete tasks gracefully. + * Consumers can check if `breakdown` exists to determine if costs are aggregated. */ export async function getDisplayCosts( taskId: string, getTaskHistory: (id: string) => Promise, ): Promise<{ displayCost: number - showAggregated: boolean breakdown?: string }> { const aggregated = await aggregateTaskCostsRecursive(taskId, getTaskHistory) @@ -82,12 +102,11 @@ export async function getDisplayCosts( let breakdown: string | undefined if (hasChildren) { - breakdown = `Own: $${aggregated.ownCost.toFixed(2)} + Subtasks: $${aggregated.childrenCost.toFixed(2)}` + breakdown = formatCostBreakdown(aggregated.ownCost, aggregated.childrenCost) } return { displayCost, - showAggregated: hasChildren, breakdown, } } diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 321a1aa3a06..9af7c59b907 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -263,5 +263,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "Propi", + "subtasks": "Subtasques" } } diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 0611cf889af..ed3bc355327 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -258,5 +258,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "Eigen", + "subtasks": "Unteraufgaben" } } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 90c409feb76..f4d115280c4 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -252,5 +252,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "Own", + "subtasks": "Subtasks" } } diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index d0a086173e9..819a570de6b 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -258,5 +258,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "Propio", + "subtasks": "Subtareas" } } diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 58350ef02ba..6e0505232f7 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -263,5 +263,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "Propre", + "subtasks": "Sous-tâches" } } diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 33277c71623..992b736080f 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -263,5 +263,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "स्वयं", + "subtasks": "उपकार्य" } } diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index c10532beef9..469539d77f3 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -263,5 +263,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "Sendiri", + "subtasks": "Subtugas" } } diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index a75dccd3875..9e3591fe099 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -263,5 +263,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "Proprio", + "subtasks": "Sottoattività" } } diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index b378f00b03f..4c3bbbd1ef8 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -263,5 +263,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "自身", + "subtasks": "サブタスク" } } diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index e7afdceabce..b66c6d5f6fc 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -263,5 +263,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "자체", + "subtasks": "하위작업" } } diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 889cd4b3ab6..0681a0feb69 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -263,5 +263,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "Eigen", + "subtasks": "Subtaken" } } diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index faa4e9ed3a5..fc6f8e7629a 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -263,5 +263,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "Własne", + "subtasks": "Podzadania" } } diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index f41a379acbc..f451bf065ab 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -263,5 +263,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "Próprio", + "subtasks": "Subtarefas" } } diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 751637f19e0..ba59846ba73 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -263,5 +263,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "Собственные", + "subtasks": "Подзадачи" } } diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 7b2ac152a9b..623d5f24c71 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -263,5 +263,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "Kendi", + "subtasks": "Alt görevler" } } diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 0d88ba07808..c28628456ef 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -270,5 +270,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "Riêng", + "subtasks": "Nhiệm vụ con" } } diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 133b3de0794..e4851c00620 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -268,5 +268,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "自身", + "subtasks": "子任务" } } diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 8039f203b62..f022889131b 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -263,5 +263,9 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "costs": { + "own": "自身", + "subtasks": "子工作" } } diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index e2d2b98eabc..81f6cbebf66 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -10,6 +10,7 @@ import { Trans } from "react-i18next" import { useDebounceEffect } from "@src/utils/useDebounceEffect" import { appendImages } from "@src/utils/imageUtils" +import { getCostBreakdownIfNeeded } from "@src/utils/costFormatting" import type { ClineAsk, ClineSayTool, ClineMessage, ExtensionMessage, AudioType } from "@roo-code/types" @@ -217,8 +218,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction !!apiConfiguration && !ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList), [apiConfiguration, organizationAllowList], @@ -1485,12 +1484,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - const costs = aggregatedCostsMap.get(currentTaskItem.id)! - return costs.childrenCost > 0 - ? `Own: $${costs.ownCost.toFixed(2)} + Subtasks: $${costs.childrenCost.toFixed(2)}` - : undefined - })() + ? getCostBreakdownIfNeeded(aggregatedCostsMap.get(currentTaskItem.id)!, { + own: t("common:costs.own"), + subtasks: t("common:costs.subtasks"), + }) : undefined } contextTokens={apiMetrics.contextTokens} diff --git a/webview-ui/src/i18n/locales/ca/common.json b/webview-ui/src/i18n/locales/ca/common.json index 08c854057c7..56e9a3745ef 100644 --- a/webview-ui/src/i18n/locales/ca/common.json +++ b/webview-ui/src/i18n/locales/ca/common.json @@ -106,5 +106,9 @@ "delegated_to": "Delegat a la tasca {{childId}}", "delegation_completed": "Subtasca completada, reprenent la tasca principal", "awaiting_child": "Esperant la tasca filla {{childId}}" + }, + "costs": { + "own": "Propi", + "subtasks": "Subtasques" } } diff --git a/webview-ui/src/i18n/locales/de/common.json b/webview-ui/src/i18n/locales/de/common.json index 51d2fbe6c7b..ab8bd6d2401 100644 --- a/webview-ui/src/i18n/locales/de/common.json +++ b/webview-ui/src/i18n/locales/de/common.json @@ -106,5 +106,9 @@ "delegated_to": "An Aufgabe {{childId}} delegiert", "delegation_completed": "Unteraufgabe abgeschlossen, übergeordnete Aufgabe wird fortgesetzt", "awaiting_child": "Warte auf Unteraufgabe {{childId}}" + }, + "costs": { + "own": "Eigen", + "subtasks": "Unteraufgaben" } } diff --git a/webview-ui/src/i18n/locales/en/common.json b/webview-ui/src/i18n/locales/en/common.json index da86e671d7d..981eaeec755 100644 --- a/webview-ui/src/i18n/locales/en/common.json +++ b/webview-ui/src/i18n/locales/en/common.json @@ -106,5 +106,9 @@ "delegated_to": "Delegated to task {{childId}}", "delegation_completed": "Subtask completed, resuming parent", "awaiting_child": "Awaiting child task {{childId}}" + }, + "costs": { + "own": "Own", + "subtasks": "Subtasks" } } diff --git a/webview-ui/src/i18n/locales/es/common.json b/webview-ui/src/i18n/locales/es/common.json index 27c0bc016d5..03455b7cadc 100644 --- a/webview-ui/src/i18n/locales/es/common.json +++ b/webview-ui/src/i18n/locales/es/common.json @@ -106,5 +106,9 @@ "delegated_to": "Delegado a la tarea {{childId}}", "delegation_completed": "Subtarea completada, reanudando tarea principal", "awaiting_child": "Esperando tarea secundaria {{childId}}" + }, + "costs": { + "own": "Propio", + "subtasks": "Subtareas" } } diff --git a/webview-ui/src/i18n/locales/fr/common.json b/webview-ui/src/i18n/locales/fr/common.json index 1e7dc5f72f0..def93ad6c5e 100644 --- a/webview-ui/src/i18n/locales/fr/common.json +++ b/webview-ui/src/i18n/locales/fr/common.json @@ -106,5 +106,9 @@ "delegated_to": "Délégué à la tâche {{childId}}", "delegation_completed": "Sous-tâche terminée, reprise de la tâche parent", "awaiting_child": "En attente de la tâche enfant {{childId}}" + }, + "costs": { + "own": "Propre", + "subtasks": "Sous-tâches" } } diff --git a/webview-ui/src/i18n/locales/hi/common.json b/webview-ui/src/i18n/locales/hi/common.json index b42cc1eb84f..076530e6b02 100644 --- a/webview-ui/src/i18n/locales/hi/common.json +++ b/webview-ui/src/i18n/locales/hi/common.json @@ -106,5 +106,9 @@ "delegated_to": "कार्य {{childId}} को सौंपा गया", "delegation_completed": "उप-कार्य पूर्ण, मुख्य कार्य फिर से शुरू हो रहा है", "awaiting_child": "चाइल्ड कार्य {{childId}} की प्रतीक्षा में" + }, + "costs": { + "own": "स्वयं", + "subtasks": "उपकार्य" } } diff --git a/webview-ui/src/i18n/locales/id/common.json b/webview-ui/src/i18n/locales/id/common.json index 041530d09d3..a65295f28d4 100644 --- a/webview-ui/src/i18n/locales/id/common.json +++ b/webview-ui/src/i18n/locales/id/common.json @@ -106,5 +106,9 @@ "delegated_to": "Didelegasikan ke tugas {{childId}}", "delegation_completed": "Subtugas selesai, melanjutkan tugas induk", "awaiting_child": "Menunggu tugas anak {{childId}}" + }, + "costs": { + "own": "Sendiri", + "subtasks": "Subtugas" } } diff --git a/webview-ui/src/i18n/locales/it/common.json b/webview-ui/src/i18n/locales/it/common.json index 5b732ad6a1f..9b801628f49 100644 --- a/webview-ui/src/i18n/locales/it/common.json +++ b/webview-ui/src/i18n/locales/it/common.json @@ -106,5 +106,9 @@ "delegated_to": "Delegato all'attività {{childId}}", "delegation_completed": "Sottoattività completata, ripresa attività padre", "awaiting_child": "In attesa dell'attività figlia {{childId}}" + }, + "costs": { + "own": "Proprio", + "subtasks": "Sottoattività" } } diff --git a/webview-ui/src/i18n/locales/ja/common.json b/webview-ui/src/i18n/locales/ja/common.json index 6c5de092a1e..b3b9d462e07 100644 --- a/webview-ui/src/i18n/locales/ja/common.json +++ b/webview-ui/src/i18n/locales/ja/common.json @@ -106,5 +106,9 @@ "delegated_to": "タスク{{childId}}に委任", "delegation_completed": "サブタスク完了、親タスクを再開", "awaiting_child": "子タスク{{childId}}を待機中" + }, + "costs": { + "own": "自身", + "subtasks": "サブタスク" } } diff --git a/webview-ui/src/i18n/locales/ko/common.json b/webview-ui/src/i18n/locales/ko/common.json index e97581a1a9a..d7120e2520d 100644 --- a/webview-ui/src/i18n/locales/ko/common.json +++ b/webview-ui/src/i18n/locales/ko/common.json @@ -106,5 +106,9 @@ "delegated_to": "작업 {{childId}}에 위임됨", "delegation_completed": "하위 작업 완료, 상위 작업 재개", "awaiting_child": "하위 작업 {{childId}} 대기 중" + }, + "costs": { + "own": "자체", + "subtasks": "하위작업" } } diff --git a/webview-ui/src/i18n/locales/nl/common.json b/webview-ui/src/i18n/locales/nl/common.json index a27d08ba490..ec6cf89ccb5 100644 --- a/webview-ui/src/i18n/locales/nl/common.json +++ b/webview-ui/src/i18n/locales/nl/common.json @@ -106,5 +106,9 @@ "delegated_to": "Gedelegeerd naar taak {{childId}}", "delegation_completed": "Subtaak voltooid, hoofdtaak wordt hervat", "awaiting_child": "Wachten op kindtaak {{childId}}" + }, + "costs": { + "own": "Eigen", + "subtasks": "Subtaken" } } diff --git a/webview-ui/src/i18n/locales/pl/common.json b/webview-ui/src/i18n/locales/pl/common.json index c7253c28e68..419aa83af1e 100644 --- a/webview-ui/src/i18n/locales/pl/common.json +++ b/webview-ui/src/i18n/locales/pl/common.json @@ -106,5 +106,9 @@ "delegated_to": "Przekazano do zadania {{childId}}", "delegation_completed": "Podzadanie ukończone, wznowienie zadania nadrzędnego", "awaiting_child": "Oczekiwanie na zadanie podrzędne {{childId}}" + }, + "costs": { + "own": "Własne", + "subtasks": "Podzadania" } } diff --git a/webview-ui/src/i18n/locales/pt-BR/common.json b/webview-ui/src/i18n/locales/pt-BR/common.json index a0abd99f20b..4990796976f 100644 --- a/webview-ui/src/i18n/locales/pt-BR/common.json +++ b/webview-ui/src/i18n/locales/pt-BR/common.json @@ -106,5 +106,9 @@ "delegated_to": "Delegado para tarefa {{childId}}", "delegation_completed": "Subtarefa concluída, retomando tarefa pai", "awaiting_child": "Aguardando tarefa filha {{childId}}" + }, + "costs": { + "own": "Próprio", + "subtasks": "Subtarefas" } } diff --git a/webview-ui/src/i18n/locales/ru/common.json b/webview-ui/src/i18n/locales/ru/common.json index 2ac643953e1..f66384a6937 100644 --- a/webview-ui/src/i18n/locales/ru/common.json +++ b/webview-ui/src/i18n/locales/ru/common.json @@ -106,5 +106,9 @@ "delegated_to": "Делегировано задаче {{childId}}", "delegation_completed": "Подзадача завершена, возобновление родительской задачи", "awaiting_child": "Ожидание дочерней задачи {{childId}}" + }, + "costs": { + "own": "Собственные", + "subtasks": "Подзадачи" } } diff --git a/webview-ui/src/i18n/locales/tr/common.json b/webview-ui/src/i18n/locales/tr/common.json index ccd68127043..db9e991cd58 100644 --- a/webview-ui/src/i18n/locales/tr/common.json +++ b/webview-ui/src/i18n/locales/tr/common.json @@ -106,5 +106,9 @@ "delegated_to": "{{childId}} görevine devredildi", "delegation_completed": "Alt görev tamamlandı, üst görev devam ediyor", "awaiting_child": "{{childId}} alt görevi bekleniyor" + }, + "costs": { + "own": "Kendi", + "subtasks": "Alt görevler" } } diff --git a/webview-ui/src/i18n/locales/vi/common.json b/webview-ui/src/i18n/locales/vi/common.json index 85f14780e84..57eb31fafa4 100644 --- a/webview-ui/src/i18n/locales/vi/common.json +++ b/webview-ui/src/i18n/locales/vi/common.json @@ -106,5 +106,9 @@ "delegated_to": "Ủy quyền cho nhiệm vụ {{childId}}", "delegation_completed": "Nhiệm vụ con hoàn thành, tiếp tục nhiệm vụ cha", "awaiting_child": "Đang chờ nhiệm vụ con {{childId}}" + }, + "costs": { + "own": "Riêng", + "subtasks": "Nhiệm vụ con" } } diff --git a/webview-ui/src/i18n/locales/zh-CN/common.json b/webview-ui/src/i18n/locales/zh-CN/common.json index f762a3044e6..10df0893334 100644 --- a/webview-ui/src/i18n/locales/zh-CN/common.json +++ b/webview-ui/src/i18n/locales/zh-CN/common.json @@ -106,5 +106,9 @@ "delegated_to": "已委托给任务 {{childId}}", "delegation_completed": "子任务已完成,恢复父任务", "awaiting_child": "等待子任务 {{childId}}" + }, + "costs": { + "own": "自身", + "subtasks": "子任务" } } diff --git a/webview-ui/src/i18n/locales/zh-TW/common.json b/webview-ui/src/i18n/locales/zh-TW/common.json index 3dd64c1c4c0..da47dec72bd 100644 --- a/webview-ui/src/i18n/locales/zh-TW/common.json +++ b/webview-ui/src/i18n/locales/zh-TW/common.json @@ -106,5 +106,9 @@ "delegated_to": "已委派給工作 {{childId}}", "delegation_completed": "子工作已完成,繼續父工作", "awaiting_child": "等待子工作 {{childId}}" + }, + "costs": { + "own": "自身", + "subtasks": "子工作" } } diff --git a/webview-ui/src/utils/costFormatting.ts b/webview-ui/src/utils/costFormatting.ts new file mode 100644 index 00000000000..362a7fd68cc --- /dev/null +++ b/webview-ui/src/utils/costFormatting.ts @@ -0,0 +1,33 @@ +/** + * Format a cost breakdown string for display. + * This mirrors the backend formatCostBreakdown function but uses the webview's i18n. + * + * @param ownCost - The task's own cost + * @param childrenCost - The sum of subtask costs + * @param labels - Labels for "Own" and "Subtasks" (from i18n) + * @returns Formatted breakdown string like "Own: $1.00 + Subtasks: $0.50" + */ +export function formatCostBreakdown( + ownCost: number, + childrenCost: number, + labels: { own: string; subtasks: string }, +): string { + return `${labels.own}: $${ownCost.toFixed(2)} + ${labels.subtasks}: $${childrenCost.toFixed(2)}` +} + +/** + * Get cost breakdown string if the task has children with costs. + * + * @param costs - Object containing ownCost and childrenCost + * @param labels - Labels for "Own" and "Subtasks" (from i18n) + * @returns Formatted breakdown string or undefined if no children costs + */ +export function getCostBreakdownIfNeeded( + costs: { ownCost: number; childrenCost: number } | undefined, + labels: { own: string; subtasks: string }, +): string | undefined { + if (!costs || costs.childrenCost <= 0) { + return undefined + } + return formatCostBreakdown(costs.ownCost, costs.childrenCost, labels) +} From fe0c1abe4e3a125e40ec6ff06d58ee3872bf8dde Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Thu, 15 Jan 2026 15:19:08 -0500 Subject: [PATCH 6/7] i18n: internationalize cost display strings in TaskHeader Add translated strings for total cost, subtask costs, and browser status to all 18 supported locales. Replaces hardcoded English strings with t() function calls using interpolation for dynamic values. --- webview-ui/src/components/chat/TaskHeader.tsx | 24 ++++++++++--------- webview-ui/src/i18n/locales/ca/chat.json | 6 +++++ webview-ui/src/i18n/locales/de/chat.json | 6 +++++ webview-ui/src/i18n/locales/en/chat.json | 6 +++++ webview-ui/src/i18n/locales/es/chat.json | 6 +++++ webview-ui/src/i18n/locales/fr/chat.json | 6 +++++ webview-ui/src/i18n/locales/hi/chat.json | 6 +++++ webview-ui/src/i18n/locales/id/chat.json | 6 +++++ webview-ui/src/i18n/locales/it/chat.json | 6 +++++ webview-ui/src/i18n/locales/ja/chat.json | 6 +++++ webview-ui/src/i18n/locales/ko/chat.json | 6 +++++ webview-ui/src/i18n/locales/nl/chat.json | 6 +++++ webview-ui/src/i18n/locales/pl/chat.json | 6 +++++ webview-ui/src/i18n/locales/pt-BR/chat.json | 6 +++++ webview-ui/src/i18n/locales/ru/chat.json | 6 +++++ webview-ui/src/i18n/locales/tr/chat.json | 6 +++++ webview-ui/src/i18n/locales/vi/chat.json | 6 +++++ webview-ui/src/i18n/locales/zh-CN/chat.json | 6 +++++ webview-ui/src/i18n/locales/zh-TW/chat.json | 6 +++++ 19 files changed, 121 insertions(+), 11 deletions(-) diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index b14aa83a166..5dca11b9634 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -70,8 +70,6 @@ const TaskHeader = ({ const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive } = useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) - - // (intentionally no debug logging) const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false) const { isOpen, openUpsell, closeUpsell, handleConnect } = useCloudUpsell({ autoOpenOnAuth: false, @@ -262,13 +260,14 @@ const TaskHeader = ({ hasSubtasks ? (
- Total Cost (including subtasks): $ - {(aggregatedCost ?? totalCost).toFixed(2)} + {t("chat:costs.totalWithSubtasks", { + cost: (aggregatedCost ?? totalCost).toFixed(2), + })}
{costBreakdown &&
{costBreakdown}
}
) : ( -
Total Cost: ${totalCost.toFixed(2)}
+
{t("chat:costs.total", { cost: totalCost.toFixed(2) })}
) } side="top" @@ -276,7 +275,7 @@ const TaskHeader = ({ ${(aggregatedCost ?? totalCost).toFixed(2)} {hasSubtasks && ( - + * )} @@ -312,7 +311,7 @@ const TaskHeader = ({ - Active + {t("chat:browser.active")} )} @@ -425,15 +424,18 @@ const TaskHeader = ({ hasSubtasks ? (
- Total Cost (including subtasks): $ - {(aggregatedCost ?? totalCost).toFixed(2)} + {t("chat:costs.totalWithSubtasks", { + cost: (aggregatedCost ?? totalCost).toFixed(2), + })}
{costBreakdown && (
{costBreakdown}
)}
) : ( -
Total Cost: ${totalCost.toFixed(2)}
+
+ {t("chat:costs.total", { cost: totalCost.toFixed(2) })} +
) } side="top" @@ -443,7 +445,7 @@ const TaskHeader = ({ {hasSubtasks && ( + title={t("chat:costs.includesSubtasks")}> * )} diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index ec994b654eb..650a4949a2c 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -347,8 +347,14 @@ }, "careers": "A més, estem contractant!" }, + "costs": { + "totalWithSubtasks": "Cost total (subtasques incloses): ${{cost}}", + "total": "Cost total: ${{cost}}", + "includesSubtasks": "Inclou els costos de les subtasques" + }, "browser": { "session": "Sessió del navegador", + "active": "Actiu", "rooWantsToUse": "Roo vol utilitzar el navegador", "consoleLogs": "Registres de consola", "noNewLogs": "(Cap registre nou)", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index a33922c3071..fc251576346 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -347,8 +347,14 @@ }, "careers": "Außerdem, wir stellen ein!" }, + "costs": { + "totalWithSubtasks": "Gesamtkosten (inkl. Unteraufgaben): ${{cost}}", + "total": "Gesamtkosten: ${{cost}}", + "includesSubtasks": "Enthält Kosten für Unteraufgaben" + }, "browser": { "session": "Browser-Sitzung", + "active": "Aktiv", "rooWantsToUse": "Roo möchte den Browser verwenden", "consoleLogs": "Konsolenprotokolle", "noNewLogs": "(Keine neuen Protokolle)", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 729cfc73b3b..3ab2c037af2 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -362,8 +362,14 @@ "copyToInput": "Copy to input (same as shift + click)", "timerPrefix": "Auto-approve enabled. Selecting in {{seconds}}s…" }, + "costs": { + "totalWithSubtasks": "Total Cost (including subtasks): ${{cost}}", + "total": "Total Cost: ${{cost}}", + "includesSubtasks": "Includes subtask costs" + }, "browser": { "session": "Browser Session", + "active": "Active", "rooWantsToUse": "Roo wants to use the browser", "consoleLogs": "Console Logs", "noNewLogs": "(No new logs)", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index e63357e52c7..09bb9c80aae 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -347,8 +347,14 @@ }, "careers": "Además, ¡estamos contratando!" }, + "costs": { + "totalWithSubtasks": "Costo total (incluyendo subtareas): ${{cost}}", + "total": "Costo total: ${{cost}}", + "includesSubtasks": "Incluye costos de subtareas" + }, "browser": { "session": "Sesión del navegador", + "active": "Activo", "rooWantsToUse": "Roo quiere usar el navegador", "consoleLogs": "Registros de la consola", "noNewLogs": "(No hay nuevos registros)", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 392fbe348ee..700b1534337 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -347,8 +347,14 @@ }, "careers": "Aussi, on recrute !" }, + "costs": { + "totalWithSubtasks": "Coût total (sous-tâches comprises) : ${{cost}}", + "total": "Coût total : ${{cost}}", + "includesSubtasks": "Inclut les coûts des sous-tâches" + }, "browser": { "session": "Session du navigateur", + "active": "Actif", "rooWantsToUse": "Roo veut utiliser le navigateur", "consoleLogs": "Journaux de console", "noNewLogs": "(Pas de nouveaux journaux)", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 4527162d9de..753fc857168 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -347,8 +347,14 @@ }, "careers": "साथ ही, हम भर्ती कर रहे हैं!" }, + "costs": { + "totalWithSubtasks": "कुल लागत (उप-कार्यों सहित): ${{cost}}", + "total": "कुल लागत: ${{cost}}", + "includesSubtasks": "उप-कार्यों की लागत शामिल है" + }, "browser": { "session": "ब्राउज़र सत्र", + "active": "सक्रिय", "rooWantsToUse": "Roo ब्राउज़र का उपयोग करना चाहता है", "consoleLogs": "कंसोल लॉग", "noNewLogs": "(कोई नया लॉग नहीं)", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 9c473dfce3f..04db7af45e8 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -368,8 +368,14 @@ "copyToInput": "Salin ke input (sama dengan shift + klik)", "timerPrefix": "Persetujuan otomatis diaktifkan. Memilih dalam {{seconds}}s…" }, + "costs": { + "totalWithSubtasks": "Total Biaya (termasuk subtugas): ${{cost}}", + "total": "Total Biaya: ${{cost}}", + "includesSubtasks": "Termasuk biaya subtugas" + }, "browser": { "session": "Sesi Browser", + "active": "Aktif", "rooWantsToUse": "Roo ingin menggunakan browser", "consoleLogs": "Log Konsol", "noNewLogs": "(Tidak ada log baru)", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 5525a31da18..73ed761b293 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -347,8 +347,14 @@ }, "careers": "Inoltre, stiamo assumendo!" }, + "costs": { + "totalWithSubtasks": "Costo totale (sottoattività incluse): ${{cost}}", + "total": "Costo totale: ${{cost}}", + "includesSubtasks": "Include i costi delle sottoattività" + }, "browser": { "session": "Sessione del browser", + "active": "Attivo", "rooWantsToUse": "Roo vuole utilizzare il browser", "consoleLogs": "Log della console", "noNewLogs": "(Nessun nuovo log)", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index d5d4d1b181b..44543b22acd 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -347,8 +347,14 @@ }, "careers": "また、採用中です!" }, + "costs": { + "totalWithSubtasks": "合計コスト(サブタスク含む): ${{cost}}", + "total": "合計コスト: ${{cost}}", + "includesSubtasks": "サブタスクのコストを含む" + }, "browser": { "session": "ブラウザセッション", + "active": "アクティブ", "rooWantsToUse": "Rooはブラウザを使用したい", "consoleLogs": "コンソールログ", "noNewLogs": "(新しいログはありません)", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index d31aebe5c90..d758f082df0 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -347,8 +347,14 @@ }, "careers": "그리고, 채용 중입니다!" }, + "costs": { + "totalWithSubtasks": "총 비용 (하위 작업 포함): ${{cost}}", + "total": "총 비용: ${{cost}}", + "includesSubtasks": "하위 작업 비용 포함" + }, "browser": { "session": "브라우저 세션", + "active": "활성", "rooWantsToUse": "Roo가 브라우저를 사용하고 싶어합니다", "consoleLogs": "콘솔 로그", "noNewLogs": "(새 로그 없음)", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 6b4f2b4b9ef..6f98a005382 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -347,8 +347,14 @@ "copyToInput": "Kopiëren naar invoer (zelfde als shift + klik)", "timerPrefix": "Automatisch goedkeuren ingeschakeld. Selecteren in {{seconds}}s…" }, + "costs": { + "totalWithSubtasks": "Totale kosten (inclusief subtaken): ${{cost}}", + "total": "Totale kosten: ${{cost}}", + "includesSubtasks": "Inclusief kosten van subtaken" + }, "browser": { "session": "Browsersessie", + "active": "Actief", "rooWantsToUse": "Roo wil de browser gebruiken", "consoleLogs": "Console-logboeken", "noNewLogs": "(Geen nieuwe logboeken)", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 2f613e853c8..eaac2b46693 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -347,8 +347,14 @@ }, "careers": "Dodatkowo, zatrudniamy!" }, + "costs": { + "totalWithSubtasks": "Całkowity koszt (w tym podzadania): ${{cost}}", + "total": "Całkowity koszt: ${{cost}}", + "includesSubtasks": "Zawiera koszty podzadań" + }, "browser": { "session": "Sesja przeglądarki", + "active": "Aktywna", "rooWantsToUse": "Roo chce użyć przeglądarki", "consoleLogs": "Logi konsoli", "noNewLogs": "(Brak nowych logów)", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index cc0e199931b..555da7dca85 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -347,8 +347,14 @@ }, "careers": "Além disso, estamos contratando!" }, + "costs": { + "totalWithSubtasks": "Custo Total (incluindo subtarefas): ${{cost}}", + "total": "Custo Total: ${{cost}}", + "includesSubtasks": "Inclui custos de subtarefas" + }, "browser": { "session": "Sessão do Navegador", + "active": "Ativo", "rooWantsToUse": "Roo quer usar o navegador", "consoleLogs": "Logs do console", "noNewLogs": "(Sem novos logs)", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 1784ef02548..7d688361e76 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -348,8 +348,14 @@ "copyToInput": "Скопировать во ввод (то же, что shift + клик)", "timerPrefix": "Автоматическое одобрение включено. Выбор через {{seconds}}s…" }, + "costs": { + "totalWithSubtasks": "Общая стоимость (включая подзадачи): ${{cost}}", + "total": "Общая стоимость: ${{cost}}", + "includesSubtasks": "Включает стоимость подзадач" + }, "browser": { "session": "Сеанс браузера", + "active": "Активен", "rooWantsToUse": "Roo хочет использовать браузер", "consoleLogs": "Логи консоли", "noNewLogs": "(Новых логов нет)", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index c43d79e43c5..733c612564c 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -348,8 +348,14 @@ }, "careers": "Ayrıca, işe alım yapıyoruz!" }, + "costs": { + "totalWithSubtasks": "Toplam Maliyet (alt görevler dahil): ${{cost}}", + "total": "Toplam Maliyet: ${{cost}}", + "includesSubtasks": "Alt görev maliyetlerini içerir" + }, "browser": { "session": "Tarayıcı Oturumu", + "active": "Aktif", "rooWantsToUse": "Roo tarayıcıyı kullanmak istiyor", "consoleLogs": "Konsol Kayıtları", "noNewLogs": "(Yeni kayıt yok)", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index d7f00f86f91..1f253189646 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -348,8 +348,14 @@ }, "careers": "Ngoài ra, chúng tôi đang tuyển dụng!" }, + "costs": { + "totalWithSubtasks": "Tổng chi phí (bao gồm các tác vụ phụ): ${{cost}}", + "total": "Tổng chi phí: ${{cost}}", + "includesSubtasks": "Bao gồm chi phí của các tác vụ phụ" + }, "browser": { "session": "Phiên trình duyệt", + "active": "Đang hoạt động", "rooWantsToUse": "Roo muốn sử dụng trình duyệt", "consoleLogs": "Nhật ký bảng điều khiển", "noNewLogs": "(Không có nhật ký mới)", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 17bfe892016..a075f2686aa 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -348,8 +348,14 @@ }, "careers": "此外,我们正在招聘!" }, + "costs": { + "totalWithSubtasks": "总成本(包括子任务): ${{cost}}", + "total": "总成本: ${{cost}}", + "includesSubtasks": "包括子任务成本" + }, "browser": { "session": "浏览器会话", + "active": "活动中", "rooWantsToUse": "Roo想使用浏览器", "consoleLogs": "控制台日志", "noNewLogs": "(没有新日志)", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index c7f9569f17b..826178ec06f 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -366,8 +366,14 @@ "copyToInput": "複製到輸入框 (或按住 Shift 並點選)", "timerPrefix": "自動批准已啟用。{{seconds}}秒後選擇中…" }, + "costs": { + "totalWithSubtasks": "總成本(包括子任務): ${{cost}}", + "total": "總成本: ${{cost}}", + "includesSubtasks": "包括子任務成本" + }, "browser": { "session": "瀏覽器會話", + "active": "活動中", "rooWantsToUse": "Roo 想要使用瀏覽器", "consoleLogs": "主控台記錄", "noNewLogs": "(沒有新記錄)", From 7aedeb1d2c97f5c8f71ba1fe10a56796593c6c2a Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Thu, 15 Jan 2026 15:44:02 -0500 Subject: [PATCH 7/7] refactor: remove unused backend cost formatting functions Backend sends raw cost data to frontend which handles all formatting with proper i18n. Removes dead code that was never used in production flow. --- .../__tests__/aggregateTaskCosts.spec.ts | 206 +----------------- src/core/webview/aggregateTaskCosts.ts | 47 ---- src/i18n/locales/ca/common.json | 4 - src/i18n/locales/de/common.json | 4 - src/i18n/locales/en/common.json | 4 - src/i18n/locales/es/common.json | 4 - src/i18n/locales/fr/common.json | 4 - src/i18n/locales/hi/common.json | 4 - src/i18n/locales/id/common.json | 4 - src/i18n/locales/it/common.json | 4 - src/i18n/locales/ja/common.json | 4 - src/i18n/locales/ko/common.json | 4 - src/i18n/locales/nl/common.json | 4 - src/i18n/locales/pl/common.json | 4 - src/i18n/locales/pt-BR/common.json | 4 - src/i18n/locales/ru/common.json | 4 - src/i18n/locales/tr/common.json | 4 - src/i18n/locales/vi/common.json | 4 - src/i18n/locales/zh-CN/common.json | 4 - src/i18n/locales/zh-TW/common.json | 4 - 20 files changed, 1 insertion(+), 324 deletions(-) diff --git a/src/core/webview/__tests__/aggregateTaskCosts.spec.ts b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts index 02a9c8298d7..ffb35f5e48c 100644 --- a/src/core/webview/__tests__/aggregateTaskCosts.spec.ts +++ b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts @@ -1,18 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest" -import { aggregateTaskCostsRecursive, getDisplayCosts, formatCostBreakdown } from "../aggregateTaskCosts.js" +import { aggregateTaskCostsRecursive } from "../aggregateTaskCosts.js" import type { HistoryItem } from "@roo-code/types" -// Mock the i18n module -vi.mock("../../../i18n", () => ({ - t: (key: string) => { - const translations: Record = { - "common:costs.own": "Own", - "common:costs.subtasks": "Subtasks", - } - return translations[key] || key - }, -})) - describe("aggregateTaskCostsRecursive", () => { let consoleWarnSpy: ReturnType @@ -335,196 +324,3 @@ describe("aggregateTaskCostsRecursive", () => { expect(result.totalCost).toBe(2.0) }) }) - -describe("getDisplayCosts", () => { - it("should return correct format for task with no children", async () => { - const mockHistory: Record = { - "task-1": { - id: "task-1", - totalCost: 1.5, - childIds: [], - } as unknown as HistoryItem, - } - - const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) - - const result = await getDisplayCosts("task-1", getTaskHistory) - - expect(result.displayCost).toBe(1.5) - expect(result.breakdown).toBeUndefined() - }) - - it("should return correct format for task with undefined childIds", async () => { - const mockHistory: Record = { - "task-1": { - id: "task-1", - totalCost: 2.0, - } as unknown as HistoryItem, - } - - const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) - - const result = await getDisplayCosts("task-1", getTaskHistory) - - expect(result.displayCost).toBe(2.0) - expect(result.breakdown).toBeUndefined() - }) - - it("should return aggregated format for task with children", async () => { - const mockHistory: Record = { - parent: { - id: "parent", - totalCost: 1.0, - childIds: ["child-1", "child-2"], - } as unknown as HistoryItem, - "child-1": { - id: "child-1", - totalCost: 0.5, - childIds: [], - } as unknown as HistoryItem, - "child-2": { - id: "child-2", - totalCost: 0.75, - childIds: [], - } as unknown as HistoryItem, - } - - const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) - - const result = await getDisplayCosts("parent", getTaskHistory) - - expect(result.displayCost).toBe(2.25) // 1.0 + 0.5 + 0.75 - expect(result.breakdown).toBeDefined() - expect(result.breakdown).toBe("Own: $1.00 + Subtasks: $1.25") - }) - - it("should format breakdown string with correct decimal places", async () => { - const mockHistory: Record = { - parent: { - id: "parent", - totalCost: 1.234, - childIds: ["child"], - } as unknown as HistoryItem, - child: { - id: "child", - totalCost: 0.567, - childIds: [], - } as unknown as HistoryItem, - } - - const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) - - const result = await getDisplayCosts("parent", getTaskHistory) - - expect(result.displayCost).toBeCloseTo(1.801, 3) - expect(result.breakdown).toBeDefined() - expect(result.breakdown).toBe("Own: $1.23 + Subtasks: $0.57") - }) - - it("should handle nested hierarchy correctly", async () => { - const mockHistory: Record = { - root: { - id: "root", - totalCost: 2.0, - childIds: ["child"], - } as unknown as HistoryItem, - child: { - id: "child", - totalCost: 1.0, - childIds: ["grandchild"], - } as unknown as HistoryItem, - grandchild: { - id: "grandchild", - totalCost: 0.5, - childIds: [], - } as unknown as HistoryItem, - } - - const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) - - const result = await getDisplayCosts("root", getTaskHistory) - - expect(result.displayCost).toBe(3.5) // 2.0 + 1.0 + 0.5 - expect(result.breakdown).toBeDefined() - expect(result.breakdown).toBe("Own: $2.00 + Subtasks: $1.50") - }) - - it("should handle zero costs gracefully", async () => { - const mockHistory: Record = { - parent: { - id: "parent", - totalCost: 0, - childIds: ["child"], - } as unknown as HistoryItem, - child: { - id: "child", - totalCost: 0, - childIds: [], - } as unknown as HistoryItem, - } - - const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) - - const result = await getDisplayCosts("parent", getTaskHistory) - - expect(result.displayCost).toBe(0) - // childrenCost is 0, so breakdown should be undefined - expect(result.breakdown).toBeUndefined() - }) - - it("should show breakdown even if own cost is zero", async () => { - const mockHistory: Record = { - parent: { - id: "parent", - totalCost: 0, - childIds: ["child"], - } as unknown as HistoryItem, - child: { - id: "child", - totalCost: 1.5, - childIds: [], - } as unknown as HistoryItem, - } - - const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) - - const result = await getDisplayCosts("parent", getTaskHistory) - - expect(result.displayCost).toBe(1.5) - expect(result.breakdown).toBeDefined() - expect(result.breakdown).toBe("Own: $0.00 + Subtasks: $1.50") - }) - - it("should handle missing task gracefully", async () => { - const mockHistory: Record = {} - - const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) - - const result = await getDisplayCosts("nonexistent", getTaskHistory) - - expect(result.displayCost).toBe(0) - expect(result.breakdown).toBeUndefined() - }) -}) - -describe("formatCostBreakdown", () => { - it("should format breakdown with default labels", () => { - const result = formatCostBreakdown(1.0, 0.5) - expect(result).toBe("Own: $1.00 + Subtasks: $0.50") - }) - - it("should format breakdown with custom labels", () => { - const result = formatCostBreakdown(1.0, 0.5, { own: "Self", subtasks: "Children" }) - expect(result).toBe("Self: $1.00 + Children: $0.50") - }) - - it("should handle zero costs", () => { - const result = formatCostBreakdown(0, 0) - expect(result).toBe("Own: $0.00 + Subtasks: $0.00") - }) - - it("should format with correct decimal places", () => { - const result = formatCostBreakdown(1.234, 5.678) - expect(result).toBe("Own: $1.23 + Subtasks: $5.68") - }) -}) diff --git a/src/core/webview/aggregateTaskCosts.ts b/src/core/webview/aggregateTaskCosts.ts index 9ccdddad7d3..3100b2a65e7 100644 --- a/src/core/webview/aggregateTaskCosts.ts +++ b/src/core/webview/aggregateTaskCosts.ts @@ -1,5 +1,4 @@ import type { HistoryItem } from "@roo-code/types" -import { t } from "../../i18n" export interface AggregatedCosts { ownCost: number // This task's own API costs @@ -11,25 +10,6 @@ export interface AggregatedCosts { } } -/** - * Format a cost breakdown string for display. - * This is the centralized function for generating breakdown strings. - * - * @param ownCost - The task's own cost - * @param childrenCost - The sum of subtask costs - * @param labels - Optional custom labels for "Own" and "Subtasks" (for i18n in webview) - * @returns Formatted breakdown string like "Own: $1.00 + Subtasks: $0.50" - */ -export function formatCostBreakdown( - ownCost: number, - childrenCost: number, - labels?: { own: string; subtasks: string }, -): string { - const ownLabel = labels?.own ?? t("common:costs.own") - const subtasksLabel = labels?.subtasks ?? t("common:costs.subtasks") - return `${ownLabel}: $${ownCost.toFixed(2)} + ${subtasksLabel}: $${childrenCost.toFixed(2)}` -} - /** * Recursively aggregate costs for a task and all its subtasks. * @@ -83,30 +63,3 @@ export async function aggregateTaskCostsRecursive( return result } - -/** - * Get aggregated costs for display, handling incomplete tasks gracefully. - * Consumers can check if `breakdown` exists to determine if costs are aggregated. - */ -export async function getDisplayCosts( - taskId: string, - getTaskHistory: (id: string) => Promise, -): Promise<{ - displayCost: number - breakdown?: string -}> { - const aggregated = await aggregateTaskCostsRecursive(taskId, getTaskHistory) - - const hasChildren = aggregated.childrenCost > 0 - const displayCost = aggregated.totalCost - - let breakdown: string | undefined - if (hasChildren) { - breakdown = formatCostBreakdown(aggregated.ownCost, aggregated.childrenCost) - } - - return { - displayCost, - breakdown, - } -} diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 9af7c59b907..321a1aa3a06 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -263,9 +263,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "Propi", - "subtasks": "Subtasques" } } diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index ed3bc355327..0611cf889af 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -258,9 +258,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "Eigen", - "subtasks": "Unteraufgaben" } } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index f4d115280c4..90c409feb76 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -252,9 +252,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "Own", - "subtasks": "Subtasks" } } diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 819a570de6b..d0a086173e9 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -258,9 +258,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "Propio", - "subtasks": "Subtareas" } } diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 6e0505232f7..58350ef02ba 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -263,9 +263,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "Propre", - "subtasks": "Sous-tâches" } } diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 992b736080f..33277c71623 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -263,9 +263,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "स्वयं", - "subtasks": "उपकार्य" } } diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index 469539d77f3..c10532beef9 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -263,9 +263,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "Sendiri", - "subtasks": "Subtugas" } } diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 9e3591fe099..a75dccd3875 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -263,9 +263,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "Proprio", - "subtasks": "Sottoattività" } } diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 4c3bbbd1ef8..b378f00b03f 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -263,9 +263,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "自身", - "subtasks": "サブタスク" } } diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index b66c6d5f6fc..e7afdceabce 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -263,9 +263,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "자체", - "subtasks": "하위작업" } } diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 0681a0feb69..889cd4b3ab6 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -263,9 +263,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "Eigen", - "subtasks": "Subtaken" } } diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index fc6f8e7629a..faa4e9ed3a5 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -263,9 +263,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "Własne", - "subtasks": "Podzadania" } } diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index f451bf065ab..f41a379acbc 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -263,9 +263,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "Próprio", - "subtasks": "Subtarefas" } } diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index ba59846ba73..751637f19e0 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -263,9 +263,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "Собственные", - "subtasks": "Подзадачи" } } diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 623d5f24c71..7b2ac152a9b 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -263,9 +263,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "Kendi", - "subtasks": "Alt görevler" } } diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index c28628456ef..0d88ba07808 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -270,9 +270,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "Riêng", - "subtasks": "Nhiệm vụ con" } } diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index e4851c00620..133b3de0794 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -268,9 +268,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "自身", - "subtasks": "子任务" } } diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index f022889131b..8039f203b62 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -263,9 +263,5 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" - }, - "costs": { - "own": "自身", - "subtasks": "子工作" } }