import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import { useNavigate } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { agentsApi, type OrgNode } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { agentUrl } from "../lib/utils"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { AgentIcon } from "../components/AgentIconPicker"; import { Network } from "lucide-react"; import type { Agent } from "@paperclipai/shared"; // Layout constants const CARD_W = 200; const CARD_H = 100; const GAP_X = 32; const GAP_Y = 80; const PADDING = 60; // ── Tree layout types ─────────────────────────────────────────────────── interface LayoutNode { id: string; name: string; role: string; status: string; x: number; y: number; children: LayoutNode[]; } // ── Layout algorithm ──────────────────────────────────────────────────── /** Compute the width each subtree needs. */ function subtreeWidth(node: OrgNode): number { if (node.reports.length === 0) return CARD_W; const childrenW = node.reports.reduce((sum, c) => sum + subtreeWidth(c), 0); const gaps = (node.reports.length - 1) * GAP_X; return Math.max(CARD_W, childrenW + gaps); } /** Recursively assign x,y positions. */ function layoutTree(node: OrgNode, x: number, y: number): LayoutNode { const totalW = subtreeWidth(node); const layoutChildren: LayoutNode[] = []; if (node.reports.length > 0) { const childrenW = node.reports.reduce((sum, c) => sum + subtreeWidth(c), 0); const gaps = (node.reports.length - 1) * GAP_X; let cx = x + (totalW - childrenW - gaps) / 2; for (const child of node.reports) { const cw = subtreeWidth(child); layoutChildren.push(layoutTree(child, cx, y + CARD_H + GAP_Y)); cx += cw + GAP_X; } } return { id: node.id, name: node.name, role: node.role, status: node.status, x: x + (totalW - CARD_W) / 2, y, children: layoutChildren, }; } /** Layout all root nodes side by side. */ function layoutForest(roots: OrgNode[]): LayoutNode[] { if (roots.length === 0) return []; const totalW = roots.reduce((sum, r) => sum + subtreeWidth(r), 0); const gaps = (roots.length - 1) * GAP_X; let x = PADDING; const y = PADDING; const result: LayoutNode[] = []; for (const root of roots) { const w = subtreeWidth(root); result.push(layoutTree(root, x, y)); x += w + GAP_X; } // Compute bounds and return return result; } /** Flatten layout tree to list of nodes. */ function flattenLayout(nodes: LayoutNode[]): LayoutNode[] { const result: LayoutNode[] = []; function walk(n: LayoutNode) { result.push(n); n.children.forEach(walk); } nodes.forEach(walk); return result; } /** Collect all parent→child edges. */ function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: LayoutNode }> { const edges: Array<{ parent: LayoutNode; child: LayoutNode }> = []; function walk(n: LayoutNode) { for (const c of n.children) { edges.push({ parent: n, child: c }); walk(c); } } nodes.forEach(walk); return edges; } // ── Status dot colors (raw hex for SVG) ───────────────────────────────── const adapterLabels: Record = { claude_local: "Claude", codex_local: "Codex", opencode_local: "OpenCode", cursor: "Cursor", openclaw: "OpenClaw", process: "Process", http: "HTTP", }; const statusDotColor: Record = { running: "#22d3ee", active: "#4ade80", paused: "#facc15", idle: "#facc15", error: "#f87171", terminated: "#a3a3a3", }; const defaultDotColor = "#a3a3a3"; // ── Main component ────────────────────────────────────────────────────── export function OrgChart() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); const { data: orgTree, isLoading } = useQuery({ queryKey: queryKeys.org(selectedCompanyId!), queryFn: () => agentsApi.org(selectedCompanyId!), enabled: !!selectedCompanyId, }); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const agentMap = useMemo(() => { const m = new Map(); for (const a of agents ?? []) m.set(a.id, a); return m; }, [agents]); useEffect(() => { setBreadcrumbs([{ label: "Org Chart" }]); }, [setBreadcrumbs]); // Layout computation const layout = useMemo(() => layoutForest(orgTree ?? []), [orgTree]); const allNodes = useMemo(() => flattenLayout(layout), [layout]); const edges = useMemo(() => collectEdges(layout), [layout]); // Compute SVG bounds 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 + PADDING, height: maxY + PADDING }; }, [allNodes]); // Pan & zoom state 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 }); // Center the chart on first load const hasInitialized = useRef(false); useEffect(() => { if (hasInitialized.current || allNodes.length === 0 || !containerRef.current) return; hasInitialized.current = true; const container = containerRef.current; const containerW = container.clientWidth; const containerH = container.clientHeight; // Fit chart to container const scaleX = (containerW - 40) / bounds.width; const scaleY = (containerH - 40) / bounds.height; const fitZoom = Math.min(scaleX, scaleY, 1); const chartW = bounds.width * fitZoom; const chartH = bounds.height * fitZoom; setZoom(fitZoom); setPan({ x: (containerW - chartW) / 2, y: (containerH - chartH) / 2, }); }, [allNodes, bounds]); const handleMouseDown = useCallback((e: React.MouseEvent) => { if (e.button !== 0) return; // Don't drag if clicking a card const target = e.target as HTMLElement; if (target.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; const dx = e.clientX - dragStart.current.x; const dy = e.clientY - dragStart.current.y; setPan({ x: dragStart.current.panX + dx, y: dragStart.current.panY + dy }); }, [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 mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const factor = e.deltaY < 0 ? 1.1 : 0.9; const newZoom = Math.min(Math.max(zoom * factor, 0.2), 2); // Zoom toward mouse position const scale = newZoom / zoom; setPan({ x: mouseX - scale * (mouseX - pan.x), y: mouseY - scale * (mouseY - pan.y), }); setZoom(newZoom); }, [zoom, pan]); if (!selectedCompanyId) { return ; } if (isLoading) { return ; } if (orgTree && orgTree.length === 0) { return ; } return (
{/* Zoom controls */}
{/* SVG layer for edges */} {edges.map(({ parent, child }) => { const x1 = parent.x + CARD_W / 2; const y1 = parent.y + CARD_H; const x2 = child.x + CARD_W / 2; const y2 = child.y; const midY = (y1 + y2) / 2; return ( ); })} {/* Card layer */}
{allNodes.map((node) => { const agent = agentMap.get(node.id); const dotColor = statusDotColor[node.status] ?? defaultDotColor; return (
navigate(agent ? agentUrl(agent) : `/agents/${node.id}`)} >
{/* Agent icon + status dot */}
{/* Name + role + adapter type */}
{node.name} {agent?.title ?? roleLabel(node.role)} {agent && ( {adapterLabels[agent.adapterType] ?? agent.adapterType} )}
); })}
); } const roleLabels: Record = { ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO", engineer: "Engineer", designer: "Designer", pm: "PM", qa: "QA", devops: "DevOps", researcher: "Researcher", general: "General", }; function roleLabel(role: string): string { return roleLabels[role] ?? role; }