FN-7923: align task-card cost badge bottom-right with other footer chips

Narrative: Reworked TaskCard footer/meta layout so the cost badge (and its sibling footer-right chips) render inline at the bottom-right of the card-meta row when the footer has no leading content, instead of always sitting in a separate footer row beside the time badge.

- Extracted the footer-right chip cluster (cost, time, retry, near-duplicate, undo-of, GitHub tracking) into a shared `footerRightCluster` render, computed once instead of duplicated inline.
- Added `footerHasLeadingContent`/`footerRightHasContent`/`placeFooterRightInMeta` derivations so the cluster moves into `.card-meta` (bottom-right, inline with other tags) when there's no files-changed button or GitHub-import leading content, and the meta row is visible; otherwise it keeps the existing `.card-footer-row` placement for in-progress/tracked cards.
- Updated dashboard-guide.md wording to describe the cost badge as appearing 'with the card's other footer/meta chips' rather than 'beside the execution-time badge'.
- Extended TaskCard.test.tsx coverage for the new placement behavior.
- Desktop local-runtime.ts: kept the previously-unused `reason` parameter on `requestRestart` explicitly referenced (void reason) for API parity/lint cleanliness, unrelated cosmetic cleanup carried in the same branch.

Files changed:
 docs/dashboard-guide.md                            |   2 +-
 packages/dashboard/app/components/TaskCard.tsx     | 242 +++++++++++----------
 .../app/components/__tests__/TaskCard.test.tsx     |  96 +++++++-
 packages/desktop/src/local-runtime.ts              |   4 +-
 4 files changed, 222 insertions(+), 122 deletions(-)

Fusion-Task-Id: FN-7923

Fusion-Task-Lineage: 7e4c3109-f39e-45c0-af13-358ce54f945c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 23:56:13 -07:00
parent 30d2e3660d
commit 967f3dd900
4 changed files with 222 additions and 122 deletions

View File

@@ -204,7 +204,7 @@ 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; priority badges include the shared urgency glyph/color language (low blue/info, high amber/warning, urgent red/error) while 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.
- **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 with the card's other footer/meta chips; 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. -->

View File

