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>
This commit is contained in:
7
.changeset/fn-7820-cost-tab-and-card-badge.md
Normal file
7
.changeset/fn-7820-cost-tab-and-card-badge.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add a Cost tab to task detail and an optional per-card cost badge (default off).
|
||||
category: feature
|
||||
dev: New shared taskTokenCost helper (read-time costFor derivation) powers the Summary tab, the new Cost tab, and a card badge gated by the default-off project setting showCostBadgeOnCards.
|
||||
@@ -194,6 +194,8 @@ Features:
|
||||
- GitLab tracking badges on task cards for linked GitLab project issues, group issues, and merge requests; stale GitLab metadata uses a warning-colored badge while GitHub badges remain unchanged.
|
||||
- GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown in the footer with other external-source metadata
|
||||
- Task card header meta badges group priority and fast mode in the header; agent-created provenance renders in a dedicated bottom-left row ahead of workflow identity so the ID/status/actions header does not wrap on narrow cards. Agent labels prefer `sourceMetadata.agentName` over raw agent IDs.
|
||||
- **Settings → Appearance → Show cost badges on task cards** is default off. When enabled, board cards with recorded positive token usage show a compact derived-cost badge beside the execution-time badge; unpriced models display `—`, and cards with no usage render no badge shell.
|
||||
<!-- FNXC:TaskCardCostBadge 2026-07-11-12:25: The card spend badge is opt-in because card footers are dense. It must remain guess-free (unpriced `—`, no fabricated `$0`) and absent for tasks without positive token usage. -->
|
||||
<!-- FNXC:TaskCardLayout 2026-07-10-00:00: FN-7780 moved agent-created provenance out of the header meta-badge cluster into a bottom row. Keep dashboard docs aligned so operators do not expect the agent chip to participate in header wrapping. -->
|
||||
<!-- FNXC:PlannerOversight 2026-07-04-00:00: FN-7516 adds a read-only effective oversight-level badge plus an active-overseer-state indicator to the card-meta-badges cluster. The overseer-state indicator is derived card-locally from already-on-Task fields (mirroring the engine's stage-resolution precedence) rather than a new engine-plumbed field, since @fusion/engine's in-memory monitor state is not persisted onto Task/exposed via API. -->
|
||||
<!-- FNXC:PlannerOversight 2026-07-04-16:00: round-2 code-review fix — when a card must fetch the workflow's effective oversight tier (no synchronous per-task override), neither badge below renders until that fetch resolves; the schema default must never render as a guess while the true workflow tier is unknown. -->
|
||||
@@ -1238,6 +1240,8 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou
|
||||
- The top-level **Chat** tab appears first for active task details and is the default landing tab for non-`done` tasks. It uses the task's effective planning model, but opening the tab is lookup-only: Fusion creates the task-scoped planner chat only after you send a composer message, starter prompt, or planner-question answer. Once a user message exists, the resumable planner chat can appear in the global Chat list; interacted chats are kept when the task reaches `done` and removed when the task is archived. Each send includes bounded server-built task context so the planner can answer current status, progress, recent activity, dependency, and task definition questions. It shows starter prompts for common planning questions, can render structured planner questions, and converts only explicit operator steering intent through the scoped steering tool. The composer stays pinned while the transcript, loading, error, starter, history, and streaming states scroll internally; on mobile/narrow task detail, the default focused Chat layout hides nonessential title/metadata/tab/action rows until you collapse it from the in-view expand control.
|
||||
- The **Activity → Live**, **Feed**, and **Raw Logs** segments remain immediately after **Chat** and share an expand/collapse control that lets the active Activity segment fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed.
|
||||
- The **Summary** tab appears for `done` tasks and remains 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`.
|
||||
- The **Cost** tab is available for tasks in every column. It shows the read-time derived per-model cost breakdown (input, output, cached, cache-write, total tokens, derived USD) and a task total; no token usage shows an explicit empty state, while unpriced or zero-usage rows use `—` instead of a guessed `$0`.
|
||||
<!-- FNXC:TaskDetailCost 2026-07-11-12:25: Cost is read-time derived from task token usage and model pricing overrides, so the always-available Cost tab documents spend without adding persistence or hiding unavailable pricing behind a false zero. -->
|
||||
- Task-detail Activity steering comments 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.
|
||||
- Execution mode has a read-mode inline lightning-bolt toggle for Fast mode on/off without opening the full edit form.
|
||||
|
||||
@@ -508,6 +508,7 @@ When `pushAfterMerge` is enabled, a completed direct merge first runs `git pull
|
||||
| `showWorktreeGrouping` | `boolean` | `false` | Default: off. When off, WIP/processing columns render plain task cards without worktree group shells or worktree-name labels in both legacy and workflow-mode boards. When on, every WIP/processing column groups tasks by worktree and shows worktree names, including workflow-mode columns flagged as counting toward WIP. |
|
||||
| `openTasksInRightSidebar` | `boolean` | `false` | Default: off. When off, board task-card clicks keep the existing full-panel task detail that replaces the board. When on and the right dock is active on desktop/tablet, board task-card clicks open the task detail in the right sidebar so the board stays visible; mobile or hidden/inactive right-dock states automatically fall back to the full-panel behavior. Non-board task-open paths, including list split detail, floating pop-outs, graph/plugin opens, and deep `changes`/`retries`/`workflow` opens, keep their existing behavior; ordinary right-dock Tasks-list cards are governed by `openMobileTasksInPopup` first and otherwise use embedded dock detail. |
|
||||
| `openMobileTasksInPopup` | `boolean` | `false` | Default: off. When off, ordinary board task-card clicks keep the existing fallback behavior: the full-panel task detail, or the right dock when `openTasksInRightSidebar` is on and the dock is active; ordinary right-dock Tasks-list clicks open embedded dock detail with the normal back-to-list controls. When on, ordinary board task-card clicks and right-dock Tasks-list clicks open the task in the existing task popup/FloatingWindow surface on desktop, tablet, and mobile so the board or dock list remains visible; this popup route takes precedence over right-dock routing for those ordinary clicks. Desktop/tablet task popups restore the last saved popup size and position across task IDs and use the board/task-detail layer rather than the global utility layer, while their Activity dropdown stays above and attached during popup drag/resize; mobile task popups remain full-screen sheets. Deep `changes`/`retries`/`workflow` opens, list/detail opens outside the right-dock Tasks list, graph/plugin opens, nested task-detail opens, and explicit pop-out actions keep their existing behavior. |
|
||||
| `showCostBadgeOnCards` | `boolean` | `false` | Default: off. When enabled from Settings → Appearance, board cards with positive recorded token usage show a read-time derived model-cost badge beside the execution-time badge. Unpriced models display `—`, and tasks with no token usage render no badge shell. |
|
||||
| `executorAllowSiblingBranchRename` | `boolean` | `false` | Opt back into the legacy executor behavior that silently allocates sibling branches (`fusion/<task-id>-2`, `-2-2`, …) when the canonical task branch is already checked out elsewhere. When disabled (default), branch conflicts fail loudly and leave the task in `todo` with `status: "failed"` so operators can resolve conflicting branches/worktrees with git tooling before retrying. See [Task Management → Branch conflict handling](./task-management.md#branch-conflict-handling). The dashboard Settings modal exposes the same toggle with warning copy because this legacy mode is discouraged. |
|
||||
| `worktreeNaming` | `"random" \| "task-id" \| "task-title"` | `"random"` | Naming mode for new worktree directories. |
|
||||
|
||||
|
||||
@@ -177,6 +177,19 @@ describe("settings defaults invariants", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("showCostBadgeOnCards default", () => {
|
||||
it("keeps showCostBadgeOnCards explicitly false in project defaults", () => {
|
||||
expect(DEFAULT_PROJECT_SETTINGS.showCostBadgeOnCards).toBe(false);
|
||||
expect("showCostBadgeOnCards" in DEFAULT_PROJECT_SETTINGS).toBe(true);
|
||||
expect(PROJECT_SETTINGS_KEYS).toContain("showCostBadgeOnCards");
|
||||
});
|
||||
|
||||
it("keeps showCostBadgeOnCards project-scoped only", () => {
|
||||
expect("showCostBadgeOnCards" in DEFAULT_GLOBAL_SETTINGS).toBe(false);
|
||||
expect(GLOBAL_SETTINGS_KEYS).not.toContain("showCostBadgeOnCards");
|
||||
});
|
||||
});
|
||||
|
||||
describe("taskDetailChatFirst default", () => {
|
||||
it("keeps taskDetailChatFirst explicitly false in project defaults", () => {
|
||||
expect(DEFAULT_PROJECT_SETTINGS.taskDetailChatFirst).toBe(false);
|
||||
|
||||
@@ -392,6 +392,11 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
*/
|
||||
openMobileTasksInPopup: false,
|
||||
/*
|
||||
FNXC:TaskCardCostBadge 2026-07-11-12:15:
|
||||
Default off preserves existing board-card density. When true, the dashboard may render a read-time derived cost badge only for tasks with positive token usage; unavailable pricing remains the guess-free “—” sentinel.
|
||||
*/
|
||||
showCostBadgeOnCards: false,
|
||||
/*
|
||||
FNXC:TaskDetailActivityFirst 2026-06-30-23:59:
|
||||
Project task-detail defaults are Activity-first unless this opt-in is true. Keeping the default false preserves explicit deep-link ids while making omitted non-done task opens land on Activity → Live.
|
||||
*/
|
||||
|
||||
@@ -4019,6 +4019,11 @@ export interface ProjectSettings {
|
||||
* This project-scoped setting is default-off so board navigation is unchanged until operators opt in. When enabled, it applies to board-card clicks on every viewport with no deep initial tab and reuses the existing task pop-out/FloatingWindow path; the popup route takes precedence over right-dock routing for those ordinary clicks while all non-board task-open paths remain governed by their existing settings and handlers.
|
||||
*/
|
||||
openMobileTasksInPopup?: boolean;
|
||||
/**
|
||||
* FNXC:TaskCardCostBadge 2026-07-11-12:15:
|
||||
* Default-off project setting that lets operators opt board cards into showing derived read-time task cost next to the execution-time badge. Missing/false preserves existing card density and no badge shell renders unless a task has positive token usage.
|
||||
*/
|
||||
showCostBadgeOnCards?: boolean;
|
||||
/**
|
||||
* FNXC:TaskDetailActivityFirst 2026-06-30-23:59:
|
||||
* Default-off keeps task details Activity-first so omitted non-done opens land on the legacy `chat` Activity → Live surface. Operators can set true to restore Chat-first ordering/default while explicit Activity/Chat/Logs deep links remain stable.
|
||||
|
||||
@@ -65,6 +65,7 @@ import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
||||
import { FileBrowserProvider } from "./context/FileBrowserContext";
|
||||
import { ShellProvider } from "./context/ShellContext";
|
||||
import { RetryWarningProvider } from "./context/RetryWarningContext";
|
||||
import { CostBadgeProvider } from "./context/CostBadgeContext";
|
||||
import { ShellHostProvider, useShellHostContext } from "./context/ShellHostContext";
|
||||
import { useShellConnection } from "./hooks/useShellConnection";
|
||||
import { useStashOrphanCount } from "./hooks/useStashOrphanCount";
|
||||
@@ -619,6 +620,8 @@ function AppInner() {
|
||||
capacityRiskTodoThreshold,
|
||||
openTasksInRightSidebar,
|
||||
openMobileTasksInPopup,
|
||||
showCostBadgeOnCards,
|
||||
modelPricingOverrides,
|
||||
taskDetailChatFirst,
|
||||
quickChatButtonMode,
|
||||
quickChatCloseOnOutsideClick,
|
||||
@@ -1506,6 +1509,7 @@ function AppInner() {
|
||||
<NavigationHistoryProvider value={{ pushNav, replaceCurrent, removeNav }}>
|
||||
<FileBrowserProvider openFile={openFileInBrowser}>
|
||||
<RetryWarningProvider value={maxTotalRetriesBeforeFail * RETRY_WARNING_RATIO}>
|
||||
<CostBadgeProvider value={{ enabled: showCostBadgeOnCards, pricingOverrides: modelPricingOverrides }}>
|
||||
{isFirstEverBoot ? (
|
||||
<>
|
||||
<DashboardLoader stage={loadingStage} />
|
||||
@@ -1856,6 +1860,7 @@ function AppInner() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CostBadgeProvider>
|
||||
</RetryWarningProvider>
|
||||
</FileBrowserProvider>
|
||||
</NavigationHistoryProvider>
|
||||
|
||||
@@ -950,6 +950,7 @@ export function SettingsModal({
|
||||
showWorktreeGrouping: false,
|
||||
openTasksInRightSidebar: false,
|
||||
openMobileTasksInPopup: false,
|
||||
showCostBadgeOnCards: false,
|
||||
taskDetailChatFirst: false,
|
||||
executorAllowSiblingBranchRename: false,
|
||||
worktreeNaming: "random",
|
||||
@@ -1306,6 +1307,11 @@ export function SettingsModal({
|
||||
ignoreHiddenOverlapPaths: s.ignoreHiddenOverlapPaths ?? true,
|
||||
allowAbsoluteFileBrowserPaths: s.allowAbsoluteFileBrowserPaths === true,
|
||||
/*
|
||||
FNXC:TaskCardCostBadge 2026-07-11-12:15:
|
||||
The Settings form normalizes missing showCostBadgeOnCards to false so upgraded projects retain no card spend badge until an operator explicitly opts in.
|
||||
*/
|
||||
showCostBadgeOnCards: s.showCostBadgeOnCards === true,
|
||||
/*
|
||||
FNXC:TaskDetailActivityFirst 2026-06-30-23:59:
|
||||
The Settings form normalizes missing taskDetailChatFirst to false so new and upgraded projects show the Activity-first default until an operator explicitly opts into Chat-first.
|
||||
*/
|
||||
|
||||
@@ -937,6 +937,7 @@ FNXC:TaskCardTimingBadge 2026-06-13-17:26:
|
||||
The execution-time badge is part of the footer's bottom-right chip cluster, so it inherits the same height, padding, font, and responsive sizing as retry and GitHub tracking badges instead of using the old meta-row placement.
|
||||
*/
|
||||
.card-time-indicator,
|
||||
.card-cost-indicator,
|
||||
.card-github-tracking-chip,
|
||||
.card-retry-badge,
|
||||
.card-create-pr-action {
|
||||
@@ -957,6 +958,7 @@ The execution-time badge is part of the footer's bottom-right chip cluster, so i
|
||||
}
|
||||
|
||||
.card-time-indicator > span,
|
||||
.card-cost-indicator > span,
|
||||
.card-github-tracking-chip > span,
|
||||
.card-retry-badge > span,
|
||||
.card-create-pr-action > span {
|
||||
@@ -967,6 +969,7 @@ The execution-time badge is part of the footer's bottom-right chip cluster, so i
|
||||
}
|
||||
|
||||
.card-time-indicator svg,
|
||||
.card-cost-indicator svg,
|
||||
.card-github-tracking-chip svg,
|
||||
.card-retry-badge svg,
|
||||
.card-create-pr-action svg,
|
||||
@@ -1042,7 +1045,8 @@ The execution-time badge is part of the footer's bottom-right chip cluster, so i
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.card-time-indicator {
|
||||
.card-time-indicator,
|
||||
.card-cost-indicator {
|
||||
border-color: color-mix(in srgb, var(--text-muted) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
|
||||
color: var(--text-muted);
|
||||
@@ -1810,6 +1814,7 @@ too so future divergence doesn't have to rediscover the shared selector group.
|
||||
}
|
||||
|
||||
.card-time-indicator,
|
||||
.card-cost-indicator,
|
||||
.card-github-tracking-chip,
|
||||
.card-retry-badge,
|
||||
.card-create-pr-action {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { memo, useCallback, useState, useRef, useEffect, useLayoutEffect, useMemo, type CSSProperties, type ReactElement } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle, ArrowUpRight, Eye, MoreHorizontal } from "lucide-react";
|
||||
import { Link, Clock, DollarSign, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle, ArrowUpRight, Eye, MoreHorizontal } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction, MergeResult, PlannerOversightLevel } from "@fusion/core";
|
||||
import {
|
||||
DEFAULT_PLANNER_OVERSIGHT_LEVEL,
|
||||
@@ -48,10 +48,12 @@ import { useConfirm } from "../hooks/useConfirm";
|
||||
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
|
||||
import { MAX_AUTO_MERGE_RETRIES, type BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
||||
import { useRetryWarning } from "../context/RetryWarningContext";
|
||||
import { useCostBadge } from "../context/CostBadgeContext";
|
||||
import { useColumnLabel } from "../i18n/labels";
|
||||
import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary";
|
||||
import { WorkflowIcon } from "./WorkflowIcon";
|
||||
import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel, type TaskContextMenuColumnFlags, type TaskContextMenuColumnMetadata, type TaskMenuActionDescriptor } from "./TaskContextMenu";
|
||||
import { formatCost, hasTaskCost, taskTotalCost } from "../utils/taskTokenCost";
|
||||
|
||||
/** Per-branch progress snapshot (U13). Surfaced as an optional additive field
|
||||
* on the task payload for the parallel-window badge (U9). */
|
||||
@@ -928,6 +930,15 @@ function TaskCardComponent({
|
||||
const { agentsMap } = useAgentsMapCache(projectId);
|
||||
const { confirm } = useConfirm();
|
||||
const retryWarningThreshold = useRetryWarning();
|
||||
const costBadge = useCostBadge();
|
||||
/*
|
||||
FNXC:TaskCardCostBadge 2026-07-11-12:20:
|
||||
The optional spend chip sits beside execution time but stays fully absent unless the default-off setting is enabled and the task has positive token usage. Use the shared read-time taskTokenCost helper so unpriced models show the guess-free “—” sentinel instead of a fabricated $0.
|
||||
*/
|
||||
const cardCost = costBadge.enabled && hasTaskCost(task as TaskDetail)
|
||||
? taskTotalCost(task as TaskDetail, costBadge.pricingOverrides)
|
||||
: null;
|
||||
const cardCostLabel = cardCost ? formatCost(cardCost.usd, cardCost.unavailable) : null;
|
||||
|
||||
// Touch gesture detection refs
|
||||
const touchStartPosRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||
@@ -3346,7 +3357,7 @@ function TaskCardComponent({
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{(filesChangedButton || isGitHubImportedTask || timeIndicator || showNearDuplicateChip || showUndoOfChip || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0) && (
|
||||
{(filesChangedButton || isGitHubImportedTask || cardCostLabel || timeIndicator || showNearDuplicateChip || showUndoOfChip || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0) && (
|
||||
<div className={`card-footer-row${chipFarRight ? " card-footer-row--chip-far-right" : ""}`}>
|
||||
{filesChangedButton}
|
||||
{isGitHubImportedTask && !showLinkedIssueChipForImport && (
|
||||
@@ -3358,7 +3369,7 @@ function TaskCardComponent({
|
||||
<ProviderIcon provider="github" size="sm" />
|
||||
</span>
|
||||
)}
|
||||
{(timeIndicator || showNearDuplicateChip || showUndoOfChip || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0) && (
|
||||
{(cardCostLabel || timeIndicator || showNearDuplicateChip || showUndoOfChip || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0) && (
|
||||
<div className="card-footer-row-right">
|
||||
{showUndoOfChip && (
|
||||
<span
|
||||
@@ -3444,6 +3455,16 @@ function TaskCardComponent({
|
||||
FNXC:TaskCardTimingBadge 2026-06-13-17:20:
|
||||
The execution-time badge belongs in the bottom-right footer cluster and must match sibling footer badge sizing while preserving its existing label, title, aria text, and live-update data.
|
||||
*/}
|
||||
{cardCostLabel && (
|
||||
<span
|
||||
className="card-cost-indicator"
|
||||
title={t("tasks.costBadgeTitle", "Estimated cost {{amount}}", { amount: cardCostLabel })}
|
||||
aria-label={t("tasks.costBadgeAriaLabel", "Estimated cost {{amount}}", { amount: cardCostLabel })}
|
||||
>
|
||||
<DollarSign size={12} />
|
||||
<span>{cardCostLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
{timeIndicator && (
|
||||
<span
|
||||
className="card-time-indicator"
|
||||
|
||||
51
packages/dashboard/app/components/TaskCostTab.css
Normal file
51
packages/dashboard/app/components/TaskCostTab.css
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
FNXC:TaskDetailCost 2026-07-11-12:05:
|
||||
The always-available Cost tab reuses the Summary token-table visual system so desktop, right-dock, and mobile task-detail surfaces inherit the same responsive stacked-table behavior without a second cost-table design.
|
||||
*/
|
||||
.task-cost-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.task-cost-heading-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.task-cost-total {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.task-cost-table {
|
||||
min-width: calc(var(--space-2xl) * 18);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.detail-section--cost {
|
||||
padding-inline: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-cost-tab {
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.task-cost-heading-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-cost-total {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
91
packages/dashboard/app/components/TaskCostTab.tsx
Normal file
91
packages/dashboard/app/components/TaskCostTab.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
@@ -44,6 +44,7 @@ import { TaskReviewTab } from "./TaskReviewTab";
|
||||
import { MergeDetails } from "./MergeDetails";
|
||||
import { TaskChangesTab } from "./TaskChangesTab";
|
||||
import { TaskSummaryTab } from "./TaskSummaryTab";
|
||||
import { TaskCostTab } from "./TaskCostTab";
|
||||
import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary";
|
||||
import { TaskForm, type PendingImage } from "./TaskForm";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
@@ -226,7 +227,7 @@ function formatDurationCompact(ageMs: number): string {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
type TabId = "summary" | "definition" | "chat" | "planner-chat" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | "terminal" | "worktree-terminal" | `plugin-${string}`;
|
||||
type TabId = "summary" | "cost" | "definition" | "chat" | "planner-chat" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | "terminal" | "worktree-terminal" | `plugin-${string}`;
|
||||
type ActivitySegment = "current" | "feed" | "raw-logs" | "interventions";
|
||||
|
||||
/*
|
||||
@@ -4383,6 +4384,13 @@ export function TaskDetailContent({
|
||||
{t("taskDetail.tabs.summary", "Summary")}
|
||||
</button>
|
||||
)}
|
||||
{/* FNXC:TaskDetailCost 2026-07-11-12:10: The Cost tab is always reachable (unlike done-only Summary) because operators need read-time model spend visibility while work is still in progress; it reuses costFor via the shared taskTokenCost helper and never persists derived USD. */}
|
||||
<button
|
||||
className={`detail-tab${activeTab === "cost" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("cost")}
|
||||
>
|
||||
{t("taskDetail.tabs.cost", "Cost")}
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "definition" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("definition")}
|
||||
@@ -4519,6 +4527,10 @@ export function TaskDetailContent({
|
||||
<div className="detail-section detail-section--summary">
|
||||
<TaskSummaryTab task={workingTask} pricingOverrides={globalSettings?.modelPricingOverrides} />
|
||||
</div>
|
||||
) : activeTab === "cost" ? (
|
||||
<div className="detail-section detail-section--cost">
|
||||
<TaskCostTab task={workingTask} pricingOverrides={globalSettings?.modelPricingOverrides} />
|
||||
</div>
|
||||
) : activeTab === "planner-chat" ? (
|
||||
<div className="detail-section detail-section--planner-chat">
|
||||
<TaskPlannerChatTab
|
||||
|
||||
@@ -3,12 +3,13 @@ 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 type { TaskDetail, TaskStep, WorkflowStepResult } from "@fusion/core";
|
||||
import 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";
|
||||
import { buildTokenCostRows, formatCost, formatCount, totalCostForRows } from "../utils/taskTokenCost";
|
||||
|
||||
const EMPTY_MARKDOWN_CHILD_SEPARATOR = "";
|
||||
const STRING_OBJECT_TAG = "[object String]";
|
||||
@@ -41,124 +42,6 @@ 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<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.
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react";
|
||||
import { TaskCard, formatElapsedDurationDone, __test_areTaskCardPropsEqual } from "../TaskCard";
|
||||
import { CostBadgeProvider } from "../../context/CostBadgeContext";
|
||||
|
||||
// Pre-existing gap (unrelated to FN-7676): TaskCard unconditionally renders
|
||||
// RuntimeFallbackBadge, which calls the shared useToast() hook directly (not
|
||||
@@ -27,6 +28,7 @@ vi.mock("lucide-react", () => ({
|
||||
GitBranch: () => null,
|
||||
Gitlab: () => null,
|
||||
Clock: () => null,
|
||||
DollarSign: () => null,
|
||||
Pencil: () => null,
|
||||
Layers: () => null,
|
||||
ChevronDown: () => null,
|
||||
@@ -3872,10 +3874,10 @@ describe("TaskCard", () => {
|
||||
it("keeps GitHub tracking chip interaction-affordance CSS contract", () => {
|
||||
const css = loadAllAppCssBaseOnly();
|
||||
|
||||
expect(css).toMatch(/\.card-time-indicator\s*,\s*\.card-github-tracking-chip\s*,\s*\.card-retry-badge\s*,\s*\.card-create-pr-action\s*\{[^}]*display:\s*inline-flex;[^}]*font-family:\s*var\(--font-mono\);[^}]*\}/);
|
||||
expect(css).toMatch(/\.card-time-indicator\s*,\s*\.card-cost-indicator\s*,\s*\.card-github-tracking-chip\s*,\s*\.card-retry-badge\s*,\s*\.card-create-pr-action\s*\{[^}]*display:\s*inline-flex;[^}]*font-family:\s*var\(--font-mono\);[^}]*\}/);
|
||||
expect(css).toContain(".card-github-tracking-chip:hover");
|
||||
expect(css).toMatch(/\.card-github-tracking-chip:focus-visible\s*\{[^}]*--focus-ring-strong/);
|
||||
expect(css).toMatch(/\.card-time-indicator\s*,\s*\.card-github-tracking-chip\s*,\s*\.card-retry-badge\s*,\s*\.card-create-pr-action\s*\{[^}]*padding:\s*var\(--space-xs\)\s+var\(--space-sm\);[^}]*height:\s*var\(--card-chip-height\);[^}]*border-radius:\s*var\(--radius-pill\);[^}]*font-size:\s*0\.6875rem;[^}]*line-height:\s*1;[^}]*\}/);
|
||||
expect(css).toMatch(/\.card-time-indicator\s*,\s*\.card-cost-indicator\s*,\s*\.card-github-tracking-chip\s*,\s*\.card-retry-badge\s*,\s*\.card-create-pr-action\s*\{[^}]*padding:\s*var\(--space-xs\)\s+var\(--space-sm\);[^}]*height:\s*var\(--card-chip-height\);[^}]*border-radius:\s*var\(--radius-pill\);[^}]*font-size:\s*0\.6875rem;[^}]*line-height:\s*1;[^}]*\}/);
|
||||
expect(css).toMatch(/\.card-github-tracking-chip\s+\.provider-icon\s+svg\s*\{[^}]*width:\s*12px;[^}]*height:\s*12px;[^}]*\}/);
|
||||
|
||||
render(
|
||||
@@ -4702,14 +4704,14 @@ describe("TaskCard", () => {
|
||||
|
||||
expect(baseCss).toMatch(/:root\s*\{[^}]*--card-chip-height:\s*22px;[^}]*--card-chip-height-mobile:\s*20px;[^}]*\}/);
|
||||
expect(baseCss).toMatch(/\.card-github-badge\s*\{[^}]*height:\s*var\(--card-chip-height\);[^}]*\}/);
|
||||
expect(baseCss).toMatch(/\.card-time-indicator\s*,\s*\.card-github-tracking-chip\s*,\s*\.card-retry-badge\s*,\s*\.card-create-pr-action\s*\{[^}]*height:\s*var\(--card-chip-height\);[^}]*\}/);
|
||||
expect(baseCss).toMatch(/\.card-time-indicator\s*,\s*\.card-cost-indicator\s*,\s*\.card-github-tracking-chip\s*,\s*\.card-retry-badge\s*,\s*\.card-create-pr-action\s*\{[^}]*height:\s*var\(--card-chip-height\);[^}]*\}/);
|
||||
});
|
||||
|
||||
it("FN-4525 applies shared mobile card-chip height token to badges and chips", () => {
|
||||
const fullCss = loadAllAppCss();
|
||||
|
||||
expect(fullCss).toMatch(/@media[^{]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-github-badge\s*\{[^}]*height:\s*var\(--card-chip-height-mobile\);[^}]*\}/);
|
||||
expect(fullCss).toMatch(/@media[^{]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-time-indicator\s*,\s*\.card-github-tracking-chip\s*,\s*\.card-retry-badge\s*,\s*\.card-create-pr-action\s*\{[^}]*height:\s*var\(--card-chip-height-mobile\);[^}]*\}/);
|
||||
expect(fullCss).toMatch(/@media[^{]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-time-indicator\s*,\s*\.card-cost-indicator\s*,\s*\.card-github-tracking-chip\s*,\s*\.card-retry-badge\s*,\s*\.card-create-pr-action\s*\{[^}]*height:\s*var\(--card-chip-height-mobile\);[^}]*\}/);
|
||||
});
|
||||
|
||||
it("keeps Create PR action on shared chip height tokens", () => {
|
||||
@@ -5196,6 +5198,77 @@ describe("TaskCard", () => {
|
||||
expect(timer?.textContent).toContain("30m");
|
||||
});
|
||||
|
||||
it("renders the optional cost badge only when enabled and token usage exists", () => {
|
||||
const pricedTask = makeTask({
|
||||
column: "done",
|
||||
tokenUsage: {
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 1_000_000,
|
||||
firstUsedAt: "2026-01-01T00:00:00Z",
|
||||
lastUsedAt: "2026-01-01T00:00:00Z",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-5-mini",
|
||||
},
|
||||
} as Partial<Task>);
|
||||
|
||||
const disabled = render(<TaskCard task={pricedTask} onOpenDetail={noop} addToast={noop} />);
|
||||
expect(disabled.container.querySelector(".card-cost-indicator")).toBeNull();
|
||||
disabled.unmount();
|
||||
|
||||
const enabled = render(
|
||||
<CostBadgeProvider value={{ enabled: true }}>
|
||||
<TaskCard task={pricedTask} onOpenDetail={noop} addToast={noop} />
|
||||
</CostBadgeProvider>,
|
||||
);
|
||||
|
||||
const costBadge = enabled.container.querySelector(".card-cost-indicator") as HTMLElement | null;
|
||||
expect(costBadge).not.toBeNull();
|
||||
expect(costBadge?.textContent).toContain("$0.25");
|
||||
expect(costBadge?.getAttribute("aria-label")).toBe("Estimated cost $0.25");
|
||||
expect(costBadge?.closest(".card-footer-row-right")).toBe(enabled.container.querySelector(".card-footer-row-right"));
|
||||
enabled.unmount();
|
||||
|
||||
const noUsage = render(
|
||||
<CostBadgeProvider value={{ enabled: true }}>
|
||||
<TaskCard task={makeTask({ id: "FN-002", column: "done" })} onOpenDetail={noop} addToast={noop} />
|
||||
</CostBadgeProvider>,
|
||||
);
|
||||
expect(noUsage.container.querySelector(".card-cost-indicator")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the cost badge unavailable sentinel for unpriceable usage", () => {
|
||||
const { container } = render(
|
||||
<CostBadgeProvider value={{ enabled: true }}>
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
column: "done",
|
||||
tokenUsage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 1,
|
||||
firstUsedAt: "2026-01-01T00:00:00Z",
|
||||
lastUsedAt: "2026-01-01T00:00:00Z",
|
||||
modelProvider: "unknown",
|
||||
modelId: "no-price",
|
||||
},
|
||||
} as Partial<Task>)}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>
|
||||
</CostBadgeProvider>,
|
||||
);
|
||||
|
||||
const costBadge = container.querySelector(".card-cost-indicator") as HTMLElement | null;
|
||||
expect(costBadge).not.toBeNull();
|
||||
expect(costBadge?.textContent).toContain("—");
|
||||
expect(costBadge?.getAttribute("title")).toBe("Estimated cost —");
|
||||
});
|
||||
|
||||
it.each(["merging", "merging-fix"] as const)("shows live merge elapsed in timer chip while task.status is %s", (status) => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-25T13:45:00.000Z"));
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import type { TaskDetail, TaskTokenUsage } from "@fusion/core";
|
||||
import { TaskCostTab } from "../TaskCostTab";
|
||||
|
||||
function usage(overrides: Partial<TaskTokenUsage> = {}): 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 task(tokenUsage?: TaskTokenUsage): TaskDetail {
|
||||
return { id: "FN-7820", title: "Cost", column: "in-progress", steps: [], dependencies: [], tokenUsage } as TaskDetail;
|
||||
}
|
||||
|
||||
describe("TaskCostTab", () => {
|
||||
it("renders per-model cost breakdown and task total", () => {
|
||||
render(<TaskCostTab task={task(usage({
|
||||
perModel: [
|
||||
{ modelProvider: "openai", modelId: "gpt-5-mini", inputTokens: 1_000_000, outputTokens: 1_000_000, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 2_000_000 },
|
||||
],
|
||||
}))} />);
|
||||
|
||||
expect(screen.getByText("Model cost")).toBeInTheDocument();
|
||||
const row = screen.getByTestId("task-cost-row");
|
||||
expect(within(row).getByText("gpt-5-mini")).toBeInTheDocument();
|
||||
expect(within(row).getAllByText("1,000,000")).toHaveLength(2);
|
||||
expect(within(row).getByText("2,000,000")).toBeInTheDocument();
|
||||
expect(within(row).getByText("$2.25")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("task-cost-total")).toHaveTextContent("Total: $2.25");
|
||||
});
|
||||
|
||||
it("renders the unavailable sentinel for unpriceable models and total", () => {
|
||||
render(<TaskCostTab task={task(usage({ modelProvider: "unknown", modelId: "no-price", inputTokens: 1, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1 }))} />);
|
||||
|
||||
const row = screen.getByTestId("task-cost-row");
|
||||
expect(within(row).getByText("—")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("task-cost-total")).toHaveTextContent("Total: —");
|
||||
});
|
||||
|
||||
it("renders an explicit empty state when token usage is missing", () => {
|
||||
render(<TaskCostTab task={task()} />);
|
||||
|
||||
expect(screen.getByTestId("task-cost-tab")).toBeInTheDocument();
|
||||
expect(screen.getByText("No model token usage has been recorded for this task yet.")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("task-cost-row")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -856,20 +856,21 @@ describe("TaskDetailModal", () => {
|
||||
);
|
||||
|
||||
// For an in-progress task (no workflow steps, no merge commit), the
|
||||
// top-level tabs are: Activity, Chat, Plan, Changes, Review, Comments,
|
||||
// top-level tabs are: Activity, Chat, Cost, Plan, Changes, Review, Comments,
|
||||
// Artifacts, Model, Workflow, Stats, Routing.
|
||||
const tabTexts = ["Activity", "Chat", "Plan", "Changes", "Review", "Comments", "Artifacts", "Model", "Workflow", "Stats", "Routing"];
|
||||
const tabTexts = ["Activity", "Chat", "Cost", "Plan", "Changes", "Review", "Comments", "Artifacts", "Model", "Workflow", "Stats", "Routing"];
|
||||
const tabs = screen.getAllByRole("button").filter((b) =>
|
||||
tabTexts.includes(b.textContent || "")
|
||||
);
|
||||
expect(tabs.map((tab) => tab.textContent)).toEqual(tabTexts);
|
||||
expect(tabs[0].textContent).toBe("Activity");
|
||||
expect(tabs[1].textContent).toBe("Chat");
|
||||
expect(tabs[2].textContent).toBe("Plan");
|
||||
expect(tabs[3].textContent).toBe("Changes");
|
||||
expect(tabs[2].textContent).toBe("Cost");
|
||||
expect(tabs[3].textContent).toBe("Plan");
|
||||
expect(tabs[4].textContent).toBe("Changes");
|
||||
expect(screen.queryByRole("button", { name: "Logs" })).toBeNull();
|
||||
|
||||
expect(container.querySelectorAll(".detail-tab").length).toBe(11);
|
||||
expect(container.querySelectorAll(".detail-tab").length).toBe(12);
|
||||
// Workflow tab should always appear even when no workflow steps are configured
|
||||
expect(screen.getByText("Workflow")).toBeInTheDocument();
|
||||
// Commits tab should NOT appear for non-done tasks
|
||||
|
||||
@@ -52,6 +52,14 @@ export function AppearanceSection({ scopeBanner, form, setForm, themeMode, color
|
||||
</label>
|
||||
<small className="form-text text-muted">{t("settings.appearance.openMobileTasksInPopupHelp", "When enabled, ordinary board task-card and right-dock Tasks-list clicks open the existing task popup so the board or list remains visible. Deep-tab and other task opens keep their current behavior. Default: disabled.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
{/* FNXC:TaskCardCostBadge 2026-07-11-12:15: This project setting is opt-in because board cards are already dense; when enabled, only tasks with recorded positive token usage render a read-time derived spend badge. */}
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={form.showCostBadgeOnCards === true} onChange={(e) => setForm((f) => ({ ...f, showCostBadgeOnCards: e.target.checked }))}/>
|
||||
<span>{t("settings.appearance.showCostBadgeOnCards", "Show cost badges on task cards")}</span>
|
||||
</label>
|
||||
<small className="form-text text-muted">{t("settings.appearance.showCostBadgeOnCardsHelp", "Default: disabled. When enabled, board cards show derived model cost next to execution time; unavailable pricing displays — and tasks without token usage show no badge.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
{/* FNXC:TaskDetailActivityFirst 2026-06-30-23:59: The project setting is opt-in because task details now default to Activity-first; explicit Activity/Chat/Logs links keep their destination regardless of this checkbox. */}
|
||||
<label className="checkbox-label">
|
||||
|
||||
@@ -21,6 +21,7 @@ function renderAppearanceSection(formOverrides: Partial<Settings> = {}) {
|
||||
autoMerge: true,
|
||||
openTasksInRightSidebar: false,
|
||||
openMobileTasksInPopup: false,
|
||||
showCostBadgeOnCards: false,
|
||||
taskDetailChatFirst: false,
|
||||
...formOverrides,
|
||||
} as SettingsFormState;
|
||||
@@ -83,6 +84,25 @@ describe("AppearanceSection", () => {
|
||||
expect(screen.getByLabelText("Open tasks as popups")).toBeChecked();
|
||||
});
|
||||
|
||||
it("renders and updates the cost badge checkbox", () => {
|
||||
const { setForm, getForm } = renderAppearanceSection();
|
||||
|
||||
const checkbox = screen.getByLabelText("Show cost badges on task cards");
|
||||
expect(checkbox).not.toBeChecked();
|
||||
expect(screen.getByText(/board cards show derived model cost next to execution time/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(setForm).toHaveBeenCalledTimes(1);
|
||||
expect(getForm().showCostBadgeOnCards).toBe(true);
|
||||
});
|
||||
|
||||
it("reflects a persisted enabled cost badge value", () => {
|
||||
renderAppearanceSection({ showCostBadgeOnCards: true });
|
||||
|
||||
expect(screen.getByLabelText("Show cost badges on task cards")).toBeChecked();
|
||||
});
|
||||
|
||||
it("renders task detail Chat-first as unchecked by default and updates it", () => {
|
||||
const { setForm, getForm } = renderAppearanceSection();
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
|
||||
// AppearanceSection
|
||||
openTasksInRightSidebar: "appearance.openTasksInRightSidebarHelp",
|
||||
openMobileTasksInPopup: "appearance.openMobileTasksInPopupHelp",
|
||||
showCostBadgeOnCards: "appearance.showCostBadgeOnCardsHelp",
|
||||
taskDetailChatFirst: "appearance.taskDetailChatFirstHelp",
|
||||
// AgentPermissionsSection
|
||||
defaultAgentPermissionPolicy: "agentPermissions.perAgentSettingsOverrideProjectDefaultsEachCategory",
|
||||
|
||||
19
packages/dashboard/app/context/CostBadgeContext.tsx
Normal file
19
packages/dashboard/app/context/CostBadgeContext.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { ModelPricingOverrides } from "../../../core/src/model-pricing";
|
||||
|
||||
export interface CostBadgeContextValue {
|
||||
enabled: boolean;
|
||||
pricingOverrides?: ModelPricingOverrides;
|
||||
}
|
||||
|
||||
const CostBadgeContext = createContext<CostBadgeContextValue>({ enabled: false });
|
||||
|
||||
export function CostBadgeProvider(
|
||||
{ value, children }: { value: CostBadgeContextValue; children: ReactNode },
|
||||
) {
|
||||
return <CostBadgeContext.Provider value={value}>{children}</CostBadgeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCostBadge(): CostBadgeContextValue {
|
||||
return useContext(CostBadgeContext);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchConfig, fetchSettings, updateSettings, updateGlobalSettings } from "../api";
|
||||
import type { GlobalSettings, ProjectSettings } from "@fusion/core";
|
||||
import type { ModelPricingOverrides } from "../../../core/src/model-pricing";
|
||||
import { setAutoReloadEnabled } from "../versionCheck";
|
||||
import { DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS, resolveDashboardKeyboardShortcuts, type DashboardKeyboardShortcutMap } from "../utils/keyboardShortcuts";
|
||||
|
||||
@@ -28,6 +29,8 @@ export interface UseAppSettingsResult {
|
||||
capacityRiskTodoThreshold: number;
|
||||
openTasksInRightSidebar: boolean;
|
||||
openMobileTasksInPopup: boolean;
|
||||
showCostBadgeOnCards: boolean;
|
||||
modelPricingOverrides?: ModelPricingOverrides;
|
||||
taskDetailChatFirst: boolean;
|
||||
quickChatButtonMode: QuickChatButtonMode;
|
||||
quickChatCloseOnOutsideClick: boolean;
|
||||
@@ -79,6 +82,8 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
const [capacityRiskTodoThreshold, setCapacityRiskTodoThreshold] = useState(20);
|
||||
const [openTasksInRightSidebar, setOpenTasksInRightSidebar] = useState(false);
|
||||
const [openMobileTasksInPopup, setOpenMobileTasksInPopup] = useState(false);
|
||||
const [showCostBadgeOnCards, setShowCostBadgeOnCards] = useState(false);
|
||||
const [modelPricingOverrides, setModelPricingOverrides] = useState<ModelPricingOverrides | undefined>(undefined);
|
||||
const [taskDetailChatFirst, setTaskDetailChatFirst] = useState(false);
|
||||
const [quickChatButtonMode, setQuickChatButtonMode] = useState<QuickChatButtonMode>("off");
|
||||
const [quickChatCloseOnOutsideClick, setQuickChatCloseOnOutsideClick] = useState(true);
|
||||
@@ -158,6 +163,12 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
setOpenTasksInRightSidebar(settings.openTasksInRightSidebar === true);
|
||||
setOpenMobileTasksInPopup(settings.openMobileTasksInPopup === true);
|
||||
/*
|
||||
FNXC:TaskCardCostBadge 2026-07-11-12:15:
|
||||
The app shell exposes the default-off card cost badge setting to the board context only after settings hydration, preserving the no-badge default for upgraded projects.
|
||||
*/
|
||||
setShowCostBadgeOnCards(settings.showCostBadgeOnCards === true);
|
||||
setModelPricingOverrides((settings as GlobalSettings).modelPricingOverrides);
|
||||
/*
|
||||
FNXC:TaskDetailActivityFirst 2026-06-30-23:59:
|
||||
App-level task-detail hosts need the project setting so Activity-first is the missing/false default and Chat-first is restored only by explicit opt-in.
|
||||
*/
|
||||
@@ -190,6 +201,8 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
setDevServerEnabled(false);
|
||||
setOpenTasksInRightSidebar(false);
|
||||
setOpenMobileTasksInPopup(false);
|
||||
setShowCostBadgeOnCards(false);
|
||||
setModelPricingOverrides(undefined);
|
||||
setTaskDetailChatFirst(false);
|
||||
setQuickChatCloseOnOutsideClick(true);
|
||||
setDashboardKeyboardShortcuts(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS);
|
||||
@@ -321,6 +334,8 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
capacityRiskTodoThreshold,
|
||||
openTasksInRightSidebar,
|
||||
openMobileTasksInPopup,
|
||||
showCostBadgeOnCards,
|
||||
modelPricingOverrides,
|
||||
taskDetailChatFirst,
|
||||
quickChatButtonMode,
|
||||
quickChatCloseOnOutsideClick,
|
||||
|
||||
62
packages/dashboard/app/utils/__tests__/taskTokenCost.test.ts
Normal file
62
packages/dashboard/app/utils/__tests__/taskTokenCost.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskDetail, TaskTokenUsage } from "@fusion/core";
|
||||
import { buildTokenCostRows, formatCost, hasTaskCost, taskTotalCost, totalCostForRows } from "../taskTokenCost";
|
||||
|
||||
function usage(overrides: Partial<TaskTokenUsage> = {}): 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 task(tokenUsage?: TaskTokenUsage): TaskDetail {
|
||||
return { id: "FN-7820", title: "Cost", column: "done", steps: [], dependencies: [], tokenUsage } as TaskDetail;
|
||||
}
|
||||
|
||||
describe("taskTokenCost", () => {
|
||||
it("prices and merges per-model buckets", () => {
|
||||
const rows = buildTokenCostRows(task(usage({
|
||||
perModel: [
|
||||
{ modelProvider: "openai", modelId: "gpt-5-mini", inputTokens: 1_000_000, outputTokens: 1_000_000, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 2_000_000 },
|
||||
{ modelProvider: "openai", modelId: "gpt-5-mini", inputTokens: 500_000, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 500_000 },
|
||||
],
|
||||
})), "(unknown)");
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ modelProvider: "openai", modelId: "gpt-5-mini", inputTokens: 1_500_000, outputTokens: 1_000_000, totalTokens: 2_500_000 });
|
||||
expect(formatCost(rows[0].cost.usd, rows[0].cost.unavailable)).toBe("$2.38");
|
||||
expect(formatCost(totalCostForRows(rows).usd, totalCostForRows(rows).unavailable)).toBe("$2.38");
|
||||
});
|
||||
|
||||
it("prices aggregate-only fallback usage", () => {
|
||||
const result = taskTotalCost(task(usage({ modelProvider: "openai", modelId: "gpt-5-mini", inputTokens: 1_000_000, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1_000_000 })));
|
||||
expect(formatCost(result.usd, result.unavailable)).toBe("$0.25");
|
||||
});
|
||||
|
||||
it("marks unpriceable positive-token rows and totals unavailable", () => {
|
||||
const rows = buildTokenCostRows(task(usage({ modelProvider: "unknown", modelId: "no-price", inputTokens: 10, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 10 })), "(unknown)");
|
||||
expect(rows[0].cost.unavailable).toBe(true);
|
||||
expect(formatCost(rows[0].cost.usd, rows[0].cost.unavailable)).toBe("—");
|
||||
expect(formatCost(totalCostForRows(rows).usd, totalCostForRows(rows).unavailable)).toBe("—");
|
||||
});
|
||||
|
||||
it("does not fabricate cost for zero usage", () => {
|
||||
const zeroTask = task(usage({ modelProvider: "unknown", modelId: "no-price" }));
|
||||
const total = taskTotalCost(zeroTask);
|
||||
expect(hasTaskCost(zeroTask)).toBe(false);
|
||||
expect(formatCost(total.usd, total.unavailable)).toBe("—");
|
||||
});
|
||||
|
||||
it("detects positive task cost availability without requiring pricing", () => {
|
||||
expect(hasTaskCost(task())).toBe(false);
|
||||
expect(hasTaskCost(task(usage()))).toBe(false);
|
||||
expect(hasTaskCost(task(usage({ totalTokens: 1 })))).toBe(true);
|
||||
expect(hasTaskCost(task(usage({ perModel: [{ modelProvider: "x", modelId: "y", inputTokens: 1, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1 }] })))).toBe(true);
|
||||
});
|
||||
});
|
||||
139
packages/dashboard/app/utils/taskTokenCost.ts
Normal file
139
packages/dashboard/app/utils/taskTokenCost.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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 };
|
||||
}
|
||||
@@ -5676,7 +5676,9 @@
|
||||
"title": "Appearance",
|
||||
"openTasksInRightSidebarHelp": "When enabled, board task cards open detail in the right sidebar when it is available; mobile and hidden-sidebar states keep the full task panel. Default: disabled.",
|
||||
"openMobileTasksInPopupHelp": "When enabled, ordinary board task-card and right-dock Tasks-list clicks open the existing task popup so the board or list remains visible. Deep-tab and other task opens keep their current behavior. Default: disabled.",
|
||||
"taskDetailChatFirstHelp": "Off by default: task details list Activity first and omitted non-done opens land on Activity. Turn on to restore Chat-first order/default; explicit Chat links still work either way."
|
||||
"taskDetailChatFirstHelp": "Off by default: task details list Activity first and omitted non-done opens land on Activity. Turn on to restore Chat-first order/default; explicit Chat links still work either way.",
|
||||
"showCostBadgeOnCards": "Show cost badges on task cards",
|
||||
"showCostBadgeOnCardsHelp": "Default: disabled. When enabled, board cards show derived model cost next to execution time; unavailable pricing displays — and tasks without token usage show no badge."
|
||||
},
|
||||
"auth": {
|
||||
"apiKeyCleared": "API key cleared",
|
||||
@@ -7832,7 +7834,8 @@
|
||||
"summary": "Summary",
|
||||
"terminal": "Session",
|
||||
"worktreeTerminal": "Terminal",
|
||||
"workflow": "Workflow"
|
||||
"workflow": "Workflow",
|
||||
"cost": "Cost"
|
||||
},
|
||||
"terminal": {
|
||||
"loading": "Loading terminal…"
|
||||
@@ -7863,7 +7866,23 @@
|
||||
},
|
||||
"workflowRuntime": "Workflow runtime",
|
||||
"workflowTimedSteps": "Workflow timed steps",
|
||||
"yes": "Yes"
|
||||
"yes": "Yes",
|
||||
"costTab": {
|
||||
"cacheWriteTokens": "Cache write",
|
||||
"cachedTokens": "Cached",
|
||||
"cost": "Derived USD",
|
||||
"costUnavailable": "No pricing for this model",
|
||||
"description": "Derived from recorded input, output, cached, and cache-write tokens. Unpriced rows use — instead of a guessed value.",
|
||||
"empty": "No model token usage has been recorded for this task yet.",
|
||||
"heading": "Model cost",
|
||||
"inputTokens": "Input",
|
||||
"model": "Model",
|
||||
"outputTokens": "Output",
|
||||
"totalCost": "Total cost",
|
||||
"totalCostLabel": "Total",
|
||||
"totalTokens": "Total tokens",
|
||||
"unknownModel": "(unknown)"
|
||||
}
|
||||
},
|
||||
"taskDocuments": {
|
||||
"cancel": "Cancel",
|
||||
@@ -8285,7 +8304,9 @@
|
||||
"usingDefault": "Using default",
|
||||
"viewDependency": "Click to view {{depId}}",
|
||||
"workflow": "workflow",
|
||||
"workflowCheck": "Workflow check"
|
||||
"workflowCheck": "Workflow check",
|
||||
"costBadgeTitle": "Estimated cost {{amount}}",
|
||||
"costBadgeAriaLabel": "Estimated cost {{amount}}"
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "Terminal arrow keys",
|
||||
|
||||
@@ -5666,7 +5666,9 @@
|
||||
"title": "Apariencia",
|
||||
"openTasksInRightSidebarHelp": "",
|
||||
"openMobileTasksInPopupHelp": "",
|
||||
"taskDetailChatFirstHelp": ""
|
||||
"taskDetailChatFirstHelp": "",
|
||||
"showCostBadgeOnCards": "",
|
||||
"showCostBadgeOnCardsHelp": ""
|
||||
},
|
||||
"auth": {
|
||||
"apiKeyCleared": "Clave API borrada",
|
||||
@@ -5798,7 +5800,8 @@
|
||||
"version": "Versión {{version}}",
|
||||
"versionShort": "",
|
||||
"checkUpdates": "",
|
||||
"helpDiscussions": ""
|
||||
"helpDiscussions": "",
|
||||
"versionShort": ""
|
||||
},
|
||||
"general": {
|
||||
"25": "",
|
||||
@@ -7767,7 +7770,8 @@
|
||||
"terminal": "Sesión",
|
||||
"worktreeTerminal": "Terminal",
|
||||
"workflow": "Flujo de trabajo",
|
||||
"summary": "Summary"
|
||||
"summary": "Summary",
|
||||
"cost": ""
|
||||
},
|
||||
"terminal": {
|
||||
"loading": ""
|
||||
@@ -7853,6 +7857,22 @@
|
||||
"unlinkMessage": "",
|
||||
"unlinkTitle": "",
|
||||
"unlinked": ""
|
||||
},
|
||||
"costTab": {
|
||||
"cacheWriteTokens": "",
|
||||
"cachedTokens": "",
|
||||
"cost": "",
|
||||
"costUnavailable": "",
|
||||
"description": "",
|
||||
"empty": "",
|
||||
"heading": "",
|
||||
"inputTokens": "",
|
||||
"model": "",
|
||||
"outputTokens": "",
|
||||
"totalCost": "",
|
||||
"totalCostLabel": "",
|
||||
"totalTokens": "",
|
||||
"unknownModel": ""
|
||||
}
|
||||
},
|
||||
"taskDocuments": {
|
||||
@@ -8275,7 +8295,9 @@
|
||||
"revertTask": "",
|
||||
"undoOf": "",
|
||||
"undoOfTitle": "",
|
||||
"undoTask": ""
|
||||
"undoTask": "",
|
||||
"costBadgeTitle": "",
|
||||
"costBadgeAriaLabel": ""
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "",
|
||||
|
||||
@@ -5666,7 +5666,9 @@
|
||||
"title": "Apparence",
|
||||
"openTasksInRightSidebarHelp": "",
|
||||
"openMobileTasksInPopupHelp": "",
|
||||
"taskDetailChatFirstHelp": ""
|
||||
"taskDetailChatFirstHelp": "",
|
||||
"showCostBadgeOnCards": "",
|
||||
"showCostBadgeOnCardsHelp": ""
|
||||
},
|
||||
"auth": {
|
||||
"apiKeyCleared": "Clé API effacée",
|
||||
@@ -7767,7 +7769,8 @@
|
||||
"terminal": "Session",
|
||||
"worktreeTerminal": "Terminal",
|
||||
"workflow": "Workflow",
|
||||
"summary": "Summary"
|
||||
"summary": "Summary",
|
||||
"cost": ""
|
||||
},
|
||||
"terminal": {
|
||||
"loading": ""
|
||||
@@ -7853,6 +7856,22 @@
|
||||
"unlinkMessage": "",
|
||||
"unlinkTitle": "",
|
||||
"unlinked": ""
|
||||
},
|
||||
"costTab": {
|
||||
"cacheWriteTokens": "",
|
||||
"cachedTokens": "",
|
||||
"cost": "",
|
||||
"costUnavailable": "",
|
||||
"description": "",
|
||||
"empty": "",
|
||||
"heading": "",
|
||||
"inputTokens": "",
|
||||
"model": "",
|
||||
"outputTokens": "",
|
||||
"totalCost": "",
|
||||
"totalCostLabel": "",
|
||||
"totalTokens": "",
|
||||
"unknownModel": ""
|
||||
}
|
||||
},
|
||||
"taskDocuments": {
|
||||
@@ -8275,7 +8294,9 @@
|
||||
"revertTask": "",
|
||||
"undoOf": "",
|
||||
"undoOfTitle": "",
|
||||
"undoTask": ""
|
||||
"undoTask": "",
|
||||
"costBadgeTitle": "",
|
||||
"costBadgeAriaLabel": ""
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "",
|
||||
|
||||
@@ -5666,7 +5666,9 @@
|
||||
"title": "모양",
|
||||
"openTasksInRightSidebarHelp": "",
|
||||
"openMobileTasksInPopupHelp": "",
|
||||
"taskDetailChatFirstHelp": ""
|
||||
"taskDetailChatFirstHelp": "",
|
||||
"showCostBadgeOnCards": "",
|
||||
"showCostBadgeOnCardsHelp": ""
|
||||
},
|
||||
"auth": {
|
||||
"apiKeyCleared": "API 키가 지워졌습니다",
|
||||
@@ -5798,7 +5800,8 @@
|
||||
"version": "버전 {{version}}",
|
||||
"versionShort": "",
|
||||
"checkUpdates": "",
|
||||
"helpDiscussions": ""
|
||||
"helpDiscussions": "",
|
||||
"versionShort": ""
|
||||
},
|
||||
"general": {
|
||||
"25": "",
|
||||
@@ -7767,7 +7770,8 @@
|
||||
"terminal": "세션",
|
||||
"worktreeTerminal": "터미널",
|
||||
"workflow": "워크플로",
|
||||
"summary": "Summary"
|
||||
"summary": "Summary",
|
||||
"cost": ""
|
||||
},
|
||||
"terminal": {
|
||||
"loading": ""
|
||||
@@ -7853,6 +7857,22 @@
|
||||
"unlinkMessage": "",
|
||||
"unlinkTitle": "",
|
||||
"unlinked": ""
|
||||
},
|
||||
"costTab": {
|
||||
"cacheWriteTokens": "",
|
||||
"cachedTokens": "",
|
||||
"cost": "",
|
||||
"costUnavailable": "",
|
||||
"description": "",
|
||||
"empty": "",
|
||||
"heading": "",
|
||||
"inputTokens": "",
|
||||
"model": "",
|
||||
"outputTokens": "",
|
||||
"totalCost": "",
|
||||
"totalCostLabel": "",
|
||||
"totalTokens": "",
|
||||
"unknownModel": ""
|
||||
}
|
||||
},
|
||||
"taskDocuments": {
|
||||
@@ -8275,7 +8295,9 @@
|
||||
"revertTask": "",
|
||||
"undoOf": "",
|
||||
"undoOfTitle": "",
|
||||
"undoTask": ""
|
||||
"undoTask": "",
|
||||
"costBadgeTitle": "",
|
||||
"costBadgeAriaLabel": ""
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "",
|
||||
|
||||
@@ -5666,7 +5666,9 @@
|
||||
"title": "外观",
|
||||
"openTasksInRightSidebarHelp": "",
|
||||
"openMobileTasksInPopupHelp": "",
|
||||
"taskDetailChatFirstHelp": ""
|
||||
"taskDetailChatFirstHelp": "",
|
||||
"showCostBadgeOnCards": "",
|
||||
"showCostBadgeOnCardsHelp": ""
|
||||
},
|
||||
"auth": {
|
||||
"apiKeyCleared": "API 密钥已清除",
|
||||
@@ -5798,7 +5800,8 @@
|
||||
"version": "版本 {{version}}",
|
||||
"versionShort": "",
|
||||
"checkUpdates": "",
|
||||
"helpDiscussions": ""
|
||||
"helpDiscussions": "",
|
||||
"versionShort": ""
|
||||
},
|
||||
"general": {
|
||||
"25": "",
|
||||
@@ -7767,7 +7770,8 @@
|
||||
"terminal": "会话",
|
||||
"worktreeTerminal": "终端",
|
||||
"workflow": "工作流",
|
||||
"summary": "Summary"
|
||||
"summary": "Summary",
|
||||
"cost": ""
|
||||
},
|
||||
"terminal": {
|
||||
"loading": ""
|
||||
@@ -7853,6 +7857,22 @@
|
||||
"unlinkMessage": "",
|
||||
"unlinkTitle": "",
|
||||
"unlinked": ""
|
||||
},
|
||||
"costTab": {
|
||||
"cacheWriteTokens": "",
|
||||
"cachedTokens": "",
|
||||
"cost": "",
|
||||
"costUnavailable": "",
|
||||
"description": "",
|
||||
"empty": "",
|
||||
"heading": "",
|
||||
"inputTokens": "",
|
||||
"model": "",
|
||||
"outputTokens": "",
|
||||
"totalCost": "",
|
||||
"totalCostLabel": "",
|
||||
"totalTokens": "",
|
||||
"unknownModel": ""
|
||||
}
|
||||
},
|
||||
"taskDocuments": {
|
||||
@@ -8275,7 +8295,9 @@
|
||||
"revertTask": "",
|
||||
"undoOf": "",
|
||||
"undoOfTitle": "",
|
||||
"undoTask": ""
|
||||
"undoTask": "",
|
||||
"costBadgeTitle": "",
|
||||
"costBadgeAriaLabel": ""
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "",
|
||||
|
||||
@@ -5666,7 +5666,9 @@
|
||||
"title": "外觀",
|
||||
"openTasksInRightSidebarHelp": "",
|
||||
"openMobileTasksInPopupHelp": "",
|
||||
"taskDetailChatFirstHelp": ""
|
||||
"taskDetailChatFirstHelp": "",
|
||||
"showCostBadgeOnCards": "",
|
||||
"showCostBadgeOnCardsHelp": ""
|
||||
},
|
||||
"auth": {
|
||||
"apiKeyCleared": "API 金鑰已清除",
|
||||
@@ -5798,7 +5800,8 @@
|
||||
"version": "版本 {{version}}",
|
||||
"versionShort": "",
|
||||
"checkUpdates": "",
|
||||
"helpDiscussions": ""
|
||||
"helpDiscussions": "",
|
||||
"versionShort": ""
|
||||
},
|
||||
"general": {
|
||||
"25": "",
|
||||
@@ -7767,7 +7770,8 @@
|
||||
"terminal": "工作階段",
|
||||
"worktreeTerminal": "終端",
|
||||
"workflow": "工作流程",
|
||||
"summary": "Summary"
|
||||
"summary": "Summary",
|
||||
"cost": ""
|
||||
},
|
||||
"terminal": {
|
||||
"loading": ""
|
||||
@@ -7853,6 +7857,22 @@
|
||||
"unlinkMessage": "",
|
||||
"unlinkTitle": "",
|
||||
"unlinked": ""
|
||||
},
|
||||
"costTab": {
|
||||
"cacheWriteTokens": "",
|
||||
"cachedTokens": "",
|
||||
"cost": "",
|
||||
"costUnavailable": "",
|
||||
"description": "",
|
||||
"empty": "",
|
||||
"heading": "",
|
||||
"inputTokens": "",
|
||||
"model": "",
|
||||
"outputTokens": "",
|
||||
"totalCost": "",
|
||||
"totalCostLabel": "",
|
||||
"totalTokens": "",
|
||||
"unknownModel": ""
|
||||
}
|
||||
},
|
||||
"taskDocuments": {
|
||||
@@ -8275,7 +8295,9 @@
|
||||
"revertTask": "",
|
||||
"undoOf": "",
|
||||
"undoOfTitle": "",
|
||||
"undoTask": ""
|
||||
"undoTask": "",
|
||||
"costBadgeTitle": "",
|
||||
"costBadgeAriaLabel": ""
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "",
|
||||
|
||||
Reference in New Issue
Block a user