feat(FN-3551): add persistent approval notification banner

- Add ApprovalNotificationBanner component and integrate it in App task flow
- Define approval trigger contract and persist banner dismissal state across refreshes
- Deduplicate approval notifications so repeated approvals do not re-show dismissed alerts
- Add App/banner tests and update dashboard docs for approval banner behavior

Fusion-Task-Id: FN-3551
This commit is contained in:
Fusion
2026-05-09 06:36:55 -07:00
committed by gsxdsm
parent 7cb13439d3
commit f406db641b
7 changed files with 413 additions and 3 deletions

View File

@@ -16,6 +16,7 @@ import { SessionNotificationBanner } from "./components/SessionNotificationBanne
import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner";
import { SetupWarningBanner } from "./components/SetupWarningBanner";
import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner";
import { ApprovalNotificationBanner } from "./components/ApprovalNotificationBanner";
import { OnboardingResumeCard } from "./components/OnboardingResumeCard";
import { PostOnboardingRecommendations } from "./components/PostOnboardingRecommendations";
import {
@@ -126,6 +127,53 @@ const ACTIVE_CHAT_SESSION_STORAGE_KEY = "kb-chat-active-session";
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";
interface ApprovalBannerCandidate {
dedupeKey: string;
updatedAtMs: number;
}
export function didEnterAwaitingApproval(nextStatus: string | undefined, previousStatus: string | undefined): boolean {
return nextStatus === "awaiting-approval" && previousStatus !== "awaiting-approval";
}
function parseDateMs(value: string | undefined): number {
if (!value) return 0;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function loadApprovalBannerDismissals(): Map<string, number> {
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<string, number>;
const map = new Map<string, number>();
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<string, number>): void {
if (typeof window === "undefined") return;
try {
const data: Record<string, number> = {};
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);
@@ -385,6 +433,10 @@ function AppInner() {
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
const [mailboxPendingApprovalCount, setMailboxPendingApprovalCount] = useState(0);
const [chatHasUnreadResponse, setChatHasUnreadResponse] = useState(false);
const [approvalBannerCandidate, setApprovalBannerCandidate] = 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());
const refreshMailboxUnreadCount = useCallback(() => {
fetchUnreadCount(currentProject?.id)
@@ -397,6 +449,19 @@ function AppInner() {
});
}, [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();
@@ -407,15 +472,70 @@ function AppInner() {
}
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": 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 (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, refreshMailboxUnreadCount]);
@@ -1443,6 +1563,20 @@ function AppInner() {
onDismiss={handleDismissSetupWarning}
/>
)}
{viewMode === "project" && currentProject && approvalBannerCandidate && (
<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);
}}
/>
)}
<div
className={`project-content${viewMode === "project" && currentProject ? " project-content--with-footer" : ""}${isMobile && !mobileKeyboardOpen ? " project-content--with-mobile-nav" : ""}`}
>

View File

