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:
7
.changeset/fn-8324-card-cost-promote.md
Normal file
7
.changeset/fn-8324-card-cost-promote.md
Normal 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.
|
||||
@@ -889,6 +889,16 @@ at desktop and mobile breakpoints.
|
||||
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 {
|
||||
display: inline-flex;
|
||||
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;
|
||||
}
|
||||
|
||||
.card-promote-cost-row {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Card: compact progress bar on narrow cards */
|
||||
.card-progress {
|
||||
gap: var(--space-xs);
|
||||
|
||||
@@ -2739,7 +2739,21 @@ function TaskCardComponent({
|
||||
&& Boolean(githubTrackedIssue);
|
||||
const footerHasLeadingContent = Boolean(filesChangedButton)
|
||||
|| (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
|
||||
|| showNearDuplicateChip
|
||||
|| showUndoOfChip
|
||||
@@ -2849,19 +2863,7 @@ 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 })}
|
||||
>
|
||||
{/*
|
||||
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>
|
||||
)}
|
||||
{!costBadgeBelowPromote && costBadgeChip}
|
||||
{timeIndicator && (
|
||||
<span
|
||||
className="card-time-indicator"
|
||||
@@ -3652,6 +3654,7 @@ function TaskCardComponent({
|
||||
</div>
|
||||
)}
|
||||
{shouldRenderActionRow && (
|
||||
<>
|
||||
<div className="card-action-row">
|
||||
{showCreatePrQuickAction && (
|
||||
<button
|
||||
@@ -3715,6 +3718,16 @@ function TaskCardComponent({
|
||||
</button>
|
||||
)}
|
||||
</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 && (
|
||||
<div className="card-agent-badge-row" data-testid="card-agent-badge-row">
|
||||
|
||||
@@ -5814,6 +5814,78 @@ describe("TaskCard", () => {
|
||||
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", () => {
|
||||
const { container } = render(
|
||||
<CostBadgeProvider value={{ enabled: true }}>
|
||||
|
||||
Reference in New Issue
Block a user