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:
@@ -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}
|
||||
|
||||
49
packages/dashboard/app/components/CapacityRiskBanner.css
Normal file
49
packages/dashboard/app/components/CapacityRiskBanner.css
Normal 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;
|
||||
}
|
||||
}
|
||||
32
packages/dashboard/app/components/CapacityRiskBanner.tsx
Normal file
32
packages/dashboard/app/components/CapacityRiskBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user