diff --git a/.changeset/fn-7820-cost-tab-and-card-badge.md b/.changeset/fn-7820-cost-tab-and-card-badge.md new file mode 100644 index 0000000000..7f73697a38 --- /dev/null +++ b/.changeset/fn-7820-cost-tab-and-card-badge.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 8946bf9c5d..a03b5a721f 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. + @@ -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`. + - 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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 57da19483b..77f2fb19c2 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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/-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. | diff --git a/packages/core/src/__tests__/settings-defaults.test.ts b/packages/core/src/__tests__/settings-defaults.test.ts index 5f36210648..77231271a2 100644 --- a/packages/core/src/__tests__/settings-defaults.test.ts +++ b/packages/core/src/__tests__/settings-defaults.test.ts @@ -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); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 2f07af69ee..d000aee294 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -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. */ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b07db8ce24..5d09aaec0c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index f0e158ca81..f342255808 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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() { + {isFirstEverBoot ? ( <> @@ -1856,6 +1860,7 @@ function AppInner() { )} )} + diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 587edd98eb..e2357c18b9 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -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. */ diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index c1a0c88e6d..b019135634 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -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 { diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index fc73d96a44..b6e4c436a0 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -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) && (
{filesChangedButton} {isGitHubImportedTask && !showLinkedIssueChipForImport && ( @@ -3358,7 +3369,7 @@ function TaskCardComponent({ )} - {(timeIndicator || showNearDuplicateChip || showUndoOfChip || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0) && ( + {(cardCostLabel || timeIndicator || showNearDuplicateChip || showUndoOfChip || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0) && (
{showUndoOfChip && ( + + {cardCostLabel} + + )} {timeIndicator && ( +
+

{t("taskDetail.costTab.heading", "Model cost")}

+

{t("taskDetail.costTab.empty", "No model token usage has been recorded for this task yet.")}

+
+
+ ); + } + + const rows = buildTokenCostRows(task, t("taskDetail.costTab.unknownModel", "(unknown)"), pricingOverrides); + const totalCost = totalCostForRows(rows); + + return ( +
+
+
+

{t("taskDetail.costTab.heading", "Model cost")}

+ + {t("taskDetail.costTab.totalCostLabel", "Total")}: {formatCost(totalCost.usd, totalCost.unavailable)} + +
+

{t("taskDetail.costTab.description", "Derived from recorded input, output, cached, and cache-write tokens. Unpriced rows use — instead of a guessed value.")}

+
+ + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + ))} + + + + + + + +
{t("taskDetail.costTab.model", "Model")}{t("taskDetail.costTab.inputTokens", "Input")}{t("taskDetail.costTab.outputTokens", "Output")}{t("taskDetail.costTab.cachedTokens", "Cached")}{t("taskDetail.costTab.cacheWriteTokens", "Cache write")}{t("taskDetail.costTab.totalTokens", "Total tokens")}{t("taskDetail.costTab.cost", "Derived USD")}
+ + + {row.label} + + {formatCount(row.inputTokens)}{formatCount(row.outputTokens)}{formatCount(row.cachedTokens)}{formatCount(row.cacheWriteTokens)}{formatCount(row.totalTokens)} + {row.cost.unavailable || row.cost.usd === null ? ( + — + ) : ( + formatCost(row.cost.usd, row.cost.unavailable) + )} +
{t("taskDetail.costTab.totalCost", "Total cost")}{formatCost(totalCost.usd, totalCost.unavailable)}
+
+
+
+ ); +} + +export default TaskCostTab; diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 332635880a..8b43b6013a 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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")} )} + {/* 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. */} +