feat(FN-4452): complete Step 4 — surface stale paused review in dashboard

Fusion-Task-Id: FN-4452
Fusion-Task-Lineage: 7d8b1c13-883b-4815-94ee-fdf18ad24e5f
This commit is contained in:
Fusion
2026-05-14 18:29:16 -07:00
committed by gsxdsm
parent 4fbc3be27d
commit 273fc7018a
8 changed files with 228 additions and 4 deletions

View File

@@ -286,6 +286,7 @@ export function ListView({
// Hide done tasks state - initialize from localStorage
const [hideDoneTasks, setHideDoneTasks] = useState<boolean>(() => readHideDoneTasks(projectId));
const [staleOnlyFilter, setStaleOnlyFilter] = useState<boolean>(() => readStaleOnlyFilter(projectId));
const [stalePausedReviewOnlyFilter, setStalePausedReviewOnlyFilter] = useState<boolean>(false);
// Collapsed sections state - initialize from localStorage
const [collapsedSections, setCollapsedSections] = useState<Set<Column>>(() =>
@@ -335,6 +336,7 @@ export function ListView({
setVisibleColumns(readVisibleColumns(projectId));
setHideDoneTasks(readHideDoneTasks(projectId));
setStaleOnlyFilter(readStaleOnlyFilter(projectId));
setStalePausedReviewOnlyFilter(false);
setCollapsedSections(readCollapsedSections(projectId));
setSelectedTaskIds(readSelectedTaskIds(projectId));
const persistedSelection = readSelectedTaskId(projectId);
@@ -533,6 +535,9 @@ export function ListView({
if (staleOnlyFilter) {
filtered = filtered.filter((t) => t.ageStaleness != null);
}
if (stalePausedReviewOnlyFilter) {
filtered = filtered.filter((t) => t.stalePausedReview != null);
}
// Then apply column filter if selected
const columnFiltered = selectedColumn
@@ -579,7 +584,7 @@ export function ListView({
});
}
return groups;
}, [tasks, searchQuery, sortField, sortDirection, hideDoneTasks, staleOnlyFilter, selectedColumn]);
}, [tasks, searchQuery, sortField, sortDirection, hideDoneTasks, staleOnlyFilter, stalePausedReviewOnlyFilter, selectedColumn]);
// Calculate total filtered count from groups
const filteredCount = useMemo(() => {
@@ -1270,6 +1275,14 @@ export function ListView({
>
{staleOnlyFilter ? "Show all" : "Stale only"}
</button>
<button
className="btn btn-sm list-hide-done-toggle"
onClick={() => setStalePausedReviewOnlyFilter((prev) => !prev)}
aria-pressed={stalePausedReviewOnlyFilter}
title={stalePausedReviewOnlyFilter ? "Show all tasks" : "Show stale paused review tasks only"}
>
{stalePausedReviewOnlyFilter ? "Show all" : "Stale paused review"}
</button>
<div className="list-drop-zones list-drop-zones--sidebar">
{COLUMNS.map((column) => {
const totalCount = tasks.filter((t) => t.column === column).length;
@@ -1370,6 +1383,7 @@ export function ListView({
) : null}
{hideDoneTasks ? <span className="list-sidebar-chip">Done hidden</span> : null}
{staleOnlyFilter ? <span className="list-sidebar-chip">Stale only</span> : null}
{stalePausedReviewOnlyFilter ? <span className="list-sidebar-chip">Stale paused review</span> : null}
{bulkEditEnabled ? (
<span className="list-sidebar-chip">Bulk edit</span>
) : null}

View File

@@ -252,6 +252,12 @@
border-color: color-mix(in srgb, var(--color-warning) 45%, transparent);
}
.card-status-badge.stale-paused-review {
background: color-mix(in srgb, var(--color-error) 14%, transparent);
color: var(--color-error);
border-color: color-mix(in srgb, var(--color-error) 45%, transparent);
}
.card-status-badge.card-task-age-staleness-badge--warning {
background: color-mix(in srgb, var(--color-warning) 14%, transparent);
color: var(--color-warning);

View File

@@ -20,6 +20,7 @@ import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
import { isTaskStuck } from "../utils/taskStuck";
import { getStalledReviewSignal } from "../utils/taskStalledReview";
import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inReviewStallCopy";
import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy";
import { getTaskAgeStalenessCopy, shouldShowTaskAgeStalenessBadge } from "../utils/taskAgeStalenessCopy";
import { getUnifiedTaskProgress } from "../utils/taskProgress";
import { getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
@@ -767,6 +768,8 @@ function TaskCardComponent({
const showStalledReview = Boolean(stalledReview && task.column === "in-review" && !isPaused);
const hasInReviewStall = shouldShowInReviewStallBadge(task);
const stallCopy = task.inReviewStall ? getInReviewStallCopy(task.inReviewStall) : undefined;
const hasStalePausedReview = shouldShowStalePausedReviewBadge(task);
const stalePausedReviewCopy = task.stalePausedReview ? getStalePausedReviewCopy(task.stalePausedReview) : undefined;
const hasTaskAgeStaleness = shouldShowTaskAgeStalenessBadge(task);
const taskAgeStalenessCopy = getTaskAgeStalenessCopy(task.ageStaleness);
const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval";
@@ -1476,6 +1479,15 @@ function TaskCardComponent({
{stallCopy.badgeLabel}
</span>
)}
{hasStalePausedReview && stalePausedReviewCopy && (
<span
className={`card-status-badge card-status-badge--in-review stale-paused-review stale-paused-review--${stalePausedReviewCopy.code}`}
title={`${stalePausedReviewCopy.headline}${stalePausedReviewCopy.description}`}
data-stale-paused-review-code={stalePausedReviewCopy.code}
>
{stalePausedReviewCopy.badgeLabel}
</span>
)}
{hasTaskAgeStaleness && taskAgeStalenessCopy && (
<span
className={`card-status-badge card-task-age-staleness-badge card-task-age-staleness-badge--${taskAgeStalenessCopy.badgeTone}`}

View File

@@ -7,7 +7,7 @@ import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction, InReviewStallCode } from "@fusion/core";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core";
import {
COLUMN_LABELS,
DEFAULT_TASK_PRIORITY,
@@ -46,6 +46,7 @@ import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
import { resolveEffectiveGithubRepoDefault } from "./githubTracking";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inReviewStallCopy";
import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy";
import { getTaskAgeStalenessCopy } from "../utils/taskAgeStalenessCopy";
import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/findInReviewStallLogEntry";
@@ -55,6 +56,7 @@ interface ModelSelection {
}
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
const STALE_PAUSED_REVIEW_LOG_REGEX = /^Stale paused review surfaced \[([^\]]+)\]/;
const markdownLinkifyComponents: Components = {
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
@@ -551,7 +553,7 @@ export function TaskDetailContent({
}, [task.id]);
const [logSubview, setLogSubview] = useState<"activity" | "agent-log">("activity");
const [highlightStallCode, setHighlightStallCode] = useState<InReviewStallCode | null>(null);
const [highlightStallCode, setHighlightStallCode] = useState<string | null>(null);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
const [uploading, setUploading] = useState(false);
@@ -2442,7 +2444,8 @@ export function TaskDetailContent({
{(() => {
let highlightedOnce = false;
return [...workingTask.log].reverse().map((entry, i) => {
const stallMatch = entry.action.match(IN_REVIEW_STALL_LOG_REGEX);
const stallMatch = entry.action.match(IN_REVIEW_STALL_LOG_REGEX)
?? entry.action.match(STALE_PAUSED_REVIEW_LOG_REGEX);
const isHighlighted = !highlightedOnce
&& highlightStallCode != null
&& stallMatch?.[1] === highlightStallCode;
@@ -3203,6 +3206,49 @@ export function TaskDetailContent({
</div>
);
})()}
{shouldShowStalePausedReviewBadge(workingTask) && workingTask.stalePausedReview && (() => {
const copy = getStalePausedReviewCopy(workingTask.stalePausedReview);
const logMatch = [...(workingTask.log ?? [])].reverse().find((entry) => {
const match = entry.action.match(STALE_PAUSED_REVIEW_LOG_REGEX);
return match?.[1] === workingTask.stalePausedReview?.code;
});
return (
<div
className={`detail-section detail-in-review-stall detail-in-review-stall--${copy.code}`}
data-stall-code={copy.code}
>
<div className="detail-in-review-stall-header">
<span className="card-status-badge card-status-badge--in-review stale-paused-review">
{copy.badgeLabel}
</span>
<span className="detail-in-review-stall-headline">{copy.headline}</span>
</div>
<div className="detail-in-review-stall-reason">{workingTask.stalePausedReview.reason}</div>
<div className="detail-in-review-stall-description">{copy.description}</div>
<div className="detail-in-review-stall-action">{copy.suggestedAction}</div>
<div className="detail-in-review-stall-meta">
<span>Age {formatDurationCompact(workingTask.stalePausedReview.ageMs)}</span>
<span>Threshold {formatDurationCompact(workingTask.stalePausedReview.thresholdMs)}</span>
<span>Observed {formatTimestamp(workingTask.stalePausedReview.observedAt)}</span>
{logMatch ? (
<button
type="button"
className="btn btn-sm detail-in-review-stall-jump"
onClick={() => {
setActiveTab("logs");
setLogSubview("activity");
setHighlightStallCode(workingTask.stalePausedReview?.code ?? null);
}}
>
View activity log
</button>
) : (
<span className="detail-in-review-stall-no-log">No log entry yet</span>
)}
</div>
</div>
);
})()}
<div className="detail-section detail-pr-section">
<PrSection
taskId={task.id}

View File

@@ -1781,6 +1781,31 @@ describe("ListView Hide Done Tasks", () => {
expect(screen.queryByText("FN-002")).toBeNull();
});
it("filters tasks when stale paused review filter is enabled", () => {
const tasks = [
createMockTask({
id: "FN-001",
column: "in-review",
paused: true,
stalePausedReview: {
code: "stale-paused-review",
reason: "Task has remained paused in review beyond threshold",
observedAt: "2026-05-14T00:00:00.000Z",
ageMs: 86_400_000,
thresholdMs: 86_400_000,
} as any,
}),
createMockTask({ id: "FN-002", column: "in-review", paused: true, stalePausedReview: undefined }),
];
renderListView({ tasks });
fireEvent.click(screen.getByRole("button", { name: /stale paused review/i }));
expect(screen.getByText("FN-001")).toBeDefined();
expect(screen.queryByText("FN-002")).toBeNull();
});
it("persists stale-only preference to localStorage", () => {
renderListView({ tasks: [createMockTask({ id: "FN-001", column: "in-progress" })] });
fireEvent.click(screen.getByRole("button", { name: /stale only/i }));

View File

@@ -513,6 +513,56 @@ describe("TaskCard", () => {
expect(screen.queryByText("Stall")).toBeNull();
});
it("renders stale paused review badge for paused in-review signal", () => {
render(
<TaskCard
task={makeTask({
column: "in-review",
paused: true,
stalePausedReview: {
code: "stale-paused-review",
reason: "Task has remained paused in review beyond threshold",
observedAt: "2026-05-14T00:00:00.000Z",
ageMs: 86_400_000,
thresholdMs: 86_400_000,
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Paused stall")).toBeDefined();
});
it("hides stale paused review badge when signal missing", () => {
render(
<TaskCard task={makeTask({ column: "in-review", paused: true, stalePausedReview: undefined })} onOpenDetail={noop} addToast={noop} />,
);
expect(screen.queryByText("Paused stall")).toBeNull();
});
it("hides stale paused review badge when task is not paused", () => {
render(
<TaskCard
task={makeTask({
column: "in-review",
paused: false,
stalePausedReview: {
code: "stale-paused-review",
reason: "Task has remained paused in review beyond threshold",
observedAt: "2026-05-14T00:00:00.000Z",
ageMs: 86_400_000,
thresholdMs: 86_400_000,
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByText("Paused stall")).toBeNull();
});
it("renders warning task-age staleness badge", () => {
render(
<TaskCard

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../stalePausedReviewCopy";
describe("stalePausedReviewCopy", () => {
it("returns populated copy", () => {
const copy = getStalePausedReviewCopy({
code: "stale-paused-review",
reason: "reason",
observedAt: "2026-05-14T00:00:00.000Z",
ageMs: 86_400_000,
thresholdMs: 86_400_000,
});
expect(copy.badgeLabel).toBe("Paused stall");
expect(copy.headline.length).toBeGreaterThan(0);
expect(copy.description.length).toBeGreaterThan(0);
expect(copy.suggestedAction).toContain("unpause");
});
it.each([
{ column: "in-review", paused: false, stalePausedReview: { code: "stale-paused-review" } },
{ column: "todo", paused: true, stalePausedReview: { code: "stale-paused-review" } },
{ column: "in-review", paused: true, stalePausedReview: undefined },
] as const)("hides badge for non-canonical visibility cases", (task) => {
expect(shouldShowStalePausedReviewBadge(task as any)).toBe(false);
});
it("shows badge only for paused in-review task with signal", () => {
expect(
shouldShowStalePausedReviewBadge({
column: "in-review",
paused: true,
stalePausedReview: {
code: "stale-paused-review",
reason: "r",
observedAt: "2026-05-14T00:00:00.000Z",
ageMs: 86_400_000,
thresholdMs: 86_400_000,
},
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,27 @@
import type { StalePausedReviewCode, StalePausedReviewSignal, Task } from "@fusion/core";
export interface StalePausedReviewCopy {
badgeLabel: string;
headline: string;
description: string;
suggestedAction: string;
code: StalePausedReviewCode;
}
const BADGE_LABEL = "Paused stall";
export function getStalePausedReviewCopy(signal: StalePausedReviewSignal): StalePausedReviewCopy {
return {
badgeLabel: BADGE_LABEL,
code: signal.code,
headline: "Paused in review beyond threshold",
description: "This task has remained paused in in-review beyond the configured stale paused review threshold.",
suggestedAction: "Disposition options: unpause, retry, archive, or create follow-up task.",
};
}
export function shouldShowStalePausedReviewBadge(
task: Pick<Task, "column" | "paused" | "stalePausedReview">,
): boolean {
return task.column === "in-review" && task.paused === true && task.stalePausedReview != null;
}