import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CornersOut, Heartbeat, Minus, Plus } from "../ui/icons"; import type { AgentRecord, ConnectorRecord } from "@shared/types"; import { formatConnectorLabel, getAgentInitials } from "../../lib/formatters"; import { EmptyState } from "../ui"; import { useT } from "../../i18n"; import { buildOrgTree, layoutForest, flattenLayout, collectEdges, statusDotColor, CARD_W, CARD_H, CHART_PADDING } from "./orgTreeLayout"; const LEGEND_ORDER: Array<{ status: string; labelKey: string }> = [ { status: "running", labelKey: "agent.form.statusRunning" }, { status: "active", labelKey: "agent.form.statusActive" }, { status: "idle", labelKey: "agent.form.statusIdle" }, { status: "paused", labelKey: "agent.form.statusPaused" }, { status: "pending_approval", labelKey: "agent.pendingApproval" }, { status: "error", labelKey: "agent.form.statusError" }, { status: "terminated", labelKey: "agent.form.statusTerminated" }, ]; export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentRecord[]; connectors?: ConnectorRecord[]; onSelectAgent?: (agentId: string) => void }) { const t = useT(); const containerRef = useRef(null); const [pan, setPan] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); const [dragging, setDragging] = useState(false); const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 }); const orgTree = useMemo(() => buildOrgTree(agents), [agents]); const layout = useMemo(() => layoutForest(orgTree), [orgTree]); const allNodes = useMemo(() => flattenLayout(layout), [layout]); const edges = useMemo(() => collectEdges(layout), [layout]); const legendEntries = useMemo(() => { const present = new Set(); for (const a of agents) present.add(a.status); return LEGEND_ORDER.filter((entry) => present.has(entry.status)); }, [agents]); const bounds = useMemo(() => { if (allNodes.length === 0) return { width: 800, height: 600 }; let maxX = 0, maxY = 0; for (const n of allNodes) { maxX = Math.max(maxX, n.x + CARD_W); maxY = Math.max(maxY, n.y + CARD_H); } return { width: maxX + CHART_PADDING, height: maxY + CHART_PADDING }; }, [allNodes]); const hasInitialized = useRef(false); useEffect(() => { if (hasInitialized.current || allNodes.length === 0 || !containerRef.current) return; hasInitialized.current = true; const cW = containerRef.current.clientWidth; const cH = containerRef.current.clientHeight; const fitZoom = Math.min((cW - 40) / bounds.width, (cH - 40) / bounds.height, 1); setZoom(fitZoom); setPan({ x: (cW - bounds.width * fitZoom) / 2, y: (cH - bounds.height * fitZoom) / 2 }); }, [allNodes, bounds]); const fitToScreen = useCallback(() => { if (!containerRef.current) return; const cW = containerRef.current.clientWidth; const cH = containerRef.current.clientHeight; const fitZoom = Math.min((cW - 40) / bounds.width, (cH - 40) / bounds.height, 1); setZoom(fitZoom); setPan({ x: (cW - bounds.width * fitZoom) / 2, y: (cH - bounds.height * fitZoom) / 2 }); }, [bounds]); const handleMouseDown = useCallback((e: React.MouseEvent) => { if (e.button !== 0 || (e.target as HTMLElement).closest("[data-org-card]")) return; setDragging(true); dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y }; }, [pan]); const handleMouseMove = useCallback((e: React.MouseEvent) => { if (!dragging) return; setPan({ x: dragStart.current.panX + (e.clientX - dragStart.current.x), y: dragStart.current.panY + (e.clientY - dragStart.current.y) }); }, [dragging]); const handleMouseUp = useCallback(() => setDragging(false), []); const handleWheel = useCallback((e: React.WheelEvent) => { e.preventDefault(); const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const mx = e.clientX - rect.left, my = e.clientY - rect.top; const nz = Math.min(Math.max(zoom * (e.deltaY < 0 ? 1.1 : 0.9), 0.2), 2); const s = nz / zoom; setPan({ x: mx - s * (mx - pan.x), y: my - s * (my - pan.y) }); setZoom(nz); }, [zoom, pan]); if (agents.length === 0) return ; const zoomIn = () => { const nz = Math.min(zoom * 1.2, 2); const c = containerRef.current; if (c) { const cx = c.clientWidth / 2; const cy = c.clientHeight / 2; const s = nz / zoom; setPan({ x: cx - s * (cx - pan.x), y: cy - s * (cy - pan.y) }); } setZoom(nz); }; const zoomOut = () => { const nz = Math.max(zoom * 0.8, 0.2); const c = containerRef.current; if (c) { const cx = c.clientWidth / 2; const cy = c.clientHeight / 2; const s = nz / zoom; setPan({ x: cx - s * (cx - pan.x), y: cy - s * (cy - pan.y) }); } setZoom(nz); }; return (
{[ { icon: Plus, ariaLabel: t("org.zoomIn"), action: zoomIn }, { icon: Minus, ariaLabel: t("org.zoomOut"), action: zoomOut }, { icon: CornersOut, ariaLabel: t("org.fitChart"), action: fitToScreen }, ].map(({ icon: Icon, ariaLabel, action }) => ( ))}
{legendEntries.length > 0 ? (
{t("org.statusLegend")}
{legendEntries.map((entry) => (
))}
) : null} {edges.map(({ parent, child }) => { const x1 = parent.x + CARD_W / 2, y1 = parent.y + CARD_H, x2 = child.x + CARD_W / 2, y2 = child.y; const midY = (y1 + y2) / 2; const isChildRunning = child.agent.status === "running"; return ; })}
{allNodes.map((node) => { const dotColor = statusDotColor[node.agent.status] ?? "var(--muted-foreground)"; const connectorRecord = connectors?.find((c) => c.id === node.agent.connectorId); const connectorLabel = formatConnectorLabel(connectorRecord, node.agent.connectorId); const initials = getAgentInitials(node.agent.name); const isRunning = node.agent.status === "running"; const isActive = node.agent.status === "active" || isRunning; const isError = node.agent.status === "error"; const isPending = node.agent.status === "pending_approval"; // Binary hierarchy: the root (no reportsTo) gets a bold primary bar; // everyone else gets a hairline neutral bar. Managerial rank is // already communicated by tree position — duplicating it via a // color (previously "warning" orange, which read as an alert // rather than a rank) was misleading. const isRoot = !node.agent.reportsTo; return (
onSelectAgent?.(node.agent.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelectAgent?.(node.agent.id); } }}>
{initials}
{isRunning ? (
{node.agent.name} {node.agent.title || node.agent.role}
{connectorLabel} {node.agent.heartbeatEnabled ? : null}
); })}
); }