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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
99
packages/dashboard/app/hooks/useCapacityRiskBanner.ts
Normal file
99
packages/dashboard/app/hooks/useCapacityRiskBanner.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user