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..33fa12ca78c 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 "./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/__tests__/aggregateTaskCosts.spec.ts b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts new file mode 100644 index 00000000000..ffb35f5e48c --- /dev/null +++ b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts @@ -0,0 +1,326 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { aggregateTaskCostsRecursive } 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") + const child1 = result.childBreakdown?.["child-1"] + expect(child1).toBeDefined() + expect(child1!.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 + 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 + 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 () => { + 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) + }) +}) diff --git a/src/core/webview/aggregateTaskCosts.ts b/src/core/webview/aggregateTaskCosts.ts new file mode 100644 index 00000000000..3100b2a65e7 --- /dev/null +++ b/src/core/webview/aggregateTaskCosts.ts @@ -0,0 +1,65 @@ +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 { + // Prevent infinite loops + if (visited.has(taskId)) { + console.warn(`[aggregateTaskCostsRecursive] Circular reference detected: ${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`) + return { ownCost: 0, childrenCost: 0, totalCost: 0 } + } + + const ownCost = history.totalCost || 0 + let childrenCost = 0 + const childBreakdown: { [childId: string]: AggregatedCosts } = {} + + // Recursively aggregate child costs + if (history.childIds && history.childIds.length > 0) { + for (const childId of history.childIds) { + const childAggregated = await aggregateTaskCostsRecursive( + childId, + getTaskHistory, + new Set(visited), // Create new Set to allow sibling traversal + ) + childrenCost += childAggregated.totalCost + childBreakdown[childId] = childAggregated + } + } + + const result: AggregatedCosts = { + ownCost, + childrenCost, + totalCost: ownCost + childrenCost, + childBreakdown, + } + + return result +} diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index ddd97dffd50..e93be3278d2 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -784,6 +784,32 @@ export const webviewMessageHandler = async ( case "exportTaskWithId": provider.exportTaskWithId(message.text!) break + case "getTaskWithAggregatedCosts": { + try { + const taskId = message.text + if (!taskId) { + throw new Error("Task ID is required") + } + const result = await provider.getTaskWithAggregatedCosts(taskId) + 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, + }) + } 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..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" @@ -166,6 +167,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(() => { @@ -467,6 +478,18 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + if (taskTs && currentTaskItem?.childIds && currentTaskItem.childIds.length > 0) { + vscode.postMessage({ + type: "getTaskWithAggregatedCosts", + text: currentTaskItem.id, + }) + } + }, [taskTs, currentTaskItem?.id, currentTaskItem?.childIds]) + useEffect(() => { if (isHidden) { everVisibleMessagesTsRef.current.clear() @@ -889,6 +912,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + const newMap = new Map(prev) + newMap.set(message.text!, message.aggregatedCosts!) + 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 +1470,26 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0 + ) + } + costBreakdown={ + currentTaskItem?.id && aggregatedCostsMap.has(currentTaskItem.id) + ? getCostBreakdownIfNeeded(aggregatedCostsMap.get(currentTaskItem.id)!, { + own: t("common:costs.own"), + subtasks: t("common:costs.subtasks"), + }) + : 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..5dca11b9634 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, @@ -248,7 +254,34 @@ const TaskHeader = ({ {formatLargeNumber(contextTokens || 0)} / {formatLargeNumber(contextWindow)} - {!!totalCost && ${totalCost.toFixed(2)}} + {!!totalCost && ( + +
+ {t("chat:costs.totalWithSubtasks", { + cost: (aggregatedCost ?? totalCost).toFixed(2), + })} +
+ {costBreakdown &&
{costBreakdown}
} + + ) : ( +
{t("chat:costs.total", { cost: totalCost.toFixed(2) })}
+ ) + } + side="top" + sideOffset={8}> + + ${(aggregatedCost ?? totalCost).toFixed(2)} + {hasSubtasks && ( + + * + + )} + +
+ )} {showBrowserGlobe && (
e.stopPropagation()}> @@ -278,7 +311,7 @@ const TaskHeader = ({ - Active + {t("chat:browser.active")} )}
@@ -386,7 +419,38 @@ const TaskHeader = ({ {t("chat:task.apiCost")} - ${totalCost?.toFixed(2)} + +
+ {t("chat:costs.totalWithSubtasks", { + cost: (aggregatedCost ?? totalCost).toFixed(2), + })} +
+ {costBreakdown && ( +
{costBreakdown}
+ )} + + ) : ( +
+ {t("chat:costs.total", { cost: totalCost.toFixed(2) })} +
+ ) + } + side="top" + sideOffset={8}> + + ${(aggregatedCost ?? totalCost).toFixed(2)} + {hasSubtasks && ( + + * + + )} + +
)} 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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-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/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": "(沒有新記錄)", 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) +}