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:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" && (
|
||||
<>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
37
packages/dashboard/app/utils/taskAgeStalenessCopy.ts
Normal file
37
packages/dashboard/app/utils/taskAgeStalenessCopy.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user