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:
@@ -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. -->
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user