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) <noreply@runfusion.ai>
334 lines
15 KiB
TypeScript
334 lines
15 KiB
TypeScript
import React from "react";
|
|
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, 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]";
|
|
|
|
const markdownLinkifyCodeComponent: NonNullable<Components["code"]> = ({ children, ...props }) => {
|
|
const text = React.Children.toArray(children).join(EMPTY_MARKDOWN_CHILD_SEPARATOR);
|
|
const linkedChildren = linkifyFilePaths(text);
|
|
if (linkedChildren.length === 1 && Object.prototype.toString.call(linkedChildren[0]) === STRING_OBJECT_TAG) {
|
|
return <code {...props}>{children}</code>;
|
|
}
|
|
return <code {...props}>{linkedChildren}</code>;
|
|
};
|
|
|
|
const markdownLinkifyComponents: Components = {
|
|
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
|
|
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
|
|
code: createMermaidCodeComponent("task-summary-mermaid-diagram", markdownLinkifyCodeComponent),
|
|
};
|
|
|
|
interface TaskSummaryTabProps {
|
|
task: TaskDetail;
|
|
pricingOverrides?: ModelPricingOverrides;
|
|
}
|
|
|
|
function getCompletedSteps(steps: TaskStep[] | undefined): TaskStep[] {
|
|
return (steps ?? []).filter((step) => step.status === "done" || step.status === "skipped");
|
|
}
|
|
|
|
function getRenderableWorkflowResults(results: WorkflowStepResult[] | undefined): WorkflowStepResult[] {
|
|
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<TaskTokenUsagePerModel, "modelProvider" | "modelId">): string {
|
|
return `${bucket.modelProvider ?? ""}:${bucket.modelId ?? ""}`;
|
|
}
|
|
|
|
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: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<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));
|
|
}
|
|
|
|
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, pricingOverrides }: TaskSummaryTabProps) {
|
|
const { t } = useTranslation("app");
|
|
const summary = task.summary?.trim();
|
|
const changedFiles = task.mergeDetails?.landedFiles?.length
|
|
? task.mergeDetails.landedFiles
|
|
: task.modifiedFiles ?? [];
|
|
const completedSteps = getCompletedSteps(task.steps);
|
|
const workflowResults = getRenderableWorkflowResults(task.workflowStepResults);
|
|
const retryTotal = task.retrySummary?.total ?? 0;
|
|
const hasChangedStats = task.mergeDetails?.filesChanged != null
|
|
|| task.mergeDetails?.insertions != null
|
|
|| 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 (
|
|
<div className="task-summary-tab" data-testid="task-summary-tab">
|
|
<section className="task-summary-section task-summary-section--completion">
|
|
<h4>{t("taskDetail.summaryTab.completionHeading", "Completion summary")}</h4>
|
|
{summary ? (
|
|
<div className="markdown-body task-summary-markdown">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={sharedRehypePlugins} components={markdownLinkifyComponents}>
|
|
{summary}
|
|
</ReactMarkdown>
|
|
</div>
|
|
) : (
|
|
<p className="task-summary-empty">{t("taskDetail.summaryTab.noCompletionSummary", "No completion summary was recorded for this task.")}</p>
|
|
)}
|
|
</section>
|
|
|
|
{hasChangedContent ? (
|
|
<section className="task-summary-section task-summary-section--changes">
|
|
<h4>{t("taskDetail.summaryTab.changedHeading", "What changed")}</h4>
|
|
{(hasChangedStats || task.mergeDetails?.commitSha) && (
|
|
<dl className="task-summary-stats">
|
|
{task.mergeDetails?.commitSha && (
|
|
<div>
|
|
<dt>{t("taskDetail.summaryTab.commit", "Commit")}</dt>
|
|
<dd><code>{task.mergeDetails.commitSha.slice(0, 7)}</code></dd>
|
|
</div>
|
|
)}
|
|
{task.mergeDetails?.filesChanged != null && (
|
|
<div>
|
|
<dt>{t("taskDetail.summaryTab.filesChanged", "Files")}</dt>
|
|
<dd>{task.mergeDetails.filesChanged}</dd>
|
|
</div>
|
|
)}
|
|
{task.mergeDetails?.insertions != null && (
|
|
<div>
|
|
<dt>{t("taskDetail.summaryTab.insertions", "Added")}</dt>
|
|
<dd className="task-summary-diff-add">+{task.mergeDetails.insertions}</dd>
|
|
</div>
|
|
)}
|
|
{task.mergeDetails?.deletions != null && (
|
|
<div>
|
|
<dt>{t("taskDetail.summaryTab.deletions", "Removed")}</dt>
|
|
<dd className="task-summary-diff-del">-{task.mergeDetails.deletions}</dd>
|
|
</div>
|
|
)}
|
|
</dl>
|
|
)}
|
|
{changedFiles.length > 0 ? (
|
|
<ul className="task-summary-file-list">
|
|
{changedFiles.map((path) => (
|
|
<li key={path}><bdo dir="ltr">{path}</bdo></li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p className="task-summary-empty">{t("taskDetail.summaryTab.noChangedFiles", "No changed-file list is available for this task.")}</p>
|
|
)}
|
|
</section>
|
|
) : null}
|
|
|
|
{task.tokenUsage ? (
|
|
<section className="task-summary-section task-summary-section--tokens" data-testid="task-summary-token-cost-section">
|
|
<h4>{t("taskDetail.summaryTab.tokenCostHeading", "Token usage & cost")}</h4>
|
|
<div className="task-summary-token-table-wrap">
|
|
<table className="task-summary-token-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{t("taskDetail.summaryTab.model", "Model")}</th>
|
|
<th>{t("taskDetail.summaryTab.inputTokens", "Input")}</th>
|
|
<th>{t("taskDetail.summaryTab.outputTokens", "Output")}</th>
|
|
<th>{t("taskDetail.summaryTab.cachedTokens", "Cached")}</th>
|
|
<th>{t("taskDetail.summaryTab.totalTokens", "Total")}</th>
|
|
<th>{t("taskDetail.summaryTab.cost", "Cost")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{tokenCostRows.map((row) => (
|
|
<tr key={row.key || row.label} data-testid="task-summary-token-row">
|
|
<td data-label={t("taskDetail.summaryTab.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.summaryTab.inputTokens", "Input")}>{formatCount(row.inputTokens)}</td>
|
|
<td data-label={t("taskDetail.summaryTab.outputTokens", "Output")}>{formatCount(row.outputTokens)}</td>
|
|
<td data-label={t("taskDetail.summaryTab.cachedTokens", "Cached")}>{formatCount(row.cachedTokens)}</td>
|
|
<td data-label={t("taskDetail.summaryTab.totalTokens", "Total")}>{formatCount(row.totalTokens)}</td>
|
|
<td data-label={t("taskDetail.summaryTab.cost", "Cost")}>
|
|
{row.cost.unavailable || row.cost.usd === null ? (
|
|
<span className="task-summary-cost-unavailable" title={t("taskDetail.summaryTab.costUnavailable", "No pricing for this model")}>—</span>
|
|
) : (
|
|
formatCost(row.cost.usd, row.cost.unavailable)
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr>
|
|
<th scope="row" colSpan={5}>{t("taskDetail.summaryTab.totalCost", "Total cost")}</th>
|
|
<td data-label={t("taskDetail.summaryTab.totalCost", "Total cost")}>{formatCost(totalCost.usd, totalCost.unavailable)}</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
{hasAgentWork ? (
|
|
<section className="task-summary-section task-summary-section--agent-work">
|
|
<h4>{t("taskDetail.summaryTab.agentWorkHeading", "Work done by agents")}</h4>
|
|
{completedSteps.length > 0 && (
|
|
<div className="task-summary-subsection">
|
|
<h5>{t("taskDetail.summaryTab.completedSteps", "Completed steps")}</h5>
|
|
<ul className="task-summary-work-list">
|
|
{completedSteps.map((step, index) => (
|
|
<li key={`${step.name}-${index}`}>
|
|
<span className={`task-summary-status task-summary-status--${step.status}`}>{step.status}</span>
|
|
<span>{step.name}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
{workflowResults.length > 0 && (
|
|
<div className="task-summary-subsection">
|
|
<h5>{t("taskDetail.summaryTab.workflowResults", "Workflow results")}</h5>
|
|
<ul className="task-summary-work-list">
|
|
{workflowResults.map((result) => (
|
|
<li key={`${result.workflowStepId}-${result.completedAt ?? result.startedAt ?? result.workflowStepName}`}>
|
|
<span className={`task-summary-status task-summary-status--${result.status}`}>{result.status.replace("_", " ")}</span>
|
|
<span>{result.workflowStepName}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
{retryTotal > 0 && (
|
|
<p className="task-summary-retries">
|
|
{t("taskDetail.summaryTab.retries", "Agents retried this task {{count}} time{{plural}}.", { count: retryTotal, plural: retryTotal === 1 ? "" : "s" })}
|
|
</p>
|
|
)}
|
|
</section>
|
|
) : (
|
|
<section className="task-summary-section task-summary-section--agent-work">
|
|
<h4>{t("taskDetail.summaryTab.agentWorkHeading", "Work done by agents")}</h4>
|
|
<p className="task-summary-empty">{t("taskDetail.summaryTab.noAgentWork", "No completed steps or workflow results are available for this task.")}</p>
|
|
</section>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|