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

@@ -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);
});
});