diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 133e6ff4ef..133a38a35f 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -21,6 +21,7 @@ import { getModelMaxOutputTokens } from "@roo/api" import { findLastIndex } from "@roo/array" import { formatLargeNumber } from "@src/utils/format" +import { getTaskHeaderCostTooltipData } from "@src/utils/taskCostBreakdown" import { cn } from "@src/lib/utils" import { StandardTooltip, Button } from "@src/components/ui" import { useExtensionState } from "@src/context/ExtensionStateContext" @@ -129,6 +130,37 @@ const TaskHeader = ({ const hasTodos = todos && Array.isArray(todos) && todos.length > 0 + const subtaskCosts = useMemo(() => { + if (!subtaskDetails || subtaskDetails.length === 0) { + return [] + } + + return subtaskDetails + .map((subtask) => subtask.cost) + .filter((cost): cost is number => typeof cost === "number" && Number.isFinite(cost)) + }, [subtaskDetails]) + + const tooltipCostData = useMemo( + () => + getTaskHeaderCostTooltipData({ + ownCost: totalCost, + aggregatedCost, + hasSubtasksProp: hasSubtasks, + costBreakdownProp: costBreakdown, + subtaskCosts, + labels: { + own: t("common:costs.own"), + subtasks: t("common:costs.subtasks"), + }, + }), + [totalCost, aggregatedCost, hasSubtasks, costBreakdown, subtaskCosts, t], + ) + + const displayTotalCost = tooltipCostData.displayTotalCost + const displayCostBreakdown = tooltipCostData.displayCostBreakdown + const shouldTreatAsHasSubtasks = tooltipCostData.hasSubtasks + const hasAnyCost = tooltipCostData.hasAnyCost + return (
{showLongRunningTaskMessage && !isTaskComplete && ( @@ -257,17 +289,19 @@ const TaskHeader = ({ {formatLargeNumber(contextTokens || 0)} / {formatLargeNumber(contextWindow)} - {!!totalCost && ( + {hasAnyCost && (
{t("chat:costs.totalWithSubtasks", { - cost: (aggregatedCost ?? totalCost).toFixed(2), + cost: displayTotalCost.toFixed(2), })}
- {costBreakdown &&
{costBreakdown}
} + {displayCostBreakdown && ( +
{displayCostBreakdown}
+ )}
) : (
{t("chat:costs.total", { cost: totalCost.toFixed(2) })}
@@ -276,8 +310,8 @@ const TaskHeader = ({ side="top" sideOffset={8}> - ${(aggregatedCost ?? totalCost).toFixed(2)} - {hasSubtasks && ( + ${displayTotalCost.toFixed(2)} + {shouldTreatAsHasSubtasks && ( * @@ -416,7 +450,7 @@ const TaskHeader = ({ )} - {!!totalCost && ( + {hasAnyCost && ( {t("chat:task.apiCost")} @@ -424,15 +458,17 @@ const TaskHeader = ({
{t("chat:costs.totalWithSubtasks", { - cost: (aggregatedCost ?? totalCost).toFixed(2), + cost: displayTotalCost.toFixed(2), })}
- {costBreakdown && ( -
{costBreakdown}
+ {displayCostBreakdown && ( +
+ {displayCostBreakdown} +
)} ) : ( @@ -444,8 +480,8 @@ const TaskHeader = ({ side="top" sideOffset={8}> - ${(aggregatedCost ?? totalCost).toFixed(2)} - {hasSubtasks && ( + ${displayTotalCost.toFixed(2)} + {shouldTreatAsHasSubtasks && ( diff --git a/webview-ui/src/utils/__tests__/taskCostBreakdown.spec.ts b/webview-ui/src/utils/__tests__/taskCostBreakdown.spec.ts new file mode 100644 index 0000000000..fe7f00771f --- /dev/null +++ b/webview-ui/src/utils/__tests__/taskCostBreakdown.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest" + +import { computeTaskCostsIncludingSubtasks, getTaskHeaderCostTooltipData } from "@src/utils/taskCostBreakdown" + +describe("taskCostBreakdown", () => { + it("sums subtask line-item costs (micros) and produces a stable total", () => { + const result = computeTaskCostsIncludingSubtasks(0.05, [0.16, 0.13]) + + expect(result.ownCostCents).toBe(5) + expect(result.subtasksCostCents).toBe(29) + expect(result.totalCostIncludingSubtasksCents).toBe(34) + expect(result.totalCostIncludingSubtasks).toBeCloseTo(0.34, 10) + }) + + it("prefers derived subtask sum over provided breakdown/aggregatedCost when details are available", () => { + const data = getTaskHeaderCostTooltipData({ + ownCost: 0.05, + aggregatedCost: 0.09, + hasSubtasksProp: true, + costBreakdownProp: "Own: $0.05 + Subtasks: $0.09", + subtaskCosts: [0.16, 0.13], + labels: { own: "Own", subtasks: "Subtasks" }, + }) + + expect(data.displayTotalCost).toBeCloseTo(0.34, 10) + expect(data.displayCostBreakdown).toBe("Own: $0.05 + Subtasks: $0.29") + expect(data.hasSubtasks).toBe(true) + expect(data.hasAnyCost).toBe(true) + }) +}) diff --git a/webview-ui/src/utils/taskCostBreakdown.ts b/webview-ui/src/utils/taskCostBreakdown.ts new file mode 100644 index 0000000000..514246e50e --- /dev/null +++ b/webview-ui/src/utils/taskCostBreakdown.ts @@ -0,0 +1,104 @@ +import { formatCostBreakdown } from "@src/utils/costFormatting" + +const MICROS_PER_DOLLAR = 1_000_000 +const MICROS_PER_CENT = 10_000 + +function dollarsToMicros(amount: number): number { + if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0) { + return 0 + } + return Math.round(amount * MICROS_PER_DOLLAR) +} + +function microsToDollars(micros: number): number { + return micros / MICROS_PER_DOLLAR +} + +export interface TaskCostsIncludingSubtasks { + ownCost: number + ownCostMicros: number + ownCostCents: number + + subtasksCost: number + subtasksCostMicros: number + subtasksCostCents: number + + totalCostIncludingSubtasks: number + totalCostIncludingSubtasksMicros: number + totalCostIncludingSubtasksCents: number +} + +/** + * Computes task costs using integer micros for stable aggregation. + * + * Note: we only have access to floating-dollar amounts in the webview. + * Converting to micros and summing avoids most floating point drift. + */ +export function computeTaskCostsIncludingSubtasks(ownCost: number, subtaskCosts: number[]): TaskCostsIncludingSubtasks { + const ownCostMicros = dollarsToMicros(ownCost) + const subtasksCostMicros = (subtaskCosts ?? []).reduce((sum, cost) => sum + dollarsToMicros(cost), 0) + const totalCostIncludingSubtasksMicros = ownCostMicros + subtasksCostMicros + + const ownCostCents = Math.round(ownCostMicros / MICROS_PER_CENT) + const subtasksCostCents = Math.round(subtasksCostMicros / MICROS_PER_CENT) + const totalCostIncludingSubtasksCents = Math.round(totalCostIncludingSubtasksMicros / MICROS_PER_CENT) + + return { + ownCost: microsToDollars(ownCostMicros), + ownCostMicros, + ownCostCents, + subtasksCost: microsToDollars(subtasksCostMicros), + subtasksCostMicros, + subtasksCostCents, + totalCostIncludingSubtasks: microsToDollars(totalCostIncludingSubtasksMicros), + totalCostIncludingSubtasksMicros, + totalCostIncludingSubtasksCents, + } +} + +export interface TaskHeaderCostTooltipData { + /** Total cost to display (includes subtasks when details provided). */ + displayTotalCost: number + /** Breakdown string to show in tooltip, if subtasks exist. */ + displayCostBreakdown?: string + /** Whether the UI should show the "includes subtasks" marker. */ + hasSubtasks: boolean + /** Whether there is any cost to render. */ + hasAnyCost: boolean +} + +/** + * Cost display logic for TaskHeader tooltip. + * + * When subtask details are available, this derives subtasks cost as the sum of + * subtask line-item totals (same source as the accordion list), rather than + * trusting any derived deltas. + */ +export function getTaskHeaderCostTooltipData(params: { + ownCost: number + aggregatedCost?: number + hasSubtasksProp?: boolean + costBreakdownProp?: string + subtaskCosts?: number[] + labels: { own: string; subtasks: string } +}): TaskHeaderCostTooltipData { + const { ownCost, aggregatedCost, hasSubtasksProp, costBreakdownProp, subtaskCosts, labels } = params + + const computed = computeTaskCostsIncludingSubtasks(ownCost, subtaskCosts ?? []) + const hasComputedSubtasks = computed.subtasksCostCents > 0 + const hasSubtasks = !!hasSubtasksProp || hasComputedSubtasks + + const displayTotalCost = hasComputedSubtasks ? computed.totalCostIncludingSubtasks : (aggregatedCost ?? ownCost) + const displayCostBreakdown = hasComputedSubtasks + ? formatCostBreakdown(computed.ownCost, computed.subtasksCost, labels) + : costBreakdownProp + + const hasAnyCost = typeof displayTotalCost === "number" && Number.isFinite(displayTotalCost) && displayTotalCost > 0 + + return { + displayTotalCost, + displayCostBreakdown, + hasSubtasks, + hasAnyCost, + } +}