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);
|
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 {
|
.card-session-files {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-top: var(--space-sm);
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-session-files:hover {
|
.card-session-files:hover {
|
||||||
@@ -444,16 +453,11 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-time-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-time-indicator {
|
.card-time-indicator {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
|
margin-left: auto;
|
||||||
padding: var(--space-xs) var(--space-sm);
|
padding: var(--space-xs) var(--space-sm);
|
||||||
border: 1px solid color-mix(in srgb, var(--text-muted) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--text-muted) 30%, transparent);
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
@@ -944,8 +948,9 @@
|
|||||||
padding: var(--space-xs) 0;
|
padding: var(--space-xs) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-time-row {
|
.card-footer-row {
|
||||||
margin-top: var(--space-xs);
|
margin-top: var(--space-xs);
|
||||||
|
gap: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-time-indicator {
|
.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 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) {
|
if (isEditing) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1166,87 +1246,19 @@ function TaskCardComponent({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{task.worktree && task.column === "in-progress" && (() => {
|
{(filesChangedButton || timeIndicator) && (
|
||||||
const activeCount = diffStats?.filesChanged;
|
<div className="card-footer-row">
|
||||||
if (activeCount == null || activeCount === 0) {
|
{filesChangedButton}
|
||||||
return null;
|
{timeIndicator && (
|
||||||
}
|
<span
|
||||||
return (
|
className="card-time-indicator"
|
||||||
<button
|
title={timeIndicator.title}
|
||||||
type="button"
|
aria-label={`Elapsed time ${timeIndicator.label}. ${timeIndicator.title}`}
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
<Folder size={12} />
|
<Clock size={12} />
|
||||||
<span>{displayCount} {displayCount === 1 ? "file" : "files"} changed</span>
|
<span>{timeIndicator.label}</span>
|
||||||
</button>
|
</span>
|
||||||
);
|
)}
|
||||||
}
|
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
|
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
|
||||||
|
|||||||
@@ -488,6 +488,42 @@ describe("TaskCard", () => {
|
|||||||
expect(timer?.textContent).toContain("3h");
|
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)(
|
it.each(["triage", "todo", "in-review", "archived"] as const)(
|
||||||
"does not render timer chip for %s cards",
|
"does not render timer chip for %s cards",
|
||||||
(column) => {
|
(column) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user