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:
gsxdsm
2026-06-23 19:46:55 -07:00
parent 475af26a6d
commit f98cce0904
5 changed files with 413 additions and 126 deletions

View File

@@ -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. */}

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

View File

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

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

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