Files
fusion/packages/dashboard/app/hooks/useCapacityRiskBanner.ts
gsxdsm b8c511fe38 refactor(dashboard): extract useCapacityRiskBanner hook from App.tsx
Move the capacity-risk signal (computeCapacityRisk), the settings-hydrate
guard, the re-enable-clears-dismissal behavior, the per-project dismiss
state, and the dismiss action into app/hooks/useCapacityRiskBanner.ts.
App computes agentStats / inProgressCount / inReviewCount / settings and
passes them in. Uses the named CapacityRiskSignal type (no ReturnType<typeof>).

Behavior-preserving; App.test.tsx identical (5 pre-existing failures, none
introduced). Verified by typecheck, eslint, and 3 renderHook tests (signal
computation, dismiss, re-enable-clears-dismissal after hydrate).

Part of U5 (App.tsx module-breakup plan).
2026-06-23 20:14:14 -07:00

100 lines
3.7 KiB
TypeScript

/*
FNXC:CapacityRisk 2026-06-24-00:00:
Capacity-risk banner signal + per-project dismiss, with a settings-hydrate guard so the banner doesn't flash on first load or on project change, and a re-enable-clears-dismissal behavior (re-enabling the banner or changing the threshold resurrects a previously-dismissed banner). Extracted from AppInner.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
computeCapacityRisk,
DEFAULT_CAPACITY_RISK_TODO_THRESHOLD,
type CapacityRiskSignal,
} from "@fusion/core";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { CAPACITY_RISK_DISMISSED_KEY } from "../utils/appLifecycle";
export interface UseCapacityRiskBannerOptions {
agentStats: { todoTaskCount?: number; idleNonEphemeralCount?: number } | null | undefined;
inProgressCount: number;
inReviewCount: number;
capacityRiskBannerEnabled: boolean | undefined;
capacityRiskTodoThreshold: number | undefined;
settingsLoaded: boolean;
currentProjectId: string | undefined;
}
export interface UseCapacityRiskBannerResult {
signal: CapacityRiskSignal;
dismissed: boolean;
dismiss: () => void;
}
export function useCapacityRiskBanner({
agentStats,
inProgressCount,
inReviewCount,
capacityRiskBannerEnabled,
capacityRiskTodoThreshold,
settingsLoaded,
currentProjectId,
}: UseCapacityRiskBannerOptions): UseCapacityRiskBannerResult {
const [dismissed, setDismissed] = useState(
() => getScopedItem(CAPACITY_RISK_DISMISSED_KEY, currentProjectId) === "true",
);
useEffect(() => {
setDismissed(getScopedItem(CAPACITY_RISK_DISMISSED_KEY, currentProjectId) === "true");
}, [currentProjectId]);
const signal = 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 previousBannerEnabledRef = useRef(capacityRiskBannerEnabled);
const previousThresholdRef = useRef(capacityRiskTodoThreshold);
const previousProjectIdRef = useRef(currentProjectId);
const settingsHydratedRef = useRef(false);
useEffect(() => {
if (!settingsLoaded) {
return;
}
if (!settingsHydratedRef.current || previousProjectIdRef.current !== currentProjectId) {
settingsHydratedRef.current = true;
previousProjectIdRef.current = currentProjectId;
previousBannerEnabledRef.current = capacityRiskBannerEnabled;
previousThresholdRef.current = capacityRiskTodoThreshold;
return;
}
const wasEnabled = previousBannerEnabledRef.current;
const previousThreshold = previousThresholdRef.current;
const bannerEnabledChangedToTrue = !wasEnabled && capacityRiskBannerEnabled;
const thresholdChanged = previousThreshold !== capacityRiskTodoThreshold;
if (bannerEnabledChangedToTrue || thresholdChanged) {
removeScopedItem(CAPACITY_RISK_DISMISSED_KEY, currentProjectId);
setDismissed(false);
}
previousProjectIdRef.current = currentProjectId;
previousBannerEnabledRef.current = capacityRiskBannerEnabled;
previousThresholdRef.current = capacityRiskTodoThreshold;
}, [settingsLoaded, capacityRiskBannerEnabled, capacityRiskTodoThreshold, currentProjectId]);
const dismiss = useCallback(() => {
setScopedItem(CAPACITY_RISK_DISMISSED_KEY, "true", currentProjectId);
setDismissed(true);
}, [currentProjectId]);
return { signal, dismissed, dismiss };
}