From 967f3dd9001fb8ebe8534b557b9ed8f3ee4c903a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 12 Jul 2026 23:56:13 -0700 Subject: [PATCH] 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) --- docs/dashboard-guide.md | 2 +- .../dashboard/app/components/TaskCard.tsx | 242 ++++++++++-------- .../components/__tests__/TaskCard.test.tsx | 96 ++++++- packages/desktop/src/local-runtime.ts | 4 +- 4 files changed, 222 insertions(+), 122 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 1ec8873b89..81b31dff20 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 608a567883..1b19385ad1 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -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 ? ( +
+ {showUndoOfChip && ( + + {t("tasks.undoOf", "Undo of {{id}}", { id: String(revertOfId) })} + + )} + {showNearDuplicateChip && ( + <> + + {t("tasks.duplicateOf", "Duplicate of {{id}}", { id: String(task.sourceMetadata?.nearDuplicateOf) })} + + {onUpdateTask && ( + + )} + + )} + {chipFarRight && (showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue && ( + e.stopPropagation()} + > + + {`#${githubTrackedIssue.number}`} + + )} + {(task.retrySummary?.total ?? 0) > 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")} + > + + {task.retrySummary?.total ?? 0} + + )} + {(!chipFarRight || !((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue)) + && (showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue && ( + e.stopPropagation()} + > + + {`#${githubTrackedIssue.number}`} + + )} + {/* + 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 && ( + + {/* + 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. + */} + {cardCostLabel} + + )} + {timeIndicator && ( + + + {timeIndicator.label} + + )} +
+ ) : 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)) && (
{filesChangedButton} {isGitHubImportedTask && !showLinkedIssueChipForImport && ( @@ -3502,117 +3629,7 @@ function TaskCardComponent({ )} - {(cardCostLabel || timeIndicator || showNearDuplicateChip || showUndoOfChip || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0) && ( -
- {showUndoOfChip && ( - - {t("tasks.undoOf", "Undo of {{id}}", { id: String(revertOfId) })} - - )} - {showNearDuplicateChip && ( - <> - - {t("tasks.duplicateOf", "Duplicate of {{id}}", { id: String(task.sourceMetadata?.nearDuplicateOf) })} - - {onUpdateTask && ( - - )} - - )} - {chipFarRight && (showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue && ( - e.stopPropagation()} - > - - {`#${githubTrackedIssue.number}`} - - )} - {(task.retrySummary?.total ?? 0) > 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")} - > - - {task.retrySummary?.total ?? 0} - - )} - {(!chipFarRight || !((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue)) - && (showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue && ( - e.stopPropagation()} - > - - {`#${githubTrackedIssue.number}`} - - )} - {/* - 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 && ( - - {/* - 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. - */} - {cardCostLabel} - - )} - {timeIndicator && ( - - - {timeIndicator.label} - - )} -
- )} + {!placeFooterRightInMeta && footerRightCluster}
)} {metaRowVisible && ( @@ -3651,6 +3668,7 @@ function TaskCardComponent({ )} {(queued || task.status === "queued") && task.column !== "in-progress" && {t("tasks.queued", "Queued")}} {showInReviewMoveControl && renderInReviewMoveControl()} + {placeFooterRightInMeta && footerRightCluster} )} {(task.assignedAgentId || taskProviders.length > 0) && ( diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index af2cc2d40f..a288691bdd 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -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( { ); 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( )} + onOpenDetail={noop} + addToast={noop} + /> + , + ); + + 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( + + { 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( + + )} + onOpenDetail={noop} + addToast={noop} + /> + , + ); + + 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(); diff --git a/packages/desktop/src/local-runtime.ts b/packages/desktop/src/local-runtime.ts index 87bb79213d..ffe598f236 100644 --- a/packages/desktop/src/local-runtime.ts +++ b/packages/desktop/src/local-runtime.ts @@ -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.