Browse Source

Feat/issue 5376 aggregate subtask costs (#10757)

T 6 days ago
parent
commit
f48ea389df
44 changed files with 773 additions and 3 deletions
  1. 9 0
      packages/types/src/vscode-extension-host.ts
  2. 15 0
      src/core/webview/ClineProvider.ts
  3. 326 0
      src/core/webview/__tests__/aggregateTaskCosts.spec.ts
  4. 65 0
      src/core/webview/aggregateTaskCosts.ts
  5. 26 0
      src/core/webview/webviewMessageHandler.ts
  6. 52 0
      webview-ui/src/components/chat/ChatView.tsx
  7. 67 3
      webview-ui/src/components/chat/TaskHeader.tsx
  8. 6 0
      webview-ui/src/i18n/locales/ca/chat.json
  9. 4 0
      webview-ui/src/i18n/locales/ca/common.json
  10. 6 0
      webview-ui/src/i18n/locales/de/chat.json
  11. 4 0
      webview-ui/src/i18n/locales/de/common.json
  12. 6 0
      webview-ui/src/i18n/locales/en/chat.json
  13. 4 0
      webview-ui/src/i18n/locales/en/common.json
  14. 6 0
      webview-ui/src/i18n/locales/es/chat.json
  15. 4 0
      webview-ui/src/i18n/locales/es/common.json
  16. 6 0
      webview-ui/src/i18n/locales/fr/chat.json
  17. 4 0
      webview-ui/src/i18n/locales/fr/common.json
  18. 6 0
      webview-ui/src/i18n/locales/hi/chat.json
  19. 4 0
      webview-ui/src/i18n/locales/hi/common.json
  20. 6 0
      webview-ui/src/i18n/locales/id/chat.json
  21. 4 0
      webview-ui/src/i18n/locales/id/common.json
  22. 6 0
      webview-ui/src/i18n/locales/it/chat.json
  23. 4 0
      webview-ui/src/i18n/locales/it/common.json
  24. 6 0
      webview-ui/src/i18n/locales/ja/chat.json
  25. 4 0
      webview-ui/src/i18n/locales/ja/common.json
  26. 6 0
      webview-ui/src/i18n/locales/ko/chat.json
  27. 4 0
      webview-ui/src/i18n/locales/ko/common.json
  28. 6 0
      webview-ui/src/i18n/locales/nl/chat.json
  29. 4 0
      webview-ui/src/i18n/locales/nl/common.json
  30. 6 0
      webview-ui/src/i18n/locales/pl/chat.json
  31. 4 0
      webview-ui/src/i18n/locales/pl/common.json
  32. 6 0
      webview-ui/src/i18n/locales/pt-BR/chat.json
  33. 4 0
      webview-ui/src/i18n/locales/pt-BR/common.json
  34. 6 0
      webview-ui/src/i18n/locales/ru/chat.json
  35. 4 0
      webview-ui/src/i18n/locales/ru/common.json
  36. 6 0
      webview-ui/src/i18n/locales/tr/chat.json
  37. 4 0
      webview-ui/src/i18n/locales/tr/common.json
  38. 6 0
      webview-ui/src/i18n/locales/vi/chat.json
  39. 4 0
      webview-ui/src/i18n/locales/vi/common.json
  40. 6 0
      webview-ui/src/i18n/locales/zh-CN/chat.json
  41. 4 0
      webview-ui/src/i18n/locales/zh-CN/common.json
  42. 6 0
      webview-ui/src/i18n/locales/zh-TW/chat.json
  43. 4 0
      webview-ui/src/i18n/locales/zh-TW/common.json
  44. 33 0
      webview-ui/src/utils/costFormatting.ts

+ 9 - 0
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"

+ 15 - 0
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.

+ 326 - 0
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<typeof vi.spyOn>
+
+	beforeEach(() => {
+		consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
+	})
+
+	it("should calculate cost for task with no children", async () => {
+		const mockHistory: Record<string, HistoryItem> = {
+			"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<string, HistoryItem> = {
+			"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<string, HistoryItem> = {
+			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<string, HistoryItem> = {
+			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<string, HistoryItem> = {
+			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<string, HistoryItem> = {
+			"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<string, HistoryItem> = {
+			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<string, HistoryItem> = {}
+
+		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<string, HistoryItem> = {
+			"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<string, HistoryItem> = {
+			"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<string, HistoryItem> = {
+			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<string, HistoryItem> = {
+			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)
+	})
+})

+ 65 - 0
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<HistoryItem | undefined>,
+	visited: Set<string> = new Set(),
+): Promise<AggregatedCosts> {
+	// 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
+}

+ 26 - 0
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,

+ 52 - 0
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<ChatViewRef, ChatViewPro
 	const autoApproveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
 	const userRespondedRef = useRef<boolean>(false)
 	const [currentFollowUpTs, setCurrentFollowUpTs] = useState<number | null>(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<ChatViewRef, ChatViewPro
 		userRespondedRef.current = false
 	}, [task?.ts])
 
+	const taskTs = task?.ts
+
+	// Request aggregated costs when task changes and has childIds
+	useEffect(() => {
+		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<ChatViewRef, ChatViewPro
 				case "interactionRequired":
 					playSound("notification")
 					break
+				case "taskWithAggregatedCosts":
+					if (message.text && message.aggregatedCosts) {
+						setAggregatedCostsMap((prev) => {
+							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<ChatViewRef, ChatViewPro
 						cacheWrites={apiMetrics.totalCacheWrites}
 						cacheReads={apiMetrics.totalCacheReads}
 						totalCost={apiMetrics.totalCost}
+						aggregatedCost={
+							currentTaskItem?.id && aggregatedCostsMap.has(currentTaskItem.id)
+								? aggregatedCostsMap.get(currentTaskItem.id)!.totalCost
+								: undefined
+						}
+						hasSubtasks={
+							!!(
+								currentTaskItem?.id &&
+								aggregatedCostsMap.has(currentTaskItem.id) &&
+								aggregatedCostsMap.get(currentTaskItem.id)!.childrenCost > 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}

+ 67 - 3
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)}
 								</span>
 							</StandardTooltip>
-							{!!totalCost && <span>${totalCost.toFixed(2)}</span>}
+							{!!totalCost && (
+								<StandardTooltip
+									content={
+										hasSubtasks ? (
+											<div>
+												<div>
+													{t("chat:costs.totalWithSubtasks", {
+														cost: (aggregatedCost ?? totalCost).toFixed(2),
+													})}
+												</div>
+												{costBreakdown && <div className="text-xs mt-1">{costBreakdown}</div>}
+											</div>
+										) : (
+											<div>{t("chat:costs.total", { cost: totalCost.toFixed(2) })}</div>
+										)
+									}
+									side="top"
+									sideOffset={8}>
+									<span>
+										${(aggregatedCost ?? totalCost).toFixed(2)}
+										{hasSubtasks && (
+											<span className="text-xs ml-1" title={t("chat:costs.includesSubtasks")}>
+												*
+											</span>
+										)}
+									</span>
+								</StandardTooltip>
+							)}
 						</div>
 						{showBrowserGlobe && (
 							<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
@@ -278,7 +311,7 @@ const TaskHeader = ({
 									<span
 										className="text-sm font-medium"
 										style={{ color: "var(--vscode-testing-iconPassed)" }}>
-										Active
+										{t("chat:browser.active")}
 									</span>
 								)}
 							</div>
@@ -386,7 +419,38 @@ const TaskHeader = ({
 												{t("chat:task.apiCost")}
 											</th>
 											<td className="font-light align-top">
-												<span>${totalCost?.toFixed(2)}</span>
+												<StandardTooltip
+													content={
+														hasSubtasks ? (
+															<div>
+																<div>
+																	{t("chat:costs.totalWithSubtasks", {
+																		cost: (aggregatedCost ?? totalCost).toFixed(2),
+																	})}
+																</div>
+																{costBreakdown && (
+																	<div className="text-xs mt-1">{costBreakdown}</div>
+																)}
+															</div>
+														) : (
+															<div>
+																{t("chat:costs.total", { cost: totalCost.toFixed(2) })}
+															</div>
+														)
+													}
+													side="top"
+													sideOffset={8}>
+													<span>
+														${(aggregatedCost ?? totalCost).toFixed(2)}
+														{hasSubtasks && (
+															<span
+																className="text-xs ml-1"
+																title={t("chat:costs.includesSubtasks")}>
+																*
+															</span>
+														)}
+													</span>
+												</StandardTooltip>
 											</td>
 										</tr>
 									)}

+ 6 - 0
webview-ui/src/i18n/locales/ca/chat.json

@@ -347,8 +347,14 @@
 		},
 		"careers": "A més, <careersLink>estem contractant!</careersLink>"
 	},
+	"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)",

+ 4 - 0
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"
 	}
 }

+ 6 - 0
webview-ui/src/i18n/locales/de/chat.json

@@ -347,8 +347,14 @@
 		},
 		"careers": "Außerdem, <careersLink>wir stellen ein!</careersLink>"
 	},
+	"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)",

+ 4 - 0
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"
 	}
 }

