From f98cce090403160a10856fbb948870a429962da9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 19:46:55 -0700 Subject: [PATCH] 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 }; +}