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>
140 lines
5.6 KiB
TypeScript
140 lines
5.6 KiB
TypeScript
import type { TaskDetail, TaskTokenUsagePerModel } from "@fusion/core";
|
|
import { costFor, type CostResult, type ModelPricingOverrides } from "../../../core/src/model-pricing";
|
|
|
|
export interface TokenCostRow {
|
|
key: string;
|
|
label: string;
|
|
modelProvider?: string;
|
|
modelId?: string;
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
cachedTokens: number;
|
|
cacheWriteTokens: number;
|
|
totalTokens: number;
|
|
cost: CostResult;
|
|
}
|
|
|
|
export function formatCount(n: number): string {
|
|
return Number.isFinite(n) ? Math.round(n).toLocaleString() : "0";
|
|
}
|
|
|
|
export function formatCost(usd: number | null, unavailable: boolean): string {
|
|
if (unavailable || usd === null || !Number.isFinite(usd)) {
|
|
return "—";
|
|
}
|
|
return `$${usd.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
}
|
|
|
|
export function toTokenBucketKey(bucket: Pick<TaskTokenUsagePerModel, "modelProvider" | "modelId">): string {
|
|
return `${bucket.modelProvider ?? ""}:${bucket.modelId ?? ""}`;
|
|
}
|
|
|
|
export function toTokenCostRow(
|
|
bucket: Pick<TaskTokenUsagePerModel, "modelProvider" | "modelId" | "inputTokens" | "outputTokens" | "cachedTokens" | "cacheWriteTokens" | "totalTokens">,
|
|
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:TaskDetailCost 2026-07-11-12:00:
|
|
* Task cost is a read-time derivation shared by the done Summary tab, always-available Cost tab, and optional card badge. Keep the costFor/pricing-overrides path centralized here so unpriced or zero-usage states keep the guess-free “—” sentinel everywhere and derived USD is never persisted.
|
|
*
|
|
* 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.
|
|
*/
|
|
export 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<string, Pick<TaskTokenUsagePerModel, "modelProvider" | "modelId" | "inputTokens" | "outputTokens" | "cachedTokens" | "cacheWriteTokens" | "totalTokens">>();
|
|
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));
|
|
}
|
|
|
|
export function totalCostForRows(rows: TokenCostRow[]): { usd: number | null; unavailable: boolean } {
|
|
let usd = 0;
|
|
let unavailable = false;
|
|
let hasPositiveUsage = false;
|
|
rows.forEach((row) => {
|
|
if (row.totalTokens <= 0) return;
|
|
hasPositiveUsage = true;
|
|
if (row.cost.unavailable || row.cost.usd === null || !Number.isFinite(row.cost.usd)) {
|
|
unavailable = true;
|
|
return;
|
|
}
|
|
usd += row.cost.usd;
|
|
});
|
|
return { usd: unavailable || !hasPositiveUsage ? null : usd, unavailable: unavailable || !hasPositiveUsage };
|
|
}
|
|
|
|
export function hasTaskCost(task: TaskDetail): boolean {
|
|
const tokenUsage = task.tokenUsage;
|
|
if (!tokenUsage) return false;
|
|
const totalTokens = tokenUsage.totalTokens
|
|
?? ((tokenUsage.inputTokens ?? 0) + (tokenUsage.outputTokens ?? 0) + (tokenUsage.cachedTokens ?? 0) + (tokenUsage.cacheWriteTokens ?? 0));
|
|
if (totalTokens > 0) return true;
|
|
return (tokenUsage.perModel ?? []).some((bucket) => bucket.totalTokens > 0);
|
|
}
|
|
|
|
export function taskTotalCost(task: TaskDetail, pricingOverrides?: ModelPricingOverrides): CostResult {
|
|
const total = totalCostForRows(buildTokenCostRows(task, "(unknown)", pricingOverrides));
|
|
return { usd: total.usd, unavailable: total.unavailable, stale: false };
|
|
}
|