/ src / components / org-chart / org-chart-edge-popover.tsx
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  }