FN-8324: place cost badge below Promote

Place enabled task cost badges directly below Promote while preserving their existing placement on other cards.

- Render the shared cost badge in a Promote-specific bottom-right row
- Preserve footer placement when Promote is unavailable
- Add placement and visibility coverage plus a patch changeset

Files changed:
 .changeset/fn-8324-card-cost-promote.md            |  7 +++
 packages/dashboard/app/components/TaskCard.css     | 14 +++++
 packages/dashboard/app/components/TaskCard.tsx     | 41 +++++++-----
 packages/dashboard/app/components/__tests__/TaskCard.test.tsx | 72 ++++++++++++++++++++++
 4 files changed, 120 insertions(+), 14 deletions(-)

Fusion-Task-Id: FN-8324

Fusion-Task-Lineage: 053da911-7f47-468c-a3b9-c0d85738bda8

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 13:25:47 -07:00
parent 1a63b6dba6
commit 40d125b9d1
4 changed files with 120 additions and 14 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Move the task-card cost badge below the Promote button in the bottom-right corner.
category: fix
dev: Cost chip (card-cost-indicator) now renders below .card-action-row on Promote-bearing cards; non-Promote card placement unchanged.

View File

@@ -889,6 +889,16 @@ at desktop and mobile breakpoints.
row-gap: var(--space-xs); row-gap: var(--space-xs);
} }
/*
FNXC:TaskCardCostBadge 2026-07-12-00:00:
On cards with Promote, the opt-in cost badge belongs directly below that action in the bottom-right corner. Keep its shared card-cost-indicator styling and only position its wrapper so cards without Promote retain their existing footer/meta layout.
*/
.card-promote-cost-row {
display: flex;
justify-content: flex-end;
margin-top: var(--space-xs);
}
.card-provider-icons { .card-provider-icons {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1910,6 +1920,10 @@ The three-dot menu is the sole card move/action entry point. Keep this shared bu
margin-left: auto; margin-left: auto;
} }
.card-promote-cost-row {
justify-content: flex-end;
}
/* Card: compact progress bar on narrow cards */ /* Card: compact progress bar on narrow cards */
.card-progress { .card-progress {
gap: var(--space-xs); gap: var(--space-xs);

View File

@@ -2739,7 +2739,21 @@ function TaskCardComponent({
&& Boolean(githubTrackedIssue); && Boolean(githubTrackedIssue);
const footerHasLeadingContent = Boolean(filesChangedButton) const footerHasLeadingContent = Boolean(filesChangedButton)
|| (isGitHubImportedTask && !showLinkedIssueChipForImport); || (isGitHubImportedTask && !showLinkedIssueChipForImport);
const footerRightHasContent = Boolean(cardCostLabel const costBadgeBelowPromote = Boolean(onPromote && cardCostLabel);
const costBadgeChip = 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>
) : null;
const footerRightHasContent = Boolean((!costBadgeBelowPromote && cardCostLabel)
|| timeIndicator || timeIndicator
|| showNearDuplicateChip || showNearDuplicateChip
|| showUndoOfChip || showUndoOfChip
@@ -2849,19 +2863,7 @@ function TaskCardComponent({
FNXC:TaskCardTimingBadge 2026-06-13-17:20: 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. 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 && ( {!costBadgeBelowPromote && costBadgeChip}
<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 && ( {timeIndicator && (
<span <span
className="card-time-indicator" className="card-time-indicator"
@@ -3652,6 +3654,7 @@ function TaskCardComponent({
</div> </div>
)} )}
{shouldRenderActionRow && ( {shouldRenderActionRow && (
<>
<div className="card-action-row"> <div className="card-action-row">
{showCreatePrQuickAction && ( {showCreatePrQuickAction && (
<button <button
@@ -3715,6 +3718,16 @@ function TaskCardComponent({
</button> </button>
)} )}
</div> </div>
{costBadgeBelowPromote && (
<div className="card-promote-cost-row">
{/*
FNXC:TaskCardCostBadge 2026-07-12-00:00:
Promote-bearing cards must place the enabled cost badge directly below Promote in the bottom-right corner. Cards without Promote retain the footer/meta placement so other footer chips and card layouts do not move.
*/}
{costBadgeChip}
</div>
)}
</>
)} )}
{isAgentCreated && ( {isAgentCreated && (
<div className="card-agent-badge-row" data-testid="card-agent-badge-row"> <div className="card-agent-badge-row" data-testid="card-agent-badge-row">

View File

@@ -5814,6 +5814,78 @@ describe("TaskCard", () => {
expect(noUsage.container.querySelector(".card-cost-indicator")).toBeNull(); expect(noUsage.container.querySelector(".card-cost-indicator")).toBeNull();
}); });
it("places an enabled cost badge below Promote without leaving it in the footer cluster", () => {
const pricedTask = makeTask({
id: "FN-8324",
column: "todo",
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 { container } = render(
<CostBadgeProvider value={{ enabled: true }}>
<TaskCard
task={pricedTask}
onOpenDetail={noop}
addToast={noop}
onPromote={vi.fn().mockResolvedValue(undefined)}
/>
</CostBadgeProvider>,
);
const promoteButton = screen.getByTestId("card-promote-FN-8324");
const costBadge = container.querySelector(".card-cost-indicator") as HTMLElement | null;
const costRow = container.querySelector(".card-promote-cost-row");
expect(costBadge).not.toBeNull();
expect(costBadge?.closest(".card-promote-cost-row")).toBe(costRow);
expect(costBadge?.closest(".card-footer-row-right")).toBeNull();
expect(promoteButton.compareDocumentPosition(costBadge!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
const css = loadAllAppCss();
expect(css).toMatch(/\.card-promote-cost-row\s*\{[^}]*justify-content:\s*flex-end;[^}]*margin-top:\s*var\(--space-xs\);[^}]*\}/);
expect(css).toMatch(/@media[^{}]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-promote-cost-row\s*\{[^}]*justify-content:\s*flex-end;[^}]*\}/);
});
it("keeps the cost badge absent below Promote when disabled or without token usage", () => {
const onPromote = vi.fn().mockResolvedValue(undefined);
const pricedTask = makeTask({
id: "FN-8324-disabled",
column: "todo",
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} onPromote={onPromote} />);
expect(disabled.container.querySelector(".card-cost-indicator")).toBeNull();
expect(disabled.container.querySelector(".card-promote-cost-row")).toBeNull();
disabled.unmount();
const noUsage = render(
<CostBadgeProvider value={{ enabled: true }}>
<TaskCard task={makeTask({ id: "FN-8324-no-usage", column: "todo" })} onOpenDetail={noop} addToast={noop} onPromote={onPromote} />
</CostBadgeProvider>,
);
expect(noUsage.container.querySelector(".card-cost-indicator")).toBeNull();
expect(noUsage.container.querySelector(".card-promote-cost-row")).toBeNull();
});
it("places a todo cost badge inside the meta row when the footer has no leading content", () => { it("places a todo cost badge inside the meta row when the footer has no leading content", () => {
const { container } = render( const { container } = render(
<CostBadgeProvider value={{ enabled: true }}> <CostBadgeProvider value={{ enabled: true }}>