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