@@ -2763,6 +2763,133 @@ function TaskCardComponent({
&& filesChangedButton == null
&& showTrackingIndicator
&& Boolean(githubTrackedIssue);
const footerHasLeadingContent = Boolean(filesChangedButton)
|| (isGitHubImportedTask && !showLinkedIssueChipForImport);
const footerRightHasContent = Boolean(cardCostLabel
|| timeIndicator
|| showNearDuplicateChip
|| showUndoOfChip
|| ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue)
|| (task.retrySummary?.total ?? 0) > 0);
/*
* FNXC:TaskCardCostBadge 2026-07-12-00:00:
* The footer-right badge cluster (cost, timing, retry, duplicate, and tracking chips) should render inline at the bottom-right of `.card-meta` when the footer has no leading content. Cards with files-changed or GitHub source-provenance leading content keep the existing `.card-footer-row` layout so in-progress and tracked-card footer behavior remains stable.
*/
const placeFooterRightInMeta = footerRightHasContent
&& !footerHasLeadingContent
&& !chipFarRight
&& metaRowVisible;
const footerRightCluster = footerRightHasContent ? (
<div className="card-footer-row-right">
{showUndoOfChip && (
<span
className="card-undo-chip"
title={t("tasks.undoOfTitle", "Created to undo {{id}}", { id: String(revertOfId) })}
aria-label={t("tasks.undoOfTitle", "Created to undo {{id}}", { id: String(revertOfId) })}
>
<span>{t("tasks.undoOf", "Undo of {{id}}", { id: String(revertOfId) })}</span>
</span>
)}
{showNearDuplicateChip && (
<>
<span
className="card-duplicate-chip"
title={t("tasks.nearDuplicateTitle", "Potential near-duplicate of {{id}}", { id: String(task.sourceMetadata?.nearDuplicateOf) })}
aria-label={t("tasks.nearDuplicateTitle", "Potential near-duplicate of {{id}}", { id: String(task.sourceMetadata?.nearDuplicateOf) })}
>
<span>{t("tasks.duplicateOf", "Duplicate of {{id}}", { id: String(task.sourceMetadata?.nearDuplicateOf) })}</span>
</span>
{onUpdateTask && (
<button
type="button"
className="card-duplicate-keep"
onClick={(e) => void handleDismissNearDuplicate(e)}
title={t("tasks.keepTaskTitle", "Keep this task and dismiss duplicate warning")}
aria-label={t("tasks.keepTaskTitle", "Keep this task and dismiss duplicate warning")}
>
{t("tasks.keep", "Keep")}
</button>
)}
</>
)}
{chipFarRight && (showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue && (
<a
className="card-github-tracking-chip card-github-tracking-link"
href={githubTrackedIssue.url}
target="_blank"
rel="noopener noreferrer"
title={t("tasks.linkedIssueChipTitle", "Linked GitHub issue: {{owner}}/{{repo}}#{{number}}", { owner: githubTrackedIssue.owner, repo: githubTrackedIssue.repo, number: githubTrackedIssue.number })}
aria-label={t("tasks.linkedIssueChipAriaLabel", "Linked GitHub issue #{{number}}", { number: githubTrackedIssue.number })}
onClick={(e) => e.stopPropagation()}
>
<ProviderIcon provider="github" size="sm" />
<span>{`#${githubTrackedIssue.number}`}</span>
</a>
)}
{(task.retrySummary?.total ?? 0) > 0 && (
<span
className={`card-retry-badge${(retryWarningThreshold != null && (task.retrySummary?.total ?? 0) >= retryWarningThreshold) ? " card-retry-badge--error" : " card-retry-badge--warning"}`}
onClick={handleOpenRetries}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
onOpenDetailWithTab?.(task, "retries");
}
}}
aria-label={t("tasks.retriesAriaLabel", "{{count}} retries", { count: task.retrySummary?.total ?? 0 })}
title={t("tasks.openRetryBreakdown", "Open retry breakdown")}
>
<RotateCw size={11} />
<span>{task.retrySummary?.total ?? 0}</span>
</span>
)}
{(!chipFarRight || !((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue))
&& (showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue && (
<a
className="card-github-tracking-chip card-github-tracking-link"
href={githubTrackedIssue.url}
target="_blank"
rel="noopener noreferrer"
title={t("tasks.linkedIssueChipTitle", "Linked GitHub issue: {{owner}}/{{repo}}#{{number}}", { owner: githubTrackedIssue.owner, repo: githubTrackedIssue.repo, number: githubTrackedIssue.number })}
aria-label={t("tasks.linkedIssueChipAriaLabel", "Linked GitHub issue #{{number}}", { number: githubTrackedIssue.number })}
onClick={(e) => e.stopPropagation()}
>
<ProviderIcon provider="github" size="sm" />
<span>{`#${githubTrackedIssue.number}`}</span>
</a>
)}
{/*
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 })}
>
{/*
FNXC:TaskCardCostBadge 2026-07-12-00:00:
The cost chip must show only the formatted amount because formatCost already includes the currency symbol; do not render a leading dollar-sign icon that duplicates the label.
*/}
<span>{cardCostLabel}</span>
</span>
)}
{timeIndicator && (
<span
className="card-time-indicator"
title={timeIndicator.title}
aria-label={timeIndicator.ariaLabel}
>
<Clock size={12} />
<span>{timeIndicator.label}</span>
</span>
)}
</div>
) : null;
const hasWorkflowBadge = typeof workflowBadge?.workflowId === "string"
&& workflowBadge.workflowId.trim().length > 0
&& typeof workflowBadge.workflowName === "string"
@@ -3490,7 +3617,7 @@ function TaskCardComponent({
</>
);
})()}
{(filesChangedButton || isGitHubImportedTask || cardCostLabel || timeIndicator || showNearDuplicateChip || showUndoOfChip || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0) && (
{(footerHasLeadingContent || (footerRightHasContent && !placeFooterRightInMeta)) && (
<div className={`card-footer-row${chipFarRight ? " card-footer-row--chip-far-right" : ""}`}>
{filesChangedButton}
{isGitHubImportedTask && !showLinkedIssueChipForImport && (
@@ -3502,117 +3629,7 @@ function TaskCardComponent({
<ProviderIcon provider="github" size="sm" />
</span>
)}
{(cardCostLabel || timeIndicator || showNearDuplicateChip || showUndoOfChip || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0) && (
<div className="card-footer-row-right">
{showUndoOfChip && (
<span
className="card-undo-chip"
title={t("tasks.undoOfTitle", "Created to undo {{id}}", { id: String(revertOfId) })}
aria-label={t("tasks.undoOfTitle", "Created to undo {{id}}", { id: String(revertOfId) })}
>
<span>{t("tasks.undoOf", "Undo of {{id}}", { id: String(revertOfId) })}</span>
</span>
)}
{showNearDuplicateChip && (
<>
<span
className="card-duplicate-chip"
title={t("tasks.nearDuplicateTitle", "Potential near-duplicate of {{id}}", { id: String(task.sourceMetadata?.nearDuplicateOf) })}
aria-label={t("tasks.nearDuplicateTitle", "Potential near-duplicate of {{id}}", { id: String(task.sourceMetadata?.nearDuplicateOf) })}
>
<span>{t("tasks.duplicateOf", "Duplicate of {{id}}", { id: String(task.sourceMetadata?.nearDuplicateOf) })}</span>
</span>
{onUpdateTask && (
<button
type="button"
className="card-duplicate-keep"
onClick={(e) => void handleDismissNearDuplicate(e)}
title={t("tasks.keepTaskTitle", "Keep this task and dismiss duplicate warning")}
aria-label={t("tasks.keepTaskTitle", "Keep this task and dismiss duplicate warning")}
>
{t("tasks.keep", "Keep")}
</button>
)}
</>
)}
{chipFarRight && (showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue && (
<a
className="card-github-tracking-chip card-github-tracking-link"
href={githubTrackedIssue.url}
target="_blank"
rel="noopener noreferrer"
title={t("tasks.linkedIssueChipTitle", "Linked GitHub issue: {{owner}}/{{repo}}#{{number}}", { owner: githubTrackedIssue.owner, repo: githubTrackedIssue.repo, number: githubTrackedIssue.number })}
aria-label={t("tasks.linkedIssueChipAriaLabel", "Linked GitHub issue #{{number}}", { number: githubTrackedIssue.number })}
onClick={(e) => e.stopPropagation()}
>
<ProviderIcon provider="github" size="sm" />
<span>{`#${githubTrackedIssue.number}`}</span>
</a>
)}
{(task.retrySummary?.total ?? 0) > 0 && (
<span
className={`card-retry-badge${(retryWarningThreshold != null && (task.retrySummary?.total ?? 0) >= retryWarningThreshold) ? " card-retry-badge--error" : " card-retry-badge--warning"}`}
onClick={handleOpenRetries}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
onOpenDetailWithTab?.(task, "retries");
}
}}
aria-label={t("tasks.retriesAriaLabel", "{{count}} retries", { count: task.retrySummary?.total ?? 0 })}
title={t("tasks.openRetryBreakdown", "Open retry breakdown")}
>
<RotateCw size={11} />
<span>{task.retrySummary?.total ?? 0}</span>
</span>
)}
{(!chipFarRight || !((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue))
&& (showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue && (
<a
className="card-github-tracking-chip card-github-tracking-link"
href={githubTrackedIssue.url}
target="_blank"
rel="noopener noreferrer"
title={t("tasks.linkedIssueChipTitle", "Linked GitHub issue: {{owner}}/{{repo}}#{{number}}", { owner: githubTrackedIssue.owner, repo: githubTrackedIssue.repo, number: githubTrackedIssue.number })}
aria-label={t("tasks.linkedIssueChipAriaLabel", "Linked GitHub issue #{{number}}", { number: githubTrackedIssue.number })}
onClick={(e) => e.stopPropagation()}
>
<ProviderIcon provider="github" size="sm" />
<span>{`#${githubTrackedIssue.number}`}</span>
</a>
)}
{/*
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 })}
>
{/*
FNXC:TaskCardCostBadge 2026-07-12-00:00:
The cost chip must show only the formatted amount because formatCost already includes the currency symbol; do not render a leading dollar-sign icon that duplicates the label.
*/}
<span>{cardCostLabel}</span>
</span>
)}
{timeIndicator && (
<span
className="card-time-indicator"
title={timeIndicator.title}
aria-label={timeIndicator.ariaLabel}
>
<Clock size={12} />
<span>{timeIndicator.label}</span>
</span>
)}
</div>
)}
{!placeFooterRightInMeta && footerRightCluster}
</div>
)}
{metaRowVisible && (
@@ -3651,6 +3668,7 @@ function TaskCardComponent({
)}
{(queued || task.status === "queued") && task.column !== "in-progress" && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: "middle" }} /> {t("tasks.queued", "Queued")}</span>}
{showInReviewMoveControl && renderInReviewMoveControl()}
{placeFooterRightInMeta && footerRightCluster}
</div>
)}
{(task.assignedAgentId || taskProviders.length > 0) && (

View File

@@ -3928,7 +3928,7 @@ describe("TaskCard", () => {
expect(screen.getByTestId("provider-icon-github")).toBeDefined();
});
it("renders the GitHub tracking link in the unified footer row above queued metadata", () => {
it("renders the GitHub tracking link inline with queued metadata when the footer has no leading content", () => {
const { container } = render(
<TaskCard
task={makeTask({
@@ -3951,13 +3951,14 @@ describe("TaskCard", () => {
);
const link = screen.getByRole("link", { name: "Linked GitHub issue #42" });
const footerRow = container.querySelector(".card-footer-row");
const metaRow = container.querySelector(".card-meta");
const queuedBadge = container.querySelector(".queued-badge");
expect(footerRow).not.toBeNull();
expect(footerRow?.contains(link)).toBe(true);
expect(container.querySelector(".card-footer-row")).toBeNull();
expect(link.closest(".card-meta")).toBe(metaRow);
expect(link.closest(".card-footer-row-right")?.closest(".card-meta")).toBe(metaRow);
expect(container.querySelector(".card-bottom-right-row")).toBeNull();
expect(queuedBadge).not.toBeNull();
expect(queuedBadge?.compareDocumentPosition(footerRow as Node) & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy();
expect(queuedBadge?.compareDocumentPosition(link) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
@@ -5428,6 +5429,8 @@ describe("TaskCard", () => {
expect(costBadge?.getAttribute("aria-label")).toBe("Estimated cost $0.25");
expect(costBadge?.getAttribute("title")).toBe("Estimated cost $0.25");
expect(costBadge?.closest(".card-footer-row-right")).toBe(enabled.container.querySelector(".card-footer-row-right"));
expect(costBadge?.closest(".card-footer-row")).toBe(enabled.container.querySelector(".card-footer-row"));
expect(costBadge?.closest(".card-meta")).toBeNull();
enabled.unmount();
const noUsage = render(
@@ -5438,12 +5441,50 @@ describe("TaskCard", () => {
expect(noUsage.container.querySelector(".card-cost-indicator")).toBeNull();
});
it("renders the cost badge unavailable sentinel for unpriceable usage", () => {
it("places a todo cost badge inside the meta row when the footer has no leading content", () => {
const { container } = render(
<CostBadgeProvider value={{ enabled: true }}>
<TaskCard
task={makeTask({
column: "done",
column: "todo",
dependencies: ["FN-000"],
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>)}
onOpenDetail={noop}
addToast={noop}
/>
</CostBadgeProvider>,
);
const costBadge = container.querySelector(".card-cost-indicator") as HTMLElement | null;
const metaRow = container.querySelector(".card-meta");
const rightCluster = container.querySelector(".card-footer-row-right");
expect(costBadge).not.toBeNull();
expect(costBadge?.textContent).toContain("$0.25");
expect(costBadge?.closest(".card-meta")).toBe(metaRow);
expect(costBadge?.closest(".card-footer-row")).toBeNull();
expect(rightCluster?.closest(".card-meta")).toBe(metaRow);
expect(rightCluster?.contains(costBadge)).toBe(true);
expect(container.querySelector(".card-footer-row")).toBeNull();
});
it("places the unavailable cost sentinel inside todo meta without adding an icon", () => {
const { container } = render(
<CostBadgeProvider value={{ enabled: true }}>
<TaskCard
task={makeTask({
column: "todo",
dependencies: ["FN-000"],
tokenUsage: {
inputTokens: 1,
outputTokens: 0,
@@ -5468,6 +5509,44 @@ describe("TaskCard", () => {
expect(costBadge?.querySelector("svg")).toBeNull();
expect(costBadge?.getAttribute("aria-label")).toBe("Estimated cost —");
expect(costBadge?.getAttribute("title")).toBe("Estimated cost —");
expect(costBadge?.closest(".card-meta")).toBe(container.querySelector(".card-meta"));
expect(costBadge?.closest(".card-footer-row")).toBeNull();
expect(container.querySelector(".card-footer-row")).toBeNull();
});
it("keeps in-progress cost badges in the footer row with files changed", () => {
const { container } = render(
<CostBadgeProvider value={{ enabled: true }}>
<TaskCard
task={makeTask({
column: "in-progress",
modifiedFiles: ["packages/dashboard/app/components/TaskCard.tsx"],
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>)}
onOpenDetail={noop}
addToast={noop}
/>
</CostBadgeProvider>,
);
const costBadge = container.querySelector(".card-cost-indicator") as HTMLElement | null;
const footerRow = container.querySelector(".card-footer-row");
const rightCluster = container.querySelector(".card-footer-row-right");
expect(container.querySelector(".card-session-files")).not.toBeNull();
expect(costBadge).not.toBeNull();
expect(costBadge?.closest(".card-footer-row")).toBe(footerRow);
expect(costBadge?.closest(".card-footer-row-right")).toBe(rightCluster);
expect(costBadge?.closest(".card-meta")).toBeNull();
});
it.each(["merging", "merging-fix"] as const)("shows live merge elapsed in timer chip while task.status is %s", (status) => {
@@ -6199,7 +6278,8 @@ describe("TaskCard workflow badges", () => {
expect(workflowRow).toContainElement(badge);
expect(agentRow.compareDocumentPosition(workflowRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
[".card-footer-row", ".card-meta", ".card-agent-row"].forEach((selector) => {
expect(container.querySelector(".card-footer-row")).toBeNull();
[".card-meta", ".card-agent-row"].forEach((selector) => {
const row = container.querySelector(selector);
expect(row, `${selector} should render for the placement fixture`).not.toBeNull();
expect(row!.compareDocumentPosition(workflowRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();

View File

@@ -131,7 +131,9 @@ export async function resolveDesktopSystemControl(): Promise<
return {
systemControl: {
supervised: true,
requestRestart: (_reason: string) => {
requestRestart: (reason: string) => {
/* FNXC:DesktopRestart 2026-07-12-23:45: Desktop accepts the dashboard restart reason for API parity even though Electron relaunch does not consume it; keep it explicitly used so lint catches real unused parameters. */
void reason;
setTimeout(() => {
electronApp.relaunch();
// Graceful quit runs before-quit teardown; force-exit only if it stalls.