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:
gsxdsm
2026-06-23 20:03:44 -07:00
parent 79b71ad236
commit f8c366d8f2
7 changed files with 259 additions and 65 deletions

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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");
});
});

View 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 };
}

View 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 };
}

View 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 };
}