feat(FN-4168): complete Step 5 — add stalled review badge UI

Fusion-Task-Id: FN-4168
Fusion-Task-Lineage: 781ffc66-cb1c-42b9-85e1-e5cbe0655863
This commit is contained in:
Fusion
2026-05-13 03:07:29 -07:00
committed by gsxdsm
parent 7b2bb00d39
commit 71b6e58933
5 changed files with 76 additions and 0 deletions

View File

@@ -156,6 +156,12 @@
animation: stuck-pulse 2s ease-in-out infinite;
}
.card-status-badge.stalled-review {
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
color: var(--color-warning);
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-warning) 30%, transparent);
}
.card-status-badge.paused {
background: var(--status-done-bg-deep);
color: var(--text-muted);

View File

@@ -19,6 +19,7 @@ import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
import { isTaskStuck } from "../utils/taskStuck";
import { getStalledReviewSignal } from "../utils/taskStalledReview";
import { getUnifiedTaskProgress } from "../utils/taskProgress";
import { getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
import type { ToastType } from "../hooks/useToast";
@@ -744,6 +745,7 @@ function TaskCardComponent({
const normalizedPriority = normalizeTaskPriorityValue(task.priority);
const showPriorityBadge = normalizedPriority !== DEFAULT_TASK_PRIORITY;
const isStuck = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);
const stalledReview = getStalledReviewSignal(task);
const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval";
const isArchived = task.column === "archived";
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
@@ -1392,6 +1394,14 @@ function TaskCardComponent({
Stuck
</span>
)}
{stalledReview && task.column === "in-review" && !isPaused && (
<span
className="card-status-badge card-status-badge--in-review stalled-review"
title={stalledReview.reason}
>
Stalled
</span>
)}
{hasGitHubBadge && (
<GitHubBadge
prInfo={livePrInfo}

View File

@@ -176,6 +176,45 @@ describe("TaskCard", () => {
expect(container.querySelector(".card-status-badge")).toBeNull();
});
it("renders stalled badge with reason tooltip when stalledReview is set", () => {
render(
<TaskCard
task={makeTask({
column: "in-review",
status: "merging",
stalledReview: {
reason: "Re-enqueued for merge 3 times in the last 60 minutes without leaving in-review",
heuristic: "reenqueue-churn",
matchCount: 3,
firstMatchAt: "2026-05-12T11:00:00.000Z",
lastMatchAt: "2026-05-12T11:50:00.000Z",
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const stalledBadge = screen.getByText("Stalled");
expect(stalledBadge.getAttribute("title")).toContain("Re-enqueued for merge 3 times");
});
it("does not render stalled badge when stalledReview is undefined", () => {
render(
<TaskCard
task={makeTask({
column: "in-review",
status: "merging",
stalledReview: undefined,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByText("Stalled")).toBeNull();
});
it("shows paused by agent label when pausedByAgentId is set", () => {
render(
<TaskCard task={makeTask({ paused: true, pausedByAgentId: "agent-1" })} onOpenDetail={noop} addToast={noop} />,

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import type { Task } from "@fusion/core";
import { getStalledReviewSignal } from "../taskStalledReview";
describe("getStalledReviewSignal", () => {
it("returns task.stalledReview when present", () => {
const task = { stalledReview: { reason: "x", heuristic: "reenqueue-churn", matchCount: 3, firstMatchAt: "a", lastMatchAt: "b" } } as Task;
expect(getStalledReviewSignal(task)?.heuristic).toBe("reenqueue-churn");
});
it("returns undefined when stalledReview is absent", () => {
const task = {} as Task;
expect(getStalledReviewSignal(task)).toBeUndefined();
});
});

View File

@@ -0,0 +1,5 @@
import type { Task, StalledReviewSignal } from "@fusion/core";
export function getStalledReviewSignal(task: Task): StalledReviewSignal | undefined {
return task.stalledReview;
}