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:
@@ -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" : ""}`}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user