From f01a48690dac82d2c277631af8bdc3702a4f8d7c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 19:17:40 -0700 Subject: [PATCH 01/14] refactor(dashboard): extract App.tsx lifecycle helpers to utils/appLifecycle Move the module-level pure functions (approval-banner dedupe helpers, CLI banner actions, boot-loader/shell-onboarding guards, remote-dashboard URL builder) and storage-key constants out of App.tsx into app/utils/appLifecycle.ts. App.tsx re-exports the seven unit-tested symbols so existing `from "../../App"` test imports resolve unchanged. Behavior-preserving; no functional change. Verified: dashboard typecheck (both passes), eslint clean on both files, 16 pure-function unit tests pass, App.test.tsx identical to pristine (5 pre-existing experimental-flag failures, none introduced by this change). U1 of the App.tsx module-breakup plan (docs/plans/2026-06-24-001-refactor-dashboard-app-tsx-module-breakup-plan.md). --- packages/dashboard/app/App.tsx | 204 ++++--------------- packages/dashboard/app/utils/appLifecycle.ts | 177 ++++++++++++++++ 2 files changed, 211 insertions(+), 170 deletions(-) create mode 100644 packages/dashboard/app/utils/appLifecycle.ts diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 4b03a187dd..8add720341 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -100,8 +100,41 @@ import { NativeShellConnectionManager } from "./components/NativeShellConnection import { ShellConnectionStatus } from "./components/ShellConnectionStatus"; import { getShellConnectionNativeResult, type ShellConnectionNativeResult } from "./shell-native"; import type { AiSessionSummary, DashboardHealthResponse, PluginDashboardViewEntry } from "./api"; -import { api, fetchDashboardHealth, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps, refreshDashboardHealth, relaunchCliSession } from "./api"; +import { api, fetchDashboardHealth, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps, refreshDashboardHealth } from "./api"; import { getScopedItem, removeScopedItem, setScopedItem } from "./utils/projectStorage"; +import { + SETUP_WARNING_DISMISSED_KEY, + WORKING_BRANCH_FILTER_STORAGE_KEY, + BASE_BRANCH_FILTER_STORAGE_KEY, + NO_BRANCH_FILTER_VALUE, + CAPACITY_RISK_DISMISSED_KEY, + RETRY_WARNING_RATIO, + parseDateMs, + loadApprovalBannerDismissals, + persistApprovalBannerDismissals, + buildRemoteDashboardUrl, + didEnterAwaitingApproval, + didEnterDone, + requiresNativeShellOnboarding, + shouldShowFirstEverBootLoader, + isSessionNeedingInputForBanner, + getCliActionDisabledReasonForBanner, + executeCliSessionBannerAction, + type ApprovalBannerCandidate, +} from "./utils/appLifecycle"; +// Re-export the unit-tested lifecycle helpers so existing `from "./App"` / +// `from "../../App"` imports keep resolving after the bodies moved to utils. +export { + didEnterAwaitingApproval, + didEnterDone, + requiresNativeShellOnboarding, + shouldShowFirstEverBootLoader, + isSessionNeedingInputForBanner, + getCliActionDisabledReasonForBanner, + executeCliSessionBannerAction, + type ApprovalBannerCandidate, + type CliActionDeps, +} from "./utils/appLifecycle"; import { subscribeSse } from "./sse-bus"; import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth"; import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog"; @@ -180,175 +213,6 @@ function prefetchLazyViews() { registerBundledPluginViews(); -const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed"; -const WORKING_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-working-branch-filter"; -const BASE_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-base-branch-filter"; -const NO_BRANCH_FILTER_VALUE = "__fusion:no-branch__"; -const APPROVAL_BANNER_DISMISSED_STORAGE_KEY = "fusion:approval-banner-dismissed"; -const CAPACITY_RISK_DISMISSED_KEY = "kb-capacity-risk-banner-dismissed"; -const RETRY_WARNING_RATIO = 0.8; - -interface ApprovalBannerCandidate { - dedupeKey: string; - updatedAtMs: number; -} - -export function didEnterAwaitingApproval(nextStatus: string | undefined, previousStatus: string | undefined): boolean { - return nextStatus === "awaiting-approval" && previousStatus !== "awaiting-approval"; -} - -export function didEnterDone(nextStatus: string | undefined, previousStatus: string | undefined): boolean { - return nextStatus === "done" && previousStatus !== undefined && previousStatus !== "done"; -} - -function parseDateMs(value: string | undefined): number { - if (!value) return 0; - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : 0; -} - -function loadApprovalBannerDismissals(): Map { - if (typeof window === "undefined") return new Map(); - try { - const raw = window.localStorage.getItem(APPROVAL_BANNER_DISMISSED_STORAGE_KEY); - if (!raw) return new Map(); - const parsed = JSON.parse(raw) as Record; - const map = new Map(); - for (const [key, value] of Object.entries(parsed)) { - if (typeof value === "number" && Number.isFinite(value)) { - map.set(key, value); - } - } - return map; - } catch { - return new Map(); - } -} - -function persistApprovalBannerDismissals(map: Map): void { - if (typeof window === "undefined") return; - try { - const data: Record = {}; - for (const [key, value] of map) { - data[key] = value; - } - window.localStorage.setItem(APPROVAL_BANNER_DISMISSED_STORAGE_KEY, JSON.stringify(data)); - } catch { - // no-op - } -} - -function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string { - const url = new URL(serverUrl); - if (authToken) { - url.searchParams.set("rt", authToken); - } - return url.toString(); -} - -export function requiresNativeShellOnboarding( - shellState: { host: "web" | "mobile-shell" | "desktop-shell"; desktopMode?: "local" | "remote"; activeProfileId: string | null }, - shellReady: boolean, - shellOnboardingComplete: boolean, -): boolean { - if (!shellReady || shellOnboardingComplete || shellState.host === "web") { - return false; - } - - if (shellState.host === "mobile-shell") { - return !shellState.activeProfileId; - } - - if (shellState.desktopMode === "local") { - return false; - } - - return !shellState.activeProfileId; -} - -export function shouldShowFirstEverBootLoader(projectsLoading: boolean, projectCount: number): boolean { - return projectsLoading && projectCount === 0; -} - -export function isSessionNeedingInputForBanner(session: AiSessionSummary): boolean { - return ( - session.status === "awaiting_input" || - session.status === "error" || - session.status === "waiting_on_input" || - session.status === "needs_attention" - ); -} - -export function getCliActionDisabledReasonForBanner(session: AiSessionSummary, action: CliActionId): string | null { - if ((action === "advance" || action === "relaunch") && !session.cliSessionId) { - return "CLI session id is missing."; - } - return null; -} - -interface CliActionDeps { - currentProjectId?: string; - retryTask: (id: string) => Promise; - moveTask: (id: string, column: "todo") => Promise; - openAuthenticationSettings: () => void; - addToast: (message: string, type: "success" | "error") => void; - apiClient?: typeof api; - relaunchCliSessionClient?: typeof relaunchCliSession; -} - -export async function executeCliSessionBannerAction( - session: AiSessionSummary, - action: CliActionId, - deps: CliActionDeps, -): Promise { - try { - /* - * FNXC:SessionBanner 2026-06-14-19:32: - * CLI banner verbs must either call an existing dashboard route/flow or be disabled by the banner. `advance` confirms the CLI session, `retry` and `cancel` reuse task operations keyed by the session id until summaries expose a distinct task id, and `reauthenticate` opens the existing authentication settings flow. - * - * FNXC:SessionBanner 2026-06-14-20:16: - * `relaunch` is now a supported route-backed action for resume-exhausted CLI sessions; if `cliSessionId` is absent the handler exits without firing a malformed API call, preserving the no-silent-no-op invariant through the banner disabled reason. - */ - if (action === "advance") { - if (!session.cliSessionId) { - throw new Error("CLI session id is required to advance this session."); - } - await (deps.apiClient ?? api)(`/cli-sessions/${encodeURIComponent(session.cliSessionId)}/confirm-advance`, { - method: "POST", - body: JSON.stringify({ decision: "advance", ...(deps.currentProjectId ? { projectId: deps.currentProjectId } : {}) }), - }); - return; - } - - if (action === "relaunch") { - if (!session.cliSessionId) return; - await (deps.relaunchCliSessionClient ?? relaunchCliSession)(session.cliSessionId, deps.currentProjectId); - deps.addToast("CLI session relaunch requested", "success"); - return; - } - - if (action === "retry") { - await deps.retryTask(session.id); - return; - } - - if (action === "cancel") { - await deps.moveTask(session.id, "todo"); - return; - } - - if (action === "reauthenticate") { - deps.openAuthenticationSettings(); - return; - } - - throw new Error("This CLI action is not supported yet."); - } catch (err) { - const message = err instanceof Error ? err.message : "CLI action failed"; - deps.addToast(message, "error"); - } -} - function AppInner() { const { t } = useTranslation("app"); const { toasts, addToast, removeToast } = useToast(); diff --git a/packages/dashboard/app/utils/appLifecycle.ts b/packages/dashboard/app/utils/appLifecycle.ts new file mode 100644 index 0000000000..0855df94cb --- /dev/null +++ b/packages/dashboard/app/utils/appLifecycle.ts @@ -0,0 +1,177 @@ +/* +FNXC:AppLifecycle 2026-06-24-00:00: +Module-level lifecycle helpers, storage-key constants, and banner/CLI-banner pure functions extracted out of App.tsx so the root component stays an orchestrator. Behavior is byte-identical to the former inline definitions; App.tsx re-exports the unit-tested symbols to preserve its import contract. +*/ + +import type { AiSessionSummary } from "../api"; +import { api, relaunchCliSession } from "../api"; +import type { CliActionId } from "../components/SessionNotificationBanner"; + +export const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed"; +export const WORKING_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-working-branch-filter"; +export const BASE_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-base-branch-filter"; +export const NO_BRANCH_FILTER_VALUE = "__fusion:no-branch__"; +export const APPROVAL_BANNER_DISMISSED_STORAGE_KEY = "fusion:approval-banner-dismissed"; +export const CAPACITY_RISK_DISMISSED_KEY = "kb-capacity-risk-banner-dismissed"; +export const RETRY_WARNING_RATIO = 0.8; + +export interface ApprovalBannerCandidate { + dedupeKey: string; + updatedAtMs: number; +} + +export function didEnterAwaitingApproval(nextStatus: string | undefined, previousStatus: string | undefined): boolean { + return nextStatus === "awaiting-approval" && previousStatus !== "awaiting-approval"; +} + +export function didEnterDone(nextStatus: string | undefined, previousStatus: string | undefined): boolean { + return nextStatus === "done" && previousStatus !== undefined && previousStatus !== "done"; +} + +export function parseDateMs(value: string | undefined): number { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function loadApprovalBannerDismissals(): Map { + if (typeof window === "undefined") return new Map(); + try { + const raw = window.localStorage.getItem(APPROVAL_BANNER_DISMISSED_STORAGE_KEY); + if (!raw) return new Map(); + const parsed = JSON.parse(raw) as Record; + const map = new Map(); + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === "number" && Number.isFinite(value)) { + map.set(key, value); + } + } + return map; + } catch { + return new Map(); + } +} + +export function persistApprovalBannerDismissals(map: Map): void { + if (typeof window === "undefined") return; + try { + const data: Record = {}; + for (const [key, value] of map) { + data[key] = value; + } + window.localStorage.setItem(APPROVAL_BANNER_DISMISSED_STORAGE_KEY, JSON.stringify(data)); + } catch { + // no-op + } +} + +export function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string { + const url = new URL(serverUrl); + if (authToken) { + url.searchParams.set("rt", authToken); + } + return url.toString(); +} + +export function requiresNativeShellOnboarding( + shellState: { host: "web" | "mobile-shell" | "desktop-shell"; desktopMode?: "local" | "remote"; activeProfileId: string | null }, + shellReady: boolean, + shellOnboardingComplete: boolean, +): boolean { + if (!shellReady || shellOnboardingComplete || shellState.host === "web") { + return false; + } + + if (shellState.host === "mobile-shell") { + return !shellState.activeProfileId; + } + + if (shellState.desktopMode === "local") { + return false; + } + + return !shellState.activeProfileId; +} + +export function shouldShowFirstEverBootLoader(projectsLoading: boolean, projectCount: number): boolean { + return projectsLoading && projectCount === 0; +} + +export function isSessionNeedingInputForBanner(session: AiSessionSummary): boolean { + return ( + session.status === "awaiting_input" || + session.status === "error" || + session.status === "waiting_on_input" || + session.status === "needs_attention" + ); +} + +export function getCliActionDisabledReasonForBanner(session: AiSessionSummary, action: CliActionId): string | null { + if ((action === "advance" || action === "relaunch") && !session.cliSessionId) { + return "CLI session id is missing."; + } + return null; +} + +export interface CliActionDeps { + currentProjectId?: string; + retryTask: (id: string) => Promise; + moveTask: (id: string, column: "todo") => Promise; + openAuthenticationSettings: () => void; + addToast: (message: string, type: "success" | "error") => void; + apiClient?: typeof api; + relaunchCliSessionClient?: typeof relaunchCliSession; +} + +export async function executeCliSessionBannerAction( + session: AiSessionSummary, + action: CliActionId, + deps: CliActionDeps, +): Promise { + try { + /* + * FNXC:SessionBanner 2026-06-14-19:32: + * CLI banner verbs must either call an existing dashboard route/flow or be disabled by the banner. `advance` confirms the CLI session, `retry` and `cancel` reuse task operations keyed by the session id until summaries expose a distinct task id, and `reauthenticate` opens the existing authentication settings flow. + * + * FNXC:SessionBanner 2026-06-14-20:16: + * `relaunch` is now a supported route-backed action for resume-exhausted CLI sessions; if `cliSessionId` is absent the handler exits without firing a malformed API call, preserving the no-silent-no-op invariant through the banner disabled reason. + */ + if (action === "advance") { + if (!session.cliSessionId) { + throw new Error("CLI session id is required to advance this session."); + } + await (deps.apiClient ?? api)(`/cli-sessions/${encodeURIComponent(session.cliSessionId)}/confirm-advance`, { + method: "POST", + body: JSON.stringify({ decision: "advance", ...(deps.currentProjectId ? { projectId: deps.currentProjectId } : {}) }), + }); + return; + } + + if (action === "relaunch") { + if (!session.cliSessionId) return; + await (deps.relaunchCliSessionClient ?? relaunchCliSession)(session.cliSessionId, deps.currentProjectId); + deps.addToast("CLI session relaunch requested", "success"); + return; + } + + if (action === "retry") { + await deps.retryTask(session.id); + return; + } + + if (action === "cancel") { + await deps.moveTask(session.id, "todo"); + return; + } + + if (action === "reauthenticate") { + deps.openAuthenticationSettings(); + return; + } + + throw new Error("This CLI action is not supported yet."); + } catch (err) { + const message = err instanceof Error ? err.message : "CLI action failed"; + deps.addToast(message, "error"); + } +} From 20b5c144ff3b06ef011f926076d215c9d5ac41a8 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 19:28:22 -0700 Subject: [PATCH 02/14] refactor(dashboard): extract useStashOrphanCount hook from App.tsx Move the 30s stash-recovery orphan poll out of AppInner into app/hooks/useStashOrphanCount.ts (byte-faithful extraction). App.tsx now consumes the hook and no longer imports `api` directly (the stash poll was its only direct call site). Behavior-preserving; verified by dashboard typecheck, eslint, and a new renderHook test (fake-timer poll assertions). Part of U2 (App.tsx module-breakup plan). --- packages/dashboard/app/App.tsx | 22 +------ .../__tests__/useStashOrphanCount.test.ts | 61 +++++++++++++++++++ .../app/hooks/useStashOrphanCount.ts | 37 +++++++++++ 3 files changed, 101 insertions(+), 19 deletions(-) create mode 100644 packages/dashboard/app/hooks/__tests__/useStashOrphanCount.test.ts create mode 100644 packages/dashboard/app/hooks/useStashOrphanCount.ts diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 8add720341..47ca888488 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -95,12 +95,13 @@ import { ShellProvider } from "./context/ShellContext"; import { RetryWarningProvider } from "./context/RetryWarningContext"; import { ShellHostProvider, useShellHostContext } from "./context/ShellHostContext"; import { useShellConnection } from "./hooks/useShellConnection"; +import { useStashOrphanCount } from "./hooks/useStashOrphanCount"; 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 { api, fetchDashboardHealth, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps, refreshDashboardHealth } from "./api"; +import { fetchDashboardHealth, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps, refreshDashboardHealth } from "./api"; import { getScopedItem, removeScopedItem, setScopedItem } from "./utils/projectStorage"; import { SETUP_WARNING_DISMISSED_KEY, @@ -579,7 +580,7 @@ function AppInner() { const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0); const [mailboxPendingApprovalCount, setMailboxPendingApprovalCount] = useState(0); const [chatHasUnreadResponse, setChatHasUnreadResponse] = useState(false); - const [stashOrphanCount, setStashOrphanCount] = useState(0); + const { stashOrphanCount } = useStashOrphanCount(currentProject?.id); const [approvalBannerCandidate, setApprovalBannerCandidate] = useState(null); const [showGitHubStarPrompt, setShowGitHubStarPrompt] = useState(false); const taskStatusByIdRef = useRef>(new Map()); @@ -698,23 +699,6 @@ function AppInner() { } }, [quickChatOpen, taskView]); - useEffect(() => { - let cancelled = false; - const load = async () => { - try { - const data = await api<{ count: number }>("/stash-recovery/orphans"); - if (!cancelled) setStashOrphanCount(data.count ?? 0); - } catch { - if (!cancelled) setStashOrphanCount(0); - } - }; - void load(); - const timer = window.setInterval(() => void load(), 30000); - return () => { - cancelled = true; - window.clearInterval(timer); - }; - }, [currentProject?.id]); useEffect(() => { const params = new URLSearchParams(); diff --git a/packages/dashboard/app/hooks/__tests__/useStashOrphanCount.test.ts b/packages/dashboard/app/hooks/__tests__/useStashOrphanCount.test.ts new file mode 100644 index 0000000000..cb13cf3700 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useStashOrphanCount.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +vi.mock("../../api", () => ({ + api: vi.fn(), +})); + +import { api } from "../../api"; +import { useStashOrphanCount } from "../useStashOrphanCount"; + +const mockedApi = vi.mocked(api); + +describe("useStashOrphanCount", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("fetches the orphan count on mount and exposes it", async () => { + mockedApi.mockResolvedValue({ count: 7 }); + const { result } = renderHook(() => useStashOrphanCount(undefined)); + + // Drain the initial load() microtask without tripping the 30s interval. + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(result.current.stashOrphanCount).toBe(7); + }); + + it("falls back to 0 when the fetch rejects", async () => { + mockedApi.mockRejectedValue(new Error("boom")); + const { result } = renderHook(() => useStashOrphanCount(undefined)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(result.current.stashOrphanCount).toBe(0); + }); + + it("re-polls on the 30s interval", async () => { + mockedApi.mockResolvedValue({ count: 1 }); + renderHook(() => useStashOrphanCount(undefined)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(mockedApi).toHaveBeenCalledTimes(1); + + // Advance exactly one 30s poll tick. + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000); + }); + expect(mockedApi).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/dashboard/app/hooks/useStashOrphanCount.ts b/packages/dashboard/app/hooks/useStashOrphanCount.ts new file mode 100644 index 0000000000..74ceb8c180 --- /dev/null +++ b/packages/dashboard/app/hooks/useStashOrphanCount.ts @@ -0,0 +1,37 @@ +/* +FNXC:StashRecovery 2026-06-24-00:00: +App-level count of orphaned stash-recovery entries, polled every 30s and surfaced as a header/mobile-nav badge. Extracted verbatim from AppInner so the root component no longer owns the polling loop. +*/ + +import { useEffect, useState } from "react"; +import { api } from "../api"; + +export interface UseStashOrphanCountResult { + stashOrphanCount: number; +} + +const POLL_INTERVAL_MS = 30000; + +export function useStashOrphanCount(currentProjectId: string | undefined): UseStashOrphanCountResult { + const [stashOrphanCount, setStashOrphanCount] = useState(0); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const data = await api<{ count: number }>("/stash-recovery/orphans"); + if (!cancelled) setStashOrphanCount(data.count ?? 0); + } catch { + if (!cancelled) setStashOrphanCount(0); + } + }; + void load(); + const timer = window.setInterval(() => void load(), POLL_INTERVAL_MS); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [currentProjectId]); + + return { stashOrphanCount }; +} From 475af26a6db33c20f1e70332b704691679b3b559 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 19:35:53 -0700 Subject: [PATCH 03/14] refactor(dashboard): extract useChatUnreadBadge hook from App.tsx Move the chat-unread-response badge (clear-on-chat-view effect plus the chat:message:added / chat:room:message:added SSE subscriber) out of AppInner into app/hooks/useChatUnreadBadge.ts (byte-faithful extraction). App.tsx no longer imports ChatRoomMessage (the chat SSE was its only user). Behavior-preserving; verified by dashboard typecheck, eslint, and a new renderHook test (assistant/user message gating + clear-on-view). Part of U2 (App.tsx module-breakup plan). --- packages/dashboard/app/App.tsx | 46 +--------- .../__tests__/useChatUnreadBadge.test.ts | 83 +++++++++++++++++++ .../dashboard/app/hooks/useChatUnreadBadge.ts | 68 +++++++++++++++ 3 files changed, 153 insertions(+), 44 deletions(-) create mode 100644 packages/dashboard/app/hooks/__tests__/useChatUnreadBadge.test.ts create mode 100644 packages/dashboard/app/hooks/useChatUnreadBadge.ts diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 47ca888488..2d44ebee80 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next"; import { computeCapacityRisk, DEFAULT_CAPACITY_RISK_TODO_THRESHOLD, - type ChatRoomMessage, type Task, type TaskDetail, type WorkflowStep, @@ -96,6 +95,7 @@ import { RetryWarningProvider } from "./context/RetryWarningContext"; import { ShellHostProvider, useShellHostContext } from "./context/ShellHostContext"; import { useShellConnection } from "./hooks/useShellConnection"; import { useStashOrphanCount } from "./hooks/useStashOrphanCount"; +import { useChatUnreadBadge } from "./hooks/useChatUnreadBadge"; import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingModal"; import { NativeShellConnectionManager } from "./components/NativeShellConnectionManager"; import { ShellConnectionStatus } from "./components/ShellConnectionStatus"; @@ -579,7 +579,7 @@ function AppInner() { // App-level mailbox/chat unread state (used for header/mobile nav badges) const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0); const [mailboxPendingApprovalCount, setMailboxPendingApprovalCount] = useState(0); - const [chatHasUnreadResponse, setChatHasUnreadResponse] = useState(false); + const { chatHasUnreadResponse } = useChatUnreadBadge(currentProject?.id, { taskView, quickChatOpen }); const { stashOrphanCount } = useStashOrphanCount(currentProject?.id); const [approvalBannerCandidate, setApprovalBannerCandidate] = useState(null); const [showGitHubStarPrompt, setShowGitHubStarPrompt] = useState(false); @@ -693,48 +693,6 @@ function AppInner() { }); }, [currentProject?.id, gitHubStarPromptShown, refreshMailboxUnreadCount]); - useEffect(() => { - if (taskView === "chat" || quickChatOpen) { - setChatHasUnreadResponse(false); - } - }, [quickChatOpen, taskView]); - - - useEffect(() => { - const params = new URLSearchParams(); - if (currentProject?.id) { - params.set("projectId", currentProject.id); - } - const query = params.size > 0 ? `?${params.toString()}` : ""; - - return subscribeSse(`/api/events${query}`, { - events: { - "chat:message:added": (event: MessageEvent) => { - try { - const payload = JSON.parse(event.data) as { role?: string; projectId?: string | null }; - if (payload.role !== "assistant") return; - if (taskView === "chat" || quickChatOpen) return; - if (payload.projectId && currentProject?.id && payload.projectId !== currentProject.id) return; - setChatHasUnreadResponse(true); - } catch { - // no-op - } - }, - "chat:room:message:added": (event: MessageEvent) => { - try { - const payload = JSON.parse(event.data) as ChatRoomMessage & { projectId?: string | null }; - if (payload.role === "user") return; - if (taskView === "chat" || quickChatOpen) return; - if (payload.projectId && currentProject?.id && payload.projectId !== currentProject.id) return; - setChatHasUnreadResponse(true); - } catch { - // no-op - } - }, - }, - }); - }, [currentProject?.id, quickChatOpen, taskView]); - const branchOptions = useMemo(() => { return Array.from( new Set( diff --git a/packages/dashboard/app/hooks/__tests__/useChatUnreadBadge.test.ts b/packages/dashboard/app/hooks/__tests__/useChatUnreadBadge.test.ts new file mode 100644 index 0000000000..e656a5f34b --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useChatUnreadBadge.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { TaskView } from "../useViewState"; + +const { handlers } = vi.hoisted(() => ({ + handlers: {} as Record void>, +})); + +vi.mock("../../sse-bus", () => ({ + subscribeSse: vi.fn((_url: string, opts: { events: Record void> }) => { + Object.assign(handlers, opts.events); + return () => {}; + }), +})); + +import { useChatUnreadBadge } from "../useChatUnreadBadge"; + +function message(data: object): MessageEvent { + return { data: JSON.stringify(data) } as MessageEvent; +} + +describe("useChatUnreadBadge", () => { + beforeEach(() => { + for (const key of Object.keys(handlers)) delete handlers[key]; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("marks unread on an assistant message while not viewing chat", () => { + const { result } = renderHook(() => + useChatUnreadBadge(undefined, { taskView: "board", quickChatOpen: false }), + ); + + act(() => { + handlers["chat:message:added"]?.(message({ role: "assistant" })); + }); + + expect(result.current.chatHasUnreadResponse).toBe(true); + }); + + it("ignores user-role messages", () => { + const { result } = renderHook(() => + useChatUnreadBadge(undefined, { taskView: "board", quickChatOpen: false }), + ); + + act(() => { + handlers["chat:message:added"]?.(message({ role: "user" })); + }); + + expect(result.current.chatHasUnreadResponse).toBe(false); + }); + + it("ignores assistant messages while the chat view is open", () => { + const { result } = renderHook(() => + useChatUnreadBadge(undefined, { taskView: "chat", quickChatOpen: false }), + ); + + act(() => { + handlers["chat:message:added"]?.(message({ role: "assistant" })); + }); + + expect(result.current.chatHasUnreadResponse).toBe(false); + }); + + it("clears the unread flag once the chat view opens", () => { + const { result, rerender } = renderHook( + ({ taskView }: { taskView: TaskView }) => + useChatUnreadBadge(undefined, { taskView, quickChatOpen: false }), + { initialProps: { taskView: "board" } }, + ); + + act(() => { + handlers["chat:message:added"]?.(message({ role: "assistant" })); + }); + expect(result.current.chatHasUnreadResponse).toBe(true); + + rerender({ taskView: "chat" }); + expect(result.current.chatHasUnreadResponse).toBe(false); + }); +}); diff --git a/packages/dashboard/app/hooks/useChatUnreadBadge.ts b/packages/dashboard/app/hooks/useChatUnreadBadge.ts new file mode 100644 index 0000000000..e988c3ccc1 --- /dev/null +++ b/packages/dashboard/app/hooks/useChatUnreadBadge.ts @@ -0,0 +1,68 @@ +/* +FNXC:ChatBadge 2026-06-24-00:00: +Header/mobile-nav unread indicator for assistant chat responses. Set when an assistant message arrives over SSE while the user is not viewing chat, and cleared when the chat view (or quick-chat window) opens. Extracted verbatim from AppInner. +*/ + +import { useEffect, useState } from "react"; +import type { ChatRoomMessage } from "@fusion/core"; +import { subscribeSse } from "../sse-bus"; +import type { TaskView } from "./useViewState"; + +export interface UseChatUnreadBadgeOptions { + taskView: TaskView; + quickChatOpen: boolean; +} + +export interface UseChatUnreadBadgeResult { + chatHasUnreadResponse: boolean; +} + +export function useChatUnreadBadge( + currentProjectId: string | undefined, + { taskView, quickChatOpen }: UseChatUnreadBadgeOptions, +): UseChatUnreadBadgeResult { + const [chatHasUnreadResponse, setChatHasUnreadResponse] = useState(false); + + useEffect(() => { + if (taskView === "chat" || quickChatOpen) { + setChatHasUnreadResponse(false); + } + }, [quickChatOpen, taskView]); + + useEffect(() => { + const params = new URLSearchParams(); + if (currentProjectId) { + params.set("projectId", currentProjectId); + } + const query = params.size > 0 ? `?${params.toString()}` : ""; + + return subscribeSse(`/api/events${query}`, { + events: { + "chat:message:added": (event: MessageEvent) => { + try { + const payload = JSON.parse(event.data) as { role?: string; projectId?: string | null }; + if (payload.role !== "assistant") return; + if (taskView === "chat" || quickChatOpen) return; + if (payload.projectId && currentProjectId && payload.projectId !== currentProjectId) return; + setChatHasUnreadResponse(true); + } catch { + // no-op + } + }, + "chat:room:message:added": (event: MessageEvent) => { + try { + const payload = JSON.parse(event.data) as ChatRoomMessage & { projectId?: string | null }; + if (payload.role === "user") return; + if (taskView === "chat" || quickChatOpen) return; + if (payload.projectId && currentProjectId && payload.projectId !== currentProjectId) return; + setChatHasUnreadResponse(true); + } catch { + // no-op + } + }, + }, + }); + }, [currentProjectId, quickChatOpen, taskView]); + + return { chatHasUnreadResponse }; +} From f98cce090403160a10856fbb948870a429962da9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 19:46:55 -0700 Subject: [PATCH 04/14] refactor(dashboard): extract useMailboxUnread + useApprovalBanner hooks Split AppInner's single /api/events subscriber (which drove mailbox counts, the approval banner, and the first-done GitHub-star prompt) across two hooks: - useMailboxUnread owns the unread/pending counts, the fetchUnreadCount refresh, and the message:*/approval:* count SSE handlers; exposes refresh (for the approval hook) and setMailboxUnreadCount (MailboxView reports its own count via onUnreadCountChange). - useApprovalBanner owns the approval-banner dedupe/dismiss state machine, the task:updated + approval:requested SSE handlers, and the star + mailbox side effects via onStarPrompt / onMailboxRefresh callbacks (KTD4: the single task:updated subscriber is preserved; the awaiting-approval mailbox refresh is preserved via the callback). The showGitHubStarPrompt boolean stays inline in App (minimal surface) wired through a stable onStarPrompt callback. App.tsx no longer imports fetchUnreadCount, parseDateMs, the dismissal helpers, or didEnterAwaitingApproval/didEnterDone (moved into the hooks; still re-exported from App for the unit-test contract). Behavior-preserving; App.test.tsx identical to before (5 pre-existing experimental-flag failures, none introduced). Verified by typecheck, eslint, and 8 new renderHook tests. Completes U2 + U3. --- packages/dashboard/app/App.tsx | 139 ++--------------- .../hooks/__tests__/useApprovalBanner.test.ts | 139 +++++++++++++++++ .../hooks/__tests__/useMailboxUnread.test.ts | 59 +++++++ .../dashboard/app/hooks/useApprovalBanner.ts | 146 ++++++++++++++++++ .../dashboard/app/hooks/useMailboxUnread.ts | 56 +++++++ 5 files changed, 413 insertions(+), 126 deletions(-) create mode 100644 packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts create mode 100644 packages/dashboard/app/hooks/__tests__/useMailboxUnread.test.ts create mode 100644 packages/dashboard/app/hooks/useApprovalBanner.ts create mode 100644 packages/dashboard/app/hooks/useMailboxUnread.ts diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 2d44ebee80..e34b3b46a8 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -96,12 +96,14 @@ import { ShellHostProvider, useShellHostContext } from "./context/ShellHostConte import { useShellConnection } from "./hooks/useShellConnection"; import { useStashOrphanCount } from "./hooks/useStashOrphanCount"; import { useChatUnreadBadge } from "./hooks/useChatUnreadBadge"; +import { useMailboxUnread } from "./hooks/useMailboxUnread"; +import { useApprovalBanner } from "./hooks/useApprovalBanner"; 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, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps, refreshDashboardHealth } from "./api"; +import { fetchDashboardHealth, fetchTaskDetail, fetchWorkflowSteps, refreshDashboardHealth } from "./api"; import { getScopedItem, removeScopedItem, setScopedItem } from "./utils/projectStorage"; import { SETUP_WARNING_DISMISSED_KEY, @@ -110,18 +112,12 @@ import { NO_BRANCH_FILTER_VALUE, CAPACITY_RISK_DISMISSED_KEY, RETRY_WARNING_RATIO, - parseDateMs, - loadApprovalBannerDismissals, - persistApprovalBannerDismissals, buildRemoteDashboardUrl, - didEnterAwaitingApproval, - didEnterDone, requiresNativeShellOnboarding, shouldShowFirstEverBootLoader, isSessionNeedingInputForBanner, getCliActionDisabledReasonForBanner, executeCliSessionBannerAction, - type ApprovalBannerCandidate, } from "./utils/appLifecycle"; // Re-export the unit-tested lifecycle helpers so existing `from "./App"` / // `from "../../App"` imports keep resolving after the bodies moved to utils. @@ -577,121 +573,19 @@ function AppInner() { useMobileViewportRestoreReset(isMobile); // App-level mailbox/chat unread state (used for header/mobile nav badges) - const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0); - const [mailboxPendingApprovalCount, setMailboxPendingApprovalCount] = useState(0); + const { mailboxUnreadCount, mailboxPendingApprovalCount, setMailboxUnreadCount, refresh: mailboxRefresh } = useMailboxUnread(currentProject?.id); const { chatHasUnreadResponse } = useChatUnreadBadge(currentProject?.id, { taskView, quickChatOpen }); const { stashOrphanCount } = useStashOrphanCount(currentProject?.id); - const [approvalBannerCandidate, setApprovalBannerCandidate] = useState(null); const [showGitHubStarPrompt, setShowGitHubStarPrompt] = useState(false); - const taskStatusByIdRef = useRef>(new Map()); - const seenApprovalKeysRef = useRef>(new Set()); - const approvalDismissalsRef = useRef>(loadApprovalBannerDismissals()); const gitHubStarPromptShown = useGitHubStarPromptShown(); - - const refreshMailboxUnreadCount = useCallback(() => { - fetchUnreadCount(currentProject?.id) - .then((data: { unreadCount: number; pendingApprovalCount?: number }) => { - setMailboxUnreadCount(data.unreadCount); - setMailboxPendingApprovalCount(data.pendingApprovalCount ?? 0); - }) - .catch((err) => { - console.warn("[App] Failed to fetch mailbox unread count:", err); - }); - }, [currentProject?.id]); - - useEffect(() => { - const next = new Map(); - const nextSeen = new Set(); - for (const task of tasks) { - next.set(task.id, task.status); - if (task.status === "awaiting-approval") { - nextSeen.add(`task:${task.id}`); - } - } - taskStatusByIdRef.current = next; - seenApprovalKeysRef.current = nextSeen; - }, [tasks]); - - // Initial fetch + live updates from mailbox SSE events. - useEffect(() => { - refreshMailboxUnreadCount(); - - const params = new URLSearchParams(); - if (currentProject?.id) { - params.set("projectId", currentProject.id); - } - const query = params.size > 0 ? `?${params.toString()}` : ""; - - const triggerApprovalBanner = (candidate: ApprovalBannerCandidate) => { - const dismissedAt = approvalDismissalsRef.current.get(candidate.dedupeKey); - if (dismissedAt !== undefined && candidate.updatedAtMs <= dismissedAt) { - return; - } - setApprovalBannerCandidate(candidate); - }; - - return subscribeSse(`/api/events${query}`, { - onReconnect: refreshMailboxUnreadCount, - events: { - "message:sent": refreshMailboxUnreadCount, - "message:received": refreshMailboxUnreadCount, - "message:read": refreshMailboxUnreadCount, - "message:deleted": refreshMailboxUnreadCount, - "approval:requested": (event: MessageEvent) => { - refreshMailboxUnreadCount(); - try { - const payload = JSON.parse(event.data) as { id?: string; taskId?: string; updatedAt?: string; createdAt?: string }; - const dedupeKey = payload.id ? `approval:${payload.id}` : payload.taskId ? `task:${payload.taskId}` : undefined; - if (!dedupeKey || seenApprovalKeysRef.current.has(dedupeKey)) { - return; - } - seenApprovalKeysRef.current.add(dedupeKey); - triggerApprovalBanner({ - dedupeKey, - updatedAtMs: parseDateMs(payload.updatedAt ?? payload.createdAt), - }); - } catch { - // no-op - } - }, - "approval:updated": refreshMailboxUnreadCount, - "approval:decided": refreshMailboxUnreadCount, - "task:updated": (event: MessageEvent) => { - try { - const payload = JSON.parse(event.data) as { id?: string; status?: string; updatedAt?: string }; - if (!payload?.id) { - return; - } - const dedupeKey = `task:${payload.id}`; - const previousStatus = taskStatusByIdRef.current.get(payload.id); - taskStatusByIdRef.current.set(payload.id, payload.status); - if (!gitHubStarPromptShown && didEnterDone(payload.status, previousStatus)) { - setShowGitHubStarPrompt(true); - } - if (payload.status !== "awaiting-approval") { - seenApprovalKeysRef.current.delete(dedupeKey); - approvalDismissalsRef.current.delete(dedupeKey); - persistApprovalBannerDismissals(approvalDismissalsRef.current); - return; - } - if (seenApprovalKeysRef.current.has(dedupeKey)) { - return; - } - if (didEnterAwaitingApproval(payload.status, previousStatus)) { - seenApprovalKeysRef.current.add(dedupeKey); - triggerApprovalBanner({ - dedupeKey, - updatedAtMs: parseDateMs(payload.updatedAt), - }); - refreshMailboxUnreadCount(); - } - } catch { - // no-op - } - }, - }, - }); - }, [currentProject?.id, gitHubStarPromptShown, refreshMailboxUnreadCount]); + const handleStarPrompt = useCallback(() => setShowGitHubStarPrompt(true), []); + const { candidate: approvalBannerCandidate, dismissApproval } = useApprovalBanner({ + tasks, + currentProjectId: currentProject?.id, + gitHubStarPromptShown, + onStarPrompt: handleStarPrompt, + onMailboxRefresh: mailboxRefresh, + }); const branchOptions = useMemo(() => { return Array.from( @@ -2247,14 +2141,7 @@ function AppInner() { handleTaskViewChange("mailbox")} - onDismiss={() => { - approvalDismissalsRef.current.set( - approvalBannerCandidate.dedupeKey, - Math.max(Date.now(), approvalBannerCandidate.updatedAtMs), - ); - persistApprovalBannerDismissals(approvalDismissalsRef.current); - setApprovalBannerCandidate(null); - }} + onDismiss={() => dismissApproval(approvalBannerCandidate)} /> )} {/* FNXC:Onboarding 2026-06-22-03:11: The one-time GitHub star prompt stays tied to first completed task, but first-run setup must finish the optional persistent-agent create/skip step before any star ask can surface. Do not add a second setup-specific star prompt. */} diff --git a/packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts b/packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts new file mode 100644 index 0000000000..a100879646 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { Task } from "@fusion/core"; + +const { handlers } = vi.hoisted(() => ({ + handlers: {} as Record void>, +})); + +vi.mock("../../sse-bus", () => ({ + subscribeSse: vi.fn((_url: string, opts: { events: Record void> }) => { + Object.assign(handlers, opts.events); + return () => {}; + }), +})); + +import { useApprovalBanner } from "../useApprovalBanner"; + +function msg(data: object): MessageEvent { + return { data: JSON.stringify(data) } as MessageEvent; +} + +const task = (id: string, status: string): Task => ({ id, status, title: id } as Task); + +describe("useApprovalBanner", () => { + beforeEach(() => { + for (const key of Object.keys(handlers)) delete handlers[key]; + }); + + it("triggers the banner + mailbox refresh when a task enters awaiting-approval", () => { + const onMailboxRefresh = vi.fn(); + const { result } = renderHook(() => + useApprovalBanner({ + tasks: [], + currentProjectId: "p1", + gitHubStarPromptShown: true, + onStarPrompt: vi.fn(), + onMailboxRefresh, + }), + ); + + act(() => { + handlers["task:updated"]?.(msg({ id: "t1", status: "awaiting-approval", updatedAt: "2026-01-01T00:00:00Z" })); + }); + + expect(result.current.candidate?.dedupeKey).toBe("task:t1"); + expect(onMailboxRefresh).toHaveBeenCalledTimes(1); + }); + + it("fires the star prompt on the first transition to done", () => { + const onStarPrompt = vi.fn(); + renderHook(() => + useApprovalBanner({ + // Seed the status map so done is a transition from in-progress. + tasks: [task("t1", "in-progress")], + currentProjectId: "p1", + gitHubStarPromptShown: false, + onStarPrompt, + onMailboxRefresh: vi.fn(), + }), + ); + + act(() => { + handlers["task:updated"]?.(msg({ id: "t1", status: "done" })); + }); + + expect(onStarPrompt).toHaveBeenCalledTimes(1); + }); + + it("does not star-prompt again once the prompt has been shown", () => { + const onStarPrompt = vi.fn(); + renderHook(() => + useApprovalBanner({ + tasks: [task("t1", "in-progress")], + currentProjectId: "p1", + gitHubStarPromptShown: true, + onStarPrompt, + onMailboxRefresh: vi.fn(), + }), + ); + + act(() => { + handlers["task:updated"]?.(msg({ id: "t1", status: "done" })); + }); + + expect(onStarPrompt).not.toHaveBeenCalled(); + }); + + it("dedupes a repeated approval:requested for the same key", () => { + const { result } = renderHook(() => + useApprovalBanner({ + tasks: [], + currentProjectId: "p1", + gitHubStarPromptShown: true, + onStarPrompt: vi.fn(), + onMailboxRefresh: vi.fn(), + }), + ); + + act(() => { + handlers["approval:requested"]?.(msg({ id: "a1", updatedAt: "2026-01-01T00:00:00Z" })); + }); + expect(result.current.candidate?.dedupeKey).toBe("approval:a1"); + + act(() => { + handlers["approval:requested"]?.(msg({ id: "a1", updatedAt: "2026-01-02T00:00:00Z" })); + }); + // Same dedupeKey — candidate stays at the first trigger's value. + expect(result.current.candidate?.dedupeKey).toBe("approval:a1"); + }); + + it("dismiss clears the candidate and suppresses re-trigger until a newer timestamp", () => { + const { result } = renderHook(() => + useApprovalBanner({ + tasks: [], + currentProjectId: "p1", + gitHubStarPromptShown: true, + onStarPrompt: vi.fn(), + onMailboxRefresh: vi.fn(), + }), + ); + + act(() => { + handlers["approval:requested"]?.(msg({ id: "a1", updatedAt: "2026-01-01T00:00:00Z" })); + }); + const dismissed = result.current.candidate!; + expect(dismissed).toBeTruthy(); + + act(() => { + result.current.dismissApproval(dismissed); + }); + expect(result.current.candidate).toBeNull(); + + // Same-or-older timestamp is suppressed after dismissal. + act(() => { + handlers["approval:requested"]?.(msg({ id: "a1", updatedAt: "2026-01-01T00:00:00Z" })); + }); + expect(result.current.candidate).toBeNull(); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useMailboxUnread.test.ts b/packages/dashboard/app/hooks/__tests__/useMailboxUnread.test.ts new file mode 100644 index 0000000000..ac13b005af --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useMailboxUnread.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; + +const { handlers } = vi.hoisted(() => ({ + handlers: {} as Record void> & { onReconnect?: () => void }, +})); + +vi.mock("../../sse-bus", () => ({ + subscribeSse: vi.fn((_url: string, opts: { onReconnect?: () => void; events: Record void> }) => { + handlers.onReconnect = opts.onReconnect; + Object.assign(handlers, opts.events); + return () => {}; + }), +})); + +const fetchUnreadCount = vi.fn(); +vi.mock("../../api", () => ({ fetchUnreadCount: (...a: unknown[]) => fetchUnreadCount(...a) })); + +import { useMailboxUnread } from "../useMailboxUnread"; + +describe("useMailboxUnread", () => { + beforeEach(() => { + for (const key of Object.keys(handlers)) delete (handlers as Record)[key]; + fetchUnreadCount.mockReset(); + }); + + it("seeds counts from the initial fetch", async () => { + fetchUnreadCount.mockResolvedValue({ unreadCount: 4, pendingApprovalCount: 2 }); + const { result } = renderHook(() => useMailboxUnread("p1")); + + await waitFor(() => expect(result.current.mailboxUnreadCount).toBe(4)); + expect(result.current.mailboxPendingApprovalCount).toBe(2); + }); + + it("refreshes counts on a message:sent SSE event", async () => { + fetchUnreadCount.mockResolvedValue({ unreadCount: 1 }); + const { result } = renderHook(() => useMailboxUnread("p1")); + await waitFor(() => expect(result.current.mailboxUnreadCount).toBe(1)); + + fetchUnreadCount.mockResolvedValue({ unreadCount: 9 }); + await act(async () => { + handlers["message:sent"]?.({} as MessageEvent); + await Promise.resolve(); + }); + + await waitFor(() => expect(result.current.mailboxUnreadCount).toBe(9)); + }); + + it("exposes setMailboxUnreadCount for MailboxView's onUnreadCountChange", () => { + fetchUnreadCount.mockResolvedValue({ unreadCount: 0 }); + const { result } = renderHook(() => useMailboxUnread(undefined)); + + act(() => { + result.current.setMailboxUnreadCount(42); + }); + + expect(result.current.mailboxUnreadCount).toBe(42); + }); +}); diff --git a/packages/dashboard/app/hooks/useApprovalBanner.ts b/packages/dashboard/app/hooks/useApprovalBanner.ts new file mode 100644 index 0000000000..4104c0ae65 --- /dev/null +++ b/packages/dashboard/app/hooks/useApprovalBanner.ts @@ -0,0 +1,146 @@ +/* +FNXC:ApprovalBanner 2026-06-24-00:00: +Approval-notification banner dedupe/dismiss state machine, driven by task:updated and approval:requested SSE events. Also fires the first-completed-task GitHub-star prompt and a mailbox-count refresh when a task enters awaiting-approval — preserving the former single-subscriber side effects via the onStarPrompt / onMailboxRefresh callbacks. Extracted from AppInner. + +FNXC:ApprovalBanner 2026-06-24-00:00: +Stale-closure / effect-identity hazard: the per-`tasks` ref-sync effect rebuilds the status + seen-key maps on every tasks change, and the dismissal-timestamp comparison (`updatedAtMs <= dismissedAt`) suppresses re-trigger. Preserve both exactly when touching this hook (see docs/solutions ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation and logic-errors/queued-chat-message-flush-trusts-stale-isgenerating). +*/ + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { Task } from "@fusion/core"; +import { subscribeSse } from "../sse-bus"; +import { + type ApprovalBannerCandidate, + didEnterAwaitingApproval, + didEnterDone, + loadApprovalBannerDismissals, + parseDateMs, + persistApprovalBannerDismissals, +} from "../utils/appLifecycle"; + +export interface UseApprovalBannerOptions { + tasks: Task[]; + currentProjectId: string | undefined; + gitHubStarPromptShown: boolean; + /** Invoked when a task first transitions to done (drives the GitHub-star prompt). */ + onStarPrompt: () => void; + /** Invoked when a task enters awaiting-approval (drives a mailbox-count refresh). */ + onMailboxRefresh: () => void; +} + +export interface UseApprovalBannerResult { + candidate: ApprovalBannerCandidate | null; + dismissApproval: (candidate: ApprovalBannerCandidate) => void; +} + +export function useApprovalBanner({ + tasks, + currentProjectId, + gitHubStarPromptShown, + onStarPrompt, + onMailboxRefresh, +}: UseApprovalBannerOptions): UseApprovalBannerResult { + const [candidate, setCandidate] = useState(null); + const taskStatusByIdRef = useRef>(new Map()); + const seenApprovalKeysRef = useRef>(new Set()); + const approvalDismissalsRef = useRef>(loadApprovalBannerDismissals()); + + useEffect(() => { + const next = new Map(); + const nextSeen = new Set(); + for (const task of tasks) { + next.set(task.id, task.status); + if (task.status === "awaiting-approval") { + nextSeen.add(`task:${task.id}`); + } + } + taskStatusByIdRef.current = next; + seenApprovalKeysRef.current = nextSeen; + }, [tasks]); + + useEffect(() => { + const params = new URLSearchParams(); + if (currentProjectId) { + params.set("projectId", currentProjectId); + } + const query = params.size > 0 ? `?${params.toString()}` : ""; + + const triggerApprovalBanner = (next: ApprovalBannerCandidate) => { + const dismissedAt = approvalDismissalsRef.current.get(next.dedupeKey); + if (dismissedAt !== undefined && next.updatedAtMs <= dismissedAt) { + return; + } + setCandidate(next); + }; + + return subscribeSse(`/api/events${query}`, { + events: { + "approval:requested": (event: MessageEvent) => { + try { + const payload = JSON.parse(event.data) as { + id?: string; + taskId?: string; + updatedAt?: string; + createdAt?: string; + }; + const dedupeKey = payload.id ? `approval:${payload.id}` : payload.taskId ? `task:${payload.taskId}` : undefined; + if (!dedupeKey || seenApprovalKeysRef.current.has(dedupeKey)) { + return; + } + seenApprovalKeysRef.current.add(dedupeKey); + triggerApprovalBanner({ + dedupeKey, + updatedAtMs: parseDateMs(payload.updatedAt ?? payload.createdAt), + }); + } catch { + // no-op + } + }, + "task:updated": (event: MessageEvent) => { + try { + const payload = JSON.parse(event.data) as { id?: string; status?: string; updatedAt?: string }; + if (!payload?.id) { + return; + } + const dedupeKey = `task:${payload.id}`; + const previousStatus = taskStatusByIdRef.current.get(payload.id); + taskStatusByIdRef.current.set(payload.id, payload.status); + if (!gitHubStarPromptShown && didEnterDone(payload.status, previousStatus)) { + onStarPrompt(); + } + if (payload.status !== "awaiting-approval") { + seenApprovalKeysRef.current.delete(dedupeKey); + approvalDismissalsRef.current.delete(dedupeKey); + persistApprovalBannerDismissals(approvalDismissalsRef.current); + return; + } + if (seenApprovalKeysRef.current.has(dedupeKey)) { + return; + } + if (didEnterAwaitingApproval(payload.status, previousStatus)) { + seenApprovalKeysRef.current.add(dedupeKey); + triggerApprovalBanner({ + dedupeKey, + updatedAtMs: parseDateMs(payload.updatedAt), + }); + onMailboxRefresh(); + } + } catch { + // no-op + } + }, + }, + }); + }, [currentProjectId, gitHubStarPromptShown, onStarPrompt, onMailboxRefresh]); + + const dismissApproval = useCallback((dismissed: ApprovalBannerCandidate) => { + approvalDismissalsRef.current.set( + dismissed.dedupeKey, + Math.max(Date.now(), dismissed.updatedAtMs), + ); + persistApprovalBannerDismissals(approvalDismissalsRef.current); + setCandidate(null); + }, []); + + return { candidate, dismissApproval }; +} diff --git a/packages/dashboard/app/hooks/useMailboxUnread.ts b/packages/dashboard/app/hooks/useMailboxUnread.ts new file mode 100644 index 0000000000..b4421b7ddb --- /dev/null +++ b/packages/dashboard/app/hooks/useMailboxUnread.ts @@ -0,0 +1,56 @@ +/* +FNXC:MailboxBadge 2026-06-24-00:00: +Header/mobile-nav unread + pending-approval counts for the mailbox, refreshed on message and approval SSE events. Extracted from AppInner; exposes `refresh` (so the approval-banner hook can re-fetch counts when a task enters awaiting-approval, preserving the former single-subscriber side effect) and `setMailboxUnreadCount` (MailboxView reports its own count changes through onUnreadCountChange). +*/ + +import { useCallback, useEffect, useState } from "react"; +import { fetchUnreadCount } from "../api"; +import { subscribeSse } from "../sse-bus"; + +export interface UseMailboxUnreadResult { + mailboxUnreadCount: number; + mailboxPendingApprovalCount: number; + setMailboxUnreadCount: (count: number) => void; + refresh: () => void; +} + +export function useMailboxUnread(currentProjectId: string | undefined): UseMailboxUnreadResult { + const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0); + const [mailboxPendingApprovalCount, setMailboxPendingApprovalCount] = useState(0); + + const refresh = useCallback(() => { + fetchUnreadCount(currentProjectId) + .then((data: { unreadCount: number; pendingApprovalCount?: number }) => { + setMailboxUnreadCount(data.unreadCount); + setMailboxPendingApprovalCount(data.pendingApprovalCount ?? 0); + }) + .catch((err) => { + console.warn("[App] Failed to fetch mailbox unread count:", err); + }); + }, [currentProjectId]); + + useEffect(() => { + refresh(); + + const params = new URLSearchParams(); + if (currentProjectId) { + params.set("projectId", currentProjectId); + } + const query = params.size > 0 ? `?${params.toString()}` : ""; + + return subscribeSse(`/api/events${query}`, { + onReconnect: refresh, + events: { + "message:sent": refresh, + "message:received": refresh, + "message:read": refresh, + "message:deleted": refresh, + "approval:requested": refresh, + "approval:updated": refresh, + "approval:decided": refresh, + }, + }); + }, [currentProjectId, refresh]); + + return { mailboxUnreadCount, mailboxPendingApprovalCount, setMailboxUnreadCount, refresh }; +} From 79b71ad23678f373220614a15d31fe8879f6a910 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 19:52:52 -0700 Subject: [PATCH 05/14] refactor(dashboard): extract useBranchTaskFilters hook from App.tsx Consolidate the working/base branch-filter state, the per-project scoped-load effect, the change handlers, and the branchOptions/baseBranchOptions/ filteredBoardTasks memos (including the NO_BRANCH_FILTER_VALUE "no branch" sentinel) into app/hooks/useBranchTaskFilters.ts. App computes the remote-aware boardSourceTasks and passes it in. Behavior-preserving; App.test.tsx identical (5 pre-existing failures, none introduced). Verified by typecheck, eslint, and 6 renderHook tests. Part of U4 (App.tsx module-breakup plan). --- packages/dashboard/app/App.tsx | 71 ++---------- .../__tests__/useBranchTaskFilters.test.ts | 106 ++++++++++++++++++ .../app/hooks/useBranchTaskFilters.ts | 103 +++++++++++++++++ 3 files changed, 219 insertions(+), 61 deletions(-) create mode 100644 packages/dashboard/app/hooks/__tests__/useBranchTaskFilters.test.ts create mode 100644 packages/dashboard/app/hooks/useBranchTaskFilters.ts diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index e34b3b46a8..4ab12255fa 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -98,6 +98,7 @@ import { useStashOrphanCount } from "./hooks/useStashOrphanCount"; import { useChatUnreadBadge } from "./hooks/useChatUnreadBadge"; import { useMailboxUnread } from "./hooks/useMailboxUnread"; import { useApprovalBanner } from "./hooks/useApprovalBanner"; +import { useBranchTaskFilters } from "./hooks/useBranchTaskFilters"; import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingModal"; import { NativeShellConnectionManager } from "./components/NativeShellConnectionManager"; import { ShellConnectionStatus } from "./components/ShellConnectionStatus"; @@ -107,9 +108,6 @@ import { fetchDashboardHealth, fetchTaskDetail, fetchWorkflowSteps, refreshDashb import { getScopedItem, removeScopedItem, setScopedItem } from "./utils/projectStorage"; import { SETUP_WARNING_DISMISSED_KEY, - WORKING_BRANCH_FILTER_STORAGE_KEY, - BASE_BRANCH_FILTER_STORAGE_KEY, - NO_BRANCH_FILTER_VALUE, CAPACITY_RISK_DISMISSED_KEY, RETRY_WARNING_RATIO, buildRemoteDashboardUrl, @@ -279,23 +277,6 @@ function AppInner() { // Search query state - must be defined before useTasks const [searchQuery, setSearchQuery] = useState(""); - const [branchFilter, setBranchFilter] = useState(""); - const [baseBranchFilter, setBaseBranchFilter] = useState(""); - - useEffect(() => { - setBranchFilter(getScopedItem(WORKING_BRANCH_FILTER_STORAGE_KEY, currentProject?.id) ?? ""); - setBaseBranchFilter(getScopedItem(BASE_BRANCH_FILTER_STORAGE_KEY, currentProject?.id) ?? ""); - }, [currentProject?.id]); - - const handleBranchFilterChange = useCallback((value: string) => { - setBranchFilter(value); - setScopedItem(WORKING_BRANCH_FILTER_STORAGE_KEY, value, currentProject?.id); - }, [currentProject?.id]); - - const handleBaseBranchFilterChange = useCallback((value: string) => { - setBaseBranchFilter(value); - setScopedItem(BASE_BRANCH_FILTER_STORAGE_KEY, value, currentProject?.id); - }, [currentProject?.id]); // Host capability handed to plugin dashboard views: subscribe to a plugin's // custom SSE events (forwarded by the server as `plugin:custom`, scoped to the @@ -587,47 +568,15 @@ function AppInner() { onMailboxRefresh: mailboxRefresh, }); - const branchOptions = useMemo(() => { - return Array.from( - new Set( - boardSourceTasks - .map((task) => task.branch?.trim()) - .filter((branch): branch is string => Boolean(branch && branch.length > 0)), - ), - ).sort((a, b) => a.localeCompare(b)); - }, [boardSourceTasks]); - - const baseBranchOptions = useMemo(() => { - return Array.from( - new Set( - boardSourceTasks - .map((task) => task.baseBranch?.trim()) - .filter((baseBranch): baseBranch is string => Boolean(baseBranch && baseBranch.length > 0)), - ), - ).sort((a, b) => a.localeCompare(b)); - }, [boardSourceTasks]); - - const filteredBoardTasks = useMemo(() => { - return boardSourceTasks.filter((task) => { - const taskBranch = task.branch?.trim() ?? ""; - const taskBaseBranch = task.baseBranch?.trim() ?? ""; - if (branchFilter === NO_BRANCH_FILTER_VALUE) { - if (taskBranch.length > 0) { - return false; - } - } else if (branchFilter.length > 0 && taskBranch !== branchFilter) { - return false; - } - if (baseBranchFilter === NO_BRANCH_FILTER_VALUE) { - if (taskBaseBranch.length > 0) { - return false; - } - } else if (baseBranchFilter.length > 0 && taskBaseBranch !== baseBranchFilter) { - return false; - } - return true; - }); - }, [boardSourceTasks, branchFilter, baseBranchFilter]); + const { + branchFilter, + baseBranchFilter, + branchOptions, + baseBranchOptions, + filteredBoardTasks, + onBranchFilterChange: handleBranchFilterChange, + onBaseBranchFilterChange: handleBaseBranchFilterChange, + } = useBranchTaskFilters({ boardSourceTasks, currentProjectId: currentProject?.id }); const [retryingProjects, setRetryingProjects] = useState(false); const [missionResumeSessionId, setMissionResumeSessionId] = useState(undefined); diff --git a/packages/dashboard/app/hooks/__tests__/useBranchTaskFilters.test.ts b/packages/dashboard/app/hooks/__tests__/useBranchTaskFilters.test.ts new file mode 100644 index 0000000000..3b6b38d972 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useBranchTaskFilters.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { Task } from "@fusion/core"; + +vi.mock("../../utils/projectStorage", () => ({ + getScopedItem: vi.fn(() => null), + setScopedItem: vi.fn(), +})); + +import { getScopedItem, setScopedItem } from "../../utils/projectStorage"; +import { useBranchTaskFilters } from "../useBranchTaskFilters"; +import { NO_BRANCH_FILTER_VALUE } from "../../utils/appLifecycle"; + +const task = (id: string, branch?: string, baseBranch?: string): Task => + ({ id, title: id, branch, baseBranch } as Task); + +describe("useBranchTaskFilters", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("derives unique, sorted branch options and drops empty branches", () => { + const { result } = renderHook(() => + useBranchTaskFilters({ + boardSourceTasks: [task("1", "zebra"), task("2", " "), task("3", "alpha"), task("4", "alpha")], + currentProjectId: "p1", + }), + ); + + expect(result.current.branchOptions).toEqual(["alpha", "zebra"]); + }); + + it("excludes tasks that have a branch under the no-branch sentinel", () => { + const { result } = renderHook(() => + useBranchTaskFilters({ + boardSourceTasks: [task("1", "feat"), task("2")], + currentProjectId: "p1", + }), + ); + + act(() => { + result.current.onBranchFilterChange(NO_BRANCH_FILTER_VALUE); + }); + + expect(result.current.filteredBoardTasks.map((t) => t.id)).toEqual(["2"]); + }); + + it("excludes tasks whose branch does not match a concrete filter", () => { + const { result } = renderHook(() => + useBranchTaskFilters({ + boardSourceTasks: [task("1", "feat"), task("2", "main")], + currentProjectId: "p1", + }), + ); + + act(() => { + result.current.onBranchFilterChange("feat"); + }); + + expect(result.current.filteredBoardTasks.map((t) => t.id)).toEqual(["1"]); + }); + + it("composes the base-branch filter independently", () => { + const { result } = renderHook(() => + useBranchTaskFilters({ + boardSourceTasks: [ + task("1", "feat", "main"), + task("2", "feat", "release"), + task("3", "other", "main"), + ], + currentProjectId: "p1", + }), + ); + + act(() => { + result.current.onBranchFilterChange("feat"); + result.current.onBaseBranchFilterChange("main"); + }); + + expect(result.current.filteredBoardTasks.map((t) => t.id)).toEqual(["1"]); + }); + + it("persists filter changes to scoped storage", () => { + const { result } = renderHook(() => + useBranchTaskFilters({ boardSourceTasks: [], currentProjectId: "p1" }), + ); + + act(() => { + result.current.onBaseBranchFilterChange("release"); + }); + + expect(setScopedItem).toHaveBeenCalledWith(expect.any(String), "release", "p1"); + }); + + it("re-reads scoped values when the project changes", () => { + const { rerender } = renderHook( + (props: { currentProjectId: string | undefined }) => + useBranchTaskFilters({ boardSourceTasks: [], currentProjectId: props.currentProjectId }), + { initialProps: { currentProjectId: "p1" } }, + ); + + rerender({ currentProjectId: "p2" }); + + expect(getScopedItem).toHaveBeenCalledWith(expect.any(String), "p2"); + }); +}); diff --git a/packages/dashboard/app/hooks/useBranchTaskFilters.ts b/packages/dashboard/app/hooks/useBranchTaskFilters.ts new file mode 100644 index 0000000000..8454e3c1e1 --- /dev/null +++ b/packages/dashboard/app/hooks/useBranchTaskFilters.ts @@ -0,0 +1,103 @@ +/* +FNXC:BoardFilters 2026-06-24-00:00: +Working/base branch filters for the board, persisted per-project via scoped storage, plus the derived branch-option lists and the filtered task set (including the NO_BRANCH_FILTER_VALUE "no branch" sentinel that excludes tasks which have a branch). Extracted from AppInner. +*/ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { Task } from "@fusion/core"; +import { getScopedItem, setScopedItem } from "../utils/projectStorage"; +import { + BASE_BRANCH_FILTER_STORAGE_KEY, + NO_BRANCH_FILTER_VALUE, + WORKING_BRANCH_FILTER_STORAGE_KEY, +} from "../utils/appLifecycle"; + +export interface UseBranchTaskFiltersOptions { + boardSourceTasks: Task[]; + currentProjectId: string | undefined; +} + +export interface UseBranchTaskFiltersResult { + branchFilter: string; + baseBranchFilter: string; + branchOptions: string[]; + baseBranchOptions: string[]; + filteredBoardTasks: Task[]; + onBranchFilterChange: (value: string) => void; + onBaseBranchFilterChange: (value: string) => void; +} + +export function useBranchTaskFilters({ + boardSourceTasks, + currentProjectId, +}: UseBranchTaskFiltersOptions): UseBranchTaskFiltersResult { + const [branchFilter, setBranchFilter] = useState(""); + const [baseBranchFilter, setBaseBranchFilter] = useState(""); + + useEffect(() => { + setBranchFilter(getScopedItem(WORKING_BRANCH_FILTER_STORAGE_KEY, currentProjectId) ?? ""); + setBaseBranchFilter(getScopedItem(BASE_BRANCH_FILTER_STORAGE_KEY, currentProjectId) ?? ""); + }, [currentProjectId]); + + const onBranchFilterChange = useCallback((value: string) => { + setBranchFilter(value); + setScopedItem(WORKING_BRANCH_FILTER_STORAGE_KEY, value, currentProjectId); + }, [currentProjectId]); + + const onBaseBranchFilterChange = useCallback((value: string) => { + setBaseBranchFilter(value); + setScopedItem(BASE_BRANCH_FILTER_STORAGE_KEY, value, currentProjectId); + }, [currentProjectId]); + + const branchOptions = useMemo(() => { + return Array.from( + new Set( + boardSourceTasks + .map((task) => task.branch?.trim()) + .filter((branch): branch is string => Boolean(branch && branch.length > 0)), + ), + ).sort((a, b) => a.localeCompare(b)); + }, [boardSourceTasks]); + + const baseBranchOptions = useMemo(() => { + return Array.from( + new Set( + boardSourceTasks + .map((task) => task.baseBranch?.trim()) + .filter((baseBranch): baseBranch is string => Boolean(baseBranch && baseBranch.length > 0)), + ), + ).sort((a, b) => a.localeCompare(b)); + }, [boardSourceTasks]); + + const filteredBoardTasks = useMemo(() => { + return boardSourceTasks.filter((task) => { + const taskBranch = task.branch?.trim() ?? ""; + const taskBaseBranch = task.baseBranch?.trim() ?? ""; + if (branchFilter === NO_BRANCH_FILTER_VALUE) { + if (taskBranch.length > 0) { + return false; + } + } else if (branchFilter.length > 0 && taskBranch !== branchFilter) { + return false; + } + if (baseBranchFilter === NO_BRANCH_FILTER_VALUE) { + if (taskBaseBranch.length > 0) { + return false; + } + } else if (baseBranchFilter.length > 0 && taskBaseBranch !== baseBranchFilter) { + return false; + } + return true; + }); + }, [boardSourceTasks, branchFilter, baseBranchFilter]); + + return { + branchFilter, + baseBranchFilter, + branchOptions, + baseBranchOptions, + filteredBoardTasks, + onBranchFilterChange, + onBaseBranchFilterChange, + }; +} From f8c366d8f28fce478d5ba0aed7cc705407e73cb9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 20:03:44 -0700 Subject: [PATCH 06/14] 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 }; +} From b8c511fe385e4be4a66d584d22a1ecc8ac63a55e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 20:14:14 -0700 Subject: [PATCH 07/14] 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 }; +} From f6f9e822f52b571a8073b97f774a99069b648b58 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 21:09:36 -0700 Subject: [PATCH 08/14] refactor(dashboard): extract task-detail, board-scroll, popped-out hooks Extract three AppInner state clusters into hooks: - useMainPanelTaskDetail: the main-panel task-detail snapshot + initial tab; setTask accepts the SetStateAction updater form so the embedded detail can patch the snapshot on task updates. - useBoardScrollRestore: the board scroll snapshot refs, capture, and the double-requestAnimationFrame restore effect keyed on taskView; exposes requestRestore for App to schedule a restore on detail close. - usePoppedOutTasks: the popped-out task-detail windows (dedupe-by-id popOut, close-by-id). App keeps the navigation-history composition (openTaskDetailInMainPanel / closeTaskDetailMainPanel) and now consumes the hooks' primitives. Behavior-preserving; App.test.tsx identical (5 pre-existing failures, none introduced). Verified by typecheck, eslint, and 5 renderHook tests. Completes U6 (App.tsx module-breakup plan). --- packages/dashboard/app/App.tsx | 58 +++---------------- .../__tests__/useBoardScrollRestore.test.ts | 19 ++++++ .../__tests__/useMainPanelTaskDetail.test.ts | 30 ++++++++++ .../hooks/__tests__/usePoppedOutTasks.test.ts | 31 ++++++++++ .../app/hooks/useBoardScrollRestore.ts | 57 ++++++++++++++++++ .../app/hooks/useMainPanelTaskDetail.ts | 22 +++++++ .../dashboard/app/hooks/usePoppedOutTasks.ts | 27 +++++++++ 7 files changed, 193 insertions(+), 51 deletions(-) create mode 100644 packages/dashboard/app/hooks/__tests__/useBoardScrollRestore.test.ts create mode 100644 packages/dashboard/app/hooks/__tests__/useMainPanelTaskDetail.test.ts create mode 100644 packages/dashboard/app/hooks/__tests__/usePoppedOutTasks.test.ts create mode 100644 packages/dashboard/app/hooks/useBoardScrollRestore.ts create mode 100644 packages/dashboard/app/hooks/useMainPanelTaskDetail.ts create mode 100644 packages/dashboard/app/hooks/usePoppedOutTasks.ts diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 974ab06876..47abe6c53a 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -68,11 +68,6 @@ import { useAuthOnboarding } from "./hooks/useAuthOnboarding"; import { useMobileKeyboard } from "./hooks/useMobileKeyboard"; import { isIOS, useMobileKeyboardViewportLock, useMobileViewportRestoreReset } from "./hooks/useMobileScrollLock"; import { computeMobileBarKeyboardFlags } from "./utils/mobileBarKeyboardFlags"; -import { - captureBoardScrollSnapshot, - restoreBoardScrollSnapshot, - type BoardScrollSnapshot, -} from "./utils/boardScrollSnapshot"; import { useSetupReadiness } from "./hooks/useSetupReadiness"; import { useUpdateCheck } from "./hooks/useUpdateCheck"; import { useViewState, type TaskView } from "./hooks/useViewState"; @@ -101,6 +96,9 @@ import { useDashboardHealth } from "./hooks/useDashboardHealth"; import { useAuthTokenRecovery } from "./hooks/useAuthTokenRecovery"; import { useScopedDismissFlag } from "./hooks/useScopedDismissFlag"; import { useCapacityRiskBanner } from "./hooks/useCapacityRiskBanner"; +import { useMainPanelTaskDetail } from "./hooks/useMainPanelTaskDetail"; +import { useBoardScrollRestore } from "./hooks/useBoardScrollRestore"; +import { usePoppedOutTasks } from "./hooks/usePoppedOutTasks"; import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingModal"; import { NativeShellConnectionManager } from "./components/NativeShellConnectionManager"; import { ShellConnectionStatus } from "./components/ShellConnectionStatus"; @@ -401,55 +399,13 @@ function AppInner() { FNXC:TaskDetail 2026-06-23-00:41: Board task-card secondary actions can deep-link into the inline main-panel task detail. Files-changed must land on the embedded Changes tab instead of reopening the task in the modal path. */ - const [mainPanelDetailTask, setMainPanelDetailTask] = useState(null); - const [mainPanelDetailInitialTab, setMainPanelDetailInitialTab] = useState("chat"); - const boardScrollSnapshotRef = useRef(null); - const pendingBoardScrollRestoreRef = useRef(false); - - const captureCurrentBoardScrollSnapshot = useCallback(() => { - boardScrollSnapshotRef.current = captureBoardScrollSnapshot(); - }, []); - - const restoreCurrentBoardScrollSnapshot = useCallback(() => { - if (restoreBoardScrollSnapshot(boardScrollSnapshotRef.current)) { - pendingBoardScrollRestoreRef.current = false; - } - }, []); - - useEffect(() => { - if (taskView !== "board" || !pendingBoardScrollRestoreRef.current) return; - const scheduleFrame = typeof window.requestAnimationFrame === "function" - ? window.requestAnimationFrame.bind(window) - : ((callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0)); - const cancelFrame = typeof window.cancelAnimationFrame === "function" - ? window.cancelAnimationFrame.bind(window) - : window.clearTimeout.bind(window); - let firstFrame = 0; - let secondFrame = 0; - /* - FNXC:BoardNavigation 2026-06-22-20:15: - Board-card task detail replaces the board instead of overlaying it. Preserve horizontal board scroll and per-column vertical scroll before opening detail, then restore after Back to board remounts the board so users return to the same lane/card context. - */ - firstFrame = scheduleFrame(() => { - secondFrame = scheduleFrame(restoreCurrentBoardScrollSnapshot); - }); - return () => { - cancelFrame(firstFrame); - cancelFrame(secondFrame); - }; - }, [restoreCurrentBoardScrollSnapshot, taskView]); - + const { task: mainPanelDetailTask, initialTab: mainPanelDetailInitialTab, setTask: setMainPanelDetailTask, setInitialTab: setMainPanelDetailInitialTab } = useMainPanelTaskDetail(); + const { capture: captureCurrentBoardScrollSnapshot, requestRestore } = useBoardScrollRestore(taskView); /* FNXC:FloatingWindow 2026-06-22-20:45: Open popped-out task-detail windows. Each entry is a task snapshot rendered inside its own movable, resizable, non-blocking FloatingWindow. Several can be open at once and coexist with the right-dock pop-out and terminal (all click-through overlays). Snapshots survive a tasks revalidation; rendering prefers the live row by id and falls back to the snapshot. Pop-out dedupes by task id — re-popping an already-open task is a no-op (its window stays; focus-to-front in FloatingWindow handles re-raising on click). */ - const [poppedOutTasks, setPoppedOutTasks] = useState>([]); - const popOutTaskDetail = useCallback((task: Task | TaskDetail) => { - setPoppedOutTasks((current) => (current.some((entry) => entry.id === task.id) ? current : [...current, task])); - }, []); - const closePoppedOutTask = useCallback((taskId: string) => { - setPoppedOutTasks((current) => current.filter((entry) => entry.id !== taskId)); - }, []); + const { tasks: poppedOutTasks, popOut: popOutTaskDetail, close: closePoppedOutTask } = usePoppedOutTasks(); const previousTaskViewRef = useRef(taskView); @@ -883,7 +839,7 @@ function AppInner() { // FNXC:Navigation 2026-06-22-00:00: Leaving task-detail clears the snapshot so a stale task never lingers if the view is reopened empty. const closeTaskDetailMainPanel = useCallback(() => { - pendingBoardScrollRestoreRef.current = true; + requestRestore(); setMainPanelDetailTask(null); setMainPanelDetailInitialTab("chat"); handleTaskViewChange("board"); diff --git a/packages/dashboard/app/hooks/__tests__/useBoardScrollRestore.test.ts b/packages/dashboard/app/hooks/__tests__/useBoardScrollRestore.test.ts new file mode 100644 index 0000000000..da0ccd12c7 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useBoardScrollRestore.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useBoardScrollRestore } from "../useBoardScrollRestore"; + +vi.mock("../../utils/boardScrollSnapshot", () => ({ + captureBoardScrollSnapshot: vi.fn(() => ({ x: 10, columns: {} })), + restoreBoardScrollSnapshot: vi.fn(() => true), +})); + +describe("useBoardScrollRestore", () => { + it("exposes capture and requestRestore without throwing", () => { + const { result } = renderHook(() => useBoardScrollRestore("board")); + + expect(typeof result.current.capture).toBe("function"); + expect(typeof result.current.requestRestore).toBe("function"); + expect(() => result.current.capture()).not.toThrow(); + expect(() => result.current.requestRestore()).not.toThrow(); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useMainPanelTaskDetail.test.ts b/packages/dashboard/app/hooks/__tests__/useMainPanelTaskDetail.test.ts new file mode 100644 index 0000000000..f10dcf33ad --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useMainPanelTaskDetail.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useMainPanelTaskDetail } from "../useMainPanelTaskDetail"; + +const task = (id: string) => ({ id, title: id, status: "todo" } as never); + +describe("useMainPanelTaskDetail", () => { + it("setTask accepts both a value and an updater", () => { + const { result } = renderHook(() => useMainPanelTaskDetail()); + + act(() => { + result.current.setTask(task("1")); + }); + expect(result.current.task?.id).toBe("1"); + + act(() => { + result.current.setTask((previous) => (previous ? { ...previous, title: "renamed" } : previous)); + }); + expect(result.current.task?.title).toBe("renamed"); + }); + + it("setInitialTab updates the tab", () => { + const { result } = renderHook(() => useMainPanelTaskDetail()); + + act(() => { + result.current.setInitialTab("changes"); + }); + expect(result.current.initialTab).toBe("changes"); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/usePoppedOutTasks.test.ts b/packages/dashboard/app/hooks/__tests__/usePoppedOutTasks.test.ts new file mode 100644 index 0000000000..3cdcd5873e --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/usePoppedOutTasks.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { usePoppedOutTasks } from "../usePoppedOutTasks"; + +const task = (id: string) => ({ id, title: id, status: "todo" } as never); + +describe("usePoppedOutTasks", () => { + it("popOut adds a task and dedupes by id", () => { + const { result } = renderHook(() => usePoppedOutTasks()); + + act(() => { + result.current.popOut(task("1")); + result.current.popOut(task("1")); + result.current.popOut(task("2")); + }); + + expect(result.current.tasks.map((t) => t.id)).toEqual(["1", "2"]); + }); + + it("close removes only the matching id", () => { + const { result } = renderHook(() => usePoppedOutTasks()); + + act(() => { + result.current.popOut(task("1")); + result.current.popOut(task("2")); + result.current.close("1"); + }); + + expect(result.current.tasks.map((t) => t.id)).toEqual(["2"]); + }); +}); diff --git a/packages/dashboard/app/hooks/useBoardScrollRestore.ts b/packages/dashboard/app/hooks/useBoardScrollRestore.ts new file mode 100644 index 0000000000..903ba47b52 --- /dev/null +++ b/packages/dashboard/app/hooks/useBoardScrollRestore.ts @@ -0,0 +1,57 @@ +/* +FNXC:BoardNavigation 2026-06-24-00:00: +Preserves horizontal board scroll and per-column vertical scroll across a board → task-detail → back-to-board round trip. capture() snapshots before opening detail; requestRestore() schedules a restore that fires (double requestAnimationFrame, after the board remounts) once the view returns to "board". Extracted from AppInner. +*/ + +import { useCallback, useEffect, useRef } from "react"; +import { + captureBoardScrollSnapshot, + restoreBoardScrollSnapshot, + type BoardScrollSnapshot, +} from "../utils/boardScrollSnapshot"; +import type { TaskView } from "./useViewState"; + +export interface UseBoardScrollRestoreResult { + capture: () => void; + requestRestore: () => void; +} + +export function useBoardScrollRestore(taskView: TaskView): UseBoardScrollRestoreResult { + const boardScrollSnapshotRef = useRef(null); + const pendingBoardScrollRestoreRef = useRef(false); + + const restore = useCallback(() => { + if (restoreBoardScrollSnapshot(boardScrollSnapshotRef.current)) { + pendingBoardScrollRestoreRef.current = false; + } + }, []); + + const capture = useCallback(() => { + boardScrollSnapshotRef.current = captureBoardScrollSnapshot(); + }, []); + + const requestRestore = useCallback(() => { + pendingBoardScrollRestoreRef.current = true; + }, []); + + useEffect(() => { + if (taskView !== "board" || !pendingBoardScrollRestoreRef.current) return; + const scheduleFrame = typeof window.requestAnimationFrame === "function" + ? window.requestAnimationFrame.bind(window) + : ((callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0)); + const cancelFrame = typeof window.cancelAnimationFrame === "function" + ? window.cancelAnimationFrame.bind(window) + : window.clearTimeout.bind(window); + let firstFrame = 0; + let secondFrame = 0; + firstFrame = scheduleFrame(() => { + secondFrame = scheduleFrame(restore); + }); + return () => { + cancelFrame(firstFrame); + cancelFrame(secondFrame); + }; + }, [restore, taskView]); + + return { capture, requestRestore }; +} diff --git a/packages/dashboard/app/hooks/useMainPanelTaskDetail.ts b/packages/dashboard/app/hooks/useMainPanelTaskDetail.ts new file mode 100644 index 0000000000..df4b5c367e --- /dev/null +++ b/packages/dashboard/app/hooks/useMainPanelTaskDetail.ts @@ -0,0 +1,22 @@ +/* +FNXC:TaskDetail 2026-06-24-00:00: +Snapshot of the task whose detail is shown in the main panel (Board card click → full-panel detail), plus its initial tab. Kept as a snapshot so the view survives a tasks revalidation. Exposes the setters so App can compose open/close with view navigation, and so the embedded detail can patch the snapshot on task updates (setTask accepts the updater form). Extracted from AppInner. +*/ + +import { useState, type Dispatch, type SetStateAction } from "react"; +import type { Task, TaskDetail } from "@fusion/core"; +import type { DetailTaskTab } from "./useModalManager"; + +export interface UseMainPanelTaskDetailResult { + task: Task | TaskDetail | null; + initialTab: DetailTaskTab; + setTask: Dispatch>; + setInitialTab: (tab: DetailTaskTab) => void; +} + +export function useMainPanelTaskDetail(): UseMainPanelTaskDetailResult { + const [task, setTask] = useState(null); + const [initialTab, setInitialTab] = useState("chat"); + + return { task, initialTab, setTask, setInitialTab }; +} diff --git a/packages/dashboard/app/hooks/usePoppedOutTasks.ts b/packages/dashboard/app/hooks/usePoppedOutTasks.ts new file mode 100644 index 0000000000..8ee1be198b --- /dev/null +++ b/packages/dashboard/app/hooks/usePoppedOutTasks.ts @@ -0,0 +1,27 @@ +/* +FNXC:FloatingWindow 2026-06-24-00:00: +Popped-out task-detail windows — movable, resizable, non-blocking FloatingWindows. Each entry is a task snapshot; several can be open at once. Snapshots survive a tasks revalidation (rendering prefers the live row by id). Pop-out dedupes by task id. Extracted from AppInner. +*/ + +import { useCallback, useState } from "react"; +import type { Task, TaskDetail } from "@fusion/core"; + +export interface UsePoppedOutTasksResult { + tasks: Array; + popOut: (task: Task | TaskDetail) => void; + close: (taskId: string) => void; +} + +export function usePoppedOutTasks(): UsePoppedOutTasksResult { + const [tasks, setTasks] = useState>([]); + + const popOut = useCallback((task: Task | TaskDetail) => { + setTasks((current) => (current.some((entry) => entry.id === task.id) ? current : [...current, task])); + }, []); + + const close = useCallback((taskId: string) => { + setTasks((current) => current.filter((entry) => entry.id !== taskId)); + }, []); + + return { tasks, popOut, close }; +} From e6729a6115bb565f1556a9bfa537132cc1dc1315 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 22:00:32 -0700 Subject: [PATCH 09/14] refactor(dashboard): extract MainContent component from App.tsx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the inline renderMainContent() view-switch (~647 lines, ~24 view branches) into a presentational component packages/dashboard/app/components/dashboard/MainContent.tsx with a typed MainContentProps interface (141 fields) in types.ts. All 18 lazy view const DECLARATIONS stay unchanged in App.tsx (the lazy-loaded-views-docs inventory guard regex-scans App.tsx for them — R5) and are threaded to MainContent as LazyExoticComponent props. Branch-local consts and render-prop arrows stay co-located inside MainContent. App.tsx: 2,219 -> 1,704 lines, now under the 2,000-line file-count ratchet (R3 achieved). Behavior-preserving; App.test.tsx identical (5 pre-existing experimental-flag failures, none introduced). Verified by typecheck, eslint, and the lazy-loaded-views-docs inventory guard. U7a (App.tsx module-breakup plan). --- packages/dashboard/app/App.tsx | 809 ++++------------- .../app/components/dashboard/MainContent.tsx | 816 ++++++++++++++++++ .../app/components/dashboard/types.ts | 217 +++++ 3 files changed, 1180 insertions(+), 662 deletions(-) create mode 100644 packages/dashboard/app/components/dashboard/MainContent.tsx create mode 100644 packages/dashboard/app/components/dashboard/types.ts diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 47abe6c53a..0d895d72a0 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -5,26 +5,16 @@ import { type TaskDetail, type WorkflowStep, } from "@fusion/core"; -import { isNearDuplicateCanonicalInactive } from "../../core/src/near-duplicate-canonical"; import { Header, useViewportMode } from "./components/Header"; -import { Board } from "./components/Board"; -import { TaskCard } from "./components/TaskCard"; -import { ListView } from "./components/ListView"; import { TaskDetailContent } from "./components/TaskDetailModal"; import { FloatingWindow } from "./components/FloatingWindow"; -import { ProjectOverview } from "./components/ProjectOverview"; -import { MissionManager } from "./components/MissionManager"; -import { MailboxView } from "./components/MailboxView"; -import { PageErrorBoundary } from "./components/ErrorBoundary"; import { AppModals } from "./components/AppModals"; -import { BackendConnectionErrorPage } from "./components/BackendConnectionErrorPage"; import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader"; import { TopProgressBar } from "./components/TopProgressBar"; import { ExecutorStatusBar } from "./components/ExecutorStatusBar"; import { SessionNotificationBanner, type CliActionId } from "./components/SessionNotificationBanner"; import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner"; import { SetupWarningBanner } from "./components/SetupWarningBanner"; -import { CapacityRiskBanner } from "./components/CapacityRiskBanner"; import { TestModeBanner } from "./components/TestModeBanner"; import { EngineUnavailableBanner } from "./components/EngineUnavailableBanner"; import { OAuthReloginBanner } from "./components/OAuthReloginBanner"; @@ -73,7 +63,6 @@ import { useUpdateCheck } from "./hooks/useUpdateCheck"; import { useViewState, type TaskView } from "./hooks/useViewState"; import { NavigationHistoryProvider, useNavigationHistory } from "./hooks/useNavigationHistory"; import { usePluginDashboardViews } from "./hooks/usePluginDashboardViews"; -import { PluginDashboardViewHost } from "./plugins/PluginDashboardViewHost"; import { isPluginViewId, isPluginViewRegistered } from "./plugins/pluginViewRegistry"; import { registerBundledPluginViews } from "./plugins/registerBundledPluginViews"; import { useProjectActions } from "./hooks/useProjectActions"; @@ -130,8 +119,8 @@ export { } from "./utils/appLifecycle"; import { subscribeSse } from "./sse-bus"; import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog"; -import { PlanningModeModal } from "./components/PlanningModeModal"; -import { PlanningWorkflowSwitcherSlot } from "./components/PlanningWorkflowSwitcherSlot"; +import { MainContent } from "./components/dashboard/MainContent"; +import type { MainContentProps } from "./components/dashboard/types"; // ChatView's CSS is imported eagerly so the styles bundle into the main // CSS file. Without this, the lazy ChatView JS chunk loaded its own CSS @@ -1109,654 +1098,150 @@ function AppInner() { Boolean(projectsError) && !isSuppressedProjectResumeError; - // Render main content based on view mode - const renderMainContent = () => { - if (showBackendConnectionErrorPage) { - return ( - { - void shellApi.openConnectionManager(); - } : undefined} - /> - ); - } - - /* - FNXC:Settings 2026-06-22-00:00: - Settings renders ahead of the overview branch so the header gear opens the embedded Settings view even when no project is selected (viewMode === "overview"), matching the prior modal which opened regardless of view mode. - */ - if (taskView === "settings") { - const closeSettingsView = () => { - modalManager.closeSettings(); - handleChangeTaskView("board"); - }; - return ( - - - <_SettingsView - onClose={closeSettingsView} - addToast={addToast} - initialSection={modalManager.settingsInitialSection} - projectId={currentProject?.id} - themeMode={themeMode} - colorTheme={colorTheme} - onThemeModeChange={setThemeMode} - onColorThemeChange={setColorTheme} - dashboardFontScalePct={dashboardFontScalePct} - shadcnCustomColors={shadcnCustomColors} - resolvedThemeMode={resolvedThemeMode} - onDashboardFontScaleChange={setDashboardFontScalePct} - onShadcnCustomColorsChange={setShadcnCustomColors} - onQuickChatButtonModeChange={setQuickChatButtonModeImmediate} - onReopenOnboarding={reopenOnboardingWithNav} - onOpenApprovals={() => handleChangeTaskView("mailbox")} - onOpenWorkflowSettings={() => { - closeSettingsView(); - modalManager.openWorkflowEditor("settings"); - }} - /> - - - ); - } - - if (viewMode === "overview") { - return ( - - - - ); - } - - const resolvedPluginTaskView = taskView === "graph" ? graphPluginTaskView : (isPluginViewId(taskView) ? taskView : null); - - // Project view - if (resolvedPluginTaskView) { - const pluginTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks; - return ( - - openDetailTask(task, initialTab), - openFile: openFileInBrowser, - renderTaskCard: (task: Task | TaskDetail) => ( - openDetailTask(value)} - addToast={addToast} - workflowStepNameLookup={workflowStepNameLookup} - disableDrag={true} - prAuthAvailable={prAuthAvailable} - autoMergeEnabled={autoMerge} - nearDuplicateCanonicalInactive={typeof task.sourceMetadata?.nearDuplicateOf === "string" - ? isNearDuplicateCanonicalInactive(pluginTasks.find((candidate) => candidate.id === task.sourceMetadata?.nearDuplicateOf)) - : undefined} - /> - ), - addToast, - }} - /> - - ); - } - - if (taskView === "skills") { - if (!settingsLoaded || !skillsEnabled) { - return null; - } - return ( - - - handleChangeTaskView("board")} - /> - - - ); - } - - if (taskView === "chat") { - return ( - - - setQuickChatOpen(true)} - /> - - - ); - } - - if (taskView === "mailbox") { - return ( - - - - ); - } - - - if (taskView === "missions") { - return ( - - { - setMissionTargetId(undefined); - setMissionResumeSessionId(undefined); - setMilestoneSliceResumeSessionId(undefined); - handleChangeTaskView("board"); - }} - addToast={addToast} - projectId={currentProject?.id} - onSelectTask={(taskId) => { - const task = tasks.find((t) => t.id === taskId); - if (task) openDetailTask(task as TaskDetail); - }} - availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))} - resumeSessionId={missionResumeSessionId} - targetMissionId={missionTargetId} - milestoneSliceResumeSessionId={milestoneSliceResumeSessionId} - onMilestoneSliceResumeFetchError={() => setMilestoneSliceResumeSessionId(undefined)} - onNavigateToGoal={(goalId) => { - setGoalAnchorId(goalId); - handleChangeTaskView("goalsView"); - }} - /> - - ); - } - - if (taskView === "agents" && agentsEnabled) { - return ( - - - - - - ); - } - - if (taskView === "documents") { - return ( - - - - - - ); - } - - if (taskView === "pull-requests") { - return ( - - - - - - ); - } - - if (taskView === "insights") { - if (!settingsLoaded || !insightsEnabled) { - return null; - } - return ( - - - handleChangeTaskView("board")} - onCreateTask={handleInsightTaskCreate} - /> - - - ); - } - - if (taskView === "research") { - if (!settingsLoaded || !researchEnabled) { - return null; - } - return ( - - - openSettingsWithNav(section as SectionId)} - readinessVersion={researchReadinessVersion} - /> - - - ); - } - - if (taskView === "evals") { - if (!settingsLoaded || !evalsEnabled) { - return null; - } - return ( - - - openSettingsWithNav(section as SectionId)} - onOpenTaskDetail={(taskId) => { - void fetchTaskDetail(taskId, currentProject?.id) - .then((task) => openDetailTask(task as TaskDetail)) - .catch((error) => addToast(error instanceof Error ? error.message : "Failed to open task detail", "error")); - }} - /> - - - ); - } - - if (taskView === "memory") { - if (!settingsLoaded || !memoryEnabled) { - return null; - } - return ( - - - - - - ); - } - - if (taskView === "secrets") { - return ( - - - - - - ); - } - - if (taskView === "goalsView") { - if (!settingsLoaded || !goalsEnabled) { - return null; - } - return ( - - - - - - ); - } - if (taskView === "todos") { - // FNXC:Todos 2026-06-21-09:21: Todos render as a docked right-content view, not a modal overlay, per FN-6829 so all dashboard navigation surfaces share the same taskView routing model. - if (!settingsLoaded || !todosEnabled) return null; - return ( - - - ingestCreatedTasks([task])} /> - - - ); - } - if (taskView === "command-center") { - return ( - - - - - - ); - } - - if (taskView === "planning") { - /* - FNXC:Navigation 2026-06-21-00:00: - FN-6886 renders Planning Mode as a top-level main-content destination. Sidebar navigation opens an empty planning view, while Board, Todos, inline create, and resume entry points carry their initial plan/workflow/session state through modalManager. - */ - const closePlanningView = () => { - modalManager.closePlanning(); - handleChangeTaskView("board"); - }; - return ( - - {/* - FNXC:Navigation 2026-06-22-00:00: - Planning shows the same board WorkflowSwitcher in the same Header workflow slot as Board/List (portaled by PlanningWorkflowSwitcherSlot), so workflow selection is reachable from the left-sidebar Planning destination. - */} - - - - ); - } - - /* - FNXC:Navigation 2026-06-22-00:00: - Workflows, Import Tasks (GitHub import), and Automations are left-sidebar destinations that render embedded in the main content area instead of as modal overlays. Closing returns to the board. The same components still mount as modals in AppModals for the mobile overflow path. - */ - if (taskView === "workflows") { - return ( - - - <_WorkflowEditorView - isOpen={true} - onClose={() => handleChangeTaskView("board")} - addToast={addToast} - projectId={currentProject?.id} - presentation="embedded" - /> - - - ); - } - - if (taskView === "import-tasks") { - return ( - - - <_ImportTasksView - isOpen={true} - onClose={() => handleChangeTaskView("board")} - onImport={handleGitHubImport} - tasks={tasks} - projectId={currentProject?.id} - presentation="embedded" - /> - - - ); - } - - if (taskView === "automations") { - return ( - - - <_AutomationsView - onClose={() => handleChangeTaskView("board")} - addToast={addToast} - projectId={currentProject?.id} - presentation="embedded" - /> - - - ); - } - - if (taskView === "devserver" || taskView === "dev-server") { - if (!settingsLoaded || !devServerEnabled) { - return null; - } - return ( - - - - - - ); - } - - /* - FNXC:Navigation 2026-06-22-00:00: - Board-opened task detail renders as a full main-content view that replaces the board. A Back-to-board button sits above an embedded TaskDetailContent (same props ListView passes to its split-detail pane). The live task is preferred from `tasks` by id so the detail updates on revalidation; the stored snapshot is the fallback. If neither resolves (snapshot cleared), fall back to the board so the panel is never blank. - */ - if (taskView === "task-detail") { - const liveDetailTask = mainPanelDetailTask - ? (tasks.find((candidate) => candidate.id === mainPanelDetailTask.id) ?? mainPanelDetailTask) - : null; - if (!liveDetailTask) { - return ( - - - - ); - } - return ( - -
-
- { popOutTaskDetail(task); closeTaskDetailMainPanel(); }} - onOpenDetail={(value) => { - setMainPanelDetailTask(value); - setMainPanelDetailInitialTab("chat"); - }} - onMoveTask={moveTask} - onDeleteTask={deleteTask} - onMergeTask={mergeTask} - onRetryTask={retryTask} - onResetTask={resetTask} - onDuplicateTask={duplicateTask} - /* - FNXC:Navigation 2026-06-22-09:00: - The full-panel task-detail must dismiss back to the board when a destructive/terminal action (delete/merge/archive/retry/reset/duplicate) fires, mirroring the modal path. Without onRequestClose the panel kept showing a ghost of the just-acted-on task. - */ - onRequestClose={closeTaskDetailMainPanel} - onTaskUpdated={(updatedTask) => { - setMainPanelDetailTask((previous) => { - if (!previous || previous.id !== updatedTask.id) return previous; - return { ...previous, ...updatedTask }; - }); - }} - addToast={addToast} - prAuthAvailable={prAuthAvailable} - autoMergeEnabled={autoMerge} - /> -
-
-
- ); - } - - if (taskView === "board") { - return ( - - {capacityRiskBannerEnabled && !capacityRiskDismissed ? ( - - ) : null} - - - ); - } - - // List view - return ( - - 0 ? remoteData.tasks : tasks} - projectId={currentProject?.id} - onMoveTask={moveTask} - onRetryTask={retryTask} - onDeleteTask={deleteTask} - onPauseTask={pauseTask} - onUnpauseTask={unpauseTask} - onArchiveTask={archiveTask} - onMergeTask={mergeTask} - onResetTask={resetTask} - onDuplicateTask={duplicateTask} - onOpenDetail={(task, options) => openDetailTask(task, undefined, options)} - onPopOut={popOutTaskDetail} - addToast={addToast} - globalPaused={globalPaused} - onNewTask={openNewTaskWithNav} - onQuickCreate={handleBoardQuickCreate} - onPlanningMode={openPlanningWithInitialPlanWithNav} - onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined} - availableModels={availableModels} - favoriteProviders={favoriteProviders} - favoriteModels={favoriteModels} - onToggleFavorite={handleToggleFavorite} - onToggleModelFavorite={handleToggleModelFavorite} - taskStuckTimeoutMs={taskStuckTimeoutMs} - searchQuery={searchQuery} - lastFetchTimeMs={lastFetchTimeMs} - prAuthAvailable={prAuthAvailable} - autoMerge={autoMerge} - onOpenWorkflowEditor={openWorkflowEditorWithNav} - onCreateWorkflow={openCreateWorkflowWithNav} - workflowColumnsEnabled - settingsLoaded={settingsLoaded} - workflowControlsInHeader={sidebarActive || isMobile} - /> - - ); + // Props for the extracted switch (see components/dashboard/MainContent.tsx). + // Every value is passed by its App name; the switch renders the same subtrees as before. + const mainContentProps: MainContentProps = { + showBackendConnectionErrorPage, + projectsError, + t, + retryingProjects, + handleRetryProjects, + shellApi, + taskView, + modalManager, + handleChangeTaskView, + addToast, + currentProject, + themeMode, + setThemeMode, + colorTheme, + setColorTheme, + dashboardFontScalePct, + setDashboardFontScalePct, + shadcnCustomColors, + setShadcnCustomColors, + resolvedThemeMode, + setQuickChatButtonModeImmediate, + reopenOnboardingWithNav, + viewMode, + projects, + projectsLoading, + handleSelectProject, + handleAddProject, + handlePauseProject, + handleResumeProject, + handleRemoveProject, + nodes, + graphPluginTaskView, + isRemote, + remoteData, + tasks, + workflowSteps, + subscribePluginEvents, + openDetailTask, + openFileInBrowser, + workflowStepNameLookup, + prAuthAvailable, + autoMerge, + settingsLoaded, + skillsEnabled, + experimentalFeatures, + setQuickChatOpen, + setMailboxUnreadCount, + setMissionTargetId, + setMissionResumeSessionId, + setMilestoneSliceResumeSessionId, + missionResumeSessionId, + missionTargetId, + milestoneSliceResumeSessionId, + setGoalAnchorId, + goalAnchorId, + agentsEnabled, + agentOnboardingEnabled, + handleOpenTaskLogs, + popOutTaskDetail, + selectedPrId, + insightsEnabled, + handleInsightTaskCreate, + researchEnabled, + openSettingsWithNav, + researchReadinessVersion, + evalsEnabled, + memoryEnabled, + goalsEnabled, + handleOpenMission, + todosEnabled, + openPlanningWithInitialPlanWithNav, + ingestCreatedTasks, + nodesEnabled, + openWorkflowEditorWithNav, + handlePlanningTaskCreated, + handlePlanningTasksCreated, + handleGitHubImport, + devServerEnabled, + mainPanelDetailTask, + filteredBoardTasks, + maxConcurrent, + moveTask, + pauseTask, + openTaskDetailInMainPanel, + openGroupModalWithNav, + handleBoardQuickCreate, + openNewTaskWithNav, + subtaskBreakdownEnabled, + openSubtaskBreakdownWithNav, + toggleAutoMerge, + globalPaused, + updateTask, + retryTask, + archiveTask, + unarchiveTask, + deleteTask, + archiveAllDone, + loadArchivedTasks, + searchQuery, + availableModels, + favoriteProviders, + favoriteModels, + handleOpenDetailWithTab, + handleToggleFavorite, + handleToggleModelFavorite, + taskStuckTimeoutMs, + staleHighFanoutBlockerAgeThresholdMs, + lastFetchTimeMs, + openCreateWorkflowWithNav, + sidebarActive, + isMobile, + mainPanelDetailInitialTab, + closeTaskDetailMainPanel, + setMainPanelDetailTask, + setMainPanelDetailInitialTab, + mergeTask, + resetTask, + duplicateTask, + unpauseTask, + capacityRiskBannerEnabled, + capacityRiskDismissed, + capacityRiskSignal, + handleDismissCapacityRisk, + AgentsView, + ChatView, + CommandCenter, + DevServerView, + DocumentsView, + EvalsView, + GoalsView, + InsightsView, + MemoryView, + PullRequestView, + ResearchView, + SecretsView, + SkillsView, + TodoView, + _AutomationsView, + _ImportTasksView, + _SettingsView, + _WorkflowEditorView, }; const showOnboardingResumeCard = !modalManager.modelOnboardingOpen && isOnboardingResumable(); @@ -1987,7 +1472,7 @@ function AppInner() {
- {renderMainContent()} +
{rightDock.dock} diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx new file mode 100644 index 0000000000..cea7bbb71b --- /dev/null +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -0,0 +1,816 @@ +/* +FNXC:MainContent 2026-06-24-00:00: +MainContent is the presentational switch for the dashboard's main content area, extracted verbatim from AppInner's renderMainContent(). It is a pure switch on taskView/viewMode returning the existing / subtrees unchanged. The lazy view chunks (and their leading-underscore inventory convention) stay declared in App.tsx per the docs guard and are threaded in as props; the eager ChatView.css import remains in App.tsx so the styles bundle into the main CSS file. +*/ +import { Suspense } from "react"; +import type { Task, TaskDetail } from "@fusion/core"; +import { Board } from "../Board"; +import { TaskCard } from "../TaskCard"; +import { ListView } from "../ListView"; +import { TaskDetailContent } from "../TaskDetailModal"; +import { ProjectOverview } from "../ProjectOverview"; +import { MissionManager } from "../MissionManager"; +import { MailboxView } from "../MailboxView"; +import { PageErrorBoundary } from "../ErrorBoundary"; +import { BackendConnectionErrorPage } from "../BackendConnectionErrorPage"; +import { CapacityRiskBanner } from "../CapacityRiskBanner"; +import { PlanningModeModal } from "../PlanningModeModal"; +import { PlanningWorkflowSwitcherSlot } from "../PlanningWorkflowSwitcherSlot"; +import { PluginDashboardViewHost } from "../../plugins/PluginDashboardViewHost"; +import { isPluginViewId } from "../../plugins/pluginViewRegistry"; +import { isNearDuplicateCanonicalInactive } from "../../../../core/src/near-duplicate-canonical"; +import { fetchTaskDetail } from "../../api"; +import type { DetailTaskTab } from "../../hooks/useModalManager"; +import type { SectionId } from "../SettingsModal"; +import type { MainContentProps } from "./types"; + +export function MainContent({ + showBackendConnectionErrorPage, + projectsError, + t, + retryingProjects, + handleRetryProjects, + shellApi, + taskView, + modalManager, + handleChangeTaskView, + addToast, + currentProject, + themeMode, + setThemeMode, + colorTheme, + setColorTheme, + dashboardFontScalePct, + setDashboardFontScalePct, + shadcnCustomColors, + setShadcnCustomColors, + resolvedThemeMode, + setQuickChatButtonModeImmediate, + reopenOnboardingWithNav, + viewMode, + projects, + projectsLoading, + handleSelectProject, + handleAddProject, + handlePauseProject, + handleResumeProject, + handleRemoveProject, + nodes, + graphPluginTaskView, + isRemote, + remoteData, + tasks, + workflowSteps, + subscribePluginEvents, + openDetailTask, + openFileInBrowser, + workflowStepNameLookup, + prAuthAvailable, + autoMerge, + settingsLoaded, + skillsEnabled, + experimentalFeatures, + setQuickChatOpen, + setMailboxUnreadCount, + setMissionTargetId, + setMissionResumeSessionId, + setMilestoneSliceResumeSessionId, + missionResumeSessionId, + missionTargetId, + milestoneSliceResumeSessionId, + setGoalAnchorId, + goalAnchorId, + agentsEnabled, + agentOnboardingEnabled, + handleOpenTaskLogs, + popOutTaskDetail, + selectedPrId, + insightsEnabled, + handleInsightTaskCreate, + researchEnabled, + openSettingsWithNav, + researchReadinessVersion, + evalsEnabled, + memoryEnabled, + goalsEnabled, + handleOpenMission, + todosEnabled, + openPlanningWithInitialPlanWithNav, + ingestCreatedTasks, + nodesEnabled, + openWorkflowEditorWithNav, + handlePlanningTaskCreated, + handlePlanningTasksCreated, + handleGitHubImport, + devServerEnabled, + mainPanelDetailTask, + filteredBoardTasks, + maxConcurrent, + moveTask, + pauseTask, + openTaskDetailInMainPanel, + openGroupModalWithNav, + handleBoardQuickCreate, + openNewTaskWithNav, + subtaskBreakdownEnabled, + openSubtaskBreakdownWithNav, + toggleAutoMerge, + globalPaused, + updateTask, + retryTask, + archiveTask, + unarchiveTask, + deleteTask, + archiveAllDone, + loadArchivedTasks, + searchQuery, + availableModels, + favoriteProviders, + favoriteModels, + handleOpenDetailWithTab, + handleToggleFavorite, + handleToggleModelFavorite, + taskStuckTimeoutMs, + staleHighFanoutBlockerAgeThresholdMs, + lastFetchTimeMs, + openCreateWorkflowWithNav, + sidebarActive, + isMobile, + mainPanelDetailInitialTab, + closeTaskDetailMainPanel, + setMainPanelDetailTask, + setMainPanelDetailInitialTab, + mergeTask, + resetTask, + duplicateTask, + unpauseTask, + capacityRiskBannerEnabled, + capacityRiskDismissed, + capacityRiskSignal, + handleDismissCapacityRisk, + AgentsView, + ChatView, + CommandCenter, + DevServerView, + DocumentsView, + EvalsView, + GoalsView, + InsightsView, + MemoryView, + PullRequestView, + ResearchView, + SecretsView, + SkillsView, + TodoView, + _AutomationsView, + _ImportTasksView, + _SettingsView, + _WorkflowEditorView, +}: MainContentProps) { + if (showBackendConnectionErrorPage) { + return ( + { + void shellApi.openConnectionManager(); + } : undefined} + /> + ); + } + + /* + FNXC:Settings 2026-06-22-00:00: + Settings renders ahead of the overview branch so the header gear opens the embedded Settings view even when no project is selected (viewMode === "overview"), matching the prior modal which opened regardless of view mode. + */ + if (taskView === "settings") { + const closeSettingsView = () => { + modalManager.closeSettings(); + handleChangeTaskView("board"); + }; + return ( + + + <_SettingsView + onClose={closeSettingsView} + addToast={addToast} + initialSection={modalManager.settingsInitialSection} + projectId={currentProject?.id} + themeMode={themeMode} + colorTheme={colorTheme} + onThemeModeChange={setThemeMode} + onColorThemeChange={setColorTheme} + dashboardFontScalePct={dashboardFontScalePct} + shadcnCustomColors={shadcnCustomColors} + resolvedThemeMode={resolvedThemeMode} + onDashboardFontScaleChange={setDashboardFontScalePct} + onShadcnCustomColorsChange={setShadcnCustomColors} + onQuickChatButtonModeChange={setQuickChatButtonModeImmediate} + onReopenOnboarding={reopenOnboardingWithNav} + onOpenApprovals={() => handleChangeTaskView("mailbox")} + onOpenWorkflowSettings={() => { + closeSettingsView(); + modalManager.openWorkflowEditor("settings"); + }} + /> + + + ); + } + + if (viewMode === "overview") { + return ( + + + + ); + } + + const resolvedPluginTaskView = taskView === "graph" ? graphPluginTaskView : (isPluginViewId(taskView) ? taskView : null); + + // Project view + if (resolvedPluginTaskView) { + const pluginTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks; + return ( + + openDetailTask(task, initialTab), + openFile: openFileInBrowser, + renderTaskCard: (task: Task | TaskDetail) => ( + openDetailTask(value)} + addToast={addToast} + workflowStepNameLookup={workflowStepNameLookup} + disableDrag={true} + prAuthAvailable={prAuthAvailable} + autoMergeEnabled={autoMerge} + nearDuplicateCanonicalInactive={typeof task.sourceMetadata?.nearDuplicateOf === "string" + ? isNearDuplicateCanonicalInactive(pluginTasks.find((candidate) => candidate.id === task.sourceMetadata?.nearDuplicateOf)) + : undefined} + /> + ), + addToast, + }} + /> + + ); + } + + if (taskView === "skills") { + if (!settingsLoaded || !skillsEnabled) { + return null; + } + return ( + + + handleChangeTaskView("board")} + /> + + + ); + } + + if (taskView === "chat") { + return ( + + + setQuickChatOpen(true)} + /> + + + ); + } + + if (taskView === "mailbox") { + return ( + + + + ); + } + + + if (taskView === "missions") { + return ( + + { + setMissionTargetId(undefined); + setMissionResumeSessionId(undefined); + setMilestoneSliceResumeSessionId(undefined); + handleChangeTaskView("board"); + }} + addToast={addToast} + projectId={currentProject?.id} + onSelectTask={(taskId) => { + const task = tasks.find((t) => t.id === taskId); + if (task) openDetailTask(task as TaskDetail); + }} + availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))} + resumeSessionId={missionResumeSessionId} + targetMissionId={missionTargetId} + milestoneSliceResumeSessionId={milestoneSliceResumeSessionId} + onMilestoneSliceResumeFetchError={() => setMilestoneSliceResumeSessionId(undefined)} + onNavigateToGoal={(goalId) => { + setGoalAnchorId(goalId); + handleChangeTaskView("goalsView"); + }} + /> + + ); + } + + if (taskView === "agents" && agentsEnabled) { + return ( + + + + + + ); + } + + if (taskView === "documents") { + return ( + + + + + + ); + } + + if (taskView === "pull-requests") { + return ( + + + + + + ); + } + + if (taskView === "insights") { + if (!settingsLoaded || !insightsEnabled) { + return null; + } + return ( + + + handleChangeTaskView("board")} + onCreateTask={handleInsightTaskCreate} + /> + + + ); + } + + if (taskView === "research") { + if (!settingsLoaded || !researchEnabled) { + return null; + } + return ( + + + openSettingsWithNav(section as SectionId)} + readinessVersion={researchReadinessVersion} + /> + + + ); + } + + if (taskView === "evals") { + if (!settingsLoaded || !evalsEnabled) { + return null; + } + return ( + + + openSettingsWithNav(section as SectionId)} + onOpenTaskDetail={(taskId) => { + void fetchTaskDetail(taskId, currentProject?.id) + .then((task) => openDetailTask(task as TaskDetail)) + .catch((error) => addToast(error instanceof Error ? error.message : "Failed to open task detail", "error")); + }} + /> + + + ); + } + + if (taskView === "memory") { + if (!settingsLoaded || !memoryEnabled) { + return null; + } + return ( + + + + + + ); + } + + if (taskView === "secrets") { + return ( + + + + + + ); + } + + if (taskView === "goalsView") { + if (!settingsLoaded || !goalsEnabled) { + return null; + } + return ( + + + + + + ); + } + if (taskView === "todos") { + // FNXC:Todos 2026-06-21-09:21: Todos render as a docked right-content view, not a modal overlay, per FN-6829 so all dashboard navigation surfaces share the same taskView routing model. + if (!settingsLoaded || !todosEnabled) return null; + return ( + + + ingestCreatedTasks([task])} /> + + + ); + } + if (taskView === "command-center") { + return ( + + + + + + ); + } + + if (taskView === "planning") { + /* + FNXC:Navigation 2026-06-21-00:00: + FN-6886 renders Planning Mode as a top-level main-content destination. Sidebar navigation opens an empty planning view, while Board, Todos, inline create, and resume entry points carry their initial plan/workflow/session state through modalManager. + */ + const closePlanningView = () => { + modalManager.closePlanning(); + handleChangeTaskView("board"); + }; + return ( + + {/* + FNXC:Navigation 2026-06-22-00:00: + Planning shows the same board WorkflowSwitcher in the same Header workflow slot as Board/List (portaled by PlanningWorkflowSwitcherSlot), so workflow selection is reachable from the left-sidebar Planning destination. + */} + + + + ); + } + + /* + FNXC:Navigation 2026-06-22-00:00: + Workflows, Import Tasks (GitHub import), and Automations are left-sidebar destinations that render embedded in the main content area instead of as modal overlays. Closing returns to the board. The same components still mount as modals in AppModals for the mobile overflow path. + */ + if (taskView === "workflows") { + return ( + + + <_WorkflowEditorView + isOpen={true} + onClose={() => handleChangeTaskView("board")} + addToast={addToast} + projectId={currentProject?.id} + presentation="embedded" + /> + + + ); + } + + if (taskView === "import-tasks") { + return ( + + + <_ImportTasksView + isOpen={true} + onClose={() => handleChangeTaskView("board")} + onImport={handleGitHubImport} + tasks={tasks} + projectId={currentProject?.id} + presentation="embedded" + /> + + + ); + } + + if (taskView === "automations") { + return ( + + + <_AutomationsView + onClose={() => handleChangeTaskView("board")} + addToast={addToast} + projectId={currentProject?.id} + presentation="embedded" + /> + + + ); + } + + if (taskView === "devserver" || taskView === "dev-server") { + if (!settingsLoaded || !devServerEnabled) { + return null; + } + return ( + + + + + + ); + } + + /* + FNXC:Navigation 2026-06-22-00:00: + Board-opened task detail renders as a full main-content view that replaces the board. A Back-to-board button sits above an embedded TaskDetailContent (same props ListView passes to its split-detail pane). The live task is preferred from `tasks` by id so the detail updates on revalidation; the stored snapshot is the fallback. If neither resolves (snapshot cleared), fall back to the board so the panel is never blank. + */ + if (taskView === "task-detail") { + const liveDetailTask = mainPanelDetailTask + ? (tasks.find((candidate) => candidate.id === mainPanelDetailTask.id) ?? mainPanelDetailTask) + : null; + if (!liveDetailTask) { + return ( + + + + ); + } + return ( + +
+
+ { popOutTaskDetail(task); closeTaskDetailMainPanel(); }} + onOpenDetail={(value) => { + setMainPanelDetailTask(value); + setMainPanelDetailInitialTab("chat"); + }} + onMoveTask={moveTask} + onDeleteTask={deleteTask} + onMergeTask={mergeTask} + onRetryTask={retryTask} + onResetTask={resetTask} + onDuplicateTask={duplicateTask} + /* + FNXC:Navigation 2026-06-22-09:00: + The full-panel task-detail must dismiss back to the board when a destructive/terminal action (delete/merge/archive/retry/reset/duplicate) fires, mirroring the modal path. Without onRequestClose the panel kept showing a ghost of the just-acted-on task. + */ + onRequestClose={closeTaskDetailMainPanel} + onTaskUpdated={(updatedTask) => { + setMainPanelDetailTask((previous) => { + if (!previous || previous.id !== updatedTask.id) return previous; + return { ...previous, ...updatedTask }; + }); + }} + addToast={addToast} + prAuthAvailable={prAuthAvailable} + autoMergeEnabled={autoMerge} + /> +
+
+
+ ); + } + + if (taskView === "board") { + return ( + + {capacityRiskBannerEnabled && !capacityRiskDismissed ? ( + + ) : null} + + + ); + } + + // List view + return ( + + 0 ? remoteData.tasks : tasks} + projectId={currentProject?.id} + onMoveTask={moveTask} + onRetryTask={retryTask} + onDeleteTask={deleteTask} + onPauseTask={pauseTask} + onUnpauseTask={unpauseTask} + onArchiveTask={archiveTask} + onMergeTask={mergeTask} + onResetTask={resetTask} + onDuplicateTask={duplicateTask} + onOpenDetail={(task, options) => openDetailTask(task, undefined, options)} + onPopOut={popOutTaskDetail} + addToast={addToast} + globalPaused={globalPaused} + onNewTask={openNewTaskWithNav} + onQuickCreate={handleBoardQuickCreate} + onPlanningMode={openPlanningWithInitialPlanWithNav} + onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined} + availableModels={availableModels} + favoriteProviders={favoriteProviders} + favoriteModels={favoriteModels} + onToggleFavorite={handleToggleFavorite} + onToggleModelFavorite={handleToggleModelFavorite} + taskStuckTimeoutMs={taskStuckTimeoutMs} + searchQuery={searchQuery} + lastFetchTimeMs={lastFetchTimeMs} + prAuthAvailable={prAuthAvailable} + autoMerge={autoMerge} + onOpenWorkflowEditor={openWorkflowEditorWithNav} + onCreateWorkflow={openCreateWorkflowWithNav} + workflowColumnsEnabled + settingsLoaded={settingsLoaded} + workflowControlsInHeader={sidebarActive || isMobile} + /> + + ); +} diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts new file mode 100644 index 0000000000..cf645bae82 --- /dev/null +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -0,0 +1,217 @@ +/** + * Props for MainContent — the presentational switch that renders the dashboard's + * main content area based on taskView/viewMode. Extracted verbatim from + * AppInner's renderMainContent(); every field is an AppInner-scoped value that + * the switch closes over. The lazy view chunks stay declared in App.tsx (per the + * inventory guard) and are threaded here as props; other helpers, types, and + * components are imported directly by MainContent.tsx. + */ +import type { Dispatch, LazyExoticComponent, SetStateAction } from "react"; +import type { TFunction } from "i18next"; +import type { + CapacityRiskSignal, + ColorTheme, + ColumnId, + GithubIssueAction, + MergeResult, + Task, + TaskCreateInput, + TaskDetail, + ThemeMode, + WorkflowStep, +} from "@fusion/core"; +import type { ModelInfo, NodeInfo, ProjectInfo, ProjectInfoWithSource } from "../../api"; +import type { FusionShellApi } from "../../types/native-shell"; +import type { DetailTaskOrigin, DetailTaskTab, ModalManager } from "../../hooks/useModalManager"; +import type { PluginTaskView, TaskView, ViewMode } from "../../hooks/useViewState"; +import type { ToastType } from "../../hooks/useToast"; +import type { QuickChatButtonMode } from "../../hooks/useAppSettings"; +import type { UseRemoteNodeDataResult } from "../../hooks/useRemoteNodeData"; +import type { SectionId } from "../SettingsModal"; +// The lazy view components are value exports; importing them as values lets us +// spell their types via `typeof` so MainContent's JSX gets full prop checking. +import { SettingsView } from "../SettingsModal"; +import { AgentsView } from "../AgentsView"; +import { ChatView } from "../ChatView"; +import { CommandCenter } from "../command-center/CommandCenter"; +import { DevServerView } from "../DevServerView"; +import { DocumentsView } from "../DocumentsView"; +import { EvalsView } from "../EvalsView"; +import { GitHubImportModal } from "../GitHubImportModal"; +import { GoalsView } from "../GoalsView"; +import { InsightsView } from "../InsightsView"; +import { MemoryView } from "../MemoryView"; +import { PullRequestView } from "../PullRequestView"; +import { ResearchView } from "../ResearchView"; +import { ScheduledTasksModal } from "../ScheduledTasksModal"; +import { SecretsView } from "../SecretsView"; +import { SkillsView } from "../SkillsView"; +import { TodoView } from "../TodoView"; +import { WorkflowNodeEditor } from "../WorkflowNodeEditor"; + +export interface MainContentProps { + showBackendConnectionErrorPage: boolean; + projectsError: string | null; + t: TFunction; + retryingProjects: boolean; + handleRetryProjects: () => Promise; + shellApi: FusionShellApi | null; + taskView: TaskView; + modalManager: ModalManager; + handleChangeTaskView: (newView: TaskView) => void; + addToast: (message: string, type?: ToastType) => void; + currentProject: ProjectInfo | null; + themeMode: ThemeMode; + setThemeMode: (mode: ThemeMode) => void; + colorTheme: ColorTheme; + setColorTheme: (theme: ColorTheme) => void; + dashboardFontScalePct: number; + setDashboardFontScalePct: (scalePct: number) => void; + shadcnCustomColors: Record; + setShadcnCustomColors: (colors: Record) => void; + resolvedThemeMode: "dark" | "light"; + setQuickChatButtonModeImmediate: (mode: QuickChatButtonMode) => void; + reopenOnboardingWithNav: () => void; + viewMode: ViewMode; + projects: ProjectInfoWithSource[]; + projectsLoading: boolean; + handleSelectProject: (project: ProjectInfo) => void; + handleAddProject: () => void; + handlePauseProject: (project: ProjectInfo) => Promise; + handleResumeProject: (project: ProjectInfo) => Promise; + handleRemoveProject: (project: ProjectInfo) => Promise; + nodes: NodeInfo[]; + graphPluginTaskView: PluginTaskView | null; + isRemote: boolean; + remoteData: UseRemoteNodeDataResult; + tasks: Task[]; + workflowSteps: WorkflowStep[]; + subscribePluginEvents: ( + pluginId: string, + onEvent: (e: { event: string; payload: unknown }) => void, + ) => () => void; + openDetailTask: ( + task: Task | TaskDetail, + initialTab?: DetailTaskTab, + options?: { origin?: DetailTaskOrigin }, + ) => void; + openFileInBrowser: (path: string, opts?: { workspace?: string; line?: number; col?: number }) => void; + workflowStepNameLookup: Map; + prAuthAvailable: boolean; + autoMerge: boolean; + settingsLoaded: boolean; + skillsEnabled: boolean; + experimentalFeatures: Record; + setQuickChatOpen: Dispatch>; + setMailboxUnreadCount: (count: number) => void; + setMissionTargetId: Dispatch>; + setMissionResumeSessionId: Dispatch>; + setMilestoneSliceResumeSessionId: Dispatch>; + missionResumeSessionId: string | undefined; + missionTargetId: string | undefined; + milestoneSliceResumeSessionId: string | undefined; + setGoalAnchorId: Dispatch>; + goalAnchorId: string | undefined; + agentsEnabled: boolean; + agentOnboardingEnabled: boolean; + handleOpenTaskLogs: (taskId: string) => Promise; + popOutTaskDetail: (task: Task | TaskDetail) => void; + selectedPrId: string | undefined; + insightsEnabled: boolean; + handleInsightTaskCreate: (input: { insightId: string; title: string; description: string }) => Promise; + researchEnabled: boolean; + openSettingsWithNav: (section?: SectionId) => void; + researchReadinessVersion: number; + evalsEnabled: boolean; + memoryEnabled: boolean; + goalsEnabled: boolean; + handleOpenMission: (missionId: string) => void; + todosEnabled: boolean; + openPlanningWithInitialPlanWithNav: (initialPlan: string, workflowId?: string | null) => void; + ingestCreatedTasks: (tasks: Task[]) => void; + nodesEnabled: boolean; + openWorkflowEditorWithNav: (workflowId?: string) => void; + handlePlanningTaskCreated: (task: Task) => void; + handlePlanningTasksCreated: (tasks: Task[]) => void; + handleGitHubImport: (task: Task) => void; + devServerEnabled: boolean; + mainPanelDetailTask: Task | TaskDetail | null; + filteredBoardTasks: Task[]; + maxConcurrent: number; + moveTask: ( + id: string, + column: ColumnId, + optionsOrPosition?: { preserveProgress?: boolean } | number, + ) => Promise; + pauseTask: (id: string) => Promise; + openTaskDetailInMainPanel: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void; + openGroupModalWithNav: (groupId: string) => void; + handleBoardQuickCreate: (input: TaskCreateInput) => Promise; + openNewTaskWithNav: () => void; + subtaskBreakdownEnabled: boolean; + openSubtaskBreakdownWithNav: (description: string, workflowId?: string | null) => void; + toggleAutoMerge: () => Promise; + globalPaused: boolean; + updateTask: ( + id: string, + updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean }, + ) => Promise; + retryTask: (id: string) => Promise; + archiveTask: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; + unarchiveTask: (id: string) => Promise; + deleteTask: ( + id: string, + options?: { + removeDependencyReferences?: boolean; + removeLineageReferences?: boolean; + githubIssueAction?: GithubIssueAction; + allowResurrection?: boolean; + }, + ) => Promise; + archiveAllDone: () => Promise; + loadArchivedTasks: () => Promise; + searchQuery: string; + availableModels: ModelInfo[]; + favoriteProviders: string[]; + favoriteModels: string[]; + handleOpenDetailWithTab: (task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => void; + handleToggleFavorite: (provider: string) => Promise; + handleToggleModelFavorite: (modelId: string) => Promise; + taskStuckTimeoutMs: number | undefined; + staleHighFanoutBlockerAgeThresholdMs: number; + lastFetchTimeMs: number | undefined; + openCreateWorkflowWithNav: () => void; + sidebarActive: boolean; + isMobile: boolean; + mainPanelDetailInitialTab: DetailTaskTab; + closeTaskDetailMainPanel: () => void; + setMainPanelDetailTask: Dispatch>; + setMainPanelDetailInitialTab: (tab: DetailTaskTab) => void; + mergeTask: (id: string) => Promise; + resetTask: (id: string) => Promise; + duplicateTask: (id: string) => Promise; + unpauseTask: (id: string) => Promise; + capacityRiskBannerEnabled: boolean; + capacityRiskDismissed: boolean; + capacityRiskSignal: CapacityRiskSignal; + handleDismissCapacityRisk: () => void; + // App-level lazy view chunks (declared in App.tsx, threaded in as props). + AgentsView: LazyExoticComponent; + ChatView: LazyExoticComponent; + CommandCenter: LazyExoticComponent; + DevServerView: LazyExoticComponent; + DocumentsView: LazyExoticComponent; + EvalsView: LazyExoticComponent; + GoalsView: LazyExoticComponent; + InsightsView: LazyExoticComponent; + MemoryView: LazyExoticComponent; + PullRequestView: LazyExoticComponent; + ResearchView: LazyExoticComponent; + SecretsView: LazyExoticComponent; + SkillsView: LazyExoticComponent; + TodoView: LazyExoticComponent; + _AutomationsView: LazyExoticComponent; + _ImportTasksView: LazyExoticComponent; + _SettingsView: LazyExoticComponent; + _WorkflowEditorView: LazyExoticComponent; +} From 5db90675e95b77717877bf8b030f15ebc281bab4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 22:12:04 -0700 Subject: [PATCH 10/14] refactor(dashboard): extract DashboardBanners component from App.tsx Extract the conditional banner cluster (~14 banners each gated on viewMode === "project" && currentProject) into a presentational component packages/dashboard/app/components/dashboard/DashboardBanners.tsx with a typed DashboardBannersProps interface (39 fields) added to types.ts. The TaskIdIntegrityBanner setDashboardHealth updater and all FNXC comments move verbatim; banner components are imported directly from siblings. App.tsx: 1,704 -> 1,636 lines. Behavior-preserving; App.test.tsx identical (5 pre-existing experimental-flag failures, none introduced). Verified by typecheck and eslint. Completes U7 (App.tsx module-breakup plan). --- packages/dashboard/app/App.tsx | 164 +++++------------ .../components/dashboard/DashboardBanners.tsx | 168 ++++++++++++++++++ .../app/components/dashboard/types.ts | 59 +++++- 3 files changed, 274 insertions(+), 117 deletions(-) create mode 100644 packages/dashboard/app/components/dashboard/DashboardBanners.tsx diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 0d895d72a0..beb4a7fb8a 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -12,20 +12,7 @@ import { AppModals } from "./components/AppModals"; import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader"; import { TopProgressBar } from "./components/TopProgressBar"; import { ExecutorStatusBar } from "./components/ExecutorStatusBar"; -import { SessionNotificationBanner, type CliActionId } from "./components/SessionNotificationBanner"; -import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner"; -import { SetupWarningBanner } from "./components/SetupWarningBanner"; -import { TestModeBanner } from "./components/TestModeBanner"; -import { EngineUnavailableBanner } from "./components/EngineUnavailableBanner"; -import { OAuthReloginBanner } from "./components/OAuthReloginBanner"; -import { TaskIdIntegrityBanner } from "./components/TaskIdIntegrityBanner"; -import { DbCorruptionBanner } from "./components/DbCorruptionBanner"; -import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner"; -import MergeAdvanceNotice from "./components/MergeAdvanceNotice"; -import { ApprovalNotificationBanner } from "./components/ApprovalNotificationBanner"; -import { GitHubStarPrompt } from "./components/GitHubStarPrompt"; -import { OnboardingResumeCard } from "./components/OnboardingResumeCard"; -import { PostOnboardingRecommendations } from "./components/PostOnboardingRecommendations"; +import { type CliActionId } from "./components/SessionNotificationBanner"; import { isOnboardingCompleted, isOnboardingResumable, @@ -120,7 +107,8 @@ export { import { subscribeSse } from "./sse-bus"; import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog"; import { MainContent } from "./components/dashboard/MainContent"; -import type { MainContentProps } from "./components/dashboard/types"; +import { DashboardBanners } from "./components/dashboard/DashboardBanners"; +import type { DashboardBannersProps, MainContentProps } from "./components/dashboard/types"; // ChatView's CSS is imported eagerly so the styles bundle into the main // CSS file. Without this, the lazy ChatView JS chunk loaded its own CSS @@ -1254,6 +1242,50 @@ function AppInner() { // Top progress bar reflects any in-flight revalidation: projects, current-project, or tasks. // Add new sources here, not inside TopProgressBar. const isRevalidating = projectsLoading || currentProjectLoading || isStale; + + // Props for the extracted cluster (see components/dashboard/DashboardBanners.tsx). + // Every value is passed by its App name; the cluster renders the same banners as before. + const dashboardBannersProps: DashboardBannersProps = { + viewMode, + currentProject, + isTestMode, + dashboardHealth, + setDashboardHealth, + taskView, + modalManager, + sessionBannersHidden, + sessionsNeedingInput, + handleOpenBackgroundSession, + handleDismissNeedingInputSession, + handleDismissAllNeedingInputSessions, + handleCliAction, + getCliActionDisabledReasonForBanner, + openSettingsWithNav, + showOnboardingResumeCard, + showPostOnboardingRecommendations, + updateAvailable, + latestVersion, + currentVersion, + updateBannerDismissed, + dismissUpdateBanner, + refreshDbCorruptionHealth, + dbCorruptionRefreshing, + dbCorruptionRefreshError, + setupReadinessLoading, + hasWarnings, + setupWarningDismissed, + handleDismissSetupWarning, + hasAiProvider, + hasGithub, + approvalBannerCandidate, + dismissApproval, + mailboxPendingApprovalCount, + handleTaskViewChange, + showGitHubStarPrompt, + gitHubStarPromptShown, + markGitHubStarPromptShown, + setShowGitHubStarPrompt, + }; const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openFileInBrowser, openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), workflowStepNameLookup, prAuthAvailable, autoMerge, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, todosEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible }); return ( @@ -1339,107 +1371,7 @@ function AppInner() { ) : undefined } /> - {viewMode === "project" && currentProject && ( - <> - - - openSettingsWithNav("authentication" as SectionId)} - /> - - )} - {viewMode === "project" && currentProject && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && ( - - )} - {viewMode === "project" && currentProject && ( - openSettingsWithNav("general" as SectionId)} - /> - )} - {viewMode === "project" && currentProject && showOnboardingResumeCard && ( - - )} - {viewMode === "project" && currentProject && showPostOnboardingRecommendations && ( - openSettingsWithNav(section as SectionId)} - /> - )} - {viewMode === "project" && currentProject && updateAvailable && latestVersion && currentVersion && !updateBannerDismissed && ( - - )} - {viewMode === "project" && currentProject && ( - - )} - {viewMode === "project" && currentProject && dashboardHealth?.taskIdIntegrity?.status === "anomaly" && dashboardHealth.taskIdIntegrity.recommendedAction && ( - { - setDashboardHealth((current) => { - if (!current) { - return null; - } - return { - ...current, - status: - report.status === "anomaly" - || !current.database.healthy - || current.database.corruptionDetected - ? "degraded" - : "ok", - taskIdIntegrity: { - ...report, - recommendedAction, - }, - }; - }); - }} - /> - )} - {viewMode === "project" && currentProject && dashboardHealth?.database?.corruptionDetected === true && ( - - )} - {viewMode === "project" && currentProject && !setupReadinessLoading && hasWarnings && !setupWarningDismissed && ( - - )} - {viewMode === "project" && currentProject && approvalBannerCandidate && ( - handleTaskViewChange("mailbox")} - onDismiss={() => dismissApproval(approvalBannerCandidate)} - /> - )} - {/* FNXC:Onboarding 2026-06-22-03:11: The one-time GitHub star prompt stays tied to first completed task, but first-run setup must finish the optional persistent-agent create/skip step before any star ask can surface. Do not add a second setup-specific star prompt. */} - {viewMode === "project" && currentProject && showGitHubStarPrompt && !gitHubStarPromptShown && !modalManager.setupWizardOpen && ( - { - markGitHubStarPromptShown(); - setShowGitHubStarPrompt(false); - }} - /> - )} +
{sidebarActive && ( + {viewMode === "project" && currentProject && ( + <> + + + openSettingsWithNav("authentication" as SectionId)} + /> + + )} + {viewMode === "project" && currentProject && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && ( + + )} + {viewMode === "project" && currentProject && ( + openSettingsWithNav("general" as SectionId)} + /> + )} + {viewMode === "project" && currentProject && showOnboardingResumeCard && ( + + )} + {viewMode === "project" && currentProject && showPostOnboardingRecommendations && ( + openSettingsWithNav(section as SectionId)} + /> + )} + {viewMode === "project" && currentProject && updateAvailable && latestVersion && currentVersion && !updateBannerDismissed && ( + + )} + {viewMode === "project" && currentProject && ( + + )} + {viewMode === "project" && currentProject && dashboardHealth?.taskIdIntegrity?.status === "anomaly" && dashboardHealth.taskIdIntegrity.recommendedAction && ( + { + setDashboardHealth((current) => { + if (!current) { + return null; + } + return { + ...current, + status: + report.status === "anomaly" + || !current.database.healthy + || current.database.corruptionDetected + ? "degraded" + : "ok", + taskIdIntegrity: { + ...report, + recommendedAction, + }, + }; + }); + }} + /> + )} + {viewMode === "project" && currentProject && dashboardHealth?.database?.corruptionDetected === true && ( + + )} + {viewMode === "project" && currentProject && !setupReadinessLoading && hasWarnings && !setupWarningDismissed && ( + + )} + {viewMode === "project" && currentProject && approvalBannerCandidate && ( + handleTaskViewChange("mailbox")} + onDismiss={() => dismissApproval(approvalBannerCandidate)} + /> + )} + {/* FNXC:Onboarding 2026-06-22-03:11: The one-time GitHub star prompt stays tied to first completed task, but first-run setup must finish the optional persistent-agent create/skip step before any star ask can surface. Do not add a second setup-specific star prompt. */} + {viewMode === "project" && currentProject && showGitHubStarPrompt && !gitHubStarPromptShown && !modalManager.setupWizardOpen && ( + { + markGitHubStarPromptShown(); + setShowGitHubStarPrompt(false); + }} + /> + )} + + ); +} diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts index cf645bae82..6e5769883f 100644 --- a/packages/dashboard/app/components/dashboard/types.ts +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -20,7 +20,14 @@ import type { ThemeMode, WorkflowStep, } from "@fusion/core"; -import type { ModelInfo, NodeInfo, ProjectInfo, ProjectInfoWithSource } from "../../api"; +import type { + AiSessionSummary, + DashboardHealthResponse, + ModelInfo, + NodeInfo, + ProjectInfo, + ProjectInfoWithSource, +} from "../../api"; import type { FusionShellApi } from "../../types/native-shell"; import type { DetailTaskOrigin, DetailTaskTab, ModalManager } from "../../hooks/useModalManager"; import type { PluginTaskView, TaskView, ViewMode } from "../../hooks/useViewState"; @@ -28,6 +35,8 @@ import type { ToastType } from "../../hooks/useToast"; import type { QuickChatButtonMode } from "../../hooks/useAppSettings"; import type { UseRemoteNodeDataResult } from "../../hooks/useRemoteNodeData"; import type { SectionId } from "../SettingsModal"; +import type { CliActionId } from "../SessionNotificationBanner"; +import type { ApprovalBannerCandidate } from "../../utils/appLifecycle"; // The lazy view components are value exports; importing them as values lets us // spell their types via `typeof` so MainContent's JSX gets full prop checking. import { SettingsView } from "../SettingsModal"; @@ -215,3 +224,51 @@ export interface MainContentProps { _SettingsView: LazyExoticComponent; _WorkflowEditorView: LazyExoticComponent; } + +/** + * Props for DashboardBanners — the conditional banner cluster rendered above + * the dashboard-project-shell, extracted verbatim from AppInner's main return + * JSX. Every field is an AppInner-scoped value the cluster closes over; the + * banner components are imported directly by DashboardBanners.tsx. + */ +export interface DashboardBannersProps { + viewMode: ViewMode; + currentProject: ProjectInfo | null; + isTestMode: boolean; + dashboardHealth: DashboardHealthResponse | null; + setDashboardHealth: Dispatch>; + taskView: TaskView; + modalManager: ModalManager; + sessionBannersHidden: boolean; + sessionsNeedingInput: AiSessionSummary[]; + handleOpenBackgroundSession: (session: AiSessionSummary) => void; + handleDismissNeedingInputSession: () => void; + handleDismissAllNeedingInputSessions: () => void; + handleCliAction: (session: AiSessionSummary, action: CliActionId) => Promise; + getCliActionDisabledReasonForBanner: (session: AiSessionSummary, action: CliActionId) => string | null; + openSettingsWithNav: (section?: SectionId) => void; + showOnboardingResumeCard: boolean; + showPostOnboardingRecommendations: boolean; + updateAvailable: boolean; + latestVersion: string | null; + currentVersion: string | null; + updateBannerDismissed: boolean; + dismissUpdateBanner: () => void; + refreshDbCorruptionHealth: () => Promise; + dbCorruptionRefreshing: boolean; + dbCorruptionRefreshError: string | null; + setupReadinessLoading: boolean; + hasWarnings: boolean; + setupWarningDismissed: boolean; + handleDismissSetupWarning: () => void; + hasAiProvider: boolean; + hasGithub: boolean; + approvalBannerCandidate: ApprovalBannerCandidate | null; + dismissApproval: (candidate: ApprovalBannerCandidate) => void; + mailboxPendingApprovalCount: number; + handleTaskViewChange: (newView: TaskView) => void; + showGitHubStarPrompt: boolean; + gitHubStarPromptShown: boolean; + markGitHubStarPromptShown: () => void; + setShowGitHubStarPrompt: Dispatch>; +} From 8d394cd898058ac7bcffdb2bafe0ec88bf646507 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 22:15:56 -0700 Subject: [PATCH 11/14] refactor(dashboard): graduate App.tsx off the line-count ratchet baseline App.tsx dropped from 2,729 to 1,636 lines through the module-breakup refactor (U1-U7), now well under the 2,000-line cap. Remove its grandfathered entry from scripts/line-count-baseline.json so the file is subject to the cap going forward and can never regress above 2,000. Scoped change: only the App.tsx entry is removed; every other ceiling is untouched (a full `--update` would have re-derived the whole baseline and re-raised ceilings for unrelated grown files, which AGENTS.md forbids). U8 (App.tsx module-breakup plan). --- scripts/line-count-baseline.json | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/line-count-baseline.json b/scripts/line-count-baseline.json index 8f81f933be..992b32b3f4 100644 --- a/scripts/line-count-baseline.json +++ b/scripts/line-count-baseline.json @@ -19,7 +19,6 @@ "packages/core/src/mission-store.ts": 4382, "packages/core/src/store.ts": 16939, "packages/core/src/types.ts": 7269, - "packages/dashboard/app/App.tsx": 2729, "packages/dashboard/app/api/legacy.ts": 10742, "packages/dashboard/app/components/AgentDetailView.tsx": 5400, "packages/dashboard/app/components/AgentsView.tsx": 2109, From 24a23dda99e9e837573912a5bd29f678ccace9b5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 22:18:07 -0700 Subject: [PATCH 12/14] docs(plans): add App.tsx module-breakup plan (completed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the reviewed, completed plan for the dashboard App.tsx module-breakup refactor (planning + two doc-review rounds). Status: completed — all 8 implementation units shipped on this branch. --- ...r-dashboard-app-tsx-module-breakup-plan.md | 301 ++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 docs/plans/2026-06-24-001-refactor-dashboard-app-tsx-module-breakup-plan.md diff --git a/docs/plans/2026-06-24-001-refactor-dashboard-app-tsx-module-breakup-plan.md b/docs/plans/2026-06-24-001-refactor-dashboard-app-tsx-module-breakup-plan.md new file mode 100644 index 0000000000..d70e23d5dd --- /dev/null +++ b/docs/plans/2026-06-24-001-refactor-dashboard-app-tsx-module-breakup-plan.md @@ -0,0 +1,301 @@ +--- +title: "refactor: Break the dashboard App.tsx into smaller modules" +type: refactor +status: completed +date: 2026-06-24 +--- + +# refactor: Break the dashboard `App.tsx` into smaller modules + +## Summary + +A behavior-preserving decomposition of `packages/dashboard/app/App.tsx` (2,724 lines today; grandfathered at a 2,729-line ratchet baseline against `scripts/check-file-line-count.mjs`; a single ~2,350-line `AppInner` component): extract its inline state/effect/handler clusters into custom hooks under `app/hooks/`, its pure helpers and constants into `app/utils/`, and its two large render blocks into presentational components under `app/components/` — mirroring the codebase's existing conventions — with the explicit goal of graduating `App.tsx` below the 2,000-line file-count cap so it leaves the ratchet. + +## Problem Frame + +`App.tsx` is the Fusion dashboard's root component and its largest file by far. It is grandfathered at 2,729 lines (the ratchet baseline; the file is currently 2,724) against `scripts/check-file-line-count.mjs` (cap 2,000). Almost all of that bulk lives in one `AppInner()` function (lines ~352–2706), which interleaves ~25 `useState` calls, dozens of `useEffect`/`useMemo`/`useCallback` blocks, several SSE subscriptions and polling loops, the approval-banner dedupe state machine, and two large JSX render blocks — a ~650-line `renderMainContent()` view-switch and a ~430-line provider/header/sidebar/modals shell tree. + +This is a maintenance and review hazard: every dashboard change edits the same monolith, behavior is hard to test in isolation, and the file's size is held in place only by a ratchet baseline rather than by design. The codebase already demonstrates the extraction target — `app/hooks/` holds ~95 custom hooks (including the 24 KB `useTasks`), and `AppInner` itself already consumes ~25 of them — so the inline logic is the remaining un-factored surface, and the conventions to factor it are established. + +The work is strictly behavior-preserving: no feature, UX, or contract change. The success metric is concrete and machine-checked — drive `App.tsx` below 2,000 lines so it leaves the ratchet baseline — gated on the existing behavior contract (`App.test.tsx`, the exported pure-function unit tests) staying green. + +--- + +## Requirements + +### Behavior preservation + +- R1. All existing dashboard behavior is preserved: no functional, UX, rendering, or timing change. Verified by `packages/dashboard/app/components/__tests__/App.test.tsx` (the 4,273-line full-render behavior contract), the exported pure-function unit tests, and a browser smoke check against a freshly built bundle. +- R2. The seven pure functions currently exported from `App.tsx` (`shouldShowFirstEverBootLoader`, `requiresNativeShellOnboarding`, `executeCliSessionBannerAction`, `getCliActionDisabledReasonForBanner`, `isSessionNeedingInputForBanner`, `didEnterAwaitingApproval`, `didEnterDone`) remain importable from `App` so their existing unit tests (`app/__tests__/App.boot-gate.test.tsx`, `App.shell-onboarding.test.tsx`, `app-cli-action-wiring.test.tsx`, and `App.test.tsx`) stay green without test edits. + +### Structure and the line-count ratchet + +- R3. `App.tsx` line count drops below 2,000, and the file is removed from the ratchet baseline (`scripts/line-count-baseline.json`) via the reviewed `--update` path so it can never regress. +- R4. Every new file is ≤ 2,000 lines and follows the established conventions: custom hooks return an object with `UseXxxOptions`/`UseXxxResult` interfaces and private helpers above the hook; imports are relative (no `@/` alias); no `any` in non-test code; no `eslint-disable react-hooks/exhaustive-deps`. + +### Load-bearing invariants honored + +- R5. The non-underscore `lazy()` view consts in `App.tsx` are unchanged; `packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts` and the AGENTS.md "Lazy-Loaded Heavy Views" 20-view inventory stay green and unchanged. +- R6. The eager `import "./components/ChatView.css"` (lines 111–115) is preserved verbatim at the `App.tsx` top level — it is an intentional anti-lazy-load that prevents a flash of unstyled chat UI, documented only in that code comment. +- R7. React's rules-of-hooks and `AppInner`'s load-bearing hook-call ordering (the explicit "MUST be called before any conditional logic" sequence) are preserved; no hook becomes conditional as a result of extraction. +- R8. `FNXC:` requirement comments are carried into the extracted modules that own the behavior they describe and kept current (dated, greppable). + +### Verification + +- R9. The merge-blocking gate (`pnpm lint`, `packages/dashboard` typecheck, `pnpm build`) is green, `App.test.tsx` is green, and focused `renderHook` unit tests are added under `app/hooks/__tests__/` for the non-trivial extracted hooks (matching the `useUpdateCheck.test.ts` / `useAgents.test.ts` template). + +--- + +## Key Technical Decisions + +- KTD1. **Extraction strategy is hooks-first plus render-block splitting.** The inline state/effect/handler clusters become custom hooks (`app/hooks/`); the two large render blocks (`renderMainContent()` and the conditional-banner cluster) become presentational components (`app/components/`). This mirrors the codebase's dominant convention (95 existing hooks) and yields the biggest maintainability and line-count win. The full provider/shell wrapper (`AppShell`) is deferred — see Scope Boundaries — because hook + MainContent + Banners extraction alone clears the 2,000-line target, and wrapping the entire provider tree carries the most prop-drifting risk for the least marginal benefit. **Line budget (back-of-envelope, vs. the 2,724-line baseline):** U1 ~150, U2 ~170, U3 ~100, U4 ~50, U5 ~200, U6 ~80, U7 ~770 (MainContent ~640 + DashboardBanners ~130) — roughly 1,520 lines removed, landing `App.tsx` near ~1,200, comfortably under 2,000 even with a pessimistic extraction. `AppShell` is therefore a pure safety net, not load-bearing for R3. +- KTD2. **Pure helpers move to `app/utils/` with re-export shims in `App.tsx`** (acknowledged transient debt — see Deferred to Follow-Up Work). Rather than rewrite the import paths of the existing pure-function unit tests, the function bodies move to `app/utils/appLifecycle.ts` and `App.tsx` re-exports the seven tested symbols. The shim is intentionally partial: the complete cutover (re-pointing the test imports and dropping the re-export lines) is deferred to keep this change free of test churn; the shim is not an intentional long-term re-export surface. +- KTD3. **Extracted hooks mirror the `useAgents`/`useTasks` shape.** Object return value, `UseXxxOptions`/`UseXxxResult` interfaces, private helpers above the hook, and `readCache`/`writeCache` (`app/utils/swrCache.ts`) + `subscribeSse` (`app/sse-bus.ts`) exactly as the data hooks already use them. +- KTD4. **Preserve the single `task:updated` subscriber and its cross-concern wiring.** Today one `/api/events` subscription drives the approval-banner state machine, the first-done GitHub-star trigger, *and* a mailbox-count refresh inside the `task:updated`→`awaiting-approval` branch (App.tsx ~826). Extraction keeps `task:updated` + `approval:requested` banner logic in one hook (`useApprovalBanner`), which exposes an `onTaskEnteredAwaitingApproval: () => void` input that `AppInner` wires to `useMailboxUnread.refresh` so that count refresh still fires on exactly that transition. `useMailboxUnread` separately subscribes to `message:*` and `approval:*` count events (idempotent, and `subscribeSse` multiplexes them onto one shared `EventSource`). The banner trigger is never split across two `task:updated` handlers, preserving single-handler ordering. +- KTD5. **Verification posture = merge gate + behavior contract + targeted hook tests + browser smoke.** The merge gate (lint/typecheck/build) is the hard CI bar; `App.test.tsx` is the non-blocking behavior contract that must stay green; new `renderHook` tests cover hooks with real logic (dedupe, filtering, thresholding); a browser smoke against a freshly built bundle catches the jsdom-misses-stale-dist class of regression documented in `docs/solutions/`. No `any`, no exhaustive-deps disables, no changeset (private package + behavior-preserving). + +--- + +## High-Level Technical Design + +The decomposition splits `AppInner` along three seams — pure helpers, cohesive state clusters, and render blocks — each landing in the directory that already owns that concern. + +```mermaid +flowchart TB + subgraph App["App.tsx (AppInner) — orchestrator only"] + ORCH["hook calls in fixed order\n+ composition of handlers\n+ provider/shell tree"] + end + + subgraph Utils["app/utils/ (U1)"] + UL["appLifecycle.ts\npure fns + module constants\n(re-exported from App)"] + end + + subgraph Hooks["app/hooks/ (U2–U6)"] + H2["notification SSE hooks\nmailbox · chat-unread · stash"] + H3["useApprovalBanner\n+ GitHub-star trigger\n(existent useGitHubStarPrompt untouched)"] + H4["useBranchTaskFilters"] + H5["health · capacity · dismiss\nauth-recovery · shell-onboarding"] + H6["task-detail · board-scroll\npopped-out windows"] + end + + subgraph Components["app/components/dashboard/ (U7)"] + MC["MainContent\n(view-switch dispatcher)"] + DB["DashboardBanners\n(conditional banner cluster)"] + end + + UL --> H3 + UL --> H4 + UL --> H5 + H2 --> ORCH + H3 --> ORCH + H4 --> ORCH + H5 --> ORCH + H6 --> ORCH + ORCH -- "props bag" --> MC + ORCH -- "banner state" --> DB + AppTest["App.test.tsx\nfull-render behavior contract"] -. asserts .-> ORCH +``` + +The dependencies flow downward and rightward: utils feed constants/helpers to the hooks; hooks expose state + actions to `AppInner`, which stays the orchestrator (hook ordering, handler composition, the provider/shell tree); `AppInner` passes a props bag into the two extracted presentational components. `App.test.tsx` keeps rendering the real ``, so every extracted hook still runs for real unless the test mocks it by path. + +--- + +## Scope Boundaries + +**In scope:** `packages/dashboard/app/App.tsx` and the new modules it spawns under `app/utils/`, `app/hooks/`, and `app/components/dashboard/`. + +**Out of scope (non-goals):** + +- The separate terminal-dashboard TUI at `packages/cli/src/commands/dashboard-tui/app.tsx` (different file, different surface). +- Any reorganization of the `lazy()` view declarations or the `lazy-loaded-views-docs.test.ts` inventory. +- Any functional, UX, rendering, or timing change; new features; dependency additions or upgrades; changeset creation (private package + behavior-preserving). + +### Deferred to Follow-Up Work + +- **`AppShell` full-layout wrapper** — extracting the provider tree + `Header` + `LeftSidebarNav` + `ExecutorStatusBar` + `MobileNavBar` + floating windows + `AppModals` into one shell component. Highest prop-surface, lowest marginal benefit once U1–U7 land; pull in only if `App.tsx` is still over target after the other units (per the KTD1 line budget it should not be needed). +- **Re-pointing the seven pure-function test imports** from `'../../App'` to the new `app/utils/` module and dropping the KTD2 re-export shims. Deferred to avoid test churn inside a behavior-preservation change; the shim is explicitly transient debt, not a long-term surface. +- **Capturing the breakup's institutional knowledge** (module boundaries, hook seams, the FOUC-import and static-literal-lazy constraints) via `/ce-compound` — there is currently no `docs/solutions/` entry for an `App.tsx` decomposition. +- A dedicated `useMobileKeyboardFlags` hook for the ~30-line keyboard-flag + scroll-lock block; marginal and tightly coupled to `AppInner`'s `isMobile`/modal state. + +--- + +## Implementation Units + +The units are phased: U1 foundation → U2–U6 state extraction → U7 render extraction → U8 verification. Each unit is independently landable as one commit (U7 lands as two: `MainContent` then `DashboardBanners`) and should be verified against the gate (`pnpm --filter @fusion/dashboard typecheck`, `pnpm lint`) plus the relevant dashboard test project before the next begins. + +### U1. Extract pure helpers and constants to `app/utils/appLifecycle.ts` + +- **Goal:** Move the module-level pure functions and constants out of `App.tsx`, keeping `App`'s public exports stable via re-export so no existing test import breaks (KTD2). +- **Requirements:** R2, R3, R4. +- **Dependencies:** none. +- **Files:** + - `packages/dashboard/app/utils/appLifecycle.ts` (new) — receives the moved definitions. + - `packages/dashboard/app/App.tsx` (modify) — removes the definitions, imports them, and re-exports the seven tested symbols plus any types imported elsewhere (`ApprovalBannerCandidate`, `CliActionDeps`). +- **Approach:** Move `didEnterAwaitingApproval`, `didEnterDone`, `parseDateMs`, `loadApprovalBannerDismissals`, `persistApprovalBannerDismissals`, `buildRemoteDashboardUrl`, `shouldShowFirstEverBootLoader`, `requiresNativeShellOnboarding`, `isSessionNeedingInputForBanner`, `getCliActionDisabledReasonForBanner`, `executeCliSessionBannerAction`, and the `ApprovalBannerCandidate` and `CliActionDeps` interfaces into the new util, along with the module-level constants: the storage-key strings (`SETUP_WARNING_DISMISSED_KEY`, `WORKING_BRANCH_FILTER_STORAGE_KEY`, `BASE_BRANCH_FILTER_STORAGE_KEY`, `APPROVAL_BANNER_DISMISSED_STORAGE_KEY`, `CAPACITY_RISK_DISMISSED_KEY`), the `NO_BRANCH_FILTER_VALUE` sentinel, and the `RETRY_WARNING_RATIO` numeric threshold. `App.tsx` keeps `export { … } from "./utils/appLifecycle";` for the seven tested symbols so existing `from "../../App"` test imports resolve unchanged. +- **Patterns to follow:** existing `app/utils/` helpers (e.g. `boardScrollSnapshot.ts`, `mobileBarKeyboardFlags.ts`) — plain typed functions, no `any`, relative imports. +- **Test scenarios:** + - The three pure-function test files import from `App` and pass unchanged; `App.test.tsx`'s use of `didEnterAwaitingApproval`/`didEnterDone` still resolves and passes (happy-path correctness regression check). +- **Verification:** `pnpm --filter @fusion/dashboard typecheck`, `pnpm lint`, and the three pure-function test files (`App.boot-gate.test.tsx`, `App.shell-onboarding.test.tsx`, `app-cli-action-wiring.test.tsx`) plus the pure-function assertions in `App.test.tsx` all green. + +### U2. Extract notification SSE hooks (`useMailboxUnread`, `useChatUnreadBadge`, `useStashOrphanCount`) + +- **Goal:** Extract the mailbox, chat, and stash badge state plus their SSE/poll wiring into self-contained hooks. +- **Requirements:** R1, R3, R4, R7. +- **Dependencies:** none (these clusters do not depend on U1's helpers). +- **Files:** + - `packages/dashboard/app/hooks/useMailboxUnread.ts` (new) + - `packages/dashboard/app/hooks/useChatUnreadBadge.ts` (new) + - `packages/dashboard/app/hooks/useStashOrphanCount.ts` (new) + - `packages/dashboard/app/hooks/__tests__/useMailboxUnread.test.ts`, `useChatUnreadBadge.test.ts`, `useStashOrphanCount.test.ts` (new) + - `packages/dashboard/app/App.tsx` (modify — consume the three hooks) +- **Approach:** `useMailboxUnread(projectId)` owns `mailboxUnreadCount`/`mailboxPendingApprovalCount`, the `fetchUnreadCount` refresh, and the `message:*` and `approval:*` count-refresh SSE handlers (it subscribes to `approval:requested`/`approval:updated`/`approval:decided` for count refresh only). `useChatUnreadBadge(projectId, { taskView, quickChatOpen })` owns `chatHasUnreadResponse`, the `chat:message:added`/`chat:room:message:added` handlers, and the clear-on-chat-view effect. `useStashOrphanCount(projectId)` owns the 30-second `/stash-recovery/orphans` poll with its `cancelled` teardown. Per KTD4, the approval-banner *trigger* and the `task:updated` subscriber stay in `useApprovalBanner` (U3); the `task:updated`→awaiting-approval mailbox refresh is preserved via the `onTaskEnteredAwaitingApproval` callback wired in `AppInner`, not by a second `task:updated` handler. `subscribeSse` multiplexes, so multiple subscriptions to `/api/events` share one `EventSource`. +- **Patterns to follow:** `useAgents.ts` (KTD3) — SWR/cache hydration where relevant, `subscribeSse` with teardown, generation-counter stale suppression, object return + `UseXxxOptions`/`UseXxxResult` interfaces. +- **Test scenarios:** + - `message:sent`/`message:received`/`message:read`/`message:deleted` each refresh the unread count; `approval:requested`/`approval:decided`/`approval:updated` refresh the count. + - An assistant `chat:message:added` sets `chatHasUnreadResponse` when `taskView !== "chat"` and quick-chat is closed; a user-role message does not; opening chat/quick-chat clears it. + - Project mismatch (`payload.projectId !== currentProject.id`) is ignored for chat. + - Stash poll sets the count on success and falls back to `0` on error; the interval is cleared on unmount/project change. + - Project switch re-subscribes (dependency on `currentProject?.id`). +- **Verification:** typecheck, lint, `pnpm --filter @fusion/dashboard test:quality:app:foundation-hooks-utils`, and `App.test.tsx` green. + +### U3. Extract `useApprovalBanner` and the GitHub-star trigger + +- **Goal:** Extract the approval-banner dedupe/dismiss state machine and the first-completed-task GitHub-star prompt, preserving the single `task:updated` subscriber and its mailbox-refresh side effect (KTD4). +- **Requirements:** R1, R3, R4, R7, R8. +- **Dependencies:** U1 (storage constants, `parseDateMs`, `loadApprovalBannerDismissals`/`persistApprovalBannerDismissals`, `didEnterAwaitingApproval`, `didEnterDone`). +- **Files:** + - `packages/dashboard/app/hooks/useApprovalBanner.ts` (new) — owns `approvalBannerCandidate`, the `taskStatusByIdRef`/`seenApprovalKeysRef`/`approvalDismissalsRef` refs, the single `task:updated` and `approval:requested` handlers, the dismiss action, the per-`tasks` ref-sync effect, and the `onTaskEnteredAwaitingApproval` callback input. + - `packages/dashboard/app/hooks/useGitHubStarPromptTrigger.ts` (new) — exposes `{ showGitHubStarPrompt, trigger, markShown }`; `trigger` is the function `useApprovalBanner`'s `task:updated` handler calls to flip the prompt on (`!gitHubStarPromptShown && didEnterDone(...)`), mirroring KTD4's `onTaskEnteredAwaitingApproval` callback for the star path. **Do not create or overwrite `useGitHubStarPrompt.ts`** — that file already exists and exports the persisted cross-tab flag `useGitHubStarPromptShown`/`markGitHubStarPromptShown` (a `useSyncExternalStore` flag imported at App.tsx:54 and consumed at App.tsx:724/2455); it stays untouched. + - `packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts` (new) + - `packages/dashboard/app/App.tsx` (modify) +- **Approach:** This is the trickiest unit — it carries the stale-closure and effect-identity hazards documented in `docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md` and `docs/solutions/logic-errors/queued-chat-message-flush-trusts-stale-isgenerating.md`. Preserve exactly: the ref-sync effect that rebuilds `taskStatusByIdRef`/`seenApprovalKeysRef` from `tasks` on every change; the dedupe-by-key behavior; the dismissal timestamp comparison (`updatedAtMs <= dismissedAt` suppresses); clearing a key when a task leaves `awaiting-approval`; the `didEnterDone` → star-prompt trigger firing at most once; and the `refreshMailboxUnreadCount()` call inside the `awaiting-approval` branch, now expressed as the `onTaskEnteredAwaitingApproval` callback that `AppInner` wires to `useMailboxUnread.refresh`. The ephemeral star trigger lives in `useGitHubStarPromptTrigger`; the *suppression guard* remains the existing `useGitHubStarPromptShown` flag, so the trigger fires only when `!gitHubStarPromptShown && didEnterDone(...)`. +- **Patterns to follow:** `useAgents.ts` ref + generation-counter patterns (KTD3); keep dependency arrays faithful (plain comment, not an eslint-disable, for any intentionally-trimmed array). +- **Test scenarios:** + - `approval:requested` for a new key triggers the banner; a repeat for the same key is a no-op. + - A dismissal persists and suppresses re-trigger until a newer `updatedAtMs` arrives. + - A `task:updated` to a non-`awaiting-approval` status clears the key and its dismissal. + - `didEnterDone` (first transition to `done`) fires the star prompt exactly once; `gitHubStarPromptShown` suppresses it. + - A `task:updated` entering `awaiting-approval` invokes `onTaskEnteredAwaitingApproval` (the mailbox refresh) exactly once. + - Refs rebuild from a fresh `tasks` array without resetting live banner state spuriously. +- **Verification:** typecheck, lint, foundation-hooks-utils test run, and `App.test.tsx`'s approval-banner assertions green. + +### U4. Extract `useBranchTaskFilters` + +- **Goal:** Extract the working/base branch-filter state, its scoped persistence, and the derived options/filtered-tasks memos. +- **Requirements:** R1, R3, R4, R7. +- **Dependencies:** U1 (`WORKING_BRANCH_FILTER_STORAGE_KEY`, `BASE_BRANCH_FILTER_STORAGE_KEY`, `NO_BRANCH_FILTER_VALUE`). +- **Files:** + - `packages/dashboard/app/hooks/useBranchTaskFilters.ts` (new) + - `packages/dashboard/app/hooks/__tests__/useBranchTaskFilters.test.ts` (new) + - `packages/dashboard/app/App.tsx` (modify) +- **Approach:** `useBranchTaskFilters({ boardSourceTasks, currentProjectId })` returns `{ branchFilter, baseBranchFilter, branchOptions, baseBranchOptions, filteredBoardTasks, onBranchFilterChange, onBaseBranchFilterChange }`. It must consume the already-resolved remote-aware `boardSourceTasks` (`isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks`), **not** raw `tasks`, so remote-node board filtering is preserved; `AppInner` passes `boardSourceTasks` in. It reads scoped values on project change via `getScopedItem`/`setScopedItem` (`app/utils/projectStorage.ts`) and recomputes `filteredBoardTasks` with the existing filter logic, including the `NO_BRANCH_FILTER_VALUE` ("no branch") sentinel that excludes tasks which *have* a branch. `branchOptions`/`baseBranchOptions` remain unique-sorted derivations of the task set. +- **Patterns to follow:** `useFavorites.ts` / `useProjectBookmarks.ts` for scoped-localStorage hydration hooks (KTD3). +- **Test scenarios:** + - Initial mount reads the scoped value for the current project; project switch reloads both filters. + - Changing a filter writes the scoped value and recomputes `filteredBoardTasks`. + - `NO_BRANCH_FILTER_VALUE` excludes tasks with a non-empty branch; a concrete filter excludes non-matching branches; base-branch filter composes independently. + - Options are unique and sorted; empty/whitespace branches are dropped. + - Remote-node tasks flow through identically to local tasks (consumes `boardSourceTasks`). +- **Verification:** typecheck, lint, foundation-hooks-utils test run, and `App.test.tsx` board-filter behavior green. + +### U5. Extract health, capacity, dismiss, auth-recovery, and shell-onboarding hooks + +- **Goal:** Extract the parallel banner-dismiss flags, dashboard health, capacity-risk signal, auth-token recovery, and native-shell onboarding. +- **Requirements:** R1, R3, R4, R7. +- **Dependencies:** U1 (`SETUP_WARNING_DISMISSED_KEY`, `CAPACITY_RISK_DISMISSED_KEY`, `requiresNativeShellOnboarding`). `useCapacityRiskBanner` must be called after `useAgents()` and `useAppSettings()` so its inputs are defined (avoid TDZ). +- **Files:** + - `packages/dashboard/app/hooks/useDashboardHealth.ts` (new) — `{ health, refreshing, refreshError, refresh, setHealth }`; mount fetch + `refreshDbCorruptionHealth`; preserves the `taskIdIntegrity` updater shape consumed by the banner. + - `packages/dashboard/app/hooks/useCapacityRiskBanner.ts` (new) — options `{ agentStats, inProgressCount, inReviewCount, capacityRiskBannerEnabled, capacityRiskTodoThreshold, settingsLoaded, currentProjectId }`; returns `{ signal, dismissed, dismiss, hydrated }`; `computeCapacityRisk` over the counts + threshold; mirrors the settings-hydrate guard effect and the re-enable-clears-dismissal effect (App.tsx ~1111) inside the hook. + - `packages/dashboard/app/hooks/useScopedDismissFlag.ts` (new) — options `{ storageKey, currentProjectId }`, returns `{ dismissed, dismiss }`; backed by `getScopedItem`/`setScopedItem` and **owns the project-change re-read effect** (re-run `getScopedItem` on `currentProjectId` change, App.tsx ~964–972) so a dismissal in one project does not leak into another. Powers the setup-warning dismiss (and is reused internally by `useCapacityRiskBanner` for its dismiss). + - `packages/dashboard/app/hooks/useAuthTokenRecovery.ts` (new) — `{ open }`; the `AUTH_TOKEN_RECOVERY_REQUIRED_EVENT` window listener. + - `packages/dashboard/app/hooks/useShellOnboarding.ts` (new) — `{ onboardingComplete, connectionManagerOpen, requiresOnboarding, setConnectionManagerOpen }`; the connection-manager open effect keyed on `openConnectionManagerSignal`/shell state. + - Co-located `__tests__/` for the hooks with non-trivial logic. + - `packages/dashboard/app/App.tsx` (modify) +- **Approach:** These are small, mostly-parallel clusters; group them as one unit to avoid a flurry of micro-commits while keeping each hook single-purpose. `useScopedDismissFlag` must own the project-change scoped re-read so dismissal state resets per project. The capacity-risk settings-hydrate guard (skip on first load or project change) must be preserved to avoid a spurious banner flash, and the re-enable-clears-dismissal behavior must be carried into `useCapacityRiskBanner`. The dashboard-health `setHealth` updater used by the `TaskIdIntegrityBanner` must keep its conditional status-derivation shape. +- **Patterns to follow:** `useUpdateCheck.ts` (KTD3; smallest mount-effect + dismiss template), `useScopedDismissFlag` mirrors the existing scoped-storage dismiss pattern. +- **Test scenarios:** + - Health: mount fetch sets/errs to `null`; `refresh` sets `refreshing`, updates health, clears on success, sets `refreshError` on failure. + - Capacity: signal computes from todo/in-progress/in-review/idle counts + threshold; dismiss persists scoped and hides; hydrate guard skips the first settings load and on project change; re-enabling the banner clears a prior dismissal. + - Scoped-dismiss: dismiss writes scoped `"true"` and flips the flag; switching `currentProjectId` re-reads the scoped value so a dismissal in project A does not persist into project B. + - Auth-recovery: the recovery event sets `open`. + - Shell-onboarding: the connection-manager effect opens on the signal; `requiresOnboarding` follows the existing `requiresNativeShellOnboarding` logic. +- **Verification:** typecheck, lint, foundation-hooks-utils test run, and `App.test.tsx` banner/onboarding assertions green. + +### U6. Extract task-detail, board-scroll, and popped-out-windows hooks + +- **Goal:** Extract the main-panel task-detail state, board scroll snapshot/restore, and popped-out task windows. +- **Requirements:** R1, R3, R4, R7. +- **Dependencies:** none (consume the existing `app/utils/boardScrollSnapshot.ts` helpers). +- **Files:** + - `packages/dashboard/app/hooks/useMainPanelTaskDetail.ts` (new) — `{ task, initialTab, open, close, setTask, setInitialTab }`. + - `packages/dashboard/app/hooks/useBoardScrollRestore.ts` (new) — `{ capture, restore }` + the `requestAnimationFrame` double-frame restore effect keyed on `taskView`. + - `packages/dashboard/app/hooks/usePoppedOutTasks.ts` (new) — `{ tasks, popOut, close }`. + - Co-located `__tests__/` where logic warrants. + - `packages/dashboard/app/App.tsx` (modify) +- **Approach:** These hooks expose primitives; `AppInner` stays the place that composes them with navigation-history pushes (`pushNav`), because the `popstate`↔React-state coordination documented in `docs/solutions/ui-bugs/navigation-history-stale-modal-stack.md` is fragile across the `App.tsx` + `AppModals.tsx` + `useModalManager` seam. Do not move the `pushNav`/`replaceCurrent`/`removeNav` composition out of `AppInner`. Preserve the double-`requestAnimationFrame` restore timing (with the `requestAnimationFrame`/`setTimeout` fallback) exactly. +- **Patterns to follow:** existing `boardScrollSnapshot.ts` util (KTD3); keep the `useRef` snapshot holders inside the hooks. +- **Test scenarios:** + - Detail: `open(task, tab)` sets task + tab; `close` clears; `setTask` merges updates for the matching id only. + - Scroll: capture stores the snapshot; restore fires on board remount via the rAF chain; both frame handles are cancelled on cleanup. + - Popped-out: `popOut` dedupes by task id (re-pop is a no-op); `close` removes by id. +- **Verification:** typecheck, lint, foundation-hooks-utils test run, and `App.test.tsx` task-detail navigation assertions green. + +### U7. Extract `MainContent` and `DashboardBanners` components + +- **Goal:** Extract the two largest render blocks into presentational components under a new `app/components/dashboard/` directory. +- **Requirements:** R1, R3, R4, R5, R6, R8. +- **Dependencies:** U2–U6 (consumes the extracted hooks' outputs as props). +- **Files:** + - `packages/dashboard/app/components/dashboard/MainContent.tsx` (new) — the `renderMainContent()` view-switch (~650 lines), as a pure presentational switch. + - `packages/dashboard/app/components/dashboard/DashboardBanners.tsx` (new) — the conditional banner cluster (~15 banners). + - `packages/dashboard/app/components/dashboard/types.ts` (new) — shared prop-bag interfaces to avoid drift between `App` and the two components. + - `packages/dashboard/app/App.tsx` (modify — render the two components, keep the provider/shell tree inline). +- **Approach:** Land as separate commits within the unit (`MainContent` first, then `DashboardBanners`) since each is independently verifiable. `MainContent`'s prop bag is large (~80–100 fields across ~24 view branches), so keep branch-local consts and render-prop arrows (e.g. `closeSettingsView`, the `renderTaskCard` arrow, `pluginTasks`) co-located *inside* `MainContent` rather than threading them as props — this shrinks the surface to the data/handlers each branch actually needs. Define the remaining prop interfaces in `types.ts` and have `App` pass a composed props bag; `MainContent` is a pure `switch` on `taskView`/`viewMode` returning the existing ``/`` subtrees unchanged. Carry every `FNXC:Navigation`/`FNXC:Settings`/`FNXC:TaskDetail` comment into the component that now owns its JSX. Keep the eager `./components/ChatView.css` import at the `App.tsx` top level (do **not** move it into `MainContent`) — R6. The "Settings renders ahead of the overview branch" ordering and the "board-opened task detail replaces the board" behavior must be preserved verbatim. +- **Patterns to follow:** existing presentational components (`Header.tsx`, `LeftSidebarNav.tsx`) (KTD3) — typed prop interfaces, co-located `.css` only if the component owns styles (these two own none — they compose existing styled children). +- **Test scenarios:** + - `MainContent` renders the correct view for each `taskView` (board, list, settings, chat, mailbox, missions, agents, documents, pull-requests, insights, research, evals, memory, secrets, goalsView, todos, command-center, planning, workflows, import-tasks, automations, devserver, task-detail) and the `viewMode === "overview"` ProjectOverview branch. + - Settings renders ahead of the overview branch when `taskView === "settings"` even with no project selected. + - Backend-connection-error page renders when `showBackendConnectionErrorPage`. + - `DashboardBanners` shows each banner only under its exact condition (test-mode, engine-unavailable, OAuth-relogin, session-needing-input, CLI-binary-install, onboarding resume/post-onboarding, update-available, merge-advance-notice, task-id-integrity anomaly, db-corruption, setup-warning, approval, GitHub-star, capacity-risk). + - `App.test.tsx` DOM assertions (`getByTitle('Settings')`, `data-testid="dashboard-project-shell"`, banner presence) pass. +- **Verification:** typecheck, lint, `pnpm build`, `App.test.tsx` green, and a browser smoke against a freshly built bundle. + +### U8. Verification, line-count graduation, and docs/test sync + +- **Goal:** Confirm end-to-end behavior preservation, graduate `App.tsx` off the ratchet, and confirm the docs invariants are intact. +- **Requirements:** R1, R3, R5, R6, R8, R9. +- **Dependencies:** U1–U7. +- **Files:** + - `scripts/line-count-baseline.json` (modify, via the reviewed `node scripts/check-file-line-count.mjs --update`). + - `packages/dashboard/app/App.tsx` (final). +- **Approach:** Run the full dashboard suite (`pnpm --filter @fusion/dashboard test`), `pnpm lint`, `packages/dashboard` typecheck, and `pnpm build`. Run a browser smoke against a freshly built bundle using the worktree-safe recipe (`FUSION_CLIENT_DIR=$PWD/packages/dashboard/dist/client node packages/cli/bin.mjs dashboard --dev --port 4101 --token cetest123`; never port 4040, never `fn daemon`) to catch the stale-dist regression class. Confirm `App.tsx` is < 2,000 lines and remove it from the ratchet baseline via `--update` after review. Confirm `lazy-loaded-views-docs.test.ts` is green and the AGENTS.md 20-view inventory is unchanged. Audit that `FNXC` comments were carried into the new modules and that the seven pure functions are still re-exported from `App`. +- **Test expectation:** none — this is a verification harness; the assertions are the gate outputs and the line-count/file-inventory invariants. +- **Verification:** line-count audit passes with `App.tsx` removed from the baseline; the full merge gate green; `App.test.tsx` green; browser smoke shows no visual/behavioral regression. + +--- + +## Risks & Dependencies + +- **Stale-closure / effect-identity regressions during hook extraction.** Moving effects out of `AppInner` can subtly change when they fire (fresh array identities in dependency arrays re-triggered the SWR highlight bug; stale client state drove the queued-chat flush bug). Mitigation: preserve every effect's exact dependencies and ref semantics; prefer plain comments over trimmed arrays; `App.test.tsx` plus new `renderHook` tests as the regression net (KTD5). (`docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md`, `docs/solutions/logic-errors/queued-chat-message-flush-trusts-stale-isgenerating.md`) +- **`task:updated` cross-concern wiring.** The single `task:updated` handler drives approval, star, and mailbox refresh together (KTD4). Mitigation: keep one subscriber in `useApprovalBanner` and surface the mailbox refresh via the `onTaskEnteredAwaitingApproval` callback rather than duplicating the handler. +- **Navigation-history ↔ modal coordination desync.** The `pushState`/`popstate`/React-state alignment across `App.tsx` + `AppModals.tsx` + `useModalManager` is documented-fragile. Mitigation: keep nav composition in `AppInner` (U6); do not push it into the extracted hooks. (`docs/solutions/ui-bugs/navigation-history-stale-modal-stack.md`) +- **`eslint-disable react-hooks/exhaustive-deps` is a hard CI error** because the rule is unregistered in the flat config, and `pnpm test`/vitest never run ESLint so it only fails the PR Lint job. Mitigation: never use the directive; run `pnpm lint` locally on every unit. (`docs/solutions/build-errors/eslint-exhaustive-deps-rule-not-registered-fails-ci-lint.md`) +- **jsdom tests pass against source while the browser serves a stale dist.** A refactor can pass `App.test.tsx` yet ship a broken bundle. Mitigation: browser smoke against a freshly built bundle in U7/U8 (KTD5). (`docs/solutions/developer-experience/browser-testing-dashboard-from-worktree-safely.md`) +- **`lazy()` static-literal constraint.** Any temptation to abstract the lazy imports behind a helper/variable breaks Vite code-splitting and the inventory test. Mitigation: do not touch the lazy-const block (R5). (`docs/solutions/integration-issues/bundled-plugin-vite-alias-missing.md`) +- **Large prop surface on `MainContent`.** The view-switch closes over ~80–100 fields; threading them as props risks a dropped prop silently changing a view. Mitigation: shared `types.ts` interfaces, co-locating branch-local consts inside `MainContent` (U7), and the `App.test.tsx` per-view render assertions. + +--- + +## Sources / Research + +- `packages/dashboard/app/App.tsx` — the refactor target; structural read of the `AppInner` body (hook ordering, state clusters, the `renderMainContent()` switch at ~1611–2258, the shell tree at ~2272–2706). +- `packages/dashboard/app/hooks/useAgents.ts`, `useTasks.ts`, `useUpdateCheck.ts` — the hook-extraction templates (object return, `UseXxxOptions`/`UseXxxResult`, `readCache`/`writeCache` + `subscribeSse`, generation counters). +- `packages/dashboard/app/hooks/useGitHubStarPrompt.ts` — the existing persisted cross-tab flag hook (`useGitHubStarPromptShown`/`markGitHubStarPromptShown`); must not be clobbered by the new ephemeral trigger (U3). +- `packages/dashboard/app/sse-bus.ts` — confirms `subscribeSse` multiplexes same-URL subscribers onto one shared `EventSource` (KTD4). +- `packages/dashboard/app/components/__tests__/App.test.tsx` — the 4,273-line full-render behavior contract; mocks hooks/components by relative path and renders the real ``. +- `packages/dashboard/vitest.config.ts` — the ~11 project partition (new hook/util tests auto-route to `dashboard-app-quality-foundation-hooks-utils`; new component tests to `dashboard-app-quality-components-a/b`; the backfill project catches any unlisted new test). +- `scripts/check-file-line-count.mjs` + `scripts/line-count-baseline.json` — the 2,000-line cap with `App.tsx` grandfathered at 2,729 (file currently 2,724); `--update` is the reviewed graduation path. +- `packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts` — the 20-view (14 App-level + `_`-prefixed embedded) inventory guard over `App.tsx` and `AppModals.tsx`. +- `AGENTS.md` — merge-gate definition, changeset rule (no changeset for behavior-preserving refactors), `FNXC` comment convention, Lazy-Loaded Heavy Views inventory. +- `docs/solutions/` — the six learnings cited in Risks & Dependencies (eslint-disable, browser-testing, navigation-history modal stack, SWR highlight reset, queued-chat stale flush, bundled-plugin Vite alias). +- `STRATEGY.md` / `CONCEPTS.md` — domain vocabulary (Surface, Workflow Runtime, Task) used to keep the plan in project terms. From 49c2de108b02924ba51bde0eee89e41f87f903a7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 24 Jun 2026 01:13:33 -0700 Subject: [PATCH 13/14] test(dashboard): harden hook tests + drop dead App.tsx re-exports (code review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address code-review findings on the App.tsx module-breakup: - Add behavior-preservation tests: useBoardScrollRestore real double-rAF restore path; useApprovalBanner clear-on-leave-awaiting + mailbox-refresh dedup; useCapacityRiskBanner threshold-change-clears + dismiss-persist assertion; useChatUnreadBadge room-message + cross-project filter; and a new sseSplitIntegration test co-mounting useMailboxUnread + useApprovalBanner to pin the KTD4 SSE split (no double-fire; awaiting-approval refresh exactly once via callback). - Add unmount/teardown assertions (stash interval clear, auth-token listener removal, dashboard-health cancelled flag). - Drop dead type-only re-exports (ApprovalBannerCandidate, CliActionDeps) from App.tsx — zero importers (verified). All hook tests green; App.test.tsx unchanged (5 pre-existing). typecheck + eslint clean. No production behavior change. --- packages/dashboard/app/App.tsx | 2 - .../__tests__/sseSplitIntegration.test.ts | 106 ++++++++++++++++++ .../hooks/__tests__/useApprovalBanner.test.ts | 60 ++++++++++ .../__tests__/useAuthTokenRecovery.test.ts | 20 +++- .../__tests__/useBoardScrollRestore.test.ts | 59 +++++++++- .../__tests__/useCapacityRiskBanner.test.ts | 21 +++- .../__tests__/useChatUnreadBadge.test.ts | 35 ++++++ .../__tests__/useDashboardHealth.test.ts | 23 ++++ .../__tests__/useStashOrphanCount.test.ts | 18 +++ 9 files changed, 336 insertions(+), 8 deletions(-) create mode 100644 packages/dashboard/app/hooks/__tests__/sseSplitIntegration.test.ts diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index beb4a7fb8a..6d9ae38755 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -101,8 +101,6 @@ export { isSessionNeedingInputForBanner, getCliActionDisabledReasonForBanner, executeCliSessionBannerAction, - type ApprovalBannerCandidate, - type CliActionDeps, } from "./utils/appLifecycle"; import { subscribeSse } from "./sse-bus"; import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog"; diff --git a/packages/dashboard/app/hooks/__tests__/sseSplitIntegration.test.ts b/packages/dashboard/app/hooks/__tests__/sseSplitIntegration.test.ts new file mode 100644 index 0000000000..1278129acf --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/sseSplitIntegration.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { Task } from "@fusion/core"; + +interface CapturedSubscription { + url: string; + onReconnect?: () => void; + events: Record void>; +} + +const { subscriptions } = vi.hoisted(() => ({ + subscriptions: [] as CapturedSubscription[], +})); + +vi.mock("../../sse-bus", () => ({ + subscribeSse: vi.fn( + ( + url: string, + sub: { onReconnect?: () => void; events: Record void> }, + ) => { + subscriptions.push({ url, onReconnect: sub.onReconnect, events: { ...sub.events } }); + return () => {}; + }, + ), +})); + +const fetchUnreadCount = vi.fn(async () => ({ unreadCount: 0 })); +vi.mock("../../api", () => ({ + fetchUnreadCount: (...a: unknown[]) => fetchUnreadCount(...a), +})); + +import { useMailboxUnread } from "../useMailboxUnread"; +import { useApprovalBanner } from "../useApprovalBanner"; + +function msg(data: object): MessageEvent { + return { data: JSON.stringify(data) } as MessageEvent; +} + +describe("SSE split (KTD4): mailbox-refresh vs approval-banner", () => { + beforeEach(() => { + subscriptions.length = 0; + fetchUnreadCount.mockReset(); + fetchUnreadCount.mockResolvedValue({ unreadCount: 0 }); + }); + + it("co-mount keeps the awaiting-approval refresh single-fired and the banner independent", async () => { + const mailboxSpy = vi.fn(); + const tasks: Task[] = []; + const onStarPrompt = vi.fn(); + + // Two independent mounts → two subscribeSse calls captured separately so + // the split handlers never overwrite each other. + renderHook(() => useMailboxUnread("p1")); + const approval = renderHook(() => + useApprovalBanner({ + tasks, + currentProjectId: "p1", + gitHubStarPromptShown: true, + onStarPrompt, + onMailboxRefresh: mailboxSpy, + }), + ); + + // Drain the mailbox hook's mount fetch so no setState leaks past the test. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + // Distinguish the two subscriptions: mailbox listens to message:sent, + // the banner listens to task:updated. + const mailboxSub = subscriptions.find((s) => "message:sent" in s.events); + const approvalSub = subscriptions.find((s) => "task:updated" in s.events); + expect(mailboxSub).toBeTruthy(); + expect(approvalSub).toBeTruthy(); + + // (i) approval:requested sets the banner candidate but does NOT fire + // mailbox-refresh; the mailbox hook's approval:requested handler + // (count refresh) is a distinct function from the banner's. + act(() => { + approvalSub!.events["approval:requested"]?.(msg({ id: "a1", updatedAt: "2026-01-01T00:00:00Z" })); + }); + expect(approval.result.current.candidate?.dedupeKey).toBe("approval:a1"); + expect(mailboxSpy).not.toHaveBeenCalled(); + expect(mailboxSub!.events["approval:requested"]).toBeTruthy(); + expect(mailboxSub!.events["approval:requested"]).not.toBe(approvalSub!.events["approval:requested"]); + + // (ii) task:updated → awaiting-approval sets the candidate + fires the + // mailbox refresh exactly once. + act(() => { + approvalSub!.events["task:updated"]?.( + msg({ id: "t1", status: "awaiting-approval", updatedAt: "2026-01-02T00:00:00Z" }), + ); + }); + expect(approval.result.current.candidate?.dedupeKey).toBe("task:t1"); + expect(mailboxSpy).toHaveBeenCalledTimes(1); + + // (iii) a second awaiting-approval for the same task is deduped — no second refresh. + act(() => { + approvalSub!.events["task:updated"]?.( + msg({ id: "t1", status: "awaiting-approval", updatedAt: "2026-01-03T00:00:00Z" }), + ); + }); + expect(mailboxSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts b/packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts index a100879646..d28d24e805 100644 --- a/packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts @@ -136,4 +136,64 @@ describe("useApprovalBanner", () => { }); expect(result.current.candidate).toBeNull(); }); + it("re-triggers after leaving and re-entering awaiting-approval (clear-on-leave)", () => { + const onMailboxRefresh = vi.fn(); + const seedTasks: Task[] = [task("t1", "awaiting-approval")]; + const { result } = renderHook(() => + useApprovalBanner({ + tasks: seedTasks, + currentProjectId: "p1", + gitHubStarPromptShown: true, + onStarPrompt: vi.fn(), + onMailboxRefresh, + }), + ); + + // The seeded awaiting-approval task is already in the seen set, so a repeat + // event for it must NOT trigger. + act(() => { + handlers["task:updated"]?.(msg({ id: "t1", status: "awaiting-approval", updatedAt: "2026-01-01T00:00:00Z" })); + }); + expect(result.current.candidate).toBeNull(); + expect(onMailboxRefresh).not.toHaveBeenCalled(); + + // Task leaves awaiting-approval → the seen-key for t1 is cleared. + act(() => { + handlers["task:updated"]?.(msg({ id: "t1", status: "approved", updatedAt: "2026-01-02T00:00:00Z" })); + }); + expect(result.current.candidate).toBeNull(); + + // Re-entering awaiting-approval re-triggers the candidate + mailbox refresh. + act(() => { + handlers["task:updated"]?.(msg({ id: "t1", status: "awaiting-approval", updatedAt: "2026-01-03T00:00:00Z" })); + }); + expect(result.current.candidate?.dedupeKey).toBe("task:t1"); + expect(onMailboxRefresh).toHaveBeenCalledTimes(1); + }); + + it("dedupes mailbox refresh on a repeated awaiting-approval task:updated", () => { + const onMailboxRefresh = vi.fn(); + const tasks: Task[] = []; + const { result } = renderHook(() => + useApprovalBanner({ + tasks, + currentProjectId: "p1", + gitHubStarPromptShown: true, + onStarPrompt: vi.fn(), + onMailboxRefresh, + }), + ); + + act(() => { + handlers["task:updated"]?.(msg({ id: "t1", status: "awaiting-approval", updatedAt: "2026-01-01T00:00:00Z" })); + }); + expect(result.current.candidate?.dedupeKey).toBe("task:t1"); + expect(onMailboxRefresh).toHaveBeenCalledTimes(1); + + // A second awaiting-approval for the same task is suppressed by seenApprovalKeys. + act(() => { + handlers["task:updated"]?.(msg({ id: "t1", status: "awaiting-approval", updatedAt: "2026-01-04T00:00:00Z" })); + }); + expect(onMailboxRefresh).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts b/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts index 799dbb1b30..54eec1cde2 100644 --- a/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts @@ -1,9 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth"; import { useAuthTokenRecovery } from "../useAuthTokenRecovery"; describe("useAuthTokenRecovery", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); it("opens when the daemon auth-failure event fires", () => { const { result } = renderHook(() => useAuthTokenRecovery()); @@ -15,4 +18,19 @@ describe("useAuthTokenRecovery", () => { expect(result.current.open).toBe(true); }); + it("removes the daemon auth-failure listener on unmount", () => { + const addSpy = vi.spyOn(window, "addEventListener"); + const removeSpy = vi.spyOn(window, "removeEventListener"); + const { unmount } = renderHook(() => useAuthTokenRecovery()); + + const addedCall = addSpy.mock.calls.find( + ([type]) => type === AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, + ); + expect(addedCall).toBeTruthy(); + const addedHandler = addedCall![1] as EventListener; + + unmount(); + + expect(removeSpy).toHaveBeenCalledWith(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, addedHandler); + }); }); diff --git a/packages/dashboard/app/hooks/__tests__/useBoardScrollRestore.test.ts b/packages/dashboard/app/hooks/__tests__/useBoardScrollRestore.test.ts index da0ccd12c7..8da0ee7f93 100644 --- a/packages/dashboard/app/hooks/__tests__/useBoardScrollRestore.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useBoardScrollRestore.test.ts @@ -1,13 +1,29 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderHook } from "@testing-library/react"; -import { useBoardScrollRestore } from "../useBoardScrollRestore"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { TaskView } from "../useViewState"; vi.mock("../../utils/boardScrollSnapshot", () => ({ - captureBoardScrollSnapshot: vi.fn(() => ({ x: 10, columns: {} })), + captureBoardScrollSnapshot: vi.fn(), restoreBoardScrollSnapshot: vi.fn(() => true), })); +import { captureBoardScrollSnapshot, restoreBoardScrollSnapshot } from "../../utils/boardScrollSnapshot"; +import { useBoardScrollRestore } from "../useBoardScrollRestore"; + +const mockedCapture = vi.mocked(captureBoardScrollSnapshot); +const mockedRestore = vi.mocked(restoreBoardScrollSnapshot); + describe("useBoardScrollRestore", () => { + beforeEach(() => { + mockedCapture.mockReset(); + mockedRestore.mockReset(); + mockedRestore.mockReturnValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it("exposes capture and requestRestore without throwing", () => { const { result } = renderHook(() => useBoardScrollRestore("board")); @@ -16,4 +32,39 @@ describe("useBoardScrollRestore", () => { expect(() => result.current.capture()).not.toThrow(); expect(() => result.current.requestRestore()).not.toThrow(); }); + + it("restores the captured snapshot after returning to the board view", () => { + const sentinel = { boardLeft: 42, boardTop: 7, columnTops: { c1: 3 } }; + mockedCapture.mockReturnValue(sentinel); + + // Make the double requestAnimationFrame fire synchronously so the restore + // lands inside the act() that commits the board-view effect. + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => { + cb(0); + return 0; + }); + + const { result, rerender } = renderHook( + ({ taskView }: { taskView: TaskView }) => useBoardScrollRestore(taskView), + { initialProps: { taskView: "task-detail" } }, + ); + + // Off the board with nothing pending → no restore yet. + expect(mockedRestore).not.toHaveBeenCalled(); + + act(() => { + result.current.capture(); + result.current.requestRestore(); + }); + + // Restore waits for the view to return to "board". + expect(mockedRestore).not.toHaveBeenCalled(); + + act(() => { + rerender({ taskView: "board" }); + }); + + expect(mockedRestore).toHaveBeenCalledTimes(1); + expect(mockedRestore).toHaveBeenCalledWith(sentinel); + }); }); diff --git a/packages/dashboard/app/hooks/__tests__/useCapacityRiskBanner.test.ts b/packages/dashboard/app/hooks/__tests__/useCapacityRiskBanner.test.ts index c830f4a987..dce8981d14 100644 --- a/packages/dashboard/app/hooks/__tests__/useCapacityRiskBanner.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useCapacityRiskBanner.test.ts @@ -7,7 +7,7 @@ vi.mock("../../utils/projectStorage", () => ({ removeScopedItem: vi.fn(), })); -import { getScopedItem, removeScopedItem } from "../../utils/projectStorage"; +import { getScopedItem, removeScopedItem, setScopedItem } from "../../utils/projectStorage"; import { useCapacityRiskBanner } from "../useCapacityRiskBanner"; const base = { @@ -41,6 +41,7 @@ describe("useCapacityRiskBanner", () => { }); expect(result.current.dismissed).toBe(true); + expect(setScopedItem).toHaveBeenCalledWith(expect.any(String), "true", "p1"); }); it("clears a prior dismissal when the banner is re-enabled after hydrate", () => { @@ -58,6 +59,24 @@ describe("useCapacityRiskBanner", () => { // Re-enabling the banner resurrects the dismissed banner. rerender({ enabled: true }); + expect(removeScopedItem).toHaveBeenCalledWith(expect.any(String), "p1"); + expect(result.current.dismissed).toBe(false); + }); + it("clears a prior dismissal when the todo threshold changes after hydrate", () => { + vi.mocked(getScopedItem).mockReturnValue("true"); + const { result, rerender } = renderHook( + (props: { threshold: number }) => + useCapacityRiskBanner({ ...base, capacityRiskTodoThreshold: props.threshold }), + { initialProps: { threshold: 3 } }, + ); + + // First settings load hydrates without clearing. + expect(result.current.dismissed).toBe(true); + expect(removeScopedItem).not.toHaveBeenCalled(); + + // Changing the threshold resurrects the previously-dismissed banner. + rerender({ threshold: 5 }); + expect(removeScopedItem).toHaveBeenCalledWith(expect.any(String), "p1"); expect(result.current.dismissed).toBe(false); }); diff --git a/packages/dashboard/app/hooks/__tests__/useChatUnreadBadge.test.ts b/packages/dashboard/app/hooks/__tests__/useChatUnreadBadge.test.ts index e656a5f34b..5e272d70c3 100644 --- a/packages/dashboard/app/hooks/__tests__/useChatUnreadBadge.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChatUnreadBadge.test.ts @@ -80,4 +80,39 @@ describe("useChatUnreadBadge", () => { rerender({ taskView: "chat" }); expect(result.current.chatHasUnreadResponse).toBe(false); }); + it("marks unread on a non-user chat:room:message:added", () => { + const { result } = renderHook(() => + useChatUnreadBadge(undefined, { taskView: "board", quickChatOpen: false }), + ); + + act(() => { + handlers["chat:room:message:added"]?.(message({ role: "assistant" })); + }); + + expect(result.current.chatHasUnreadResponse).toBe(true); + }); + + it("ignores user-role chat:room:message:added events", () => { + const { result } = renderHook(() => + useChatUnreadBadge(undefined, { taskView: "board", quickChatOpen: false }), + ); + + act(() => { + handlers["chat:room:message:added"]?.(message({ role: "user" })); + }); + + expect(result.current.chatHasUnreadResponse).toBe(false); + }); + + it("ignores assistant messages scoped to a different project", () => { + const { result } = renderHook(() => + useChatUnreadBadge("p1", { taskView: "board", quickChatOpen: false }), + ); + + act(() => { + handlers["chat:message:added"]?.(message({ role: "assistant", projectId: "p2" })); + }); + + expect(result.current.chatHasUnreadResponse).toBe(false); + }); }); diff --git a/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts b/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts index 6d6184a35f..b99b0f40cd 100644 --- a/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts @@ -59,4 +59,27 @@ describe("useDashboardHealth", () => { expect(result.current.refreshError).toBe("nope"); expect(result.current.refreshing).toBe(false); }); + it("does not apply the mount fetch after unmount", async () => { + let resolveMount: (value: { status: string }) => void = () => {}; + fetchDashboardHealth.mockImplementation( + () => + new Promise<{ status: string }>((resolve) => { + resolveMount = resolve; + }), + ); + + const { result, unmount } = renderHook(() => useDashboardHealth()); + expect(result.current.health).toBeNull(); + + unmount(); + resolveMount({ status: "ok" }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + // The cancelled flag suppresses setState — health stays at its initial null. + expect(result.current.health).toBeNull(); + }); }); diff --git a/packages/dashboard/app/hooks/__tests__/useStashOrphanCount.test.ts b/packages/dashboard/app/hooks/__tests__/useStashOrphanCount.test.ts index cb13cf3700..95ec89cff2 100644 --- a/packages/dashboard/app/hooks/__tests__/useStashOrphanCount.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useStashOrphanCount.test.ts @@ -58,4 +58,22 @@ describe("useStashOrphanCount", () => { }); expect(mockedApi).toHaveBeenCalledTimes(2); }); + it("stops polling once unmounted", async () => { + mockedApi.mockResolvedValue({ count: 1 }); + const { unmount } = renderHook(() => useStashOrphanCount(undefined)); + + // Initial mount fetch. + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(mockedApi).toHaveBeenCalledTimes(1); + + unmount(); + + // Advance well past the 30s interval — the cleared timer must not fire. + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + expect(mockedApi).toHaveBeenCalledTimes(1); + }); }); From 25044479269270783f1eeb2dc7a9119c9dbbcf5c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 24 Jun 2026 09:22:05 -0700 Subject: [PATCH 14/14] test(dashboard): apply test-quality findings from focused re-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the 6 findings from the focused re-review of the hardening commit (test-only; no production changes): - useDashboardHealth: drop the false-confidence unmount-state assertion (React 19 silently drops setState on unmounted components, so it pinned nothing) and document the cancelled-guard as a React-19-untestable-via-state invariant; keep the meaningful mount-fetch assertions. - sseSplitIntegration: assert the onReconnect split explicitly (mailbox onReconnect wired, approval onReconnect undefined); prove the mailbox approval:requested handler actually refreshes (fetchUnreadCount called); replace the magic 2x microtask drain with a deterministic waitFor. - Extract the duplicated msg()/message() SSE-event helper to a shared sseTestHelpers.ts (per-file vi.mock factories stay — vitest hoists them). - useChatUnreadBadge: add a cross-project filter case for chat:room:message:added. Full hook suite green (1366 tests); App.test.tsx unchanged; typecheck + eslint clean. --- .../__tests__/sseSplitIntegration.test.ts | 25 +++++++++++++------ .../app/hooks/__tests__/sseTestHelpers.ts | 13 ++++++++++ .../hooks/__tests__/useApprovalBanner.test.ts | 5 +--- .../__tests__/useChatUnreadBadge.test.ts | 16 +++++++++--- .../__tests__/useDashboardHealth.test.ts | 15 ++++++++--- 5 files changed, 54 insertions(+), 20 deletions(-) create mode 100644 packages/dashboard/app/hooks/__tests__/sseTestHelpers.ts diff --git a/packages/dashboard/app/hooks/__tests__/sseSplitIntegration.test.ts b/packages/dashboard/app/hooks/__tests__/sseSplitIntegration.test.ts index 1278129acf..0e2d2897b3 100644 --- a/packages/dashboard/app/hooks/__tests__/sseSplitIntegration.test.ts +++ b/packages/dashboard/app/hooks/__tests__/sseSplitIntegration.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderHook, act } from "@testing-library/react"; +import { renderHook, act, waitFor } from "@testing-library/react"; import type { Task } from "@fusion/core"; interface CapturedSubscription { @@ -31,10 +31,7 @@ vi.mock("../../api", () => ({ import { useMailboxUnread } from "../useMailboxUnread"; import { useApprovalBanner } from "../useApprovalBanner"; - -function msg(data: object): MessageEvent { - return { data: JSON.stringify(data) } as MessageEvent; -} +import { msg } from "./sseTestHelpers"; describe("SSE split (KTD4): mailbox-refresh vs approval-banner", () => { beforeEach(() => { @@ -61,10 +58,11 @@ describe("SSE split (KTD4): mailbox-refresh vs approval-banner", () => { }), ); - // Drain the mailbox hook's mount fetch so no setState leaks past the test. + // Drain the mailbox hook's mount fetch deterministically — wait for the + // refresh call to fire and settle, so its setState doesn't leak past the + // test. (Replaces a magic 2x microtask flush.) await act(async () => { - await Promise.resolve(); - await Promise.resolve(); + await waitFor(() => expect(fetchUnreadCount).toHaveBeenCalled()); }); // Distinguish the two subscriptions: mailbox listens to message:sent, @@ -73,6 +71,10 @@ describe("SSE split (KTD4): mailbox-refresh vs approval-banner", () => { const approvalSub = subscriptions.find((s) => "task:updated" in s.events); expect(mailboxSub).toBeTruthy(); expect(approvalSub).toBeTruthy(); + // The split extends to reconnect handling: the mailbox subscription wires + // an onReconnect (re-fetch counts), the approval banner does not. + expect(mailboxSub!.onReconnect).toBeTruthy(); + expect(approvalSub!.onReconnect).toBeUndefined(); // (i) approval:requested sets the banner candidate but does NOT fire // mailbox-refresh; the mailbox hook's approval:requested handler @@ -84,6 +86,13 @@ describe("SSE split (KTD4): mailbox-refresh vs approval-banner", () => { expect(mailboxSpy).not.toHaveBeenCalled(); expect(mailboxSub!.events["approval:requested"]).toBeTruthy(); expect(mailboxSub!.events["approval:requested"]).not.toBe(approvalSub!.events["approval:requested"]); + // (ib) … and the mailbox handler actually refreshes the count (wires to + // fetchUnreadCount), proving it's a live handler — not merely present. + const refreshCallsBefore = fetchUnreadCount.mock.calls.length; + act(() => { + mailboxSub!.events["approval:requested"]?.(msg({ id: "a2", updatedAt: "2026-01-02T00:00:00Z" })); + }); + expect(fetchUnreadCount).toHaveBeenCalledTimes(refreshCallsBefore + 1); // (ii) task:updated → awaiting-approval sets the candidate + fires the // mailbox refresh exactly once. diff --git a/packages/dashboard/app/hooks/__tests__/sseTestHelpers.ts b/packages/dashboard/app/hooks/__tests__/sseTestHelpers.ts new file mode 100644 index 0000000000..0e0adaee01 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/sseTestHelpers.ts @@ -0,0 +1,13 @@ +/** + * Shared helpers for SSE-driven hook tests. Builds a synthetic MessageEvent + * whose `data` is the JSON-stringified payload, matching the shape these hooks + * parse inside their event handlers. + * + * NOTE: each consuming test still owns its own `vi.mock("../../sse-bus", …)` + * factory — vitest hoists `vi.mock` and resolves the path relative to the + * caller, so the mock cannot be shared from here. + */ +export const msg = (data: object): MessageEvent => + ({ data: JSON.stringify(data) } as MessageEvent); + +export const message = msg; diff --git a/packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts b/packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts index d28d24e805..5d54569bcb 100644 --- a/packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts @@ -14,10 +14,7 @@ vi.mock("../../sse-bus", () => ({ })); import { useApprovalBanner } from "../useApprovalBanner"; - -function msg(data: object): MessageEvent { - return { data: JSON.stringify(data) } as MessageEvent; -} +import { msg } from "./sseTestHelpers"; const task = (id: string, status: string): Task => ({ id, status, title: id } as Task); diff --git a/packages/dashboard/app/hooks/__tests__/useChatUnreadBadge.test.ts b/packages/dashboard/app/hooks/__tests__/useChatUnreadBadge.test.ts index 5e272d70c3..48c97b06fa 100644 --- a/packages/dashboard/app/hooks/__tests__/useChatUnreadBadge.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChatUnreadBadge.test.ts @@ -14,10 +14,7 @@ vi.mock("../../sse-bus", () => ({ })); import { useChatUnreadBadge } from "../useChatUnreadBadge"; - -function message(data: object): MessageEvent { - return { data: JSON.stringify(data) } as MessageEvent; -} +import { message } from "./sseTestHelpers"; describe("useChatUnreadBadge", () => { beforeEach(() => { @@ -113,6 +110,17 @@ describe("useChatUnreadBadge", () => { handlers["chat:message:added"]?.(message({ role: "assistant", projectId: "p2" })); }); + expect(result.current.chatHasUnreadResponse).toBe(false); + }); + it("ignores assistant chat:room:message:added events scoped to a different project", () => { + const { result } = renderHook(() => + useChatUnreadBadge("p1", { taskView: "board", quickChatOpen: false }), + ); + + act(() => { + handlers["chat:room:message:added"]?.(message({ role: "assistant", projectId: "p2" })); + }); + expect(result.current.chatHasUnreadResponse).toBe(false); }); }); diff --git a/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts b/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts index b99b0f40cd..e865938de9 100644 --- a/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts @@ -59,7 +59,7 @@ describe("useDashboardHealth", () => { expect(result.current.refreshError).toBe("nope"); expect(result.current.refreshing).toBe(false); }); - it("does not apply the mount fetch after unmount", async () => { + it("fires the mount fetch and tolerates an unmount before it resolves", async () => { let resolveMount: (value: { status: string }) => void = () => {}; fetchDashboardHealth.mockImplementation( () => @@ -69,17 +69,24 @@ describe("useDashboardHealth", () => { ); const { result, unmount } = renderHook(() => useDashboardHealth()); + // The effect has fired the mount fetch; health starts null until it settles. + expect(fetchDashboardHealth).toHaveBeenCalledTimes(1); expect(result.current.health).toBeNull(); + // Unmount while the fetch is still in flight, then resolve it. unmount(); resolveMount({ status: "ok" }); - await act(async () => { await Promise.resolve(); await Promise.resolve(); }); - // The cancelled flag suppresses setState — health stays at its initial null. - expect(result.current.health).toBeNull(); + // NOTE: the effect's `cancelled` guard defensively suppresses setHealth + // after unmount, but under React 19 setState on an unmounted component is + // silently dropped — `result.current.health` stays null *whether or not the + // guard exists*. Asserting state here would give false confidence (the test + // passes even with the guard removed), so the guard is treated as a + // React-19-untestable-via-state invariant and is intentionally NOT asserted + // here. Verified empirically: removing the guard leaves the suite green. }); });