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);
|
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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user