+ 6 - 0
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)",

+ 4 - 0
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"
 	}
 }

+ 6 - 0
webview-ui/src/i18n/locales/es/chat.json

@@ -347,8 +347,14 @@
 		},
 		"careers": "Además, <careersLink>¡estamos contratando!</careersLink>"
 	},
+	"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)",

+ 4 - 0
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"
 	}
 }

+ 6 - 0
webview-ui/src/i18n/locales/fr/chat.json

@@ -347,8 +347,14 @@
 		},
 		"careers": "Aussi, <careersLink>on recrute !</careersLink>"
 	},
+	"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)",

+ 4 - 0
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"
 	}
 }

+ 6 - 0
webview-ui/src/i18n/locales/hi/chat.json

@@ -347,8 +347,14 @@
 		},
 		"careers": "साथ ही, <careersLink>हम भर्ती कर रहे हैं!</careersLink>"
 	},
+	"costs": {
+		"totalWithSubtasks": "कुल लागत (उप-कार्यों सहित): ${{cost}}",
+		"total": "कुल लागत: ${{cost}}",
+		"includesSubtasks": "उप-कार्यों की लागत शामिल है"
+	},
 	"browser": {
 		"session": "ब्राउज़र सत्र",
+		"active": "सक्रिय",
 		"rooWantsToUse": "Roo ब्राउज़र का उपयोग करना चाहता है",
 		"consoleLogs": "कंसोल लॉग",
 		"noNewLogs": "(कोई नया लॉग नहीं)",

+ 4 - 0
webview-ui/src/i18n/locales/hi/common.json

@@ -106,5 +106,9 @@
 		"delegated_to": "कार्य {{childId}} को सौंपा गया",
 		"delegation_completed": "उप-कार्य पूर्ण, मुख्य कार्य फिर से शुरू हो रहा है",
 		"awaiting_child": "चाइल्ड कार्य {{childId}} की प्रतीक्षा में"
+	},
+	"costs": {
+		"own": "स्वयं",
+		"subtasks": "उपकार्य"
 	}
 }

