Files
fusion/packages/dashboard/app/hooks/useApprovalBanner.ts
gsxdsm f4b25dd2c9 FN-7096: gate approval banner to mailbox requests
Limit the dashboard approval banner to real mailbox approval requests instead of task plan-approval states.

- Stop awaiting-approval task updates from fabricating Open Mailbox banner candidates or refreshing mailbox counts.
- Gate DashboardBanners rendering to approval:<id> candidates while preserving pending-count race protection.
- Update dashboard tests, documentation, and release notes for mailbox-only approval banners.

Files changed:
 .changeset/fn-7096-approval-banner-mailbox.md      |   7 +
 docs/dashboard-guide.md                            |   2 +-
 packages/dashboard/app/App.tsx                     |   3 +-
 .../app/components/__tests__/App.test.tsx          |  33 ++--
 .../app/components/dashboard/DashboardBanners.tsx  |   9 +-
 .../dashboard/__tests__/DashboardBanners.test.tsx  |  65 +++++++-
 .../hooks/__tests__/sseSplitIntegration.test.ts    |  28 ++--
 .../app/hooks/__tests__/useApprovalBanner.test.ts  | 177 ++++++++-------------
 packages/dashboard/app/hooks/useApprovalBanner.ts  |  34 +---
 packages/dashboard/app/hooks/useMailboxUnread.ts   |   5 +-
 10 files changed, 185 insertions(+), 178 deletions(-)

Fusion-Task-Id: FN-7096

Fusion-Task-Lineage: 40885aba-6a9f-4720-bea6-5a6f155c326d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-06-27 09:28:29 -07:00

125 lines
4.7 KiB
TypeScript

/*
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. 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).
FNXC:ApprovalBanner 2026-06-26-00:00:
The mailbox approval banner represents only real ApprovalRequest rows delivered by approval:requested SSE with approval:<id> dedupe keys. Task awaiting-approval is a plan-approval lifecycle state surfaced on the triage board and must not create an Open Mailbox banner or refresh mailbox counts.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { Task } from "@fusion/core";
import { subscribeSse } from "../sse-bus";
import {
type ApprovalBannerCandidate,
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;
}
export interface UseApprovalBannerResult {
candidate: ApprovalBannerCandidate | null;
dismissApproval: (candidate: ApprovalBannerCandidate) => void;
}
export function useApprovalBanner({
tasks,
currentProjectId,
gitHubStarPromptShown,
onStarPrompt,
}: 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);
}
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}` : 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 previousStatus = taskStatusByIdRef.current.get(payload.id);
taskStatusByIdRef.current.set(payload.id, payload.status);
if (!gitHubStarPromptShown && didEnterDone(payload.status, previousStatus)) {
onStarPrompt();
}
} catch {
// no-op
}
},
},
});
}, [currentProjectId, gitHubStarPromptShown, onStarPrompt]);
const dismissApproval = useCallback((dismissed: ApprovalBannerCandidate) => {
approvalDismissalsRef.current.set(
dismissed.dedupeKey,
Math.max(Date.now(), dismissed.updatedAtMs),
);
persistApprovalBannerDismissals(approvalDismissalsRef.current);
setCandidate(null);
}, []);
return { candidate, dismissApproval };
}