feat(FN-4551): complete Step 1 — measurement-based connectors

Fusion-Task-Id: FN-4551
Fusion-Task-Lineage: 1e34e4ba-25a3-4ad8-b68c-3323ae458ba2
This commit is contained in:
Fusion
2026-05-14 20:42:57 -07:00
committed by gsxdsm
parent c546fec23a
commit a81058cffe
2 changed files with 351 additions and 183 deletions

View File

@@ -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;
}

View File

@@ -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<OrgChartLink[]>;
};
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 (
<div
className={`org-chart-node${children.length > 0 ? " org-chart-node--has-children" : ""}`}
style={nodeStyle}
>
<div className={`org-chart-node${children.length > 0 ? " org-chart-node--has-children" : ""}`} style={nodeStyle}>
<div
className={`${stateNodeClass}${selectedAgentId === agent.id ? " agent-card--selected" : ""}`}
ref={(element) => 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({
<span className="org-chart-node__name">{agent.name}</span>
</div>
<div className="org-chart-node__meta">
<span
className={`org-chart-node__badge ${stateBadgeClass}`}
>
{agent.state}
</span>
<span className={`org-chart-node__badge ${stateBadgeClass}`}>{agent.state}</span>
<span className="org-chart-node__health" style={{ color: health.color }} title={healthSummary.title}>
{health.icon}
{healthSummary.label && <span className="text-secondary">{healthSummary.label}</span>}
@@ -174,23 +166,101 @@ function OrgChartNode({
</div>
</div>
{children.length > 0 && (
<div className="org-chart-children" style={childrenStyle} role="group" aria-label={`${agent.name} employees`}>
{children.map((child) => (
<OrgChartNode
key={child.agent.id}
node={child}
onSelect={onSelect}
getHealthStatus={getHealthStatus}
getRoleIcon={getRoleIcon}
selectedAgentId={selectedAgentId}
/>
))}
<div className="org-chart-children" role="group" aria-label={`${agent.name} employees`}>
{children.map((child) => {
linksRef.current.push({ parentId: agent.id, childId: child.agent.id });
return (
<OrgChartNode
key={child.agent.id}
node={child}
onSelect={onSelect}
getHealthStatus={getHealthStatus}
selectedAgentId={selectedAgentId}
registerNodeElement={registerNodeElement}
linksRef={linksRef}
/>
);
})}
</div>
)}
</div>
);
}
function OrgChartConnectors({
links,
nodeElements,
canvasRef,
viewportRef,
layoutMode,
transform,
}: {
links: OrgChartLink[];
nodeElements: Map<string, HTMLDivElement>;
canvasRef: RefObject<HTMLDivElement | null>;
viewportRef: RefObject<HTMLDivElement | null>;
layoutMode: OrgChartLayoutMode;
transform: OrgChartTransform;
}) {
const [paths, setPaths] = useState<string[]>([]);
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 (
<svg className="agent-org-chart-connectors" aria-hidden="true">
{paths.map((d, index) => (
<path key={`${index}-${d}`} d={d} />
))}
</svg>
);
}
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<OrgChartTransform>({ scale: 1, x: 0, y: 0 });
const [isOrgChartPanning, setIsOrgChartPanning] = useState(false);
const controlsPanelRef = useRef<HTMLDivElement>(null);
const orgChartViewportRef = useRef<HTMLDivElement>(null);
const orgChartCanvasRef = useRef<HTMLDivElement>(null);
const orgChartNodeRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const orgChartLinksRef = useRef<OrgChartLink[]>([]);
const activePointersRef = useRef<Map<number, { x: number; y: number }>>(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<HTMLButtonElement>(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(() => (
<>
<button type="button" className="btn-icon touch-target" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, scale: current.scale * 0.9 }))} aria-label="Zoom out org chart" title="Zoom out">
<ZoomOut size={16} />
</button>
<span className="agent-org-chart-controls__zoom-label" aria-live="polite">{Math.round(orgChartTransform.scale * 100)}%</span>
<button type="button" className="btn-icon touch-target" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, scale: current.scale * 1.1 }))} aria-label="Zoom in org chart" title="Zoom in">
<ZoomIn size={16} />
</button>
<button type="button" className="btn touch-target btn-sm agent-org-chart-controls__fit-btn" onClick={fitToViewport} aria-label="Fit org chart" title="Fit org chart">
<Minimize2 size={16} />
Fit
</button>
<button type="button" className="btn touch-target btn-sm" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, x: 0, y: 0 }))} aria-label="Center org chart" title="Center org chart">
<Move size={16} />
Center
</button>
</>
), [clampTransform, fitToViewport, orgChartTransform.scale]);
const handleOrgChartWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 ? (
<div className="agent-org-chart-controls" data-testid="agent-org-chart-controls">
{renderOrgChartLayoutToggle()}
<button
type="button"
className="btn-icon touch-target"
onClick={() => setOrgChartZoomIndex((value) => Math.max(0, value - 1))}
disabled={orgChartZoomIndex === 0}
aria-label="Zoom out org chart"
title="Zoom out"
>
<ZoomOut size={16} />
</button>
<span className="agent-org-chart-controls__zoom-label" aria-live="polite">{Math.round(orgChartZoom * 100)}%</span>
<button
type="button"
className="btn-icon touch-target"
onClick={() => setOrgChartZoomIndex((value) => Math.min(ORG_CHART_ZOOM_LEVELS.length - 1, value + 1))}
disabled={orgChartZoomIndex === ORG_CHART_ZOOM_LEVELS.length - 1}
aria-label="Zoom in org chart"
title="Zoom in"
>
<ZoomIn size={16} />
</button>
<button
type="button"
className="btn touch-target btn-sm agent-org-chart-controls__fit-btn"
onClick={() => setOrgChartZoomIndex(1)}
aria-label="Fit org chart"
title="Fit org chart"
>
<Minimize2 size={16} />
Fit
</button>
{renderOrgChartZoomControls()}
</div>
) : (
<div className="agent-org-chart-toolbar">
{renderOrgChartLayoutToggle()}
<div className="agent-org-chart-controls" data-testid="agent-org-chart-controls">
{renderOrgChartZoomControls()}
</div>
</div>
)}
<div
ref={orgChartViewportRef}
className="agent-org-chart-viewport"
data-testid="agent-org-chart-viewport"
tabIndex={0}
role="region"
aria-label="Org chart canvas"
onWheel={handleOrgChartWheel}
onPointerDown={handleOrgChartPointerDown}
onPointerMove={handleOrgChartPointerMove}
onPointerUp={handleOrgChartPointerEnd}
onPointerCancel={handleOrgChartPointerEnd}
onKeyDown={handleOrgChartKeyDown}
data-panning={isOrgChartPanning ? "true" : "false"}
>
<div className={`agent-org-chart-canvas agent-org-chart-canvas--zoom-${Math.round(orgChartZoom * 100)}`}>
<div
ref={orgChartCanvasRef}
className="agent-org-chart-canvas"
data-panning={isOrgChartPanning ? "true" : "false"}
style={{ transform: `translate(${orgChartTransform.x}px, ${orgChartTransform.y}px) scale(${orgChartTransform.scale})` }}
>
<div
className={`agent-org-chart${orgChartLayoutMode === "vertical" ? " agent-org-chart--vertical" : ""}`}
data-testid="agent-org-chart"
@@ -1141,18 +1365,30 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
) : displayOrgTree.length === 0 ? (
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
) : (
displayOrgTree.map((node) => (
<OrgChartNode
key={node.agent.id}
node={node}
onSelect={handleOrgChartNodeSelect}
getHealthStatus={getHealthStatus}
getRoleIcon={getRoleIcon}
selectedAgentId={selectedOrgChartAgentId}
/>
))
(() => {
orgChartLinksRef.current = [];
return displayOrgTree.map((node) => (
<OrgChartNode
key={node.agent.id}
node={node}
onSelect={handleOrgChartNodeSelect}
getHealthStatus={getHealthStatus}
selectedAgentId={selectedOrgChartAgentId}
registerNodeElement={registerOrgChartNodeElement}
linksRef={orgChartLinksRef}
/>
));
})()
)}
</div>
<OrgChartConnectors
links={orgChartLinksRef.current}
nodeElements={orgChartNodeRefs.current}
canvasRef={orgChartCanvasRef}
viewportRef={orgChartViewportRef}
layoutMode={orgChartLayoutMode}
transform={orgChartTransform}
/>
</div>
</div>
</div>