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).
This commit is contained in:
@@ -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<DashboardHealthResponse | null>(null);
|
||||
const [dbCorruptionRefreshing, setDbCorruptionRefreshing] = useState(false);
|
||||
const [dbCorruptionRefreshError, setDbCorruptionRefreshError] = useState<string | null>(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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
28
packages/dashboard/app/hooks/useAuthTokenRecovery.ts
Normal file
28
packages/dashboard/app/hooks/useAuthTokenRecovery.ts
Normal file
@@ -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 };
|
||||
}
|
||||
57
packages/dashboard/app/hooks/useDashboardHealth.ts
Normal file
57
packages/dashboard/app/hooks/useDashboardHealth.ts
Normal file
@@ -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<SetStateAction<DashboardHealthResponse | null>>;
|
||||
refreshing: boolean;
|
||||
refreshError: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useDashboardHealth(): UseDashboardHealthResult {
|
||||
const [health, setHealth] = useState<DashboardHealthResponse | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshError, setRefreshError] = useState<string | null>(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 };
|
||||
}
|
||||
32
packages/dashboard/app/hooks/useScopedDismissFlag.ts
Normal file
32
packages/dashboard/app/hooks/useScopedDismissFlag.ts
Normal file
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user