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:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
27
packages/dashboard/app/utils/stalePausedReviewCopy.ts
Normal file
27
packages/dashboard/app/utils/stalePausedReviewCopy.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user