// ======================================== // NotificationPanel Component // ======================================== // Slide-over drawer notification panel with persistent notifications import { useState, useCallback, useEffect } from 'react'; import { useIntl } from 'react-intl'; import { Bell, X, Check, Trash2, ChevronDown, ChevronUp, Info, CheckCircle, AlertTriangle, XCircle, File, Download, Loader2, RotateCcw, Code, Database, Mail, MailOpen, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/Button'; import { Badge } from '@/components/ui/Badge'; import { A2UIRenderer } from '@/packages/a2ui-runtime/renderer'; import { useNotificationStore, selectPersistentNotifications } from '@/stores'; import type { Toast, NotificationAttachment, NotificationAction, ActionStateType, NotificationSource } from '@/types/store'; // ========== Helper Functions ========== function formatTimeAgo(timestamp: string, formatMessage: (message: { id: string; values?: Record }) => string): string { const now = Date.now(); const time = new Date(timestamp).getTime(); const diffMs = now - time; const seconds = Math.floor(diffMs / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (seconds < 60) return formatMessage({ id: 'notifications.justNow' }); if (minutes < 60) { return formatMessage({ id: minutes === 1 ? 'notifications.oneMinuteAgo' : 'notifications.minutesAgo', values: { 0: String(minutes) } }); } if (hours < 24) { return formatMessage({ id: hours === 1 ? 'notifications.oneHourAgo' : 'notifications.hoursAgo', values: { 0: String(hours) } }); } if (days < 7) { return formatMessage({ id: days === 1 ? 'notifications.oneDayAgo' : 'notifications.daysAgo', values: { 0: String(days) } }); } return new Date(timestamp).toLocaleDateString(); } // ========== Main Types ========== function getNotificationIcon(type: Toast['type']) { const iconClassName = 'h-4 w-4 shrink-0'; switch (type) { case 'success': return ; case 'warning': return ; case 'error': return ; case 'info': default: return ; } } function getSourceColor(source: NotificationSource): string { switch (source) { case 'system': return 'bg-blue-500/10 text-blue-600 border-blue-200 dark:border-blue-800'; case 'websocket': return 'bg-purple-500/10 text-purple-600 border-purple-200 dark:border-purple-800'; case 'cli': return 'bg-green-500/10 text-green-600 border-green-200 dark:border-green-800'; case 'workflow': return 'bg-orange-500/10 text-orange-600 border-orange-200 dark:border-orange-800'; case 'user': return 'bg-cyan-500/10 text-cyan-600 border-cyan-200 dark:border-cyan-800'; case 'external': return 'bg-pink-500/10 text-pink-600 border-pink-200 dark:border-pink-800'; default: return 'bg-gray-500/10 text-gray-600 border-gray-200 dark:border-gray-800'; } } function getTypeBorder(type: Toast['type']): string { switch (type) { case 'success': return 'border-l-green-500'; case 'warning': return 'border-l-yellow-500'; case 'error': return 'border-l-red-500'; case 'info': default: return 'border-l-blue-500'; } } // ========== Sub-Components ========== interface PanelHeaderProps { notificationCount: number; hasNotifications: boolean; hasUnread: boolean; onClose: () => void; onMarkAllRead: () => void; onClearAll: () => void; } function PanelHeader({ notificationCount, hasNotifications, hasUnread, onClose, onMarkAllRead, onClearAll, }: PanelHeaderProps) { const { formatMessage } = useIntl(); return (

{formatMessage({ id: 'notifications.title' }) || 'Notifications'}

