FN-8636: hide unavailable task card cost badges
Hide dash-only cost badges from board task cards when pricing is unavailable. - Suppress unavailable cost labels and their empty layout shells. - Cover priced and unavailable badges with and without Promote across desktop and mobile widths. - Add a patch changeset for the board-card fix. Files changed: .changeset/fn-8636-card-cost-badge-dash.md | 7 ++ packages/dashboard/app/components/TaskCard.tsx | 8 +- .../__tests__/TaskCard.cost-badge.test.tsx | 93 ++++++++++++++++------ .../app/components/__tests__/TaskCard.test.tsx | 14 ++-- 4 files changed, 84 insertions(+), 38 deletions(-) Fusion-Task-Id: FN-8636 Fusion-Task-Lineage: 31fbf82a-7a67-4629-bf82-48faf3c3a9d7 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8636-card-cost-badge-dash.md
Normal file
7
.changeset/fn-8636-card-cost-badge-dash.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Hide unavailable cost badges on board task cards.
|
||||||
|
category: fix
|
||||||
|
dev: Board cards now omit unavailable costs while detail and analytics surfaces retain the guess-free sentinel.
|
||||||
@@ -1158,13 +1158,15 @@ function TaskCardComponent({
|
|||||||
const retryWarningThreshold = useRetryWarning();
|
const retryWarningThreshold = useRetryWarning();
|
||||||
const costBadge = useCostBadge();
|
const costBadge = useCostBadge();
|
||||||
/*
|
/*
|
||||||
FNXC:TaskCardCostBadge 2026-07-11-12:20:
|
FNXC:TaskCardCostBadge 2026-07-31-23:39:
|
||||||
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.
|
Board cards must omit the optional spend chip entirely when the derived cost is unavailable, avoiding a dash-only badge or empty Promote row. Detail and analytics surfaces retain formatCost's guess-free “—” sentinel rather than fabricating a $0 value.
|
||||||
*/
|
*/
|
||||||
const cardCost = costBadge.enabled && hasTaskCost(task as TaskDetail)
|
const cardCost = costBadge.enabled && hasTaskCost(task as TaskDetail)
|
||||||
? taskTotalCost(task as TaskDetail, costBadge.pricingOverrides)
|
? taskTotalCost(task as TaskDetail, costBadge.pricingOverrides)
|
||||||
: null;
|
: null;
|
||||||
const cardCostLabel = cardCost ? formatCost(cardCost.usd, cardCost.unavailable) : null;
|
const cardCostLabel = cardCost && !cardCost.unavailable && cardCost.usd !== null
|
||||||
|
? formatCost(cardCost.usd, false)
|
||||||
|
: null;
|
||||||
|
|
||||||
// Touch gesture detection refs
|
// Touch gesture detection refs
|
||||||
const touchStartPosRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
const touchStartPosRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||||
|
|||||||
@@ -108,33 +108,74 @@ describe("TaskCard cost badge", () => {
|
|||||||
expect(badges[0]?.closest(".card-footer-row-right")).not.toBeNull();
|
expect(badges[0]?.closest(".card-footer-row-right")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps unpriced usage guess-free and renders once below Promote", () => {
|
it.each([1280, 390])("omits unavailable cost chips and their shells at %ipx with or without Promote", (width) => {
|
||||||
const { container } = render(
|
const originalWidth = window.innerWidth;
|
||||||
<CostBadgeProvider value={{ enabled: true }}>
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
|
||||||
<TaskCard
|
|
||||||
task={taskWithUsage({
|
|
||||||
tokenUsage: {
|
|
||||||
...taskWithUsage().tokenUsage!,
|
|
||||||
modelProvider: "unknown",
|
|
||||||
modelId: "no-price",
|
|
||||||
perModel: [
|
|
||||||
{ modelProvider: "openai", modelId: "gpt-5-mini", inputTokens: 1_000_000, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1_000_000 },
|
|
||||||
{ modelProvider: "unknown", modelId: "no-price", inputTokens: 1, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
onOpenDetail={noop}
|
|
||||||
addToast={noop}
|
|
||||||
onPromote={vi.fn().mockResolvedValue(undefined)}
|
|
||||||
/>
|
|
||||||
</CostBadgeProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const badges = container.querySelectorAll(".card-cost-indicator");
|
try {
|
||||||
expect(badges).toHaveLength(1);
|
const unavailableWithoutPromote = render(
|
||||||
expect(badges[0]?.textContent).toBe("—");
|
<CostBadgeProvider value={{ enabled: true }}>
|
||||||
expect(badges[0]?.closest(".card-promote-cost-row")).not.toBeNull();
|
<TaskCard
|
||||||
expect(badges[0]?.closest(".card-footer-row-right")).toBeNull();
|
task={taskWithUsage({
|
||||||
|
tokenUsage: { ...taskWithUsage().tokenUsage!, modelProvider: "unknown", modelId: "no-price" },
|
||||||
|
})}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
/>
|
||||||
|
</CostBadgeProvider>,
|
||||||
|
);
|
||||||
|
expect(unavailableWithoutPromote.container.querySelector(".card-cost-indicator")).toBeNull();
|
||||||
|
expect(unavailableWithoutPromote.container.querySelector(".card-cost-indicator[aria-label]")).toBeNull();
|
||||||
|
expect(unavailableWithoutPromote.container.querySelector(".card-promote-cost-row")).toBeNull();
|
||||||
|
unavailableWithoutPromote.unmount();
|
||||||
|
|
||||||
|
const unavailableWithPromote = render(
|
||||||
|
<CostBadgeProvider value={{ enabled: true }}>
|
||||||
|
<TaskCard
|
||||||
|
task={taskWithUsage({
|
||||||
|
tokenUsage: {
|
||||||
|
...taskWithUsage().tokenUsage!,
|
||||||
|
modelProvider: "unknown",
|
||||||
|
modelId: "no-price",
|
||||||
|
perModel: [
|
||||||
|
{ modelProvider: "openai", modelId: "gpt-5-mini", inputTokens: 1_000_000, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1_000_000 },
|
||||||
|
{ modelProvider: "unknown", modelId: "no-price", inputTokens: 1, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
onPromote={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
/>
|
||||||
|
</CostBadgeProvider>,
|
||||||
|
);
|
||||||
|
expect(unavailableWithPromote.container.querySelector(".card-cost-indicator")).toBeNull();
|
||||||
|
expect(unavailableWithPromote.container.querySelector(".card-cost-indicator[aria-label]")).toBeNull();
|
||||||
|
expect(unavailableWithPromote.container.querySelector(".card-promote-cost-row")).toBeNull();
|
||||||
|
unavailableWithPromote.unmount();
|
||||||
|
|
||||||
|
const pricedWithoutPromote = render(
|
||||||
|
<CostBadgeProvider value={{ enabled: true }}>
|
||||||
|
<TaskCard task={taskWithUsage()} onOpenDetail={noop} addToast={noop} />
|
||||||
|
</CostBadgeProvider>,
|
||||||
|
);
|
||||||
|
const footerBadge = pricedWithoutPromote.container.querySelectorAll(".card-cost-indicator");
|
||||||
|
expect(footerBadge).toHaveLength(1);
|
||||||
|
expect(footerBadge[0]?.textContent).toContain("$");
|
||||||
|
pricedWithoutPromote.unmount();
|
||||||
|
|
||||||
|
const pricedWithPromote = render(
|
||||||
|
<CostBadgeProvider value={{ enabled: true }}>
|
||||||
|
<TaskCard task={taskWithUsage()} onOpenDetail={noop} addToast={noop} onPromote={vi.fn().mockResolvedValue(undefined)} />
|
||||||
|
</CostBadgeProvider>,
|
||||||
|
);
|
||||||
|
const promoteBadge = pricedWithPromote.container.querySelectorAll(".card-cost-indicator");
|
||||||
|
expect(promoteBadge).toHaveLength(1);
|
||||||
|
expect(promoteBadge[0]?.textContent).toContain("$");
|
||||||
|
expect(promoteBadge[0]?.closest(".card-promote-cost-row")).not.toBeNull();
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalWidth });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the shared card cost chip visible at the mobile breakpoint", () => {
|
it("keeps the shared card cost chip visible at the mobile breakpoint", () => {
|
||||||
|
|||||||
@@ -6617,7 +6617,7 @@ describe("TaskCard", () => {
|
|||||||
expect(container.querySelector(".card-footer-row")).toBeNull();
|
expect(container.querySelector(".card-footer-row")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("places the unavailable cost sentinel inside todo meta without adding an icon", () => {
|
it("omits an unavailable cost chip and its footer shell", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<CostBadgeProvider value={{ enabled: true }}>
|
<CostBadgeProvider value={{ enabled: true }}>
|
||||||
<TaskCard
|
<TaskCard
|
||||||
@@ -6642,14 +6642,10 @@ describe("TaskCard", () => {
|
|||||||
</CostBadgeProvider>,
|
</CostBadgeProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const costBadge = container.querySelector(".card-cost-indicator") as HTMLElement | null;
|
expect(container.querySelector(".card-cost-indicator")).toBeNull();
|
||||||
expect(costBadge).not.toBeNull();
|
expect(container.querySelector(".card-cost-indicator[aria-label]")).toBeNull();
|
||||||
expect(costBadge?.textContent).toContain("—");
|
expect(container.querySelector(".card-promote-cost-row")).toBeNull();
|
||||||
expect(costBadge?.querySelector("svg")).toBeNull();
|
expect(container.querySelector(".card-footer-row-right")).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();
|
expect(container.querySelector(".card-footer-row")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user