org-chart-edge-popover.tsx
1 'use client' 2 3 import { useCallback, useEffect, useState } from 'react' 4 import { api } from '@/lib/app/api-client' 5 import type { Agent, DelegationJobRecord } from '@/types' 6 7 interface Props { 8 parentAgent: Agent 9 childAgent: Agent 10 x: number 11 y: number 12 onClose: () => void 13 } 14 15 const STATUS_BADGE: Record<string, { label: string; cls: string }> = { 16 queued: { label: 'Queued', cls: 'text-text-3 bg-white/[0.06]' }, 17 running: { label: 'Running', cls: 'text-amber-400 bg-amber-400/10' }, 18 completed: { label: 'Completed', cls: 'text-emerald-400 bg-emerald-400/10' }, 19 failed: { label: 'Failed', cls: 'text-red-400 bg-red-400/10' }, 20 cancelled: { label: 'Cancelled', cls: 'text-text-3 bg-white/[0.06]' }, 21 } 22 23 function timeAgo(ts: number): string { 24 const s = Math.floor((Date.now() - ts) / 1000) 25 if (s < 60) return `${s}s ago` 26 const m = Math.floor(s / 60) 27 if (m < 60) return `${m}m ago` 28 const h = Math.floor(m / 60) 29 return `${h}h ago` 30 } 31 32 export function OrgChartEdgePopover({ parentAgent, childAgent, x, y, onClose }: Props) { 33 const [jobs, setJobs] = useState<DelegationJobRecord[]>([]) 34 const [loading, setLoading] = useState(true) 35 36 const refresh = useCallback(async () => { 37 try { 38 const all = await api<DelegationJobRecord[]>('GET', '/delegation-jobs') 39 // Filter to jobs for this specific parent→child edge 40 const filtered = all.filter((j) => j.agentId === childAgent.id) 41 setJobs(filtered) 42 } catch { 43 // ignore 44 } finally { 45 setLoading(false) 46 } 47 }, [childAgent.id]) 48 49 useEffect(() => { refresh() }, [refresh]) 50 51 // Close on escape 52 useEffect(() => { 53 const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } 54 document.addEventListener('keydown', handler) 55 return () => document.removeEventListener('keydown', handler) 56 }, [onClose]) 57 58 // Close on click outside 59 useEffect(() => { 60 const handler = (e: MouseEvent) => { 61 const target = e.target as HTMLElement 62 if (!target.closest('[data-edge-popover]')) onClose() 63 } 64 // Delay to avoid closing immediately from the click that opened it 65 const timer = setTimeout(() => document.addEventListener('click', handler), 50) 66 return () => { clearTimeout(timer); document.removeEventListener('click', handler) } 67 }, [onClose]) 68 69 return ( 70 <div 71 data-edge-popover 72 className="absolute z-50 rounded-[12px] border border-white/[0.08] bg-[#12121e] shadow-2xl shadow-black/60 overflow-hidden" 73 style={{ left: x, top: y, width: 320, maxHeight: 360, transform: 'translate(-50%, -50%)' }} 74 onPointerDown={(e) => e.stopPropagation()} 75 onClick={(e) => e.stopPropagation()} 76 onWheel={(e) => e.stopPropagation()} 77 > 78 {/* Header */} 79 <div className="flex items-center gap-2 px-3 py-2 border-b border-white/[0.06] bg-white/[0.02]"> 80 <span className="text-[11px] font-600 text-text truncate">{parentAgent.name}</span> 81 <svg width="12" height="8" viewBox="0 0 12 8" fill="none" className="text-text-3/50 shrink-0"> 82 <path d="M0 4h9M7 1l3 3-3 3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" /> 83 </svg> 84 <span className="text-[11px] font-600 text-text truncate">{childAgent.name}</span> 85 <div className="flex-1" /> 86 <button 87 onClick={onClose} 88 className="w-5 h-5 rounded-[4px] flex items-center justify-center text-text-3 hover:text-text hover:bg-white/[0.08] cursor-pointer border-none transition-colors" 89 > 90 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> 91 <path d="M1 1l8 8M9 1l-8 8" /> 92 </svg> 93 </button> 94 </div> 95 96 {/* Content */} 97 <div className="overflow-y-auto px-3 py-2 space-y-2" style={{ maxHeight: 310 }}> 98 {loading && ( 99 <div className="text-[11px] text-text-3/50 text-center py-6">Loading...</div> 100 )} 101 {!loading && jobs.length === 0 && ( 102 <div className="text-[11px] text-text-3/40 text-center py-6"> 103 No recent delegation activity between these agents 104 </div> 105 )} 106 {jobs.map((job) => { 107 const badge = STATUS_BADGE[job.status] || STATUS_BADGE.queued 108 return ( 109 <div key={job.id} className="rounded-[8px] border border-white/[0.06] bg-white/[0.02] p-2.5"> 110 {/* Status + time */} 111 <div className="flex items-center gap-1.5 mb-1"> 112 <span className={`text-[8px] font-600 uppercase tracking-wider px-1.5 py-0.5 rounded-[3px] leading-none ${badge.cls}`}> 113 {badge.label} 114 </span> 115 <span className="text-[9px] text-text-3/40 ml-auto">{timeAgo(job.updatedAt || job.createdAt)}</span> 116 </div> 117 118 {/* Task */} 119 <div className="text-[11px] text-text-2 leading-snug mb-1"> 120 <span className="text-text-3/50 font-500">Task: </span> 121 {job.task.length > 120 ? job.task.slice(0, 120) + '...' : job.task} 122 </div> 123 124 {/* Result preview */} 125 {job.resultPreview && ( 126 <div className="text-[10px] text-emerald-400/70 leading-snug mt-1"> 127 <span className="text-text-3/50 font-500">Result: </span> 128 {job.resultPreview.length > 120 ? job.resultPreview.slice(0, 120) + '...' : job.resultPreview} 129 </div> 130 )} 131 132 {/* Error */} 133 {job.error && ( 134 <div className="text-[10px] text-red-400/70 leading-snug mt-1"> 135 <span className="text-text-3/50 font-500">Error: </span> 136 {job.error.length > 120 ? job.error.slice(0, 120) + '...' : job.error} 137 </div> 138 )} 139 </div> 140 ) 141 })} 142 </div> 143 </div> 144 ) 145 }