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:
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
5
packages/dashboard/app/utils/taskStalledReview.ts
Normal file
5
packages/dashboard/app/utils/taskStalledReview.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { Task, StalledReviewSignal } from "@fusion/core";
|
||||
|
||||
export function getStalledReviewSignal(task: Task): StalledReviewSignal | undefined {
|
||||
return task.stalledReview;
|
||||
}
|
||||
Reference in New Issue
Block a user