feat(FN-2511): unify task card footer metadata row

- Refactor TaskCard to compute files-changed metadata once and render it through a shared footer slot
- Render elapsed time chip in the same footer row as file-change metadata when either element is present
- Update TaskCard styles to add a reusable .card-footer-row layout and align the timer chip to the row end
- Add regression coverage asserting files-changed and timer chips coexist in one footer container
This commit is contained in:
Fusion
2026-04-25 13:17:47 -07:00
committed by gsxdsm
parent f560af593a
commit 20635652bc
3 changed files with 141 additions and 88 deletions

View File

@@ -422,17 +422,26 @@
margin-top: var(--space-sm);
}
.card-footer-row {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-sm);
min-width: 0;
flex-wrap: wrap;
}
.card-session-files {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: var(--space-sm);
padding: 0;
border: none;
background: transparent;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
min-width: 0;
}
.card-session-files:hover {
@@ -444,16 +453,11 @@
opacity: 0.7;
}
.card-time-row {
display: flex;
justify-content: flex-end;
margin-top: var(--space-sm);
}
.card-time-indicator {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
margin-left: auto;
padding: var(--space-xs) var(--space-sm);
border: 1px solid color-mix(in srgb, var(--text-muted) 30%, transparent);
border-radius: var(--radius-pill);
@@ -944,8 +948,9 @@
padding: var(--space-xs) 0;
}
.card-time-row {
.card-footer-row {
margin-top: var(--space-xs);
gap: var(--space-xs);
}
.card-time-indicator {

View File

@@ -898,6 +898,86 @@ function TaskCardComponent({
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
const filesChangedButton = (() => {
if (task.worktree && task.column === "in-progress") {
const activeCount = diffStats?.filesChanged;
if (activeCount == null || activeCount === 0) {
return null;
}
return (
<button
type="button"
className="card-session-files"
onClick={handleOpenFiles}
disabled={!onOpenDetailWithTab}
>
<Folder size={12} />
<span>{activeCount} {activeCount === 1 ? "file" : "files"} changed</span>
</button>
);
}
if (task.column === "in-review") {
const reviewDiffCount = diffStats?.filesChanged;
const fallbackCount = reviewDiffCount == null ? task.modifiedFiles?.length : undefined;
const displayCount = reviewDiffCount ?? fallbackCount;
if (displayCount == null || displayCount === 0) {
return null;
}
return (
<button
type="button"
className="card-session-files"
onClick={handleOpenFiles}
disabled={!onOpenDetailWithTab}
>
<Folder size={12} />
<span>{displayCount} {displayCount === 1 ? "file" : "files"} changed</span>
</button>
);
}
if (task.column === "done") {
// Prefer diff stats from the same endpoint the modal uses so the
// count is always consistent with the Changes tab.
const diffCount = diffStats?.filesChanged;
const mergedCount = task.mergeDetails?.filesChanged;
const displayCount = diffCount ?? mergedCount;
if (displayCount != null && displayCount > 0) {
return (
<button
type="button"
className="card-session-files"
onClick={handleOpenFiles}
disabled={!onOpenDetailWithTab}
>
<Folder size={12} />
<span>{displayCount} {displayCount === 1 ? "file" : "files"} changed</span>
</button>
);
}
const modifiedCount = task.modifiedFiles?.length;
if (modifiedCount != null && modifiedCount > 0) {
return (
<button
type="button"
className="card-session-files"
onClick={handleOpenFiles}
disabled={!onOpenDetailWithTab}
>
<Folder size={12} />
<span>{modifiedCount} {modifiedCount === 1 ? "file" : "files"} changed</span>
</button>
);
}
}
return null;
})();
if (isEditing) {
return (
<div
@@ -1166,87 +1246,19 @@ function TaskCardComponent({
</>
);
})()}
{task.worktree && task.column === "in-progress" && (() => {
const activeCount = diffStats?.filesChanged;
if (activeCount == null || activeCount === 0) {
return null;
}
return (
<button
type="button"
className="card-session-files"
onClick={handleOpenFiles}
disabled={!onOpenDetailWithTab}
>
<Folder size={12} />
<span>{activeCount} {activeCount === 1 ? "file" : "files"} changed</span>
</button>
);
})()}
{task.column === "in-review" && (() => {
const reviewDiffCount = diffStats?.filesChanged;
const fallbackCount = reviewDiffCount == null ? task.modifiedFiles?.length : undefined;
const displayCount = reviewDiffCount ?? fallbackCount;
if (displayCount == null || displayCount === 0) {
return null;
}
return (
<button
type="button"
className="card-session-files"
onClick={handleOpenFiles}
disabled={!onOpenDetailWithTab}
>
<Folder size={12} />
<span>{displayCount} {displayCount === 1 ? "file" : "files"} changed</span>
</button>
);
})()}
{task.column === "done" && (() => {
// Prefer diff stats from the same endpoint the modal uses so the
// count is always consistent with the Changes tab.
const diffCount = diffStats?.filesChanged;
const mergedCount = task.mergeDetails?.filesChanged;
const displayCount = diffCount ?? mergedCount;
if (displayCount != null && displayCount > 0) {
return (
<button
type="button"
className="card-session-files"
onClick={handleOpenFiles}
disabled={!onOpenDetailWithTab}
{(filesChangedButton || timeIndicator) && (
<div className="card-footer-row">
{filesChangedButton}
{timeIndicator && (
<span
className="card-time-indicator"
title={timeIndicator.title}
aria-label={`Elapsed time ${timeIndicator.label}. ${timeIndicator.title}`}
>
<Folder size={12} />
<span>{displayCount} {displayCount === 1 ? "file" : "files"} changed</span>
</button>
);
}
const modifiedCount = task.modifiedFiles?.length;
if (modifiedCount != null && modifiedCount > 0) {
return (
<button
type="button"
className="card-session-files"
onClick={handleOpenFiles}
disabled={!onOpenDetailWithTab}
>
<Folder size={12} />
<span>{modifiedCount} {modifiedCount === 1 ? "file" : "files"} changed</span>
</button>
);
}
return null;
})()}
{timeIndicator && (
<div className="card-time-row">
<span
className="card-time-indicator"
title={timeIndicator.title}
aria-label={`Elapsed time ${timeIndicator.label}. ${timeIndicator.title}`}
>
<Clock size={12} />
<span>{timeIndicator.label}</span>
</span>
<Clock size={12} />
<span>{timeIndicator.label}</span>
</span>
)}
</div>
)}
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (

View File

@@ -488,6 +488,42 @@ describe("TaskCard", () => {
expect(timer?.textContent).toContain("3h");
});
it("renders files-changed metadata and timer chip in the same footer row", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
column: "done",
columnMovedAt: "2026-04-25T15:00:00.000Z",
updatedAt: "2026-04-25T14:00:00.000Z",
createdAt: "2026-04-25T13:00:00.000Z",
mergeDetails: {
commitSha: "abc123",
filesChanged: 4,
insertions: 10,
deletions: 2,
mergedAt: "2026-04-25T15:00:00.000Z",
mergeConfirmed: true,
},
})}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
const footerRow = container.querySelector(".card-footer-row");
const filesChanged = container.querySelector(".card-session-files");
const timer = container.querySelector(".card-time-indicator");
expect(footerRow).not.toBeNull();
expect(filesChanged).not.toBeNull();
expect(timer).not.toBeNull();
expect(footerRow?.contains(filesChanged)).toBe(true);
expect(footerRow?.contains(timer)).toBe(true);
});
it.each(["triage", "todo", "in-review", "archived"] as const)(
"does not render timer chip for %s cards",
(column) => {