import { AgentAvatar } from "@/components/AgentAvatar"; import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import { Link, 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 { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { Download, Maximize2, Minus, Network, Plus, Upload } from "lucide-react"; import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared"; import { useCloudInstance } from "@/hooks/useCloudInstance"; import { useHiddenSettings } from "@/hooks/useHiddenSettings"; // Layout constants const CARD_W = 200; const CARD_H = 100; const GAP_X = 32; const GAP_Y = 80; const PADDING = 60; const MIN_ZOOM = 0.2; const MAX_ZOOM = 2; const FIT_PADDING = 40; const TOUCH_MOVE_THRESHOLD = 6; // ── Tree layout types ─────────────────────────────────────────────────── interface LayoutNode { id: string; name: string; role: string; status: string; x: number; y: number; children: LayoutNode[]; } interface Point { x: number; y: number; } interface TouchGesture { mode: "pan" | "pinch" | null; startPoint: Point; startPan: Point; startZoom: number; startDistance: number; startCenter: Point; moved: boolean; } // ── 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; } function clampZoom(value: number): number { return Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM); } function fitChartToViewport( containerWidth: number, containerHeight: number, bounds: { width: number; height: number }, ): { zoom: number; pan: Point } | null { if (containerWidth <= FIT_PADDING || containerHeight <= FIT_PADDING) return null; const scaleX = (containerWidth - FIT_PADDING) / bounds.width; const scaleY = (containerHeight - FIT_PADDING) / bounds.height; const zoom = clampZoom(Math.min(scaleX, scaleY, 1)); const chartWidth = bounds.width * zoom; const chartHeight = bounds.height * zoom; return { zoom, pan: { x: (containerWidth - chartWidth) / 2, y: (containerHeight - chartHeight) / 2, }, }; } function touchPoint(touch: React.Touch): Point { return { x: touch.clientX, y: touch.clientY }; } function touchDistance(a: React.Touch, b: React.Touch): number { const dx = a.clientX - b.clientX; const dy = a.clientY - b.clientY; return Math.hypot(dx, dy); } function touchCenter(a: React.Touch, b: React.Touch, container: HTMLDivElement): Point { const rect = container.getBoundingClientRect(); return { x: (a.clientX + b.clientX) / 2 - rect.left, y: (a.clientY + b.clientY) / 2 - rect.top, }; } // ── Status dot colors (raw hex for SVG) ───────────────────────────────── import { agentDisplayAdapterType, getAdapterLabel } from "../adapters/adapter-display-registry"; const statusDotColor: Record = { running: "var(--hex-22d3ee)", active: "var(--hex-4ade80)", paused: "var(--hex-facc15)", idle: "var(--hex-facc15)", error: "var(--hex-f87171)", terminated: "var(--hex-a3a3a3)", }; const defaultDotColor = "var(--hex-a3a3a3)"; // ── Main component ────────────────────────────────────────────────────── export interface OrgChartProps { /** Pre-filtered tree for embedding the chart in another collection page. */ orgTree?: OrgNode[]; /** Agent records paired with a pre-filtered embedded tree. */ agents?: Agent[]; /** Hides page-level actions and breadcrumb ownership. */ embedded?: boolean; } export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, embedded = false }: OrgChartProps = {}) { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); // Import is floored server-side on cloud-managed instances (403 cloud_managed), so the // button is hidden rather than dead-ending. Export stays available. Both // buttons also respect the operator-hidden settings registry. const isCloud = Boolean(useCloudInstance()); const { hidden: hiddenSettings } = useHiddenSettings(); const showImport = !isCloud && !hiddenSettings.has("company.import"); const showExport = !hiddenSettings.has("company.export"); const { data: queriedOrgTree, isLoading } = useQuery({ queryKey: queryKeys.org(selectedCompanyId!), queryFn: () => agentsApi.org(selectedCompanyId!), enabled: !!selectedCompanyId && providedOrgTree === undefined, }); const { data: queriedAgents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId && providedAgents === undefined, }); const orgTree = providedOrgTree ?? queriedOrgTree; const agents = providedAgents ?? queriedAgents; const agentMap = useMemo(() => { const m = new Map(); for (const a of agents ?? []) m.set(a.id, a); return m; }, [agents]); useEffect(() => { if (!embedded) setBreadcrumbs([{ label: "Org Chart" }]); }, [embedded, 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 }); const touchGesture = useRef({ mode: null, startPoint: { x: 0, y: 0 }, startPan: { x: 0, y: 0 }, startZoom: 1, startDistance: 0, startCenter: { x: 0, y: 0 }, moved: false, }); const suppressNextCardClick = useRef(false); const suppressClickTimerRef = useRef(null); useEffect(() => { return () => { if (suppressClickTimerRef.current !== null) { window.clearTimeout(suppressClickTimerRef.current); } }; }, []); // Center the chart on first load const hasInitialized = useRef(false); useEffect(() => { hasInitialized.current = false; }, [orgTree]); useEffect(() => { if (hasInitialized.current || allNodes.length === 0 || !containerRef.current) return; const container = containerRef.current; const fitted = fitChartToViewport(container.clientWidth, container.clientHeight, bounds); if (!fitted) return; hasInitialized.current = true; setZoom(fitted.zoom); setPan(fitted.pan); }, [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 = clampZoom(zoom * factor); // 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]); const zoomTowardPoint = useCallback((newZoom: number, point: Point) => { const clampedZoom = clampZoom(newZoom); const scale = clampedZoom / zoom; setPan({ x: point.x - scale * (point.x - pan.x), y: point.y - scale * (point.y - pan.y), }); setZoom(clampedZoom); }, [zoom, pan]); const fitToScreen = useCallback(() => { if (!containerRef.current) return; const fitted = fitChartToViewport( containerRef.current.clientWidth, containerRef.current.clientHeight, bounds, ); if (!fitted) return; setZoom(fitted.zoom); setPan(fitted.pan); }, [bounds]); const handleTouchStart = useCallback((e: React.TouchEvent) => { if (e.touches.length >= 2 && containerRef.current) { const [first, second] = [e.touches[0]!, e.touches[1]!]; touchGesture.current = { mode: "pinch", startPoint: { x: 0, y: 0 }, startPan: pan, startZoom: zoom, startDistance: touchDistance(first, second), startCenter: touchCenter(first, second, containerRef.current), moved: false, }; return; } const touch = e.touches[0]; if (!touch) return; touchGesture.current = { mode: "pan", startPoint: touchPoint(touch), startPan: pan, startZoom: zoom, startDistance: 0, startCenter: { x: 0, y: 0 }, moved: false, }; }, [pan, zoom]); const handleTouchMove = useCallback((e: React.TouchEvent) => { const container = containerRef.current; if (!container || !touchGesture.current.mode) return; if (e.touches.length >= 2) { const [first, second] = [e.touches[0]!, e.touches[1]!]; const distance = touchDistance(first, second); const center = touchCenter(first, second, container); if (touchGesture.current.mode !== "pinch" || touchGesture.current.startDistance === 0) { touchGesture.current = { mode: "pinch", startPoint: { x: 0, y: 0 }, startPan: pan, startZoom: zoom, startDistance: distance, startCenter: center, moved: false, }; return; } const gesture = touchGesture.current; const nextZoom = clampZoom(gesture.startZoom * (distance / gesture.startDistance)); const scale = nextZoom / gesture.startZoom; const dx = center.x - gesture.startCenter.x; const dy = center.y - gesture.startCenter.y; gesture.moved = gesture.moved || Math.abs(distance - gesture.startDistance) > TOUCH_MOVE_THRESHOLD || Math.hypot(dx, dy) > TOUCH_MOVE_THRESHOLD; setZoom(nextZoom); setPan({ x: center.x - scale * (gesture.startCenter.x - gesture.startPan.x), y: center.y - scale * (gesture.startCenter.y - gesture.startPan.y), }); return; } const touch = e.touches[0]; if (!touch || touchGesture.current.mode !== "pan") return; const dx = touch.clientX - touchGesture.current.startPoint.x; const dy = touch.clientY - touchGesture.current.startPoint.y; touchGesture.current.moved = touchGesture.current.moved || Math.hypot(dx, dy) > TOUCH_MOVE_THRESHOLD; setPan({ x: touchGesture.current.startPan.x + dx, y: touchGesture.current.startPan.y + dy, }); }, [pan, zoom]); const handleTouchEnd = useCallback(() => { if (touchGesture.current.moved) { suppressNextCardClick.current = true; if (suppressClickTimerRef.current !== null) { window.clearTimeout(suppressClickTimerRef.current); } suppressClickTimerRef.current = window.setTimeout(() => { suppressNextCardClick.current = false; suppressClickTimerRef.current = null; }, 400); } touchGesture.current = { mode: null, startPoint: { x: 0, y: 0 }, startPan: pan, startZoom: zoom, startDistance: 0, startCenter: { x: 0, y: 0 }, moved: false, }; }, [pan, zoom]); if (!selectedCompanyId) { return ; } if (providedOrgTree === undefined && isLoading) { return ; } if (orgTree && orgTree.length === 0) { return ; } return (
{!embedded && (showImport || showExport) ? (
{showImport ? ( ) : null} {showExport ? ( ) : null}
) : null}
{/* 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}`)} onClickCapture={(e) => { if (!suppressNextCardClick.current) return; suppressNextCardClick.current = false; e.preventDefault(); e.stopPropagation(); }} >
{/* Agent icon + status dot */}
{/* Name + role + adapter type */}
{node.name} {agent?.title ?? roleLabel(node.role)} {agent && ( {getAdapterLabel(agentDisplayAdapterType(agent))} )} {agent && agent.capabilities && ( {agent.capabilities} )}
); })}
); } const roleLabels: Record = AGENT_ROLE_LABELS; function roleLabel(role: string): string { return roleLabels[role] ?? role; }