feat(FN-4199): complete Step 3 — add capacity risk banner

Fusion-Task-Id: FN-4199
Fusion-Task-Lineage: fd530f3d-ef2e-4684-ab05-5aaab6856f15
This commit is contained in:
Fusion
2026-05-13 04:05:43 -07:00
committed by gsxdsm
parent e8f2d0b41c
commit 2c50fb6b2d
5 changed files with 188 additions and 1 deletions

View File

@@ -1,5 +1,11 @@
import { useState, useCallback, useEffect, useMemo, useRef, lazy, Suspense } from "react";
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
import {
computeCapacityRisk,
DEFAULT_CAPACITY_RISK_TODO_THRESHOLD,
type Task,
type TaskDetail,
type WorkflowStep,
} from "@fusion/core";
import { Header, useViewportMode } from "./components/Header";
import { Board } from "./components/Board";
import { TaskCard } from "./components/TaskCard";
@@ -15,6 +21,7 @@ import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner";
import { SetupWarningBanner } from "./components/SetupWarningBanner";
import { CapacityRiskBanner } from "./components/CapacityRiskBanner";
import { TaskIdIntegrityBanner } from "./components/TaskIdIntegrityBanner";
import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner";
import { ApprovalNotificationBanner } from "./components/ApprovalNotificationBanner";
@@ -33,6 +40,7 @@ import { useBackgroundSessions } from "./hooks/useBackgroundSessions";
import { useSessionBannersHidden } from "./hooks/useSessionBannerPref";
import { useTasks } from "./hooks/useTasks";
import { useProjects } from "./hooks/useProjects";
import { useAgents } from "./hooks/useAgents";
import { useNodes } from "./hooks/useNodes";
import { useCurrentProject } from "./hooks/useCurrentProject";
import { ToastProvider, useToast } from "./hooks/useToast";
@@ -708,6 +716,7 @@ function AppInner() {
enginePaused,
taskStuckTimeoutMs,
staleHighFanoutBlockerAgeThresholdMs,
capacityRiskTodoThreshold,
showQuickChatFAB,
prAuthAvailable,
settingsLoaded,
@@ -722,6 +731,28 @@ function AppInner() {
refresh: refreshAppSettings,
} = useAppSettings(currentProject?.id);
const { stats: agentStats } = useAgents(currentProject?.id);
const inProgressCount = useMemo(
() => boardSourceTasks.filter((task) => task.column === "in-progress").length,
[boardSourceTasks],
);
const inReviewCount = useMemo(
() => boardSourceTasks.filter((task) => task.column === "in-review").length,
[boardSourceTasks],
);
const capacityRiskSignal = useMemo(
() =>
computeCapacityRisk({
todoCount: agentStats?.todoTaskCount ?? 0,
inProgressCount,
inReviewCount,
idleNonEphemeralAgentCount: agentStats?.idleNonEphemeralCount ?? 0,
threshold: capacityRiskTodoThreshold ?? DEFAULT_CAPACITY_RISK_TODO_THRESHOLD,
}),
[agentStats?.todoTaskCount, agentStats?.idleNonEphemeralCount, inProgressCount, inReviewCount, capacityRiskTodoThreshold],
);
const skillsEnabled = experimentalFeatures.skillsView === true;
const nodesEnabled = experimentalFeatures.nodesView === true;
const researchEnabled = experimentalFeatures.researchView === true;
@@ -1432,6 +1463,7 @@ function AppInner() {
if (taskView === "board") {
return (
<PageErrorBoundary>
<CapacityRiskBanner signal={capacityRiskSignal} />
<Board
tasks={filteredBoardTasks}
projectId={currentProject?.id}

View File

@@ -0,0 +1,49 @@
.capacity-risk-banner {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-md);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
border-inline-start: var(--space-xs) solid var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 8%, transparent);
}
.capacity-risk-banner__content {
color: var(--text);
line-height: 1.4;
}
.capacity-risk-banner__dismiss {
border: 0;
background: none;
color: var(--text-muted);
padding: var(--space-xs);
border-radius: var(--radius-sm);
line-height: 0;
flex-shrink: 0;
cursor: pointer;
transition: color var(--transition-fast), background var(--transition-fast);
}
.capacity-risk-banner__dismiss:hover {
color: var(--text);
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
}
.capacity-risk-banner__dismiss:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
@media (max-width: 768px) {
.capacity-risk-banner {
flex-direction: column;
align-items: stretch;
}
.capacity-risk-banner__dismiss {
align-self: flex-end;
}
}

View File

@@ -0,0 +1,32 @@
import type { CapacityRiskSignal } from "@fusion/core";
import { X } from "lucide-react";
import "./CapacityRiskBanner.css";
interface CapacityRiskBannerProps {
signal: CapacityRiskSignal | null;
onDismiss?: () => void;
}
export function CapacityRiskBanner({ signal, onDismiss }: CapacityRiskBannerProps) {
if (!signal || !signal.atRisk) {
return null;
}
return (
<div className={`capacity-risk-banner${onDismiss ? " capacity-risk-banner--dismissible" : ""}`} role="status" aria-live="polite">
<div className="capacity-risk-banner__content">
<strong>Capacity risk:</strong> Todo {signal.todoCount} (threshold {signal.threshold}) · In Progress {signal.inProgressCount} · In Review {signal.inReviewCount} · Idle agents {signal.idleNonEphemeralAgentCount}
</div>
{onDismiss ? (
<button
type="button"
className="capacity-risk-banner__dismiss touch-target"
aria-label="Dismiss capacity warning"
onClick={onDismiss}
>
<X aria-hidden="true" />
</button>
) : null}
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { CapacityRiskBanner } from "../CapacityRiskBanner";
describe("CapacityRiskBanner", () => {
it("renders nothing when signal is null", () => {
const { container } = render(<CapacityRiskBanner signal={null} />);
expect(container.firstChild).toBeNull();
});
it("renders nothing when signal is not at risk", () => {
const { container } = render(
<CapacityRiskBanner
signal={{
atRisk: false,
todoCount: 10,
inProgressCount: 2,
inReviewCount: 1,
idleNonEphemeralAgentCount: 1,
threshold: 20,
reason: "ok",
}}
/>,
);
expect(container.firstChild).toBeNull();
});
it("renders counts and threshold when at risk", () => {
render(
<CapacityRiskBanner
signal={{
atRisk: true,
todoCount: 21,
inProgressCount: 3,
inReviewCount: 2,
idleNonEphemeralAgentCount: 0,
threshold: 20,
reason: "todo-exceeds-threshold-and-no-idle-agents",
}}
/>,
);
expect(screen.getByText(/Todo 21/)).toBeInTheDocument();
expect(screen.getByText(/threshold 20/)).toBeInTheDocument();
expect(screen.getByText(/In Progress 3/)).toBeInTheDocument();
expect(screen.getByText(/In Review 2/)).toBeInTheDocument();
expect(screen.getByText(/Idle agents 0/)).toBeInTheDocument();
});
it("calls onDismiss when dismiss button is clicked", () => {
const onDismiss = vi.fn();
render(
<CapacityRiskBanner
signal={{
atRisk: true,
todoCount: 21,
inProgressCount: 3,
inReviewCount: 2,
idleNonEphemeralAgentCount: 0,
threshold: 20,
reason: "todo-exceeds-threshold-and-no-idle-agents",
}}
onDismiss={onDismiss}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /dismiss capacity warning/i }));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});

View File

@@ -13,6 +13,7 @@ export interface UseAppSettingsResult {
enginePaused: boolean;
taskStuckTimeoutMs: number | undefined;
staleHighFanoutBlockerAgeThresholdMs: number;
capacityRiskTodoThreshold: number;
showQuickChatFAB: boolean;
prAuthAvailable: boolean;
settingsLoaded: boolean;
@@ -42,6 +43,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [enginePaused, setEnginePaused] = useState(false);
const [taskStuckTimeoutMs, setTaskStuckTimeoutMs] = useState<number | undefined>(undefined);
const [staleHighFanoutBlockerAgeThresholdMs, setStaleHighFanoutBlockerAgeThresholdMs] = useState(2 * 60 * 60 * 1000);
const [capacityRiskTodoThreshold, setCapacityRiskTodoThreshold] = useState(20);
const [showQuickChatFAB, setShowQuickChatFAB] = useState(false);
const [prAuthAvailable, setPrAuthAvailable] = useState(false);
const [settingsLoaded, setSettingsLoaded] = useState(false);
@@ -78,6 +80,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
settings.staleHighFanoutBlockerAgeThresholdMs ?? 2 * 60 * 60 * 1000,
);
setShowQuickChatFAB(settings.showQuickChatFAB === true);
setCapacityRiskTodoThreshold(settings.capacityRiskTodoThreshold ?? 20);
setExperimentalFeatures(settings.experimentalFeatures ?? {});
const features = settings.experimentalFeatures ?? {};
setInsightsEnabled(features.insights === true);
@@ -174,6 +177,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
enginePaused,
taskStuckTimeoutMs,
staleHighFanoutBlockerAgeThresholdMs,
capacityRiskTodoThreshold,
showQuickChatFAB,
prAuthAvailable,
settingsLoaded,