Files
fusion/packages/dashboard/app/components/TaskCostTab.tsx
gsxdsm 937650472a FN-7820: add Cost tab to task detail and optional card cost badge
Adds a shared cost-derivation utility and surfaces token/cost info in a new Cost tab on the task detail modal, plus an opt-in per-card cost badge on the board.

- Extract token-cost calculation into a shared taskTokenCost helper (read-time costFor derivation) reused by the Summary tab, new Cost tab, and card badge
- Add TaskDetailModal Cost tab (TaskCostTab.tsx/.css) showing cost breakdown for a task
- Simplify TaskSummaryTab by delegating cost math to the shared helper
- Add default-off project setting showCostBadgeOnCards (settings-schema.ts, types.ts) with a SettingsModal/AppearanceSection toggle
- Add CostBadgeContext to thread the setting into TaskCard without prop drilling
- Show an optional cost badge on TaskCard when the setting is enabled
- Update i18n strings across en/es/fr/ko/zh-CN/zh-TW locales
- Update docs (dashboard-guide.md, settings-reference.md) and add changeset fn-7820-cost-tab-and-card-badge.md

Files changed:
 .changeset/fn-7820-cost-tab-and-card-badge.md      |   7 ++
 docs/dashboard-guide.md                            |   4 +
 docs/settings-reference.md                         |   1 +
 .../core/src/__tests__/settings-defaults.test.ts   |  13 ++
 packages/core/src/settings-schema.ts               |   5 +
 packages/core/src/types.ts                         |   5 +
 packages/dashboard/app/App.tsx                     |   5 +
 .../dashboard/app/components/SettingsModal.tsx     |   6 +
 packages/dashboard/app/components/TaskCard.css     |   7 +-
 packages/dashboard/app/components/TaskCard.tsx     |  27 +++-
 packages/dashboard/app/components/TaskCostTab.css  |  51 ++++++++
 packages/dashboard/app/components/TaskCostTab.tsx  |  91 ++++++++++++++
 .../dashboard/app/components/TaskDetailModal.tsx   |  14 ++-
 .../dashboard/app/components/TaskSummaryTab.tsx    | 123 +-----------------
 .../app/components/__tests__/TaskCard.test.tsx     |  81 +++++++++++-
 .../app/components/__tests__/TaskCostTab.test.tsx  |  55 ++++++++
 .../TaskDetailModal.attachments-and-tabs.test.tsx  |  11 +-
 .../settings/sections/AppearanceSection.tsx        |   8 ++
 .../sections/__tests__/AppearanceSection.test.tsx  |  20 +++
 .../settings-default-descriptions.test.tsx         |   1 +
 .../dashboard/app/context/CostBadgeContext.tsx     |  19 +++
 packages/dashboard/app/hooks/useAppSettings.ts     |  15 +++
 .../app/utils/__tests__/taskTokenCost.test.ts      |  62 +++++++++
 packages/dashboard/app/utils/taskTokenCost.ts      | 139 +++++++++++++++++++++
 packages/i18n/locales/en/app.json                  |  29 ++++-
 packages/i18n/locales/es/app.json                  |  30 ++++-
 packages/i18n/locales/fr/app.json                  |  27 +++-
 packages/i18n/locales/ko/app.json                  |  30 ++++-
 packages/i18n/locales/zh-CN/app.json               |  30 ++++-
 packages/i18n/locales/zh-TW/app.json               |  30 ++++-
 30 files changed, 789 insertions(+), 157 deletions(-)

Fusion-Task-Id: FN-7820

Fusion-Task-Lineage: d33c5678-a68c-4b29-9db1-8ff0369dfd72

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-11 18:07:00 -07:00

92 lines
4.6 KiB
TypeScript

