From b8c511fe385e4be4a66d584d22a1ecc8ac63a55e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 20:14:14 -0700 Subject: [PATCH] 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). 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). --- packages/dashboard/app/App.tsx | 72 ++------------ .../__tests__/useCapacityRiskBanner.test.ts | 64 ++++++++++++ .../app/hooks/useCapacityRiskBanner.ts | 99 +++++++++++++++++++ 3 files changed, 173 insertions(+), 62 deletions(-) create mode 100644 packages/dashboard/app/hooks/__tests__/useCapacityRiskBanner.test.ts create mode 100644 packages/dashboard/app/hooks/useCapacityRiskBanner.ts diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 4b8c34918e..974ab06876 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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; diff --git a/packages/dashboard/app/hooks/__tests__/useCapacityRiskBanner.test.ts b/packages/dashboard/app/hooks/__tests__/useCapacityRiskBanner.test.ts new file mode 100644 index 0000000000..c830f4a987 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useCapacityRiskBanner.test.ts @@ -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); + }); +}); diff --git a/packages/dashboard/app/hooks/useCapacityRiskBanner.ts b/packages/dashboard/app/hooks/useCapacityRiskBanner.ts new file mode 100644 index 0000000000..dc6fe7ebc4 --- /dev/null +++ b/packages/dashboard/app/hooks/useCapacityRiskBanner.ts @@ -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 }; +}