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:
@@ -124,6 +124,7 @@ Mailbox view shows inbox/outbox communication threads and unread state.
|
|||||||
- mailbox now includes an **Approvals** tab with pending and history filters (`approved` / `denied` / `completed`), approval detail context, and inline approve/deny actions for pending requests
|
- mailbox now includes an **Approvals** tab with pending and history filters (`approved` / `denied` / `completed`), approval detail context, and inline approve/deny actions for pending requests
|
||||||
- mailbox entry points now show pending-approval indicators: Header mailbox toggle dot, Header overflow mailbox badge, Mobile mailbox tab dot, and Mobile More → Mailbox badge
|
- mailbox entry points now show pending-approval indicators: Header mailbox toggle dot, Header overflow mailbox badge, Mobile mailbox tab dot, and Mobile More → Mailbox badge
|
||||||
- approval lifecycle SSE events (`approval:requested`, `approval:updated`, `approval:decided`) trigger mailbox approvals refresh without manual reload
|
- approval lifecycle SSE events (`approval:requested`, `approval:updated`, `approval:decided`) trigger mailbox approvals refresh without manual reload
|
||||||
|
- when a task newly enters `awaiting-approval`, the app shows a persistent approval banner above project content with an **Open Mailbox** CTA; dismissals are remembered per approval item until that item advances or a different one arrives
|
||||||
- Visible message history/threading is driven by explicit `message.metadata.replyTo.messageId` links
|
- Visible message history/threading is driven by explicit `message.metadata.replyTo.messageId` links
|
||||||
- Separate top-level messages from the same sender remain independent in the inbox and detail pane
|
- Separate top-level messages from the same sender remain independent in the inbox and detail pane
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Fusion intentionally uses multiple realtime mechanisms. Keep their ownership bou
|
|||||||
- Endpoint: `GET /api/events`
|
- Endpoint: `GET /api/events`
|
||||||
- Browser owner: `packages/dashboard/app/sse-bus.ts`
|
- Browser owner: `packages/dashboard/app/sse-bus.ts`
|
||||||
- Main task consumer: `packages/dashboard/app/hooks/useTasks.ts`
|
- Main task consumer: `packages/dashboard/app/hooks/useTasks.ts`
|
||||||
- Additional consumers: `packages/dashboard/app/App.tsx` (mailbox unread updates)
|
- Additional consumers: `packages/dashboard/app/App.tsx` (mailbox unread updates + approval banner trigger handling for new `awaiting-approval` transitions)
|
||||||
|
|
||||||
Contract: **one `EventSource` per URL**, fan-out via `subscribeSse(...)`.
|
Contract: **one `EventSource` per URL**, fan-out via `subscribeSse(...)`.
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { SessionNotificationBanner } from "./components/SessionNotificationBanne
|
|||||||
import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner";
|
import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner";
|
||||||
import { SetupWarningBanner } from "./components/SetupWarningBanner";
|
import { SetupWarningBanner } from "./components/SetupWarningBanner";
|
||||||
import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner";
|
import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner";
|
||||||
|
import { ApprovalNotificationBanner } from "./components/ApprovalNotificationBanner";
|
||||||
import { OnboardingResumeCard } from "./components/OnboardingResumeCard";
|
import { OnboardingResumeCard } from "./components/OnboardingResumeCard";
|
||||||
import { PostOnboardingRecommendations } from "./components/PostOnboardingRecommendations";
|
import { PostOnboardingRecommendations } from "./components/PostOnboardingRecommendations";
|
||||||
import {
|
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 WORKING_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-working-branch-filter";
|
||||||
const BASE_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-base-branch-filter";
|
const BASE_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-base-branch-filter";
|
||||||
const NO_BRANCH_FILTER_VALUE = "__fusion:no-branch__";
|
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 {
|
function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string {
|
||||||
const url = new URL(serverUrl);
|
const url = new URL(serverUrl);
|
||||||
@@ -385,6 +433,10 @@ function AppInner() {
|
|||||||
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
|
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
|
||||||
const [mailboxPendingApprovalCount, setMailboxPendingApprovalCount] = useState(0);
|
const [mailboxPendingApprovalCount, setMailboxPendingApprovalCount] = useState(0);
|
||||||
const [chatHasUnreadResponse, setChatHasUnreadResponse] = useState(false);
|
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(() => {
|
const refreshMailboxUnreadCount = useCallback(() => {
|
||||||
fetchUnreadCount(currentProject?.id)
|
fetchUnreadCount(currentProject?.id)
|
||||||
@@ -397,6 +449,19 @@ function AppInner() {
|
|||||||
});
|
});
|
||||||
}, [currentProject?.id]);
|
}, [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.
|
// Initial fetch + live updates from mailbox SSE events.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshMailboxUnreadCount();
|
refreshMailboxUnreadCount();
|
||||||
@@ -407,15 +472,70 @@ function AppInner() {
|
|||||||
}
|
}
|
||||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
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}`, {
|
return subscribeSse(`/api/events${query}`, {
|
||||||
|
onReconnect: refreshMailboxUnreadCount,
|
||||||
events: {
|
events: {
|
||||||
"message:sent": refreshMailboxUnreadCount,
|
"message:sent": refreshMailboxUnreadCount,
|
||||||
"message:received": refreshMailboxUnreadCount,
|
"message:received": refreshMailboxUnreadCount,
|
||||||
"message:read": refreshMailboxUnreadCount,
|
"message:read": refreshMailboxUnreadCount,
|
||||||
"message:deleted": 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:updated": refreshMailboxUnreadCount,
|
||||||
"approval:decided": 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]);
|
}, [currentProject?.id, refreshMailboxUnreadCount]);
|
||||||
@@ -1443,6 +1563,20 @@ function AppInner() {
|
|||||||
onDismiss={handleDismissSetupWarning}
|
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
|
<div
|
||||||
className={`project-content${viewMode === "project" && currentProject ? " project-content--with-footer" : ""}${isMobile && !mobileKeyboardOpen ? " project-content--with-mobile-nav" : ""}`}
|
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",
|
getViewportMode: () => "desktop",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { App } from "../../App";
|
import { App, didEnterAwaitingApproval } from "../../App";
|
||||||
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth";
|
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth";
|
||||||
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews } from "../../api";
|
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews } from "../../api";
|
||||||
import { __resetShellHostContextForTests } from "../../shell-host";
|
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", () => {
|
describe("App mailbox unread count", () => {
|
||||||
it("logs a warning when unread count fetch fails and keeps the zero-count fallback", async () => {
|
it("logs a warning when unread count fetch fails and keeps the zero-count fallback", async () => {
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
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", () => {
|
describe("App chat unread response indicator", () => {
|
||||||
it("shows unread indicator when assistant message arrives for active session after leaving chat", async () => {
|
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");
|
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