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).
This commit is contained in:
gsxdsm
2026-06-23 20:14:14 -07:00
parent f8c366d8f2
commit b8c511fe38
3 changed files with 173 additions and 62 deletions

View File

@@ -1,8 +1,6 @@
import { useState, useCallback, useEffect, useMemo, useRef, lazy, Suspense } from "react";
import { useTranslation } from "react-i18next";
import {
computeCapacityRisk,
DEFAULT_CAPACITY_RISK_TODO_THRESHOLD,
type Task,
type TaskDetail,
type WorkflowStep,
@@ -102,16 +100,15 @@ import { useBranchTaskFilters } from "./hooks/useBranchTaskFilters";
import { useDashboardHealth } from "./hooks/useDashboardHealth";
import { useAuthTokenRecovery } from "./hooks/useAuthTokenRecovery";
import { useScopedDismissFlag } from "./hooks/useScopedDismissFlag";
import { useCapacityRiskBanner } from "./hooks/useCapacityRiskBanner";
import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingModal";
import { NativeShellConnectionManager } from "./components/NativeShellConnectionManager";
import { ShellConnectionStatus } from "./components/ShellConnectionStatus";
import { getShellConnectionNativeResult, type ShellConnectionNativeResult } from "./shell-native";
import type { AiSessionSummary, PluginDashboardViewEntry } from "./api";
import { fetchTaskDetail, fetchWorkflowSteps } from "./api";
import { getScopedItem, removeScopedItem, setScopedItem } from "./utils/projectStorage";
import {
SETUP_WARNING_DISMISSED_KEY,
CAPACITY_RISK_DISMISSED_KEY,
RETRY_WARNING_RATIO,
buildRemoteDashboardUrl,
requiresNativeShellOnboarding,
@@ -610,20 +607,6 @@ function AppInner() {
refresh: refreshDbCorruptionHealth,
} = useDashboardHealth();
const { dismissed: setupWarningDismissed, dismiss: handleDismissSetupWarning } = useScopedDismissFlag(SETUP_WARNING_DISMISSED_KEY, currentProject?.id);
const [capacityRiskDismissed, setCapacityRiskDismissed] = useState(
() => getScopedItem(CAPACITY_RISK_DISMISSED_KEY, currentProject?.id) === "true",
);
useEffect(() => {
setCapacityRiskDismissed(
getScopedItem(CAPACITY_RISK_DISMISSED_KEY, currentProject?.id) === "true",
);
}, [currentProject?.id]);
const handleDismissCapacityRisk = useCallback(() => {
setScopedItem(CAPACITY_RISK_DISMISSED_KEY, "true", currentProject?.id);
setCapacityRiskDismissed(true);
}, [currentProject?.id]);
// Settings state
const {
@@ -670,50 +653,15 @@ function AppInner() {
() => 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 previousCapacityRiskBannerEnabledRef = useRef(capacityRiskBannerEnabled);
const previousCapacityRiskTodoThresholdRef = useRef(capacityRiskTodoThreshold);
const previousCapacityRiskProjectIdRef = useRef(currentProject?.id);
const capacityRiskSettingsHydratedRef = useRef(false);
useEffect(() => {
if (!settingsLoaded) {
return;
}
if (!capacityRiskSettingsHydratedRef.current || previousCapacityRiskProjectIdRef.current !== currentProject?.id) {
capacityRiskSettingsHydratedRef.current = true;
previousCapacityRiskProjectIdRef.current = currentProject?.id;
previousCapacityRiskBannerEnabledRef.current = capacityRiskBannerEnabled;
previousCapacityRiskTodoThresholdRef.current = capacityRiskTodoThreshold;
return;
}
const wasEnabled = previousCapacityRiskBannerEnabledRef.current;
const previousThreshold = previousCapacityRiskTodoThresholdRef.current;
const bannerEnabledChangedToTrue = !wasEnabled && capacityRiskBannerEnabled;
const thresholdChanged = previousThreshold !== capacityRiskTodoThreshold;
if (bannerEnabledChangedToTrue || thresholdChanged) {
removeScopedItem(CAPACITY_RISK_DISMISSED_KEY, currentProject?.id);
setCapacityRiskDismissed(false);
}
previousCapacityRiskProjectIdRef.current = currentProject?.id;
previousCapacityRiskBannerEnabledRef.current = capacityRiskBannerEnabled;
previousCapacityRiskTodoThresholdRef.current = capacityRiskTodoThreshold;
}, [settingsLoaded, capacityRiskBannerEnabled, capacityRiskTodoThreshold, currentProject?.id]);
const { signal: capacityRiskSignal, dismissed: capacityRiskDismissed, dismiss: handleDismissCapacityRisk } = useCapacityRiskBanner({
agentStats,
inProgressCount,
inReviewCount,
capacityRiskBannerEnabled,
capacityRiskTodoThreshold,
settingsLoaded,
currentProjectId: currentProject?.id,
});
/* FNXC:DefaultNavigation 2026-06-23-01:26: Skills graduated from Experimental and should remain visible on upgrades even when stale `experimentalFeatures.skillsView=false` is present. */
const skillsEnabled = true;

View File

@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
vi.mock("../../utils/projectStorage", () => ({
getScopedItem: vi.fn(() => null),
setScopedItem: vi.fn(),
removeScopedItem: vi.fn(),
}));
import { getScopedItem, removeScopedItem } from "../../utils/projectStorage";
import { useCapacityRiskBanner } from "../useCapacityRiskBanner";
const base = {
agentStats: { todoTaskCount: 5, idleNonEphemeralCount: 0 },
inProgressCount: 1,
inReviewCount: 0,
capacityRiskBannerEnabled: true,
capacityRiskTodoThreshold: 3,
settingsLoaded: true,
currentProjectId: "p1",
};
describe("useCapacityRiskBanner", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("computes the capacity-risk signal from counts + threshold", () => {
const { result } = renderHook(() => useCapacityRiskBanner(base));
expect(result.current.signal).toBeTruthy();
expect(result.current.signal.atRisk).toBe(true);
expect(result.current.signal.threshold).toBe(3);
});
it("dismiss persists to scoped storage and hides", () => {
const { result } = renderHook(() => useCapacityRiskBanner(base));
act(() => {
result.current.dismiss();
});
expect(result.current.dismissed).toBe(true);
});
it("clears a prior dismissal when the banner is re-enabled after hydrate", () => {
vi.mocked(getScopedItem).mockReturnValue("true");
const { result, rerender } = renderHook(
(props: { enabled: boolean }) =>
useCapacityRiskBanner({ ...base, capacityRiskBannerEnabled: props.enabled }),
{ initialProps: { enabled: false } },
);
// First settings load hydrates without clearing.
expect(result.current.dismissed).toBe(true);
expect(removeScopedItem).not.toHaveBeenCalled();
// Re-enabling the banner resurrects the dismissed banner.
rerender({ enabled: true });
expect(removeScopedItem).toHaveBeenCalledWith(expect.any(String), "p1");
expect(result.current.dismissed).toBe(false);
});
});

View File

@@ -0,0 +1,99 @@
/*
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 };
}