/** * ErrorReportNotification * * Non-blocking notification UI rendered via createPortal into its own React root. * Subscribes to the error report queue and lets users inspect, dismiss, or * report each error individually. */ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; import { isAnalyticsEnabled } from '../services/analytics'; import { dequeueError, getErrors, type PendingErrorReport, sendToSentry, subscribe, } from '../services/errorReportQueue'; const MAX_VISIBLE = 3; const AUTO_DISMISS_MS = 30_000; // --------------------------------------------------------------------------- // Single notification card // --------------------------------------------------------------------------- function NotificationCard({ report, onDismiss, }: { report: PendingErrorReport; onDismiss: (id: string) => void; }) { const [expanded, setExpanded] = useState(false); const [sent, setSent] = useState(false); const [exiting, setExiting] = useState(false); const timerRef = useRef>(undefined); const analyticsEnabled = isAnalyticsEnabled(); const isDevOnly = !report.sentryEvent; const animateOut = useCallback( (id: string) => { setExiting(true); setTimeout(() => onDismiss(id), 200); }, [onDismiss] ); // Auto-dismiss timer useEffect(() => { timerRef.current = setTimeout(() => { animateOut(report.id); }, AUTO_DISMISS_MS); return () => clearTimeout(timerRef.current); }, [report.id, animateOut]); const handleDismiss = useCallback(() => { clearTimeout(timerRef.current); animateOut(report.id); }, [report.id, animateOut]); const handleReport = useCallback(() => { clearTimeout(timerRef.current); const ok = sendToSentry(report); if (ok) { setSent(true); setTimeout(() => onDismiss(report.id), 1200); } }, [report, onDismiss]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Escape') handleDismiss(); if (e.key === 'Enter') setExpanded(prev => !prev); }, [handleDismiss] ); return (
{/* Header */}
{/* Error icon */}
{report.title} {isDevOnly && ( DEV )} {report.source === 'skill' && ( SKILL )}

{report.message}

{/* Close button */}
{/* Expand toggle */} {report.sentryEvent && ( )} {/* Expanded payload viewer */} {expanded && report.sentryEvent && (
            {JSON.stringify(report.sentryEvent, null, 2)}
          
)} {/* Actions */}
{sent ? ( Sent ) : isDevOnly ? ( Console only ) : !analyticsEnabled ? ( ) : ( )}
); } // --------------------------------------------------------------------------- // Main notification container // --------------------------------------------------------------------------- export default function ErrorReportNotification() { const errors = useSyncExternalStore(subscribe, getErrors, getErrors); const handleDismiss = useCallback((id: string) => { dequeueError(id); }, []); // Escape key dismisses topmost notification useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && errors.length > 0) { dequeueError(errors[errors.length - 1].id); } }; window.addEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey); }, [errors]); if (errors.length === 0) return null; const visible = errors.slice(-MAX_VISIBLE); const hiddenCount = errors.length - MAX_VISIBLE; return createPortal(
{visible.map(report => ( ))} {hiddenCount > 0 && (
+{hiddenCount} more {hiddenCount === 1 ? 'error' : 'errors'}
)}
, document.body ); }