feat(FN-4465): complete Step 4 — dashboard stale signal surfacing

Fusion-Task-Id: FN-4465
Fusion-Task-Lineage: 4365d4cb-ab93-4ed5-add9-3e14b26237ad
This commit is contained in:
Fusion
2026-05-14 05:44:25 -07:00
committed by gsxdsm
parent 3577a8babd
commit 927a32182d
8 changed files with 246 additions and 1 deletions

View File

@@ -88,6 +88,19 @@ function readHideDoneTasks(projectId?: string): boolean {
return false;
}
function readStaleOnlyFilter(projectId?: string): boolean {
try {
const saved = getScopedItem("kb-dashboard-stale-only-filter", projectId);
if (saved !== null) {
return saved === "true";
}
} catch {
// Invalid localStorage data - fall through to default
}
return false;
}
function readCollapsedSections(projectId?: string): Set<Column> {
try {
const saved = getScopedItem("kb-dashboard-list-collapsed", projectId);
@@ -272,6 +285,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));
// Collapsed sections state - initialize from localStorage
const [collapsedSections, setCollapsedSections] = useState<Set<Column>>(() =>
@@ -292,6 +306,13 @@ export function ListView({
}
}, [hideDoneTasks, projectId]);
// Persist stale-only filter state to localStorage
useEffect(() => {
if (typeof window !== "undefined") {
setScopedItem("kb-dashboard-stale-only-filter", staleOnlyFilter.toString(), projectId);
}
}, [projectId, staleOnlyFilter]);
// Persist collapsed sections state to localStorage
useEffect(() => {
if (typeof window !== "undefined") {
@@ -313,6 +334,7 @@ export function ListView({
useEffect(() => {
setVisibleColumns(readVisibleColumns(projectId));
setHideDoneTasks(readHideDoneTasks(projectId));
setStaleOnlyFilter(readStaleOnlyFilter(projectId));
setCollapsedSections(readCollapsedSections(projectId));
setSelectedTaskIds(readSelectedTaskIds(projectId));
const persistedSelection = readSelectedTaskId(projectId);
@@ -506,6 +528,11 @@ export function ListView({
filtered = filtered.filter((t) => t.column !== "done" && t.column !== "archived");
}
// Then apply stale-only filter if selected
if (staleOnlyFilter) {
filtered = filtered.filter((t) => t.ageStaleness != null);
}
// Then apply column filter if selected
const columnFiltered = selectedColumn
? filtered.filter((t) => t.column === selectedColumn)
@@ -548,7 +575,7 @@ export function ListView({
});
}
return groups;
}, [tasks, searchQuery, sortField, sortDirection, hideDoneTasks, selectedColumn]);
}, [tasks, searchQuery, sortField, sortDirection, hideDoneTasks, staleOnlyFilter, selectedColumn]);
// Calculate total filtered count from groups
const filteredCount = useMemo(() => {
@@ -1231,6 +1258,14 @@ export function ListView({
{hideDoneTasks ? <Eye size={14} /> : <EyeOff size={14} />}
{hideDoneTasks ? "Show Done" : "Hide Done"}
</button>
<button
className="btn btn-sm list-hide-done-toggle"
onClick={() => setStaleOnlyFilter((prev) => !prev)}
aria-pressed={staleOnlyFilter}
title={staleOnlyFilter ? "Show all tasks" : "Show stale tasks only"}
>
{staleOnlyFilter ? "Show all" : "Stale only"}
</button>
<div className="list-drop-zones list-drop-zones--sidebar">
{COLUMNS.map((column) => {
const totalCount = tasks.filter((t) => t.column === column).length;
@@ -1330,6 +1365,7 @@ export function ListView({
</button>
) : null}
{hideDoneTasks ? <span className="list-sidebar-chip">Done hidden</span> : null}
{staleOnlyFilter ? <span className="list-sidebar-chip">Stale only</span> : null}
{bulkEditEnabled ? (
<span className="list-sidebar-chip">Bulk edit</span>
) : null}

View File

@@ -214,6 +214,18 @@
border-color: color-mix(in srgb, var(--color-warning) 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);
border-color: color-mix(in srgb, var(--color-warning) 45%, transparent);
}
.card-status-badge.card-task-age-staleness-badge--critical {
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.paused {
background: var(--status-done-bg-deep);
color: var(--text-muted);

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 { getTaskAgeStalenessCopy, shouldShowTaskAgeStalenessBadge } from "../utils/taskAgeStalenessCopy";
import { getUnifiedTaskProgress } from "../utils/taskProgress";
import { getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
import type { ToastType } from "../hooks/useToast";
@@ -472,6 +473,14 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previousTask.stalledReview?.matchCount === nextTask.stalledReview?.matchCount &&
previousTask.stalledReview?.firstMatchAt === nextTask.stalledReview?.firstMatchAt &&
previousTask.stalledReview?.lastMatchAt === nextTask.stalledReview?.lastMatchAt &&
previousTask.ageStaleness?.level === nextTask.ageStaleness?.level &&
previousTask.ageStaleness?.reason === nextTask.ageStaleness?.reason &&
previousTask.ageStaleness?.observedAt === nextTask.ageStaleness?.observedAt &&
previousTask.ageStaleness?.ageMs === nextTask.ageStaleness?.ageMs &&
previousTask.ageStaleness?.warningThresholdMs === nextTask.ageStaleness?.warningThresholdMs &&
previousTask.ageStaleness?.criticalThresholdMs === nextTask.ageStaleness?.criticalThresholdMs &&
previousTask.ageStaleness?.column === nextTask.ageStaleness?.column &&
previousTask.ageStaleness?.paused === nextTask.ageStaleness?.paused &&
areAttachmentsEqual(previousTask.attachments, nextTask.attachments) &&
areCommentsEqual(previousTask.comments, nextTask.comments) &&
areTaskDependenciesEqual(previousTask.dependencies, nextTask.dependencies) &&
@@ -754,6 +763,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 hasTaskAgeStaleness = shouldShowTaskAgeStalenessBadge(task);
const taskAgeStalenessCopy = getTaskAgeStalenessCopy(task.ageStaleness);
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));
@@ -1455,6 +1466,14 @@ function TaskCardComponent({
{stallCopy.badgeLabel}
</span>
)}
{hasTaskAgeStaleness && taskAgeStalenessCopy && (
<span
className={`card-status-badge card-task-age-staleness-badge card-task-age-staleness-badge--${taskAgeStalenessCopy.badgeTone}`}
title={`${taskAgeStalenessCopy.headline} — ${taskAgeStalenessCopy.description}`}
>
{taskAgeStalenessCopy.badgeLabel}
</span>
)}
{isStuck && (isPaused || !task.status || task.status === "queued") && (
<span className="card-status-badge stuck">
Stuck

View File

@@ -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 { getTaskAgeStalenessCopy } from "../utils/taskAgeStalenessCopy";
import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/findInReviewStallLogEntry";
interface ModelSelection {
@@ -260,6 +261,16 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDurationCompact(ageMs: number): string {
const totalMinutes = Math.max(1, Math.floor(ageMs / 60_000));
const days = Math.floor(totalMinutes / (24 * 60));
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
const minutes = totalMinutes % 60;
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
type TabId = "definition" | "logs" | "changes" | "review" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | `plugin-${string}`;
export interface TaskDetailModalProps {
@@ -3023,6 +3034,25 @@ export function TaskDetailContent({
<div className="detail-empty-inline">(no downstream tasks blocked)</div>
)}
</div>
{workingTask.ageStaleness && (() => {
const copy = getTaskAgeStalenessCopy(workingTask.ageStaleness);
if (!copy) return null;
return (
<div className="detail-section">
<div className="detail-sidebar-title">Task age staleness</div>
<div>{copy.headline}</div>
<div className="detail-description">{copy.description}</div>
<div className="detail-in-review-stall-meta">
<span>Column {workingTask.ageStaleness.column}</span>
<span>Age {formatDurationCompact(workingTask.ageStaleness.ageMs)}</span>
<span>Warning {formatDurationCompact(workingTask.ageStaleness.warningThresholdMs)}</span>
<span>Critical {formatDurationCompact(workingTask.ageStaleness.criticalThresholdMs)}</span>
<span>Observed {formatTimestamp(workingTask.ageStaleness.observedAt)}</span>
<span>{workingTask.ageStaleness.paused ? "Paused" : "Active"}</span>
</div>
</div>
);
})()}
{/* PR Section - only for in-review tasks */}
{task.column === "in-review" && (
<>

View File

@@ -1767,6 +1767,26 @@ describe("ListView Hide Done Tasks", () => {
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-hide-done"))).toBe("true");
});
it("filters tasks when stale only is enabled", () => {
const tasks = [
createMockTask({ id: "FN-001", column: "in-progress", ageStaleness: { level: "warning", reason: "r", observedAt: "2026-05-14T00:00:00.000Z", ageMs: 5 * 60 * 60_000, warningThresholdMs: 4 * 60 * 60_000, criticalThresholdMs: 24 * 60 * 60_000, column: "in-progress", paused: false } as any }),
createMockTask({ id: "FN-002", column: "in-progress", ageStaleness: undefined }),
];
renderListView({ tasks });
fireEvent.click(screen.getByRole("button", { name: /stale only/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 }));
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-stale-only-filter"))).toBe("true");
});
it("initializes hide done state from localStorage", () => {
// Set up localStorage with hide done enabled
localStorage.setItem(scopedStorageKey("kb-dashboard-hide-done"), "true");

View File

@@ -509,6 +509,58 @@ describe("TaskCard", () => {
expect(screen.queryByText("Stall")).toBeNull();
});
it("renders warning task-age staleness badge", () => {
render(
<TaskCard
task={makeTask({
ageStaleness: {
level: "warning",
reason: "in-progress age exceeded warning threshold",
observedAt: "2026-05-14T00:00:00.000Z",
ageMs: 5 * 60 * 60_000,
warningThresholdMs: 4 * 60 * 60_000,
criticalThresholdMs: 24 * 60 * 60_000,
column: "in-progress",
paused: false,
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Stale")).toBeDefined();
});
it("renders critical task-age staleness badge", () => {
render(
<TaskCard
task={makeTask({
ageStaleness: {
level: "critical",
reason: "in-review age exceeded critical threshold",
observedAt: "2026-05-14T00:00:00.000Z",
ageMs: 80 * 60 * 60_000,
warningThresholdMs: 24 * 60 * 60_000,
criticalThresholdMs: 72 * 60 * 60_000,
column: "in-review",
paused: true,
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Stale (critical)")).toBeDefined();
});
it("hides task-age staleness badge when signal is absent", () => {
render(<TaskCard task={makeTask({ ageStaleness: undefined })} onOpenDetail={noop} addToast={noop} />);
expect(screen.queryByText("Stale")).toBeNull();
expect(screen.queryByText("Stale (critical)")).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,39 @@
import { describe, expect, it } from "vitest";
import { getTaskAgeStalenessCopy, shouldShowTaskAgeStalenessBadge } from "../taskAgeStalenessCopy";
describe("taskAgeStalenessCopy", () => {
it("returns warning copy", () => {
const copy = getTaskAgeStalenessCopy({
level: "warning",
reason: "",
observedAt: "2026-05-14T00:00:00.000Z",
ageMs: 26 * 60 * 60_000,
warningThresholdMs: 24 * 60 * 60_000,
criticalThresholdMs: 72 * 60 * 60_000,
column: "in-review",
paused: false,
});
expect(copy?.badgeTone).toBe("warning");
expect(copy?.description).not.toContain("while paused");
});
it("returns critical + paused phrasing", () => {
const copy = getTaskAgeStalenessCopy({
level: "critical",
reason: "",
observedAt: "2026-05-14T00:00:00.000Z",
ageMs: 80 * 60 * 60_000,
warningThresholdMs: 24 * 60 * 60_000,
criticalThresholdMs: 72 * 60 * 60_000,
column: "in-review",
paused: true,
});
expect(copy?.badgeTone).toBe("critical");
expect(copy?.description).toContain("while paused");
});
it("returns null/false when absent", () => {
expect(getTaskAgeStalenessCopy(undefined)).toBeNull();
expect(shouldShowTaskAgeStalenessBadge({ ageStaleness: undefined })).toBe(false);
});
});

View File

@@ -0,0 +1,37 @@
import type { Task, TaskAgeStalenessSignal } from "@fusion/core";
export interface TaskAgeStalenessCopy {
badgeLabel: string;
badgeTone: "warning" | "critical";
headline: string;
description: string;
}
function formatAge(ageMs: number): string {
const totalMinutes = Math.max(1, Math.floor(ageMs / 60_000));
const days = Math.floor(totalMinutes / 1440);
const hours = Math.floor((totalMinutes % 1440) / 60);
const minutes = totalMinutes % 60;
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0 && days === 0) parts.push(`${minutes}m`);
return parts.join(" ");
}
export function getTaskAgeStalenessCopy(signal: TaskAgeStalenessSignal | undefined | null): TaskAgeStalenessCopy | null {
if (!signal) return null;
const ageLabel = formatAge(signal.ageMs);
const pausedNote = signal.paused ? " while paused" : "";
const tone = signal.level === "critical" ? "critical" : "warning";
return {
badgeLabel: tone === "critical" ? "Stale (critical)" : "Stale",
badgeTone: tone,
headline: `${signal.column} task stale for ${ageLabel}`,
description: `Task has been stale for ${ageLabel}${pausedNote}. Thresholds: warning ${formatAge(signal.warningThresholdMs)}, critical ${formatAge(signal.criticalThresholdMs)}.`,
};
}
export function shouldShowTaskAgeStalenessBadge(task: Pick<Task, "ageStaleness">): boolean {
return task.ageStaleness != null;
}