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>
92 lines
4.6 KiB
TypeScript
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;
|