import "./TaskCostTab.css";
import { useTranslation } from "react-i18next";
import type { TaskDetail } from "@fusion/core";
import type { ModelPricingOverrides } from "../../../core/src/model-pricing";
import { ProviderIcon } from "./ProviderIcon";
import { inferProviderIconKey } from "../utils/providerIconKey";
import { buildTokenCostRows, formatCost, formatCount, totalCostForRows } from "../utils/taskTokenCost";
interface TaskCostTabProps {
task: TaskDetail;
pricingOverrides?: ModelPricingOverrides;
}
export function TaskCostTab({ task, pricingOverrides }: TaskCostTabProps) {
const { t } = useTranslation("app");
if (!task.tokenUsage) {
return (
<div className="task-cost-tab" data-testid="task-cost-tab">
<section className="task-summary-section task-cost-section task-cost-section--empty">
<h4>{t("taskDetail.costTab.heading", "Model cost")}</h4>
<p className="task-summary-empty">{t("taskDetail.costTab.empty", "No model token usage has been recorded for this task yet.")}</p>
</section>
</div>
);
}
const rows = buildTokenCostRows(task, t("taskDetail.costTab.unknownModel", "(unknown)"), pricingOverrides);
const totalCost = totalCostForRows(rows);
return (
<div className="task-cost-tab" data-testid="task-cost-tab">
<section className="task-summary-section task-cost-section">
<div className="task-cost-heading-row">
<h4>{t("taskDetail.costTab.heading", "Model cost")}</h4>
<span className="task-cost-total" data-testid="task-cost-total">
{t("taskDetail.costTab.totalCostLabel", "Total")}: {formatCost(totalCost.usd, totalCost.unavailable)}
</span>
</div>
<p className="task-summary-empty">{t("taskDetail.costTab.description", "Derived from recorded input, output, cached, and cache-write tokens. Unpriced rows use — instead of a guessed value.")}</p>
<div className="task-summary-token-table-wrap task-cost-table-wrap">
<table className="task-summary-token-table task-cost-table">
<thead>
<tr>
<th>{t("taskDetail.costTab.model", "Model")}</th>
<th>{t("taskDetail.costTab.inputTokens", "Input")}</th>
<th>{t("taskDetail.costTab.outputTokens", "Output")}</th>
<th>{t("taskDetail.costTab.cachedTokens", "Cached")}</th>
<th>{t("taskDetail.costTab.cacheWriteTokens", "Cache write")}</th>
<th>{t("taskDetail.costTab.totalTokens", "Total tokens")}</th>
<th>{t("taskDetail.costTab.cost", "Derived USD")}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.key || row.label} data-testid="task-cost-row">
<td data-label={t("taskDetail.costTab.model", "Model")}>
<span className="task-summary-model-label">
<ProviderIcon provider={inferProviderIconKey(row.modelId ?? "")} size="sm" />
<span>{row.label}</span>
</span>
</td>
<td data-label={t("taskDetail.costTab.inputTokens", "Input")}>{formatCount(row.inputTokens)}</td>
<td data-label={t("taskDetail.costTab.outputTokens", "Output")}>{formatCount(row.outputTokens)}</td>
<td data-label={t("taskDetail.costTab.cachedTokens", "Cached")}>{formatCount(row.cachedTokens)}</td>
<td data-label={t("taskDetail.costTab.cacheWriteTokens", "Cache write")}>{formatCount(row.cacheWriteTokens)}</td>
<td data-label={t("taskDetail.costTab.totalTokens", "Total tokens")}>{formatCount(row.totalTokens)}</td>
<td data-label={t("taskDetail.costTab.cost", "Derived USD")}>
{row.cost.unavailable || row.cost.usd === null ? (
<span className="task-summary-cost-unavailable" title={t("taskDetail.costTab.costUnavailable", "No pricing for this model")}>—</span>
) : (
formatCost(row.cost.usd, row.cost.unavailable)
)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<th scope="row" colSpan={6}>{t("taskDetail.costTab.totalCost", "Total cost")}</th>
<td data-label={t("taskDetail.costTab.totalCost", "Total cost")}>{formatCost(totalCost.usd, totalCost.unavailable)}</td>
</tr>
</tfoot>
</table>
</div>
</section>
</div>
);
}
export default TaskCostTab;