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.
This commit is contained in:
@@ -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<ApprovalBannerCandidate | null>(null);
|
||||
const [showGitHubStarPrompt, setShowGitHubStarPrompt] = useState(false);
|
||||
const taskStatusByIdRef = useRef<Map<string, string | undefined>>(new Map());
|
||||
const seenApprovalKeysRef = useRef<Set<string>>(new Set());
|
||||
const approvalDismissalsRef = useRef<Map<string, number>>(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<string, string | undefined>();
|
||||
const nextSeen = new Set<string>();
|
||||
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() {
|
||||
<ApprovalNotificationBanner
|
||||
pendingCount={Math.max(mailboxPendingApprovalCount, 1)}
|
||||
onOpenMailbox={() => 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. */}
|
||||
|
||||
139
packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts
Normal file
139
packages/dashboard/app/hooks/__tests__/useApprovalBanner.test.ts
Normal file
@@ -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<string, (e: MessageEvent) => void>,
|
||||
}));
|
||||
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: vi.fn((_url: string, opts: { events: Record<string, (e: MessageEvent) => 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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, (e: MessageEvent) => void> & { onReconnect?: () => void },
|
||||
}));
|
||||
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: vi.fn((_url: string, opts: { onReconnect?: () => void; events: Record<string, (e: MessageEvent) => 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<string, unknown>)[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);
|
||||
});
|
||||
});
|
||||
146
packages/dashboard/app/hooks/useApprovalBanner.ts
Normal file
146
packages/dashboard/app/hooks/useApprovalBanner.ts
Normal file
@@ -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<ApprovalBannerCandidate | null>(null);
|
||||
const taskStatusByIdRef = useRef<Map<string, string | undefined>>(new Map());
|
||||
const seenApprovalKeysRef = useRef<Set<string>>(new Set());
|
||||
const approvalDismissalsRef = useRef<Map<string, number>>(loadApprovalBannerDismissals());
|
||||
|
||||
useEffect(() => {
|
||||
const next = new Map<string, string | undefined>();
|
||||
const nextSeen = new Set<string>();
|
||||
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 };
|
||||
}
|
||||
56
packages/dashboard/app/hooks/useMailboxUnread.ts
Normal file
56
packages/dashboard/app/hooks/useMailboxUnread.ts
Normal file
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user