From f8c366d8f28fce478d5ba0aed7cc705407e73cb9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 20:03:44 -0700 Subject: [PATCH] refactor(dashboard): extract useDashboardHealth, useAuthTokenRecovery, useScopedDismissFlag Extract three more AppInner state clusters into hooks: - useDashboardHealth: dashboard health state + mount fetch + on-demand refresh; exposes setHealth for the TaskIdIntegrityBanner remediation callback. - useAuthTokenRecovery: the auth-token-recovery dialog open state driven by the AUTH_TOKEN_RECOVERY_REQUIRED_EVENT window listener. - useScopedDismissFlag: a generic per-project dismissable banner flag (scoped storage + project-change re-read + dismiss); backs the setup-warning banner. Capacity-risk dismiss stays inline pending its dedicated useCapacityRiskBanner hook. Behavior-preserving; App.test.tsx identical (5 pre-existing failures, none introduced). Verified by typecheck, eslint, and 7 renderHook tests. Part of U5 (App.tsx module-breakup plan). --- packages/dashboard/app/App.tsx | 79 ++++--------------- .../__tests__/useAuthTokenRecovery.test.ts | 18 +++++ .../__tests__/useDashboardHealth.test.ts | 62 +++++++++++++++ .../__tests__/useScopedDismissFlag.test.ts | 48 +++++++++++ .../app/hooks/useAuthTokenRecovery.ts | 28 +++++++ .../dashboard/app/hooks/useDashboardHealth.ts | 57 +++++++++++++ .../app/hooks/useScopedDismissFlag.ts | 32 ++++++++ 7 files changed, 259 insertions(+), 65 deletions(-) create mode 100644 packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts create mode 100644 packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts create mode 100644 packages/dashboard/app/hooks/__tests__/useScopedDismissFlag.test.ts create mode 100644 packages/dashboard/app/hooks/useAuthTokenRecovery.ts create mode 100644 packages/dashboard/app/hooks/useDashboardHealth.ts create mode 100644 packages/dashboard/app/hooks/useScopedDismissFlag.ts diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 4ab12255fa..4b8c34918e 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -99,12 +99,15 @@ import { useChatUnreadBadge } from "./hooks/useChatUnreadBadge"; import { useMailboxUnread } from "./hooks/useMailboxUnread"; import { useApprovalBanner } from "./hooks/useApprovalBanner"; import { useBranchTaskFilters } from "./hooks/useBranchTaskFilters"; +import { useDashboardHealth } from "./hooks/useDashboardHealth"; +import { useAuthTokenRecovery } from "./hooks/useAuthTokenRecovery"; +import { useScopedDismissFlag } from "./hooks/useScopedDismissFlag"; 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, DashboardHealthResponse, PluginDashboardViewEntry } from "./api"; -import { fetchDashboardHealth, fetchTaskDetail, fetchWorkflowSteps, refreshDashboardHealth } from "./api"; +import type { AiSessionSummary, PluginDashboardViewEntry } from "./api"; +import { fetchTaskDetail, fetchWorkflowSteps } from "./api"; import { getScopedItem, removeScopedItem, setScopedItem } from "./utils/projectStorage"; import { SETUP_WARNING_DISMISSED_KEY, @@ -131,7 +134,6 @@ export { type CliActionDeps, } from "./utils/appLifecycle"; import { subscribeSse } from "./sse-bus"; -import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth"; import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog"; import { PlanningModeModal } from "./components/PlanningModeModal"; import { PlanningWorkflowSwitcherSlot } from "./components/PlanningWorkflowSwitcherSlot"; @@ -599,78 +601,25 @@ function AppInner() { setSelectedPrId(undefined); } }, [selectedPrId, taskView]); - const [authTokenRecoveryOpen, setAuthTokenRecoveryOpen] = useState(false); - const [dashboardHealth, setDashboardHealth] = useState(null); - const [dbCorruptionRefreshing, setDbCorruptionRefreshing] = useState(false); - const [dbCorruptionRefreshError, setDbCorruptionRefreshError] = useState(null); - const [setupWarningDismissed, setSetupWarningDismissed] = useState( - () => getScopedItem(SETUP_WARNING_DISMISSED_KEY, currentProject?.id) === "true", - ); + const { open: authTokenRecoveryOpen } = useAuthTokenRecovery(); + const { + health: dashboardHealth, + setHealth: setDashboardHealth, + refreshing: dbCorruptionRefreshing, + refreshError: dbCorruptionRefreshError, + 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(() => { - setSetupWarningDismissed( - getScopedItem(SETUP_WARNING_DISMISSED_KEY, currentProject?.id) === "true", - ); - }, [currentProject?.id]); - useEffect(() => { setCapacityRiskDismissed( getScopedItem(CAPACITY_RISK_DISMISSED_KEY, currentProject?.id) === "true", ); }, [currentProject?.id]); - const refreshDbCorruptionHealth = useCallback(async () => { - setDbCorruptionRefreshing(true); - setDbCorruptionRefreshError(null); - try { - const health = await refreshDashboardHealth(); - setDashboardHealth(health); - } catch (error) { - setDbCorruptionRefreshError(error instanceof Error ? error.message : "Failed to refresh database health."); - } finally { - setDbCorruptionRefreshing(false); - } - }, []); - - useEffect(() => { - let cancelled = false; - - fetchDashboardHealth() - .then((health) => { - if (!cancelled) { - setDashboardHealth(health); - } - }) - .catch(() => { - if (!cancelled) { - setDashboardHealth(null); - } - }); - - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - const handleDaemonAuthFailure = () => { - setAuthTokenRecoveryOpen(true); - }; - - window.addEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleDaemonAuthFailure); - return () => { - window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleDaemonAuthFailure); - }; - }, []); - - const handleDismissSetupWarning = useCallback(() => { - setScopedItem(SETUP_WARNING_DISMISSED_KEY, "true", currentProject?.id); - setSetupWarningDismissed(true); - }, [currentProject?.id]); - const handleDismissCapacityRisk = useCallback(() => { setScopedItem(CAPACITY_RISK_DISMISSED_KEY, "true", currentProject?.id); setCapacityRiskDismissed(true); diff --git a/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts b/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts new file mode 100644 index 0000000000..799dbb1b30 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth"; +import { useAuthTokenRecovery } from "../useAuthTokenRecovery"; + +describe("useAuthTokenRecovery", () => { + it("opens when the daemon auth-failure event fires", () => { + const { result } = renderHook(() => useAuthTokenRecovery()); + + expect(result.current.open).toBe(false); + + act(() => { + window.dispatchEvent(new Event(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT)); + }); + + expect(result.current.open).toBe(true); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts b/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts new file mode 100644 index 0000000000..6d6184a35f --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; + +const fetchDashboardHealth = vi.fn(); +const refreshDashboardHealth = vi.fn(); +vi.mock("../../api", () => ({ + fetchDashboardHealth: (...a: unknown[]) => fetchDashboardHealth(...a), + refreshDashboardHealth: (...a: unknown[]) => refreshDashboardHealth(...a), +})); + +import { useDashboardHealth } from "../useDashboardHealth"; + +describe("useDashboardHealth", () => { + beforeEach(() => { + fetchDashboardHealth.mockReset(); + refreshDashboardHealth.mockReset(); + }); + + it("seeds health from the mount fetch and falls back to null on failure", async () => { + fetchDashboardHealth.mockResolvedValue({ status: "ok" }); + const { result } = renderHook(() => useDashboardHealth()); + + await waitFor(() => expect(result.current.health).toEqual({ status: "ok" })); + + fetchDashboardHealth.mockResolvedValue(undefined); + fetchDashboardHealth.mockRejectedValue(new Error("boom")); + const failing = renderHook(() => useDashboardHealth()); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(failing.result.current.health).toBeNull(); + }); + + it("refresh sets refreshing, updates health, and clears refreshing on success", async () => { + fetchDashboardHealth.mockResolvedValue(null); + refreshDashboardHealth.mockResolvedValue({ status: "degraded" }); + const { result } = renderHook(() => useDashboardHealth()); + + await act(async () => { + await result.current.refresh(); + }); + + expect(refreshDashboardHealth).toHaveBeenCalledTimes(1); + expect(result.current.health).toEqual({ status: "degraded" }); + expect(result.current.refreshing).toBe(false); + expect(result.current.refreshError).toBeNull(); + }); + + it("refresh records an error message on failure", async () => { + fetchDashboardHealth.mockResolvedValue(null); + refreshDashboardHealth.mockRejectedValue(new Error("nope")); + const { result } = renderHook(() => useDashboardHealth()); + + await act(async () => { + await result.current.refresh(); + }); + + expect(result.current.refreshError).toBe("nope"); + expect(result.current.refreshing).toBe(false); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useScopedDismissFlag.test.ts b/packages/dashboard/app/hooks/__tests__/useScopedDismissFlag.test.ts new file mode 100644 index 0000000000..f093daf079 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useScopedDismissFlag.test.ts @@ -0,0 +1,48 @@ +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(), +})); + +import { getScopedItem, setScopedItem } from "../../utils/projectStorage"; +import { useScopedDismissFlag } from "../useScopedDismissFlag"; + +describe("useScopedDismissFlag", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("seeds dismissed from scoped storage on mount", () => { + vi.mocked(getScopedItem).mockReturnValue("true"); + const { result } = renderHook(() => useScopedDismissFlag("key", "p1")); + + expect(result.current.dismissed).toBe(true); + }); + + it("dismiss writes scoped storage and flips the flag", () => { + vi.mocked(getScopedItem).mockReturnValue(null); + const { result } = renderHook(() => useScopedDismissFlag("key", "p1")); + + act(() => { + result.current.dismiss(); + }); + + expect(setScopedItem).toHaveBeenCalledWith("key", "true", "p1"); + expect(result.current.dismissed).toBe(true); + }); + + it("re-reads the scoped value when the project changes (no cross-project leak)", () => { + vi.mocked(getScopedItem).mockReturnValue(null); + const { rerender } = renderHook( + (props: { id: string | undefined }) => useScopedDismissFlag("key", props.id), + { initialProps: { id: "p1" } }, + ); + + rerender({ id: "p2" }); + + // The project-change re-read must consult scoped storage for the new project. + expect(getScopedItem).toHaveBeenCalledWith("key", "p2"); + }); +}); diff --git a/packages/dashboard/app/hooks/useAuthTokenRecovery.ts b/packages/dashboard/app/hooks/useAuthTokenRecovery.ts new file mode 100644 index 0000000000..e799face87 --- /dev/null +++ b/packages/dashboard/app/hooks/useAuthTokenRecovery.ts @@ -0,0 +1,28 @@ +/* +FNXC:AuthTokenRecovery 2026-06-24-00:00: +App-level open state for the auth-token recovery dialog, opened when the daemon signals auth failure (AUTH_TOKEN_RECOVERY_REQUIRED_EVENT). Extracted verbatim from AppInner. +*/ + +import { useEffect, useState } from "react"; +import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../auth"; + +export interface UseAuthTokenRecoveryResult { + open: boolean; +} + +export function useAuthTokenRecovery(): UseAuthTokenRecoveryResult { + const [open, setOpen] = useState(false); + + useEffect(() => { + const handleDaemonAuthFailure = () => { + setOpen(true); + }; + + window.addEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleDaemonAuthFailure); + return () => { + window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleDaemonAuthFailure); + }; + }, []); + + return { open }; +} diff --git a/packages/dashboard/app/hooks/useDashboardHealth.ts b/packages/dashboard/app/hooks/useDashboardHealth.ts new file mode 100644 index 0000000000..5cbf75b73d --- /dev/null +++ b/packages/dashboard/app/hooks/useDashboardHealth.ts @@ -0,0 +1,57 @@ +/* +FNXC:DashboardHealth 2026-06-24-00:00: +Dashboard backend health (engine availability, task-id integrity, db-corruption status), fetched on mount and refreshable on demand. Extracted from AppInner; exposes setHealth so the TaskIdIntegrityBanner can patch the cached health from its own remediation callback. +*/ + +import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react"; +import type { DashboardHealthResponse } from "../api"; +import { fetchDashboardHealth, refreshDashboardHealth } from "../api"; + +export interface UseDashboardHealthResult { + health: DashboardHealthResponse | null; + setHealth: Dispatch>; + refreshing: boolean; + refreshError: string | null; + refresh: () => Promise; +} + +export function useDashboardHealth(): UseDashboardHealthResult { + const [health, setHealth] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [refreshError, setRefreshError] = useState(null); + + const refresh = useCallback(async () => { + setRefreshing(true); + setRefreshError(null); + try { + const next = await refreshDashboardHealth(); + setHealth(next); + } catch (error) { + setRefreshError(error instanceof Error ? error.message : "Failed to refresh database health."); + } finally { + setRefreshing(false); + } + }, []); + + useEffect(() => { + let cancelled = false; + + fetchDashboardHealth() + .then((next) => { + if (!cancelled) { + setHealth(next); + } + }) + .catch(() => { + if (!cancelled) { + setHealth(null); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + return { health, setHealth, refreshing, refreshError, refresh }; +} diff --git a/packages/dashboard/app/hooks/useScopedDismissFlag.ts b/packages/dashboard/app/hooks/useScopedDismissFlag.ts new file mode 100644 index 0000000000..b7bbc444be --- /dev/null +++ b/packages/dashboard/app/hooks/useScopedDismissFlag.ts @@ -0,0 +1,32 @@ +/* +FNXC:ScopedDismissFlag 2026-06-24-00:00: +A per-project dismissable boolean banner flag (e.g. setup-warning, capacity-risk) backed by scoped storage. Owns the initial scoped read, the project-change re-read (so a dismissal in one project does not leak into another), and the dismiss action. Extracted from AppInner. +*/ + +import { useCallback, useEffect, useState } from "react"; +import { getScopedItem, setScopedItem } from "../utils/projectStorage"; + +export interface UseScopedDismissFlagResult { + dismissed: boolean; + dismiss: () => void; +} + +export function useScopedDismissFlag( + storageKey: string, + currentProjectId: string | undefined, +): UseScopedDismissFlagResult { + const [dismissed, setDismissed] = useState( + () => getScopedItem(storageKey, currentProjectId) === "true", + ); + + useEffect(() => { + setDismissed(getScopedItem(storageKey, currentProjectId) === "true"); + }, [storageKey, currentProjectId]); + + const dismiss = useCallback(() => { + setScopedItem(storageKey, "true", currentProjectId); + setDismissed(true); + }, [storageKey, currentProjectId]); + + return { dismissed, dismiss }; +}