From 63d1079d440ab5c8d6aeba2bc411550c03c387e0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 17:56:32 -0700 Subject: [PATCH] FN-7142: show task summary token costs Add task summary visibility into token usage, model-level costs, and locale coverage. - Render done-task Summary token usage by model with input, output, cached, total, and estimated cost columns. - Thread global model pricing overrides into the summary cost calculation and mark unpriced models as unavailable. - Add responsive token-cost styling, summary-tab coverage, dashboard docs, release notes, and locale keys. Files changed: .changeset/fn-7142-summary-token-cost.md | 7 + docs/dashboard-guide.md | 2 +- .../dashboard/app/components/TaskDetailModal.css | 125 +++++++++++ .../dashboard/app/components/TaskDetailModal.tsx | 2 +- .../dashboard/app/components/TaskSummaryTab.tsx | 177 ++++++++++++++- .../__tests__/TaskDetailModal.summary-tab.test.tsx | 241 ++++++++++++++++++++- packages/i18n/locales/en/app.json | 11 + packages/i18n/locales/es/app.json | 52 ++++- packages/i18n/locales/fr/app.json | 52 ++++- packages/i18n/locales/ko/app.json | 52 ++++- packages/i18n/locales/zh-CN/app.json | 52 ++++- packages/i18n/locales/zh-TW/app.json | 52 ++++- 12 files changed, 794 insertions(+), 31 deletions(-) Fusion-Task-Id: FN-7142 Fusion-Task-Lineage: ec1c7244-4779-46e7-be7c-b0e00b361fd7 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7142-summary-token-cost.md | 7 + docs/dashboard-guide.md | 2 +- .../app/components/TaskDetailModal.css | 125 +++++++++ .../app/components/TaskDetailModal.tsx | 2 +- .../app/components/TaskSummaryTab.tsx | 177 ++++++++++++- .../TaskDetailModal.summary-tab.test.tsx | 241 +++++++++++++++++- packages/i18n/locales/en/app.json | 11 + packages/i18n/locales/es/app.json | 52 +++- packages/i18n/locales/fr/app.json | 52 +++- packages/i18n/locales/ko/app.json | 52 +++- packages/i18n/locales/zh-CN/app.json | 52 +++- packages/i18n/locales/zh-TW/app.json | 52 +++- 12 files changed, 794 insertions(+), 31 deletions(-) create mode 100644 .changeset/fn-7142-summary-token-cost.md diff --git a/.changeset/fn-7142-summary-token-cost.md b/.changeset/fn-7142-summary-token-cost.md new file mode 100644 index 0000000000..f4e4716021 --- /dev/null +++ b/.changeset/fn-7142-summary-token-cost.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Done-task Summary tab now shows token usage by model with estimated cost per model and a task total. +category: feature +dev: TaskSummaryTab derives per-model USD cost client-side via costFor + global modelPricingOverrides from task.tokenUsage.perModel; unpriced models render "—" (never $0). diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 1dd1f68f57..c0fb04838d 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -974,7 +974,7 @@ For related global/project configuration behavior, see [Settings reference](./se Inspect task definition, logs, review feedback, comments, artifacts, workflow outcomes, model overrides, and task routing from a single modal. - Editable tasks with descriptions show **Summarize as title** beside the read-mode title; it asks AI to generate a concise title from the description and saves it without opening the edit form. -- The **Summary** tab appears first for `done` tasks and is their default landing tab. It shows the recorded completion summary, changed-file/merge stats when available, completed steps, workflow results, and retry counts from the already-loaded task detail; non-`done` tasks still open on **Chat** by default. +- The **Summary** tab appears first for `done` tasks and is their default landing tab. It shows the recorded completion summary, changed-file/merge stats when available, completed steps, workflow results, retry counts, and a token usage & cost section broken down by model from the already-loaded task detail; unpriced models show cost as unavailable rather than `$0`. Non-`done` tasks still open on **Chat** by default. - The **Chat** tab includes an expand/collapse control that lets the transcript and composer fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed. - Task-detail Chat messages are persisted as user comments/steering guidance and surfaced to every relevant agent lane: live executor sessions receive steering injection, while planner, reviewer (spec/plan/code), and merger agents (standard and clean-room AI merge/review) receive the latest user comments in their next prompt/pass. - The priority chip in task metadata is an inline picker: you can change priority directly without entering full edit mode. diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index 38cd414c80..39390439ad 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -1642,6 +1642,64 @@ The done-task Summary tab is a scrollable overview composed from existing detail color: var(--color-error); } +/* +FNXC:TaskDetailSummaryTokenCost 2026-06-27-00:00: +The done-task Summary token-cost section must reuse task-summary card hierarchy while showing per-model counts and cost without hardcoded colors or spacing. Mobile converts table rows to stacked cards so the modal and right dock do not trap horizontal scrolling. +*/ +.task-summary-token-table-wrap { + width: 100%; + overflow-x: auto; +} + +.task-summary-token-table { + width: 100%; + border-collapse: collapse; + color: var(--text); +} + +.task-summary-token-table th, +.task-summary-token-table td { + padding: var(--space-sm); + border-bottom: var(--btn-border-width) solid var(--border); + text-align: left; + vertical-align: middle; +} + +.task-summary-token-table th { + color: var(--text-muted); + font-size: calc(var(--space-sm) + var(--space-xs) * 0.5); + font-weight: 600; + text-transform: uppercase; + letter-spacing: calc(var(--btn-border-width) * 0.5); +} + +.task-summary-token-table tbody td:not(:first-child), +.task-summary-token-table tfoot td { + font-family: var(--font-mono); + white-space: nowrap; +} + +.task-summary-token-table tfoot th, +.task-summary-token-table tfoot td { + border-bottom: 0; + font-weight: 700; +} + +.task-summary-model-label { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + min-width: 0; +} + +.task-summary-model-label span:last-child { + overflow-wrap: anywhere; +} + +.task-summary-cost-unavailable { + color: var(--color-warning); +} + .task-summary-file-list, .task-summary-work-list { display: flex; @@ -1731,6 +1789,73 @@ The done-task Summary tab is a scrollable overview composed from existing detail grid-template-columns: 1fr; } + .task-summary-token-table-wrap { + overflow-x: visible; + } + + .task-summary-token-table, + .task-summary-token-table thead, + .task-summary-token-table tbody, + .task-summary-token-table tfoot, + .task-summary-token-table tr, + .task-summary-token-table th, + .task-summary-token-table td { + display: block; + } + + .task-summary-token-table thead { + display: none; + } + + .task-summary-token-table tbody tr, + .task-summary-token-table tfoot tr { + padding: var(--space-sm); + background: var(--surface); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-md); + } + + .task-summary-token-table tbody, + .task-summary-token-table tfoot { + display: flex; + flex-direction: column; + gap: var(--space-xs); + } + + .task-summary-token-table td, + .task-summary-token-table tfoot th { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + padding: var(--space-xs) 0; + border-bottom: 0; + text-align: right; + } + + .task-summary-token-table td::before { + content: attr(data-label); + color: var(--text-muted); + font-family: var(--font-sans); + font-weight: 600; + text-align: left; + } + + .task-summary-token-table tfoot td::before { + content: none; + } + + .task-summary-token-table tfoot th { + color: var(--text); + text-align: left; + text-transform: none; + letter-spacing: normal; + } + + .task-summary-token-table tfoot th::after { + content: ":"; + } + .task-summary-work-list li { align-items: flex-start; } diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index dfd02746ba..50838ad1f0 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -3223,7 +3223,7 @@ export function TaskDetailContent({ ) : activeTab === "summary" && task.column === "done" ? (
- +
) : activeTab === "chat" ? (
diff --git a/packages/dashboard/app/components/TaskSummaryTab.tsx b/packages/dashboard/app/components/TaskSummaryTab.tsx index d589920552..ba302a3f3b 100644 --- a/packages/dashboard/app/components/TaskSummaryTab.tsx +++ b/packages/dashboard/app/components/TaskSummaryTab.tsx @@ -3,9 +3,12 @@ import { useTranslation } from "react-i18next"; import ReactMarkdown from "react-markdown"; import type { Components } from "react-markdown"; import remarkGfm from "remark-gfm"; -import type { TaskDetail, TaskStep, WorkflowStepResult } from "@fusion/core"; +import type { TaskDetail, TaskStep, TaskTokenUsagePerModel, WorkflowStepResult } from "@fusion/core"; +import { costFor, type CostResult, type ModelPricingOverrides } from "../../../core/src/model-pricing"; import { createMermaidCodeComponent, sharedRehypePlugins } from "./markdownPipeline"; +import { ProviderIcon } from "./ProviderIcon"; import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify"; +import { inferProviderIconKey } from "../utils/providerIconKey"; const EMPTY_MARKDOWN_CHILD_SEPARATOR = ""; const STRING_OBJECT_TAG = "[object String]"; @@ -27,6 +30,7 @@ const markdownLinkifyComponents: Components = { interface TaskSummaryTabProps { task: TaskDetail; + pricingOverrides?: ModelPricingOverrides; } function getCompletedSteps(steps: TaskStep[] | undefined): TaskStep[] { @@ -37,11 +41,129 @@ function getRenderableWorkflowResults(results: WorkflowStepResult[] | undefined) return (results ?? []).filter((result) => result.status !== "pending"); } +interface TokenCostRow { + key: string; + label: string; + modelProvider?: string; + modelId?: string; + inputTokens: number; + outputTokens: number; + cachedTokens: number; + cacheWriteTokens: number; + totalTokens: number; + cost: CostResult; +} + +function formatCount(n: number): string { + return Number.isFinite(n) ? Math.round(n).toLocaleString() : "0"; +} + +function formatCost(usd: number | null, unavailable: boolean): string { + if (unavailable || usd === null || !Number.isFinite(usd)) { + return "—"; + } + return `$${usd.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +function toTokenBucketKey(bucket: Pick): string { + return `${bucket.modelProvider ?? ""}:${bucket.modelId ?? ""}`; +} + +function toTokenCostRow( + bucket: Pick, + unknownLabel: string, + now: number, + pricingOverrides?: ModelPricingOverrides, +): TokenCostRow { + const modelId = bucket.modelId?.trim() || undefined; + const modelProvider = bucket.modelProvider?.trim() || undefined; + const label = modelId ?? unknownLabel; + return { + key: toTokenBucketKey({ modelProvider, modelId }), + label, + modelProvider, + modelId, + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cachedTokens: bucket.cachedTokens, + cacheWriteTokens: bucket.cacheWriteTokens, + totalTokens: bucket.totalTokens, + cost: costFor( + { + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cachedTokens: bucket.cachedTokens, + cacheWriteTokens: bucket.cacheWriteTokens, + }, + { provider: modelProvider, model: modelId }, + now, + pricingOverrides, + ), + }; +} + +/** + * FNXC:TaskDetailSummaryTokenCost 2026-06-27-00:00: + * Done-task Summary shows durable token usage broken down by model with derived USD cost. Use already-loaded task.tokenUsage.perModel buckets plus costFor and global pricing overrides threaded from TaskDetailModal; do not fetch or persist cost here. Unpriced models render “—” instead of $0 and make the task total unavailable so estimates are never understated. + */ +function buildTokenCostRows(task: TaskDetail, unknownLabel: string, pricingOverrides?: ModelPricingOverrides): TokenCostRow[] { + const tokenUsage = task.tokenUsage; + if (!tokenUsage) return []; + + const buckets = tokenUsage.perModel?.length + ? tokenUsage.perModel + : [ + { + modelProvider: tokenUsage.modelProvider, + modelId: tokenUsage.modelId, + inputTokens: tokenUsage.inputTokens, + outputTokens: tokenUsage.outputTokens, + cachedTokens: tokenUsage.cachedTokens, + cacheWriteTokens: tokenUsage.cacheWriteTokens, + totalTokens: tokenUsage.totalTokens, + }, + ]; + + const merged = new Map>(); + buckets.forEach((bucket) => { + const modelProvider = bucket.modelProvider?.trim() || undefined; + const modelId = bucket.modelId?.trim() || undefined; + const key = toTokenBucketKey({ modelProvider, modelId }); + const current = merged.get(key); + if (!current) { + merged.set(key, { ...bucket, modelProvider, modelId }); + return; + } + current.inputTokens += bucket.inputTokens; + current.outputTokens += bucket.outputTokens; + current.cachedTokens += bucket.cachedTokens; + current.cacheWriteTokens += bucket.cacheWriteTokens; + current.totalTokens += bucket.totalTokens; + }); + + const now = Date.now(); + return Array.from(merged.values()).map((bucket) => toTokenCostRow(bucket, unknownLabel, now, pricingOverrides)); +} + +function totalCostForRows(rows: TokenCostRow[]): { usd: number | null; unavailable: boolean } { + let usd = 0; + let unavailable = false; + rows.forEach((row) => { + if (row.totalTokens <= 0) return; + if (row.cost.unavailable || row.cost.usd === null || !Number.isFinite(row.cost.usd)) { + unavailable = true; + return; + } + usd += row.cost.usd; + }); + return { usd: unavailable ? null : usd, unavailable }; +} + /** * FNXC:TaskDetailSummaryTab 2026-06-27-00:00: * TaskSummaryTab aggregates read-only completion data already loaded on TaskDetail: agent-written summary, changed-file metadata, implementation steps, workflow-step outcomes, and retry counts. It does not fetch, persist, or generate AI content so done-task details remain a front-end composition only. */ -export function TaskSummaryTab({ task }: TaskSummaryTabProps) { +export function TaskSummaryTab({ task, pricingOverrides }: TaskSummaryTabProps) { const { t } = useTranslation("app"); const summary = task.summary?.trim(); const changedFiles = task.mergeDetails?.landedFiles?.length @@ -55,6 +177,8 @@ export function TaskSummaryTab({ task }: TaskSummaryTabProps) { || task.mergeDetails?.deletions != null; const hasChangedContent = changedFiles.length > 0 || hasChangedStats || Boolean(task.mergeDetails?.commitSha); const hasAgentWork = completedSteps.length > 0 || workflowResults.length > 0 || retryTotal > 0; + const tokenCostRows = buildTokenCostRows(task, t("taskDetail.summaryTab.unknownModel", "(unknown)"), pricingOverrides); + const totalCost = totalCostForRows(tokenCostRows); return (
@@ -114,6 +238,55 @@ export function TaskSummaryTab({ task }: TaskSummaryTabProps) { ) : null} + {task.tokenUsage ? ( +
+

{t("taskDetail.summaryTab.tokenCostHeading", "Token usage & cost")}

+
+ + + + + + + + + + + + + {tokenCostRows.map((row) => ( + + + + + + + + + ))} + + + + + + + +
{t("taskDetail.summaryTab.model", "Model")}{t("taskDetail.summaryTab.inputTokens", "Input")}{t("taskDetail.summaryTab.outputTokens", "Output")}{t("taskDetail.summaryTab.cachedTokens", "Cached")}{t("taskDetail.summaryTab.totalTokens", "Total")}{t("taskDetail.summaryTab.cost", "Cost")}
+ + + {row.label} + + {formatCount(row.inputTokens)}{formatCount(row.outputTokens)}{formatCount(row.cachedTokens)}{formatCount(row.totalTokens)} + {row.cost.unavailable || row.cost.usd === null ? ( + — + ) : ( + formatCost(row.cost.usd, row.cost.unavailable) + )} +
{t("taskDetail.summaryTab.totalCost", "Total cost")}{formatCost(totalCost.usd, totalCost.unavailable)}
+
+
+ ) : null} + {hasAgentWork ? (

{t("taskDetail.summaryTab.agentWorkHeading", "Work done by agents")}

diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.summary-tab.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.summary-tab.test.tsx index 5aaf57296b..13733099a5 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.summary-tab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.summary-tab.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { render, screen } from "@testing-library/react"; -import type { Column } from "@fusion/core"; +import { render, screen, within } from "@testing-library/react"; +import type { Column, ModelPricingOverrides, TaskTokenUsage } from "@fusion/core"; import { makeTask, noop, @@ -8,9 +8,11 @@ import { noopMerge, noopMove, noopOpenDetail, + readDashboardStylesSource, setupTaskDetailModalHooks, } from "./TaskDetailModal.test-helpers"; import { TaskDetailContent, TaskDetailModal } from "../TaskDetailModal"; +import { TaskSummaryTab } from "../TaskSummaryTab"; setupTaskDetailModalHooks(); @@ -18,6 +20,19 @@ function expectButtonActive(button: HTMLElement): void { expect(button.classList.contains("detail-tab-active")).toBe(true); } +function tokenUsage(overrides: Partial = {}): TaskTokenUsage { + return { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + firstUsedAt: "2026-01-01T00:00:00Z", + lastUsedAt: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + function doneTask(overrides = {}) { return makeTask({ column: "done", @@ -157,6 +172,228 @@ describe("TaskDetailModal Summary tab", () => { } }); + it("omits the token-cost section when token usage is absent", () => { + render(); + + expect(screen.queryByText("Token usage & cost")).toBeNull(); + expect(screen.queryByTestId("task-summary-token-cost-section")).toBeNull(); + }); + + it("renders multi-model token counts with priced, unpriced, and unavailable total cost states", () => { + render( + , + ); + + const rows = screen.getAllByTestId("task-summary-token-row"); + expect(rows).toHaveLength(2); + expect(within(rows[0]).getByText("claude-sonnet-4-6")).toBeTruthy(); + expect(within(rows[0]).getAllByText("1,000,000")).toHaveLength(2); + expect(within(rows[0]).getByText("2,000,000")).toBeTruthy(); + expect(within(rows[0]).getByText("$18.00")).toBeTruthy(); + expect(within(rows[0]).getByTestId("anthropic-icon")).toBeTruthy(); + expect(within(rows[1]).getByText("unpriced-model")).toBeTruthy(); + expect(within(rows[1]).getByText("—")).toBeTruthy(); + expect(screen.getByText("Total cost")).toBeTruthy(); + expect(screen.getAllByText("—").length).toBeGreaterThanOrEqual(2); + expect(screen.queryByText("$0.00")).toBeNull(); + }); + + it("renders a numeric task total when all contributing models are priced", () => { + render( + , + ); + + expect(screen.getByText("$18.00")).toBeTruthy(); + expect(screen.getByText("$0.75")).toBeTruthy(); + expect(screen.getByText("$18.75")).toBeTruthy(); + }); + + it("synthesizes a single aggregate row when per-model buckets are absent", () => { + render( + , + ); + + const rows = screen.getAllByTestId("task-summary-token-row"); + expect(rows).toHaveLength(1); + expect(within(rows[0]).getByText("(unknown)")).toBeTruthy(); + expect(within(rows[0]).getByText("1,234")).toBeTruthy(); + expect(within(rows[0]).getByText("5,678")).toBeTruthy(); + expect(within(rows[0]).getByText("90")).toBeTruthy(); + expect(within(rows[0]).getByText("7,014")).toBeTruthy(); + }); + + it("applies pricing overrides passed into the summary component", () => { + const pricingOverrides: ModelPricingOverrides = { + "custom:override-model": { + inputPer1M: 2, + outputPer1M: 3, + cacheReadPer1M: 4, + cacheWritePer1M: 5, + source: "test override", + }, + }; + + render( + , + ); + + expect(screen.getAllByText("$14.00").length).toBeGreaterThanOrEqual(1); + }); + + it("renders the shared token-cost section through embedded and full detail content entrypoints", () => { + const task = doneTask({ + tokenUsage: tokenUsage({ + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 2_000_000, + modelProvider: "anthropic", + modelId: "claude-sonnet-4-6", + }), + }); + + const full = render( + , + ); + expect(screen.getByText("Token usage & cost")).toBeTruthy(); + expect(screen.getByText("claude-sonnet-4-6")).toBeTruthy(); + full.unmount(); + + render( + , + ); + expect(screen.getByText("Token usage & cost")).toBeTruthy(); + expect(screen.getByText("claude-sonnet-4-6")).toBeTruthy(); + }); + + it("keeps token-cost summary styling responsive without hardcoded colors", () => { + const css = readDashboardStylesSource(); + expect(css).toContain(".task-summary-token-table"); + expect(css).toContain("@media (max-width: 768px)"); + expect(css).toContain(".task-summary-token-table td::before"); + expect(css).toContain("var(--color-warning)"); + expect(css).not.toMatch(/task-summary-token[^{}]*#[0-9a-fA-F]{3,8}/); + }); + it("renders graceful empty states without orphaned changed-file headings", () => { render(