Feat/issue 5376 aggregate subtask costs (#10757)

This commit is contained in:
T 2026-01-15 21:44:55 +00:00 committed by GitHub
parent f2b16d400d
commit f48ea389df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 773 additions and 3 deletions

View file

@ -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"

View file

@ -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.

View file

@ -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)
})
})

View file

@ -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
}

View file

@ -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,

View file

@ -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}

View file

@ -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>
)}

View file

@ -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)",

View file

@ -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"
}
}

View file

@ -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)",

View file

@ -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"
}
}

View file

@ -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)",

View file

@ -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"
}
}

View file

@ -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)",

View file

@ -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"
}
}

View file

@ -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)",

View file

@ -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"
}
}

View file

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

View file

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

View file

@ -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)",

View file

@ -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"
}
}

View file

@ -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)",

View file

@ -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à"
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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)",

View file

@ -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"
}
}

View file

@ -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)",

View file

@ -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"
}
}

View file

@ -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)",

View file

@ -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"
}
}

View file

@ -348,8 +348,14 @@
"copyToInput": "Скопировать во ввод (то же, что shift + клик)",
"timerPrefix": "Автоматическое одобрение включено. Выбор через {{seconds}}s…"
},
"costs": {
"totalWithSubtasks": "Общая стоимость (включая подзадачи): ${{cost}}",
"total": "Общая стоимость: ${{cost}}",
"includesSubtasks": "Включает стоимость подзадач"
},
"browser": {
"session": "Сеанс браузера",
"active": "Активен",
"rooWantsToUse": "Roo хочет использовать браузер",
"consoleLogs": "Логи консоли",
"noNewLogs": "(Новых логов нет)",

View file

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

View file

@ -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)",

View file

@ -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"
}
}

View file

@ -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)",

View file

@ -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"
}
}

View file

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

View file

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

View file

@ -366,8 +366,14 @@
"copyToInput": "複製到輸入框 (或按住 Shift 並點選)",
"timerPrefix": "自動批准已啟用。{{seconds}}秒後選擇中…"
},
"costs": {
"totalWithSubtasks": "總成本(包括子任務): ${{cost}}",
"total": "總成本: ${{cost}}",
"includesSubtasks": "包括子任務成本"
},
"browser": {
"session": "瀏覽器會話",
"active": "活動中",
"rooWantsToUse": "Roo 想要使用瀏覽器",
"consoleLogs": "主控台記錄",
"noNewLogs": "(沒有新記錄)",

View file

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

View file

@ -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)
}