+ 6 - 0
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)",

+ 4 - 0
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"
 	}
 }

+ 6 - 0
webview-ui/src/i18n/locales/it/chat.json

@@ -347,8 +347,14 @@
 		},
 		"careers": "Inoltre, <careersLink>stiamo assumendo!</careersLink>"
 	},
+	"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)",

+ 4 - 0
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à"
 	}
 }

+ 6 - 0
webview-ui/src/i18n/locales/ja/chat.json

@@ -347,8 +347,14 @@
 		},
 		"careers": "また、<careersLink>採用中です!</careersLink>"
 	},
+	"costs": {
+		"totalWithSubtasks": "合計コスト(サブタスク含む): ${{cost}}",
+		"total": "合計コスト: ${{cost}}",
+		"includesSubtasks": "サブタスクのコストを含む"
+	},
 	"browser": {
 		"session": "ブラウザセッション",
+		"active": "アクティブ",
 		"rooWantsToUse": "Rooはブラウザを使用したい",
 		"consoleLogs": "コンソールログ",
 		"noNewLogs": "(新しいログはありません)",

+ 4 - 0
webview-ui/src/i18n/locales/ja/common.json

@@ -106,5 +106,9 @@
 		"delegated_to": "タスク{{childId}}に委任",
 		"delegation_completed": "サブタスク完了、親タスクを再開",
 		"awaiting_child": "子タスク{{childId}}を待機中"
+	},
+	"costs": {
+		"own": "自身",
+		"subtasks": "サブタスク"
 	}
 }

+ 6 - 0
webview-ui/src/i18n/locales/ko/chat.json

@@ -347,8 +347,14 @@
 		},
 		"careers": "그리고, <careersLink>채용 중입니다!</careersLink>"
 	},