@@ -0,0 +1,54 @@
.approval-notification-banner {
padding: var(--space-sm) var(--space-lg);
border-bottom: var(--btn-border-width) solid var(--border);
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
}
.approval-notification-banner__content {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
}
.approval-notification-banner__headline {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
color: var(--text);
font-weight: 600;
}
.approval-notification-banner__actions {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
.approval-notification-banner__actions .btn {
border-color: var(--color-warning);
}
.approval-notification-banner__dismiss {
color: var(--text-muted);
}
.approval-notification-banner__dismiss:hover {
color: var(--text);
}
@media (max-width: 768px) {
.approval-notification-banner {
padding: var(--space-sm) var(--space-md);
}
.approval-notification-banner__content {
flex-direction: column;
align-items: flex-start;
}
.approval-notification-banner__actions {
width: 100%;
justify-content: space-between;
}
}

View File

@@ -0,0 +1,37 @@
import { AlertTriangle, Inbox, X } from "lucide-react";
import "./ApprovalNotificationBanner.css";
interface ApprovalNotificationBannerProps {
pendingCount: number;
onOpenMailbox: () => void;
onDismiss: () => void;
}
export function ApprovalNotificationBanner({
pendingCount,
onOpenMailbox,
onDismiss,
}: ApprovalNotificationBannerProps) {
const noun = pendingCount === 1 ? "request" : "requests";
return (
<section className="approval-notification-banner" role="region" aria-live="polite" aria-label="Approval requests">
<div className="approval-notification-banner__content">
<div className="approval-notification-banner__headline">
<span className="status-dot" aria-hidden="true" />
<AlertTriangle aria-hidden="true" />
<span>{pendingCount} approval {noun} need your attention</span>
</div>
<div className="approval-notification-banner__actions">
<button type="button" className="btn btn-sm" onClick={onOpenMailbox}>
<Inbox aria-hidden="true" />
<span>Open Mailbox</span>
</button>
<button type="button" className="btn-icon approval-notification-banner__dismiss" onClick={onDismiss} aria-label="Dismiss approval notification banner">
<X aria-hidden="true" />
</button>
</div>
</div>
</section>
);
}

View File

@@ -537,7 +537,7 @@ vi.mock("../../hooks/useViewportMode", () => ({
getViewportMode: () => "desktop",
}));
import { App } from "../../App";
import { App, didEnterAwaitingApproval } from "../../App";
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth";
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews } from "../../api";
import { __resetShellHostContextForTests } from "../../shell-host";
@@ -684,6 +684,14 @@ describe("App backend-unreachable first-run flow", () => {
});
});
describe("didEnterAwaitingApproval", () => {
it("returns true only when status newly enters awaiting-approval", () => {
expect(didEnterAwaitingApproval("awaiting-approval", "in-progress")).toBe(true);
expect(didEnterAwaitingApproval("awaiting-approval", "awaiting-approval")).toBe(false);
expect(didEnterAwaitingApproval("done", "in-progress")).toBe(false);
});
});
describe("App mailbox unread count", () => {
it("logs a warning when unread count fetch fails and keeps the zero-count fallback", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -738,6 +746,156 @@ describe("App mailbox unread count", () => {
});
});
describe("App approval notification banner", () => {
it("shows banner when a task newly enters awaiting-approval", async () => {
mockUseTasks.mockImplementation(() => ({
tasks: [{ id: "FN-1", title: "Task", description: "x", status: "in-progress", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }],
createTask: mockCreateTask,
moveTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
pauseTask: vi.fn(),
resetTask: vi.fn(),
loadArchivedTasks: vi.fn(),
ingestCreatedTasks: vi.fn(),
lastFetchTimeMs: Date.now(),
}));
render(<App />);
await waitFor(() => expect(mockSubscribeSse).toHaveBeenCalled());
const mailboxSubscriptionCall = mockSubscribeSse.mock.calls.find(
([url, sub]) => String(url).startsWith("/api/events") && typeof (sub as { events?: Record<string, unknown> })?.events?.["task:updated"] === "function",
);
const subscriptionConfig = mailboxSubscriptionCall?.[1] as {
events: Record<string, (event: MessageEvent) => void>;
};
await act(async () => {
subscriptionConfig.events["task:updated"](
new MessageEvent("task:updated", {
data: JSON.stringify({ id: "FN-1", status: "awaiting-approval", updatedAt: "2026-05-05T10:00:00.000Z" }),
}),
);
});
expect(screen.getByLabelText("Approval requests")).toBeInTheDocument();
});
it("persists dismissals and suppresses repeat alerts for the same approval item", async () => {
mockUseTasks.mockImplementation(() => ({
tasks: [{ id: "FN-4", title: "Task", description: "x", status: "in-progress", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }],
createTask: mockCreateTask,
moveTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
pauseTask: vi.fn(),
resetTask: vi.fn(),
loadArchivedTasks: vi.fn(),
ingestCreatedTasks: vi.fn(),
lastFetchTimeMs: Date.now(),
}));
const { unmount } = render(<App />);
await waitFor(() => expect(mockSubscribeSse).toHaveBeenCalled());
const mailboxSubscriptionCall = mockSubscribeSse.mock.calls.find(
([url, sub]) => String(url).startsWith("/api/events") && typeof (sub as { events?: Record<string, unknown> })?.events?.["task:updated"] === "function",
);
const subscriptionConfig = mailboxSubscriptionCall?.[1] as {
events: Record<string, (event: MessageEvent) => void>;
};
await act(async () => {
subscriptionConfig.events["task:updated"](
new MessageEvent("task:updated", {
data: JSON.stringify({ id: "FN-4", status: "awaiting-approval", updatedAt: "2026-05-05T10:00:00.000Z" }),
}),
);
});
fireEvent.click(screen.getByLabelText("Dismiss approval notification banner"));
expect(screen.queryByLabelText("Approval requests")).toBeNull();
unmount();
render(<App />);
const latestSubscription = mockSubscribeSse.mock.calls
.slice()
.reverse()
.find(([, sub]) => typeof (sub as { events?: Record<string, unknown> })?.events?.["task:updated"] === "function");
const latestConfig = latestSubscription?.[1] as {
events: Record<string, (event: MessageEvent) => void>;
};
await act(async () => {
latestConfig.events["task:updated"](
new MessageEvent("task:updated", {
data: JSON.stringify({ id: "FN-4", status: "awaiting-approval", updatedAt: "2026-05-05T10:00:00.000Z" }),
}),
);
});
expect(screen.queryByLabelText("Approval requests")).toBeNull();
});
it("does not show banner for already-awaiting tasks", async () => {
mockUseTasks.mockImplementation(() => ({
tasks: [{ id: "FN-2", title: "Task", description: "x", status: "awaiting-approval", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }],
createTask: mockCreateTask,
moveTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
pauseTask: vi.fn(),
resetTask: vi.fn(),
loadArchivedTasks: vi.fn(),
ingestCreatedTasks: vi.fn(),
lastFetchTimeMs: Date.now(),
}));
render(<App />);
await waitFor(() => expect(mockSubscribeSse).toHaveBeenCalled());
const mailboxSubscriptionCall = mockSubscribeSse.mock.calls.find(
([url, sub]) => String(url).startsWith("/api/events") && typeof (sub as { events?: Record<string, unknown> })?.events?.["task:updated"] === "function",
);
const subscriptionConfig = mailboxSubscriptionCall?.[1] as {
events: Record<string, (event: MessageEvent) => void>;
};
await act(async () => {
subscriptionConfig.events["task:updated"](
new MessageEvent("task:updated", {
data: JSON.stringify({ id: "FN-2", status: "awaiting-approval", updatedAt: "2026-05-05T10:00:00.000Z" }),
}),
);
});
expect(screen.queryByLabelText("Approval requests")).toBeNull();
});
});
describe("App chat unread response indicator", () => {
it("shows unread indicator when assistant message arrives for active session after leaving chat", async () => {
localStorage.setItem(scopedKey("kb-chat-active-session", "proj_123"), "sess-active");

View File

@@ -0,0 +1,26 @@
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { ApprovalNotificationBanner } from "../ApprovalNotificationBanner";
describe("ApprovalNotificationBanner", () => {
it("renders count and handles actions", () => {
const onOpenMailbox = vi.fn();
const onDismiss = vi.fn();
render(
<ApprovalNotificationBanner
pendingCount={2}
onOpenMailbox={onOpenMailbox}
onDismiss={onDismiss}
/>,
);
expect(screen.getByText("2 approval requests need your attention")).toBeInTheDocument();
fireEvent.click(screen.getByText("Open Mailbox"));
expect(onOpenMailbox).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByLabelText("Dismiss approval notification banner"));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});