diff --git a/frontend/src/components/ApprovalBell.tsx b/frontend/src/components/ApprovalBell.tsx new file mode 100644 index 00000000..95ff435f --- /dev/null +++ b/frontend/src/components/ApprovalBell.tsx @@ -0,0 +1,264 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Bell, CheckCircle, ChevronDown, ChevronUp, Clock, XCircle } from 'lucide-react'; +import { approveAction, denyAction, fetchPendingApprovals } from '../lib/api'; +import type { PendingApproval } from '../lib/api'; + +const TIER_STYLES: Record = { + trivial: { label: 'Trivial', color: 'var(--color-text-secondary)', bg: 'color-mix(in srgb, var(--color-text-secondary) 10%, transparent)' }, + low: { label: 'Low', color: '#3b82f6', bg: 'rgba(59,130,246,0.12)' }, + medium: { label: 'Medium', color: 'var(--color-warning)', bg: 'color-mix(in srgb, var(--color-warning) 12%, transparent)' }, + high: { label: 'High', color: 'var(--color-error)', bg: 'color-mix(in srgb, var(--color-error) 12%, transparent)' }, +}; + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const m = Math.floor(diff / 60000); + if (m < 1) return 'just now'; + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +export function ApprovalBell() { + const [approvals, setApprovals] = useState([]); + const [open, setOpen] = useState(false); + const [expanded, setExpanded] = useState>({}); + const [processing, setProcessing] = useState>({}); + const containerRef = useRef(null); + + const load = useCallback(async () => { + try { + setApprovals(await fetchPendingApprovals()); + } catch { + // backend may not be running yet + } + }, []); + + useEffect(() => { + load(); + const id = setInterval(load, 10000); + return () => clearInterval(id); + }, [load]); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + const handleApprove = async (id: string) => { + setProcessing(p => ({ ...p, [id]: true })); + try { + await approveAction(id); + setApprovals(prev => prev.filter(a => a.id !== id)); + } finally { + setProcessing(p => ({ ...p, [id]: false })); + } + }; + + const handleDeny = async (id: string) => { + setProcessing(p => ({ ...p, [id]: true })); + try { + await denyAction(id); + setApprovals(prev => prev.filter(a => a.id !== id)); + } finally { + setProcessing(p => ({ ...p, [id]: false })); + } + }; + + const count = approvals.length; + + return ( +
+ {/* Bell trigger */} + + + {/* Dropdown */} + {open && ( +
+ {/* Header */} +
+
+ + + Agent Approvals + +
+ {count > 0 && ( + + {count} pending + + )} +
+ + {/* Body */} +
+ {count === 0 ? ( +
+ + + No pending approvals + +
+ ) : ( + approvals.map((action, idx) => { + const tier = TIER_STYLES[action.tier] ?? TIER_STYLES.medium; + const isExpanded = !!expanded[action.id]; + const isLoading = !!processing[action.id]; + const hasPayload = Object.keys(action.payload ?? {}).length > 0; + + return ( +
+ {/* Row 1: action type + tier + time */} +
+ + {action.action_type} + +
+ + {tier.label} + + + + {timeAgo(action.created_at)} + +
+
+ + {/* Description */} +

+ {action.description} +

+ + {/* Expandable payload */} + {hasPayload && ( + + )} + + {isExpanded && ( +
+                        {JSON.stringify(action.payload, null, 2)}
+                      
+ )} + + {/* Approve / Deny */} +
+ + +
+
+ ); + }) + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index eaddd1ca..073af286 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { Outlet, useNavigate } from 'react-router'; +import { ApprovalBell } from './ApprovalBell'; import { Sidebar } from './Sidebar/Sidebar'; import { SystemPulse } from './SystemPulse'; import { useAppStore } from '../lib/store'; @@ -27,6 +28,7 @@ export function Layout() {