+	"costs": {
+		"totalWithSubtasks": "총 비용 (하위 작업 포함): ${{cost}}",
+		"total": "총 비용: ${{cost}}",
+		"includesSubtasks": "하위 작업 비용 포함"
+	},
 	"browser": {
 		"session": "브라우저 세션",
+		"active": "활성",
 		"rooWantsToUse": "Roo가 브라우저를 사용하고 싶어합니다",
 		"consoleLogs": "콘솔 로그",
 		"noNewLogs": "(새 로그 없음)",

+ 4 - 0
webview-ui/src/i18n/locales/ko/common.json

@@ -106,5 +106,9 @@
 		"delegated_to": "작업 {{childId}}에 위임됨",
 		"delegation_completed": "하위 작업 완료, 상위 작업 재개",
 		"awaiting_child": "하위 작업 {{childId}} 대기 중"
+	},
+	"costs": {
+		"own": "자체",
+		"subtasks": "하위작업"
 	}
 }

+ 6 - 0
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)",

+ 4 - 0
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"
 	}
 }

+ 6 - 0
webview-ui/src/i18n/locales/pl/chat.json

@@ -347,8 +347,14 @@
 		},
 		"careers": "Dodatkowo, <careersLink>zatrudniamy!</careersLink>"
 	},
+	"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)",

+ 4 - 0
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"
 	}
 }

+ 6 - 0
webview-ui/src/i18n/locales/pt-BR/chat.json

@@ -347,8 +347,14 @@
 		},
 		"careers": "Além disso, <careersLink>estamos contratando!</careersLink>"
 	},
+	"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)",

+ 4 - 0
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"
 	}
 }

+ 6 - 0
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": "(Новых логов нет)",

+ 4 - 0
webview-ui/src/i18n/locales/ru/common.json

@@ -106,5 +106,9 @@
 		"delegated_to": "Делегировано задаче {{childId}}",
 		"delegation_completed": "Подзадача завершена, возобновление родительской задачи",
 		"awaiting_child": "Ожидание дочерней задачи {{childId}}"
+	},
+	"costs": {
+		"own": "Собственные",
+		"subtasks": "Подзадачи"
 	}
 }

+ 6 - 0
webview-ui/src/i18n/locales/tr/chat.json

@@ -348,8 +348,14 @@
 		},
 		"careers": "Ayrıca, <careersLink>işe alım yapıyoruz!</careersLink>"
 	},
+	"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)",

+ 4 - 0
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"
 	}
 }

+ 6 - 0
webview-ui/src/i18n/locales/vi/chat.json

@@ -348,8 +348,14 @@
 		},
 		"careers": "Ngoài ra, <careersLink>chúng tôi đang tuyển dụng!</careersLink>"
 	},
+	"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)",

+ 4 - 0
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"
 	}
 }

+ 6 - 0
webview-ui/src/i18n/locales/zh-CN/chat.json

@@ -348,8 +348,14 @@
 		},
 		"careers": "此外,<careersLink>我们正在招聘!</careersLink>"
 	},
+	"costs": {
+		"totalWithSubtasks": "总成本(包括子任务): ${{cost}}",
+		"total": "总成本: ${{cost}}",
+		"includesSubtasks": "包括子任务成本"
+	},
 	"browser": {
 		"session": "浏览器会话",
+		"active": "活动中",
 		"rooWantsToUse": "Roo想使用浏览器",
 		"consoleLogs": "控制台日志",
 		"noNewLogs": "(没有新日志)",

+ 4 - 0
webview-ui/src/i18n/locales/zh-CN/common.json

@@ -106,5 +106,9 @@
 		"delegated_to": "已委托给任务 {{childId}}",
 		"delegation_completed": "子任务已完成,恢复父任务",
 		"awaiting_child": "等待子任务 {{childId}}"
+	},
+	"costs": {
+		"own": "自身",
+		"subtasks": "子任务"
 	}
 }

+ 6 - 0
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": "(沒有新記錄)",

+ 4 - 0
webview-ui/src/i18n/locales/zh-TW/common.json

@@ -106,5 +106,9 @@
 		"delegated_to": "已委派給工作 {{childId}}",
 		"delegation_completed": "子工作已完成,繼續父工作",
 		"awaiting_child": "等待子工作 {{childId}}"
+	},
+	"costs": {
+		"own": "自身",
+		"subtasks": "子工作"
 	}
 }

+ 33 - 0
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)
+}