diff --git a/packages/dashboard/app/components/AgentsView.css b/packages/dashboard/app/components/AgentsView.css index d7c5457fa..84528956d 100644 --- a/packages/dashboard/app/components/AgentsView.css +++ b/packages/dashboard/app/components/AgentsView.css @@ -866,6 +866,7 @@ .agent-org-chart-toolbar { display: flex; justify-content: flex-end; + gap: var(--space-sm); } .agent-org-chart-layout-toggle { @@ -904,36 +905,44 @@ .agent-org-chart-viewport { flex: 1; min-height: 0; - overflow: auto; + overflow: hidden; max-width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-lg); overscroll-behavior: contain; -webkit-overflow-scrolling: touch; - touch-action: pan-x pan-y; + touch-action: none; + cursor: grab; +} + +.agent-org-chart-viewport[data-panning="true"] { + cursor: grabbing; } .agent-org-chart-canvas { width: max-content; + position: relative; transform-origin: top left; transition: transform var(--transition-fast); } -.agent-org-chart-canvas--zoom-75 { - transform: scale(0.75); +.agent-org-chart-canvas[data-panning="true"] { + transition: none; } -.agent-org-chart-canvas--zoom-100 { - transform: scale(1); +.agent-org-chart-connectors { + position: absolute; + inset: 0; + overflow: visible; + pointer-events: none; } -.agent-org-chart-canvas--zoom-125 { - transform: scale(1.25); -} - -.agent-org-chart-canvas--zoom-150 { - transform: scale(1.5); +.agent-org-chart-connectors path { + stroke: var(--org-chart-connector-color); + stroke-width: 1px; + fill: none; + shape-rendering: crispEdges; } .agent-org-chart { @@ -943,8 +952,6 @@ --org-chart-children-offset: calc(var(--space-lg) + var(--space-sm)); --org-chart-root-gap: var(--space-xl); --org-chart-connector-color: color-mix(in srgb, var(--text-muted) 60%, transparent); - --org-chart-first-child-leaves: 1; - --org-chart-last-child-leaves: 1; display: flex; align-items: flex-start; justify-content: flex-start; @@ -1061,32 +1068,7 @@ font-size: var(--space-md); } -.org-chart-node--has-children > .org-chart-node-card::after { - content: ""; - position: absolute; - left: 50%; - transform: translateX(-50%); - bottom: calc(var(--org-chart-connector-gap) * -1); - width: 1px; - height: var(--org-chart-children-offset); - background: var(--org-chart-connector-color); -} - .org-chart-children { - --org-chart-first-child-leaves-number: var(--org-chart-first-child-leaves, 1); - --org-chart-last-child-leaves-number: var(--org-chart-last-child-leaves, 1); - --org-chart-first-child-center-offset: calc( - ( - var(--org-chart-first-child-leaves-number) * var(--org-chart-node-width) + - (var(--org-chart-first-child-leaves-number) - 1) * var(--org-chart-sibling-gap) - ) / 2 - ); - --org-chart-last-child-center-offset: calc( - ( - var(--org-chart-last-child-leaves-number) * var(--org-chart-node-width) + - (var(--org-chart-last-child-leaves-number) - 1) * var(--org-chart-sibling-gap) - ) / 2 - ); position: relative; display: flex; align-items: flex-start; @@ -1097,16 +1079,6 @@ margin-top: var(--org-chart-connector-gap); } -.org-chart-children::before { - content: ""; - position: absolute; - top: 0; - left: var(--org-chart-first-child-center-offset); - right: var(--org-chart-last-child-center-offset); - height: 1px; - background: var(--org-chart-connector-color); -} - .agent-org-chart--vertical { flex-direction: column; gap: var(--space-md); @@ -1127,11 +1099,6 @@ margin-left: 0; } -.agent-org-chart--vertical .org-chart-node--has-children > .org-chart-node-card::after { - left: calc(var(--space-lg) + var(--space-xs)); - transform: none; -} - .agent-org-chart--vertical .org-chart-children { display: flex; flex-direction: column; @@ -1143,36 +1110,6 @@ margin-top: 0; } -.agent-org-chart--vertical .org-chart-children::before { - top: 0; - bottom: var(--space-sm); - left: var(--space-sm); - right: auto; - width: 1px; - height: auto; - background: var(--org-chart-connector-color); -} - -.agent-org-chart--vertical .org-chart-children > .org-chart-node::before { - top: calc(var(--space-md) * -1); - left: calc(var(--space-sm) * -1); - transform: none; - width: var(--space-sm); - height: 1px; - background: var(--org-chart-connector-color); -} - -.org-chart-children > .org-chart-node::before { - content: ""; - position: absolute; - top: calc(var(--org-chart-children-offset) * -1); - left: 50%; - transform: translateX(-50%); - width: 1px; - height: var(--org-chart-children-offset); - background: var(--org-chart-connector-color); -} - @media (max-width: 768px) { .agent-org-chart-shell { @@ -1289,11 +1226,6 @@ order: 2; } - .agent-org-chart-controls .btn-icon, - .agent-org-chart-controls .agent-org-chart-controls__fit-btn { - min-height: calc(var(--space-lg) * 2 + var(--space-xs)); - } - .agent-org-chart-controls .agent-org-chart-controls__fit-btn { margin-left: 0; } @@ -1302,7 +1234,7 @@ min-height: calc(var(--space-2xl) * 4); flex: 1; min-width: 0; - overflow: auto; + overflow: hidden; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; } diff --git a/packages/dashboard/app/components/AgentsView.tsx b/packages/dashboard/app/components/AgentsView.tsx index 60625347a..f553e9fd2 100644 --- a/packages/dashboard/app/components/AgentsView.tsx +++ b/packages/dashboard/app/components/AgentsView.tsx @@ -1,6 +1,6 @@ import "./AgentsView.css"; -import { useState, useEffect, useCallback, useRef, useMemo, useId, lazy, Suspense, type CSSProperties, type ReactNode } from "react"; -import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, Filter, Upload, Network, SlidersHorizontal, ZoomIn, ZoomOut, Minimize2, Info } from "lucide-react"; +import { useState, useEffect, useCallback, useRef, useMemo, useId, useLayoutEffect, lazy, Suspense, type CSSProperties, type ReactNode, type MutableRefObject, type RefObject, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, Filter, Upload, Network, SlidersHorizontal, ZoomIn, ZoomOut, Minimize2, Move, Info } from "lucide-react"; import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api"; import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api"; @@ -54,7 +54,10 @@ const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [ const HEARTBEAT_MULTIPLIER_PRESETS = [0.1, 0.25, 0.5, 1, 2, 3, 5, 10] as const; -const ORG_CHART_ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const; +const ORG_CHART_SCALE_MIN = 0.25; +const ORG_CHART_SCALE_MAX = 3; +const ORG_CHART_KEYBOARD_PAN_STEP = 16; +const ORG_CHART_OVERSCROLL = 32; function getStateBadgeClass(state: AgentState): string { switch (state) { @@ -111,19 +114,19 @@ function getHealthSummary(agent: Agent, health: AgentHealthStatus): { title: str }; } -function OrgChartNode({ - node, - onSelect, - getHealthStatus, - getRoleIcon, - selectedAgentId, -}: { +type OrgChartLink = { parentId: string; childId: string }; +type OrgChartTransform = { scale: number; x: number; y: number }; + +type OrgChartNodeProps = { node: OrgTreeNode; onSelect: (id: string) => void; getHealthStatus: (agent: Agent) => AgentHealthStatus; - getRoleIcon: (role: AgentCapability) => string; selectedAgentId: string | null; -}) { + registerNodeElement: (id: string, element: HTMLDivElement | null) => void; + linksRef: MutableRefObject; +}; + +function OrgChartNode({ node, onSelect, getHealthStatus, selectedAgentId, registerNodeElement, linksRef }: OrgChartNodeProps) { const { agent, children } = node; const health = getHealthStatus(agent); const healthSummary = getHealthSummary(agent, health); @@ -131,20 +134,13 @@ function OrgChartNode({ const stateNodeClass = getStateCardClass("org-chart-node-card", agent.state); const subtreeLeafCount = getOrgChartLeafCount(node); const nodeStyle = { "--org-chart-subtree-leaves": String(subtreeLeafCount) } as CSSProperties; - const firstChildLeafCount = children.length > 0 ? getOrgChartLeafCount(children[0]) : 1; - const lastChildLeafCount = children.length > 0 ? getOrgChartLeafCount(children[children.length - 1]) : 1; - const childrenStyle = { - "--org-chart-first-child-leaves": String(firstChildLeafCount), - "--org-chart-last-child-leaves": String(lastChildLeafCount), - } as CSSProperties; return ( -
0 ? " org-chart-node--has-children" : ""}`} - style={nodeStyle} - > +
0 ? " org-chart-node--has-children" : ""}`} style={nodeStyle}>
registerNodeElement(agent.id, element)} + data-agent-id={agent.id} + className={`org-chart-node-card ${stateNodeClass}${selectedAgentId === agent.id ? " agent-card--selected" : ""}`} onClick={() => onSelect(agent.id)} role="button" tabIndex={0} @@ -162,11 +158,7 @@ function OrgChartNode({ {agent.name}
- - {agent.state} - + {agent.state} {health.icon} {healthSummary.label && {healthSummary.label}} @@ -174,23 +166,101 @@ function OrgChartNode({
{children.length > 0 && ( -
- {children.map((child) => ( - - ))} +
+ {children.map((child) => { + linksRef.current.push({ parentId: agent.id, childId: child.agent.id }); + return ( + + ); + })}
)}
); } +function OrgChartConnectors({ + links, + nodeElements, + canvasRef, + viewportRef, + layoutMode, + transform, +}: { + links: OrgChartLink[]; + nodeElements: Map; + canvasRef: RefObject; + viewportRef: RefObject; + layoutMode: OrgChartLayoutMode; + transform: OrgChartTransform; +}) { + const [paths, setPaths] = useState([]); + + useLayoutEffect(() => { + const canvas = canvasRef.current; + const viewport = viewportRef.current; + if (!canvas || !viewport || links.length === 0) { + setPaths([]); + return; + } + + const recompute = () => { + const canvasRect = canvas.getBoundingClientRect(); + const next = links.flatMap(({ parentId, childId }) => { + const parent = nodeElements.get(parentId); + const child = nodeElements.get(childId); + if (!parent || !child) return []; + const parentRect = parent.getBoundingClientRect(); + const childRect = child.getBoundingClientRect(); + const pLeft = (parentRect.left - canvasRect.left) / transform.scale; + const pTop = (parentRect.top - canvasRect.top) / transform.scale; + const cLeft = (childRect.left - canvasRect.left) / transform.scale; + const cTop = (childRect.top - canvasRect.top) / transform.scale; + + if (layoutMode === "vertical") { + const startX = pLeft; + const startY = pTop + parentRect.height / transform.scale / 2; + const endX = cLeft; + const endY = cTop + childRect.height / transform.scale / 2; + const midX = startX - (startX - endX) / 2; + return [`M ${startX} ${startY} L ${midX} ${startY} L ${midX} ${endY} L ${endX} ${endY}`]; + } + + const startX = pLeft + parentRect.width / transform.scale / 2; + const startY = pTop + parentRect.height / transform.scale; + const endX = cLeft + childRect.width / transform.scale / 2; + const endY = cTop; + const midY = startY + (endY - startY) / 2; + return [`M ${startX} ${startY} L ${startX} ${midY} L ${endX} ${midY} L ${endX} ${endY}`]; + }); + setPaths(next); + }; + + recompute(); + const resizeObserver = new ResizeObserver(recompute); + resizeObserver.observe(canvas); + resizeObserver.observe(viewport); + nodeElements.forEach((node) => resizeObserver.observe(node)); + return () => resizeObserver.disconnect(); + }, [canvasRef, layoutMode, links, nodeElements, transform.scale, viewportRef]); + + return ( + + ); +} + export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardingEnabled = false }: AgentsViewProps) { const [showSystemAgents, setShowSystemAgents] = useState(false); const viewportMode = useViewportMode(); @@ -224,9 +294,17 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin const [orgChartViewportWidth, setOrgChartViewportWidth] = useState(0); const [isControlsPanelOpen, setIsControlsPanelOpen] = useState(false); const [isOverviewOpen, setIsOverviewOpen] = useState(false); - const [orgChartZoomIndex, setOrgChartZoomIndex] = useState(1); + const [orgChartTransform, setOrgChartTransform] = useState({ scale: 1, x: 0, y: 0 }); + const [isOrgChartPanning, setIsOrgChartPanning] = useState(false); const controlsPanelRef = useRef(null); const orgChartViewportRef = useRef(null); + const orgChartCanvasRef = useRef(null); + const orgChartNodeRefs = useRef>(new Map()); + const orgChartLinksRef = useRef([]); + const activePointersRef = useRef>(new Map()); + const panStartRef = useRef<{ x: number; y: number; pointerId: number } | null>(null); + const pinchStartRef = useRef<{ distance: number; scale: number; centerX: number; centerY: number } | null>(null); + const orgChartFitDoneRef = useRef(false); const { confirm } = useConfirm(); const controlsTriggerRef = useRef(null); const controlsPanelId = useId(); @@ -724,10 +802,65 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin } }; + const clampScale = useCallback((scale: number) => Math.min(ORG_CHART_SCALE_MAX, Math.max(ORG_CHART_SCALE_MIN, scale)), []); + + const clampTransform = useCallback((next: OrgChartTransform): OrgChartTransform => { + const viewport = orgChartViewportRef.current; + const chart = orgChartCanvasRef.current?.querySelector(".agent-org-chart") as HTMLDivElement | null; + if (!viewport || !chart) return { ...next, scale: clampScale(next.scale) }; + const scale = clampScale(next.scale); + const contentWidth = chart.scrollWidth * scale; + const contentHeight = chart.scrollHeight * scale; + const viewportWidth = viewport.clientWidth; + const viewportHeight = viewport.clientHeight; + const minX = Math.min(ORG_CHART_OVERSCROLL, viewportWidth - contentWidth - ORG_CHART_OVERSCROLL); + const maxX = ORG_CHART_OVERSCROLL; + const minY = Math.min(ORG_CHART_OVERSCROLL, viewportHeight - contentHeight - ORG_CHART_OVERSCROLL); + const maxY = ORG_CHART_OVERSCROLL; + return { + scale, + x: Math.max(minX, Math.min(maxX, next.x)), + y: Math.max(minY, Math.min(maxY, next.y)), + }; + }, [clampScale]); + + const zoomAtPoint = useCallback((nextScale: number, clientX: number, clientY: number) => { + const viewport = orgChartViewportRef.current; + if (!viewport) return; + setOrgChartTransform((current) => { + const scale = clampScale(nextScale); + const viewportRect = viewport.getBoundingClientRect(); + const offsetX = clientX - viewportRect.left; + const offsetY = clientY - viewportRect.top; + const worldX = (offsetX - current.x) / current.scale; + const worldY = (offsetY - current.y) / current.scale; + return clampTransform({ + scale, + x: offsetX - worldX * scale, + y: offsetY - worldY * scale, + }); + }); + }, [clampScale, clampTransform]); + + const fitToViewport = useCallback(() => { + const viewport = orgChartViewportRef.current; + const chart = orgChartCanvasRef.current?.querySelector(".agent-org-chart") as HTMLDivElement | null; + if (!viewport || !chart) return; + const viewportWidth = viewport.clientWidth; + const viewportHeight = viewport.clientHeight; + const contentWidth = chart.scrollWidth; + const contentHeight = chart.scrollHeight; + if (contentWidth <= 0 || contentHeight <= 0) return; + const scale = clampScale(Math.min(viewportWidth / contentWidth, viewportHeight / contentHeight, 1)); + const x = (viewportWidth - contentWidth * scale) / 2; + const y = (viewportHeight - contentHeight * scale) / 2; + setOrgChartTransform(clampTransform({ scale, x, y })); + }, [clampScale, clampTransform]); + const handleAgentViewChange = useCallback((nextView: "list" | "board" | "org") => { setAgentView(nextView); if (nextView !== "org") { - setOrgChartZoomIndex(1); + setOrgChartTransform({ scale: 1, x: 0, y: 0 }); } if (isMobileViewport && selectedAgentId) { handleCloseDetail(); @@ -736,7 +869,6 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role; const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "◆"; - const orgChartZoom = ORG_CHART_ZOOM_LEVELS[orgChartZoomIndex]; const orgChartLayoutMode: OrgChartLayoutMode = useMemo(() => resolveOrgChartLayoutMode({ tree: displayOrgTree, availableWidth: orgChartViewportWidth, @@ -744,16 +876,120 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin }), [displayOrgTree, orgChartLayoutPreference, orgChartViewportWidth]); const handleOrgChartLayoutPreferenceChange = useCallback((preference: OrgChartLayoutPreference) => { setOrgChartLayoutPreference(preference); - const isEdgeZoom = orgChartZoomIndex === 0 || orgChartZoomIndex === ORG_CHART_ZOOM_LEVELS.length - 1; - const nextLayoutMode = resolveOrgChartLayoutMode({ - tree: displayOrgTree, - availableWidth: orgChartViewportWidth, - preference, - }); - if (isEdgeZoom && nextLayoutMode !== orgChartLayoutMode) { - setOrgChartZoomIndex(1); + setOrgChartTransform({ scale: 1, x: 0, y: 0 }); + }, []); + + useEffect(() => { + setOrgChartTransform({ scale: 1, x: 0, y: 0 }); + orgChartFitDoneRef.current = false; + }, [projectId, orgChartLayoutMode]); + + useLayoutEffect(() => { + if (agentView !== "org" || isOrgTreeLoading || displayOrgTree.length === 0 || orgChartFitDoneRef.current) return; + fitToViewport(); + orgChartFitDoneRef.current = true; + }, [agentView, displayOrgTree.length, fitToViewport, isOrgTreeLoading]); + + const registerOrgChartNodeElement = useCallback((id: string, element: HTMLDivElement | null) => { + if (element) { + orgChartNodeRefs.current.set(id, element); + } else { + orgChartNodeRefs.current.delete(id); } - }, [displayOrgTree, orgChartLayoutMode, orgChartViewportWidth, orgChartZoomIndex]); + }, []); + + const renderOrgChartZoomControls = useCallback(() => ( + <> + + {Math.round(orgChartTransform.scale * 100)}% + + + + + ), [clampTransform, fitToViewport, orgChartTransform.scale]); + + const handleOrgChartWheel = useCallback((event: ReactWheelEvent) => { + if (!(event.ctrlKey || event.metaKey)) return; + event.preventDefault(); + const factor = event.deltaY > 0 ? 0.9 : 1.1; + zoomAtPoint(orgChartTransform.scale * factor, event.clientX, event.clientY); + }, [orgChartTransform.scale, zoomAtPoint]); + + const handleOrgChartPointerDown = useCallback((event: ReactPointerEvent) => { + if ((event.target as HTMLElement).closest(".org-chart-node-card")) return; + const viewport = event.currentTarget; + viewport.setPointerCapture?.(event.pointerId); + activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }); + if (activePointersRef.current.size === 1) { + panStartRef.current = { x: event.clientX - orgChartTransform.x, y: event.clientY - orgChartTransform.y, pointerId: event.pointerId }; + setIsOrgChartPanning(true); + return; + } + if (activePointersRef.current.size === 2) { + const points = Array.from(activePointersRef.current.values()); + const dx = points[0].x - points[1].x; + const dy = points[0].y - points[1].y; + pinchStartRef.current = { + distance: Math.hypot(dx, dy), + scale: orgChartTransform.scale, + centerX: (points[0].x + points[1].x) / 2, + centerY: (points[0].y + points[1].y) / 2, + }; + panStartRef.current = null; + } + }, [orgChartTransform.scale, orgChartTransform.x, orgChartTransform.y]); + + const handleOrgChartPointerMove = useCallback((event: ReactPointerEvent) => { + if (!activePointersRef.current.has(event.pointerId)) return; + activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }); + if (activePointersRef.current.size >= 2) { + const points = Array.from(activePointersRef.current.values()); + const start = pinchStartRef.current; + if (!start) return; + const dx = points[0].x - points[1].x; + const dy = points[0].y - points[1].y; + const nextDistance = Math.hypot(dx, dy); + const nextScale = start.scale * (nextDistance / start.distance); + zoomAtPoint(nextScale, start.centerX, start.centerY); + return; + } + if (!panStartRef.current || panStartRef.current.pointerId !== event.pointerId) return; + setOrgChartTransform((current) => clampTransform({ ...current, x: event.clientX - panStartRef.current!.x, y: event.clientY - panStartRef.current!.y })); + }, [clampTransform, zoomAtPoint]); + + const handleOrgChartPointerEnd = useCallback((event: ReactPointerEvent) => { + activePointersRef.current.delete(event.pointerId); + if (activePointersRef.current.size < 2) { + pinchStartRef.current = null; + } + if (activePointersRef.current.size === 0) { + panStartRef.current = null; + setIsOrgChartPanning(false); + } + }, []); + + const handleOrgChartKeyDown = useCallback((event: ReactKeyboardEvent) => { + if (event.key === "ArrowLeft") setOrgChartTransform((current) => clampTransform({ ...current, x: current.x + ORG_CHART_KEYBOARD_PAN_STEP })); + else if (event.key === "ArrowRight") setOrgChartTransform((current) => clampTransform({ ...current, x: current.x - ORG_CHART_KEYBOARD_PAN_STEP })); + else if (event.key === "ArrowUp") setOrgChartTransform((current) => clampTransform({ ...current, y: current.y + ORG_CHART_KEYBOARD_PAN_STEP })); + else if (event.key === "ArrowDown") setOrgChartTransform((current) => clampTransform({ ...current, y: current.y - ORG_CHART_KEYBOARD_PAN_STEP })); + else if (event.key === "+" || event.key === "=") setOrgChartTransform((current) => clampTransform({ ...current, scale: current.scale * 1.1 })); + else if (event.key === "-") setOrgChartTransform((current) => clampTransform({ ...current, scale: current.scale * 0.9 })); + else if (event.key === "0") fitToViewport(); + else if (event.key === "Home") setOrgChartTransform({ scale: 1, x: 0, y: 0 }); + else return; + event.preventDefault(); + }, [clampTransform, fitToViewport]); const renderOrgChartLayoutToggle = useCallback(() => { const options: Array<{ value: OrgChartLayoutPreference; label: string; icon: ReactNode; ariaLabel: string }> = [ @@ -1085,49 +1321,37 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin {isMobileViewport ? (
{renderOrgChartLayoutToggle()} - - {Math.round(orgChartZoom * 100)}% - - + {renderOrgChartZoomControls()}
) : (
{renderOrgChartLayoutToggle()} +
+ {renderOrgChartZoomControls()} +
)}
-
+
) : ( - displayOrgTree.map((node) => ( - - )) + (() => { + orgChartLinksRef.current = []; + return displayOrgTree.map((node) => ( + + )); + })() )}
+