{notificationCount > 0 && ( {notificationCount} )}
{/* Mark All Read button */} {hasNotifications && ( )} {/* Clear All button */} {hasNotifications && ( )} {/* Close button */}
); } // ========== Helper Components for Attachments and Actions ========== interface NotificationAttachmentItemProps { attachment: NotificationAttachment; } function NotificationAttachmentItem({ attachment }: NotificationAttachmentItemProps) { const { formatMessage } = useIntl(); // Format file size function formatFileSize(bytes?: number): string { if (!bytes) return ''; if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } // Render different attachment types switch (attachment.type) { case 'image': return (
{attachment.url ? ( {attachment.filename ) : attachment.content ? ( {attachment.filename ) : null} {attachment.filename && (
{attachment.filename}
)}
); case 'code': return (
{attachment.filename || formatMessage({ id: 'notifications.attachments.code' }) || 'Code'}
{attachment.mimeType && ( {attachment.mimeType.replace('text/', '').replace('application/', '')} )}
{attachment.content && (
              {attachment.content}
            
)}
); case 'file': return (
{attachment.filename || formatMessage({ id: 'notifications.attachments.file' }) || 'File'}
{attachment.size && (
{formatFileSize(attachment.size)}
)}
{attachment.url && ( )}
); case 'data': return (
{formatMessage({ id: 'notifications.attachments.data' }) || 'Data'}
{attachment.content && (
              
                {JSON.stringify(JSON.parse(attachment.content), null, 2)}
              
            
)}
); default: return null; } } interface NotificationActionsProps { actions: NotificationAction[]; } function NotificationActions({ actions }: NotificationActionsProps) { const { formatMessage } = useIntl(); const [actionStates, setActionStates] = useState>({}); const [retryCounts, setRetryCounts] = useState>({}); const handleActionClick = useCallback( async (action: NotificationAction, index: number) => { const actionKey = `${index}-${action.label}`; // Skip if already loading if (actionStates[actionKey] === 'loading') { return; } // Handle confirmation if present if (action.confirm) { const confirmed = window.confirm( action.confirm.message || action.label ); if (!confirmed) { return; } } // Set loading state setActionStates((prev) => ({ ...prev, [actionKey]: 'loading' })); try { // Call the action handler await action.onClick(); // Set success state setActionStates((prev) => ({ ...prev, [actionKey]: 'success' })); // Reset after 2 seconds setTimeout(() => { setActionStates((prev) => ({ ...prev, [actionKey]: 'idle' })); }, 2000); } catch (error) { // Set error state setActionStates((prev) => ({ ...prev, [actionKey]: 'error' })); // Increment retry count setRetryCounts((prev) => ({ ...prev, [actionKey]: (prev[actionKey] || 0) + 1, })); // Log error console.error('[NotificationActions] Action failed:', error); } }, [actionStates] ); const getActionButtonContent = (action: NotificationAction, index: number) => { const actionKey = `${index}-${action.label}`; const state = actionStates[actionKey]; const retryCount = retryCounts[actionKey] || 0; switch (state) { case 'loading': return ( <> {formatMessage({ id: 'notifications.actions.loading' }) || 'Loading...'} ); case 'success': return ( <> {formatMessage({ id: 'notifications.actions.success' }) || 'Done'} ); case 'error': return ( <> {formatMessage({ id: 'notifications.actions.retry' }) || 'Retry'} {retryCount > 0 && ( ({retryCount}) )} ); default: return action.label; } }; if (actions.length === 0) return null; return (
{actions.map((action, index) => { const actionKey = `${index}-${action.label}`; const state = actionStates[actionKey]; return ( ); })}
); } interface NotificationItemProps { notification: Toast; onDelete: (id: string) => void; onToggleRead?: (id: string) => void; } function NotificationItem({ notification, onDelete, onToggleRead }: NotificationItemProps) { const [isExpanded, setIsExpanded] = useState(false); const hasDetails = notification.message && notification.message.length > 100; const { formatMessage } = useIntl(); const isRead = notification.read ?? false; const hasActions = notification.actions && notification.actions.length > 0; const hasLegacyAction = notification.action && !hasActions; const hasAttachments = notification.attachments && notification.attachments.length > 0; const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction); // Check if this is an A2UI notification const isA2UI = notification.type === 'a2ui' && notification.a2uiSurface; // Format absolute timestamp const absoluteTime = new Date(notification.timestamp).toLocaleString(); return (
{/* Icon */}
{getNotificationIcon(notification.type)}
{/* Content */}
{/* Header row: title + actions */}
{/* Title with source badge */}

{notification.title}

{/* Source badge */} {notification.source && ( {notification.source} )} {/* Read/Unread status badge */} {!isRead && ( {formatMessage({ id: 'notifications.unread' }) || '未读'} )} {isRead && ( {formatMessage({ id: 'notifications.read' }) || '已读'} )}
{/* Timestamp row: absolute + relative */}
{absoluteTime} ({formatTimeAgo(notification.timestamp, formatMessage)})
{/* Action buttons */}
{/* Read/unread toggle */} {onToggleRead && ( )} {/* Delete button */}
{/* A2UI Surface Content */} {isA2UI && notification.a2uiSurface ? (
{ // Send A2UI action back to backend via WebSocket sendA2UIAction(actionId, notification.a2uiSurface!.surfaceId, params); // ask_question surfaces should disappear after the user answers const maybeQuestionId = (notification.a2uiSurface?.initialState as Record | undefined) ?.questionId; const isAskQuestionSurface = typeof maybeQuestionId === 'string'; const resolvesQuestion = actionId === 'confirm' || actionId === 'cancel' || actionId === 'submit' || actionId === 'answer'; if (isAskQuestionSurface && resolvesQuestion) { onDelete(notification.id); } }} />
) : ( <> {/* Regular message content */} {notification.message && (

{isExpanded || !hasDetails ? notification.message : notification.message.slice(0, 100) + '...'}

)} {/* Expand toggle */} {hasDetails && ( )} {/* Attachments */} {hasAttachments && notification.attachments && (
{notification.attachments.map((attachment, index) => ( ))}
)} {/* Action buttons (new actions array) */} {hasActions && notification.actions && ( )} {/* Legacy single action button */} {hasLegacyAction && notification.action && ( )} )}
); } interface NotificationListProps { notifications: Toast[]; onDelete: (id: string) => void; onToggleRead?: (id: string) => void; } function NotificationList({ notifications, onDelete, onToggleRead }: NotificationListProps) { if (notifications.length === 0) return null; return (
{notifications.map((notification) => ( ))}
); } interface EmptyStateProps { message?: string; } function EmptyState({ message }: EmptyStateProps) { const { formatMessage } = useIntl(); return (

{message || formatMessage({ id: 'notifications.empty' }) || 'No notifications'}

{formatMessage({ id: 'notifications.emptyHint' }) || 'Notifications will appear here'}

); } // ========== Main Component ========== export interface NotificationPanelProps { isOpen: boolean; onClose: () => void; } export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) { // Store state const persistentNotifications = useNotificationStore(selectPersistentNotifications); const removePersistentNotification = useNotificationStore( (state) => state.removePersistentNotification ); const clearPersistentNotifications = useNotificationStore( (state) => state.clearPersistentNotifications ); const toggleNotificationRead = useNotificationStore( (state) => state.toggleNotificationRead ); // Check if markAllAsRead exists const store = useNotificationStore.getState(); const markAllAsRead = 'markAllAsRead' in store ? (store.markAllAsRead as () => void) : undefined; // Reverse chronological order (newest first) const sortedNotifications = [...persistentNotifications].sort( (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() ); // Delete handler const handleDelete = useCallback( (id: string) => { // Find the notification being deleted const notification = persistentNotifications.find((n) => n.id === id); // If it's an A2UI notification, also remove from a2uiSurfaces Map if (notification?.type === 'a2ui' && notification.a2uiSurface) { const store = useNotificationStore.getState(); const newSurfaces = new Map(store.a2uiSurfaces); newSurfaces.delete(notification.a2uiSurface.surfaceId); // Update the store's a2uiSurfaces directly useNotificationStore.setState({ a2uiSurfaces: newSurfaces }); } removePersistentNotification(id); }, [removePersistentNotification, persistentNotifications] ); // Mark all read handler const handleMarkAllRead = useCallback(() => { if (markAllAsRead) { markAllAsRead(); } else { // Placeholder for T5 console.log('[NotificationPanel] markAllAsRead will be implemented in T5'); } }, [markAllAsRead]); // Clear all handler const handleClearAll = useCallback(() => { clearPersistentNotifications(); }, [clearPersistentNotifications]); // Toggle read handler const handleToggleRead = useCallback( (id: string) => { toggleNotificationRead(id); }, [toggleNotificationRead] ); // ESC key to close useEffect(() => { const handleEsc = (e: KeyboardEvent) => { if (e.key === 'Escape' && isOpen) { onClose(); } }; window.addEventListener('keydown', handleEsc); return () => window.removeEventListener('keydown', handleEsc); }, [isOpen, onClose]); // Check for unread notifications based on read field const hasUnread = sortedNotifications.some((n) => !n.read); if (!isOpen) { return null; } return ( <> {/* Overlay */}