import "./AgentsView.css"; 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 { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api"; const AgentDetailView = lazy(() => import("./AgentDetailView").then((m) => ({ default: m.AgentDetailView }))); import { AgentTokenStatsPanel } from "./AgentTokenStatsPanel"; import { AgentsOverviewBar } from "./AgentsOverviewBar"; import { AgentEmptyState } from "./AgentEmptyState"; import { useAgents } from "../hooks/useAgents"; import { useConfirm } from "../hooks/useConfirm"; import { NewAgentDialog } from "./NewAgentDialog"; import { AgentImportModal } from "./AgentImportModal"; import { getScopedItem, setScopedItem } from "../utils/projectStorage"; import { useViewportMode } from "../hooks/useViewportMode"; import { getAgentHealthStatus } from "../utils/agentHealth"; import type { AgentHealthStatus } from "../utils/agentHealth"; import { formatHeartbeatInterval, getHeartbeatIntervalOptions, resolveHeartbeatIntervalMs, MIN_HEARTBEAT_INTERVAL_MS, HEARTBEAT_INTERVAL_PRESETS, } from "../utils/heartbeatIntervals"; import { isEphemeralAgent, getErrorMessage } from "@fusion/core"; import { formatAgentSkillBadgeLabel } from "../utils/agentSkills"; import { ORG_CHART_LAYOUT_STORAGE_KEY, isOrgChartLayoutPreference, resolveOrgChartLayoutMode, type OrgChartLayoutMode, type OrgChartLayoutPreference, } from "./agentsOrgChartLayout"; import { AgentAvatar } from "./AgentAvatar"; import { AgentErrorIndicator } from "./AgentErrorDetailsModal"; export interface AgentsViewProps { addToast: (message: string, type?: "success" | "error") => void; projectId?: string; onOpenTaskLogs?: (taskId: string) => void; agentOnboardingEnabled?: boolean; } const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [ { value: "triage", label: "Triage", icon: "⊕" }, { value: "executor", label: "Executor", icon: "▶" }, { value: "reviewer", label: "Reviewer", icon: "⊙" }, { value: "merger", label: "Merger", icon: "⊞" }, { value: "scheduler", label: "Scheduler", icon: "◷" }, { value: "engineer", label: "Engineer", icon: "⎔" }, { value: "custom", label: "Custom", icon: "✦" }, ]; const HEARTBEAT_MULTIPLIER_PRESETS = [0.1, 0.25, 0.5, 1, 2, 3, 5, 10] 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) { case "running": return "agent-badge--running"; case "active": return "agent-badge--active"; case "paused": return "agent-badge--paused"; case "error": return "agent-badge--error"; case "idle": default: return "agent-badge--idle"; } } function getStateCardClass( prefix: "agent-card" | "agent-board-card" | "org-chart-node-card", state: AgentState, ): string { switch (state) { case "running": return `${prefix}--running`; case "active": return `${prefix}--active`; case "paused": return `${prefix}--paused`; case "error": return `${prefix}--error`; case "idle": default: return `${prefix}--idle`; } } function getOrgChartLeafCount(node: OrgTreeNode): number { if (node.children.length === 0) { return 1; } return node.children.reduce((sum, child) => sum + getOrgChartLeafCount(child), 0); } function getHealthSummary(agent: Agent, health: AgentHealthStatus): { title: string | undefined; label: string | null } { if (agent.state === "error") { return { title: undefined, label: "Error" }; } return { title: health.reason ?? health.label, label: health.stateDerived ? null : health.label, }; } 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; 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); const stateBadgeClass = getStateBadgeClass(agent.state); const stateNodeClass = getStateCardClass("org-chart-node-card", agent.state); const subtreeLeafCount = getOrgChartLeafCount(node); const nodeStyle = { "--org-chart-subtree-leaves": String(subtreeLeafCount) } as CSSProperties; return (
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} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { if (e.key === " ") { e.preventDefault(); } onSelect(agent.id); } }} >
{agent.name}
{agent.state} {health.icon} {healthSummary.label && {healthSummary.label}}
{children.length > 0 && (
{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(); const isMobileViewport = viewportMode === "mobile"; const [filterState, setFilterState] = useState("all"); const { agents, stats, isLoading, loadAgents, refreshAgents } = useAgents(projectId, { filterState, showSystemAgents, }); const [isCreating, setIsCreating] = useState(false); const [onboardingDraft, setOnboardingDraft] = useState(null); const [isImporting, setIsImporting] = useState(false); const [selectedAgentId, setSelectedAgentId] = useState(null); const [selectedOrgChartAgentId, setSelectedOrgChartAgentId] = useState(null); const isMobileDetailOpen = isMobileViewport && !!selectedAgentId; const [selectedAgentInitialTab, setSelectedAgentInitialTab] = useState<"dashboard" | "runs">("dashboard"); const [selectedAgentInitialRunId, setSelectedAgentInitialRunId] = useState(null); const [selectedAgentPreferActiveRun, setSelectedAgentPreferActiveRun] = useState(false); const [agentView, setAgentView] = useState<"list" | "board" | "org">(() => { if (typeof window === "undefined") return "list"; const saved = getScopedItem("fn-agent-view", projectId); return (saved === "list" || saved === "board" || saved === "org") ? saved : "list"; }); const [orgChartLayoutPreference, setOrgChartLayoutPreference] = useState(() => { if (typeof window === "undefined") return "auto"; const saved = getScopedItem(ORG_CHART_LAYOUT_STORAGE_KEY, projectId); return isOrgChartLayoutPreference(saved) ? saved : "auto"; }); const [orgTree, setOrgTree] = useState([]); const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false); const [orgChartViewportWidth, setOrgChartViewportWidth] = useState(0); const [isControlsPanelOpen, setIsControlsPanelOpen] = useState(false); const [isOverviewOpen, setIsOverviewOpen] = useState(false); const [isBulkActionRunning, setIsBulkActionRunning] = useState(false); const [isBulkEligibilityLoading, setIsBulkEligibilityLoading] = useState(false); const [bulkPauseEligibleCount, setBulkPauseEligibleCount] = useState(0); const [bulkResumeEligibleCount, setBulkResumeEligibleCount] = useState(0); 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(); useEffect(() => { const saved = getScopedItem("fn-agent-view", projectId); if (saved === "list" || saved === "board" || saved === "org") { setAgentView(saved); return; } setAgentView("list"); }, [projectId]); // Persist view preference to localStorage useEffect(() => { setScopedItem("fn-agent-view", agentView, projectId); }, [agentView, projectId]); useEffect(() => { const saved = getScopedItem(ORG_CHART_LAYOUT_STORAGE_KEY, projectId); setOrgChartLayoutPreference(isOrgChartLayoutPreference(saved) ? saved : "auto"); }, [projectId]); useEffect(() => { setScopedItem(ORG_CHART_LAYOUT_STORAGE_KEY, orgChartLayoutPreference, projectId); }, [orgChartLayoutPreference, projectId]); const [editingRoleForAgent, setEditingRoleForAgent] = useState(null); const roleSelectRef = useRef(null); const [updatingHeartbeatAgentId, setUpdatingHeartbeatAgentId] = useState(null); /** Agent ID currently showing custom heartbeat input */ const [customHeartbeatAgentId, setCustomHeartbeatAgentId] = useState(null); /** Custom minutes input value for each agent */ const [customHeartbeatMinutes, setCustomHeartbeatMinutes] = useState>({}); /** Global heartbeat multiplier loaded from project settings */ const [heartbeatMultiplier, setHeartbeatMultiplier] = useState(1); /** Whether the heartbeat multiplier is currently being saved */ const [isSavingMultiplier, setIsSavingMultiplier] = useState(false); /** Agent IDs with an in-flight state transition (for optimistic update guard) */ const [transitioningAgentIds, setTransitioningAgentIds] = useState>(new Set()); /** Optimistic state overrides keyed by agent ID while pause/resume/start API call is in-flight */ const [optimisticStateOverrides, setOptimisticStateOverrides] = useState>(new Map()); const isMountedRef = useRef(true); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); // Load heartbeat multiplier from project settings on mount useEffect(() => { fetchSettings(projectId) .then((settings) => { if (!isMountedRef.current) return; setHeartbeatMultiplier(settings.heartbeatMultiplier ?? 1); }) .catch(() => { // Use default on error }); }, [projectId]); /** Handle saving heartbeat multiplier to project settings */ const handleHeartbeatMultiplierChange = useCallback(async (multiplier: number) => { const clampedValue = Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1; setHeartbeatMultiplier(clampedValue); setIsSavingMultiplier(true); try { await updateSettings({ heartbeatMultiplier: clampedValue }, projectId); addToast(`Heartbeat speed set to ×${clampedValue.toFixed(1)}`, "success"); } catch (err) { addToast(`Failed to save heartbeat multiplier: ${getErrorMessage(err)}`, "error"); } finally { if (isMountedRef.current) { setIsSavingMultiplier(false); } } }, [projectId, addToast]); const optimisticAgents = useMemo(() => { if (optimisticStateOverrides.size === 0) { return agents; } return agents.map((agent) => { const optimisticState = optimisticStateOverrides.get(agent.id); return optimisticState ? { ...agent, state: optimisticState } : agent; }); }, [agents, optimisticStateOverrides]); // Filter agents for display. "All States" means all non-ephemeral agents, // including paused/error agents and heartbeat-disabled agents that still carry configuration. // When "Show system agents" is enabled, include ephemeral/internal agents. const displayAgents = useMemo(() => { return optimisticAgents.filter((agent) => showSystemAgents || !isEphemeralAgent(agent)); }, [optimisticAgents, showSystemAgents]); const displayActiveAgents = useMemo(() => { return optimisticAgents.filter((agent) => { if (agent.state !== "active" && agent.state !== "running") { return false; } return showSystemAgents || !isEphemeralAgent(agent); }); }, [optimisticAgents, showSystemAgents]); // Filter org tree to exclude ephemeral agents in default view. const displayOrgTree = useMemo(() => { if (showSystemAgents) { return orgTree; } // Recursively filter out ephemeral agents from the org tree. const filterNode = (node: OrgTreeNode): OrgTreeNode | null => { if (isEphemeralAgent(node.agent)) return null; return { ...node, children: node.children .map(filterNode) .filter((n): n is OrgTreeNode => n !== null), }; }; return orgTree .map(filterNode) .filter((n): n is OrgTreeNode => n !== null); }, [orgTree, showSystemAgents]); useEffect(() => { if (agentView !== "org") return; const viewport = orgChartViewportRef.current; if (!viewport) return; const updateWidth = () => { setOrgChartViewportWidth(viewport.clientWidth); }; updateWidth(); const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(updateWidth) : null; resizeObserver?.observe(viewport); window.addEventListener("resize", updateWidth); return () => { resizeObserver?.disconnect(); window.removeEventListener("resize", updateWidth); }; }, [agentView, displayOrgTree.length]); useEffect(() => { if (agentView !== "org") return; let cancelled = false; setIsOrgTreeLoading(true); fetchOrgTree(projectId, { includeEphemeral: showSystemAgents }) .then((data) => { if (!cancelled) { setOrgTree(data); } }) .catch((err) => { if (!cancelled) { addToast(`Failed to load org chart: ${getErrorMessage(err)}`, "error"); setOrgTree([]); } }) .finally(() => { if (!cancelled) { setIsOrgTreeLoading(false); } }); return () => { cancelled = true; }; }, [agentView, projectId, showSystemAgents, addToast]); // Poll for agent updates to keep health statuses fresh (every 30 seconds) // This ensures health badges stay current while the view is open. // SSE refreshes are handled by useAgents. useEffect(() => { const pollInterval = setInterval(() => { void loadAgents(); }, 30_000); return () => { clearInterval(pollInterval); }; }, [loadAgents]); useEffect(() => { if (!isControlsPanelOpen) return; let cancelled = false; setIsBulkEligibilityLoading(true); void fetchAgents(undefined, projectId) .then((projectAgents) => { if (cancelled) return; const nonEphemeralAgents = projectAgents.filter((projectAgent) => !isEphemeralAgent(projectAgent)); setBulkPauseEligibleCount( nonEphemeralAgents.filter((projectAgent) => projectAgent.state === "active" || projectAgent.state === "running").length, ); setBulkResumeEligibleCount(nonEphemeralAgents.filter((projectAgent) => projectAgent.state === "paused").length); }) .catch((err) => { if (cancelled) return; setBulkPauseEligibleCount(0); setBulkResumeEligibleCount(0); addToast(`Failed to load bulk agent actions: ${getErrorMessage(err)}`, "error"); }) .finally(() => { if (!cancelled) { setIsBulkEligibilityLoading(false); } }); const handlePointerDown = (event: MouseEvent | TouchEvent) => { const target = event.target as Node | null; if (!target) return; if (controlsPanelRef.current?.contains(target)) return; if (controlsTriggerRef.current?.contains(target)) return; setIsControlsPanelOpen(false); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; setIsControlsPanelOpen(false); controlsTriggerRef.current?.focus(); }; document.addEventListener("mousedown", handlePointerDown); document.addEventListener("touchstart", handlePointerDown); document.addEventListener("keydown", handleKeyDown); return () => { cancelled = true; document.removeEventListener("mousedown", handlePointerDown); document.removeEventListener("touchstart", handlePointerDown); document.removeEventListener("keydown", handleKeyDown); }; }, [addToast, isControlsPanelOpen, projectId]); const handleBulkStateChange = async (targetState: "paused" | "active") => { if (isBulkActionRunning) return; setIsBulkActionRunning(true); try { const projectAgents = await fetchAgents(undefined, projectId); const nonEphemeralAgents = projectAgents.filter((projectAgent) => !isEphemeralAgent(projectAgent)); const eligibleAgents = nonEphemeralAgents.filter((projectAgent) => ( targetState === "paused" ? projectAgent.state === "active" || projectAgent.state === "running" : projectAgent.state === "paused" )); const skippedCount = nonEphemeralAgents.length - eligibleAgents.length; if (eligibleAgents.length === 0) { addToast(`No agents eligible to ${targetState === "paused" ? "pause" : "resume"}`, "error"); return; } const confirmed = await confirm({ title: targetState === "paused" ? "Pause All Agents" : "Resume All Agents", message: `${targetState === "paused" ? "Pause" : "Resume"} ${eligibleAgents.length} agent${eligibleAgents.length === 1 ? "" : "s"} in this project?`, danger: targetState === "paused", }); if (!confirmed) return; const results = await Promise.allSettled( eligibleAgents.map((projectAgent) => updateAgentState(projectAgent.id, targetState, projectId)), ); const failedResults = results .map((result, index) => ({ result, agent: eligibleAgents[index] })) .filter((entry): entry is { result: PromiseRejectedResult; agent: Agent } => entry.result.status === "rejected"); const successCount = results.length - failedResults.length; const failureCount = failedResults.length; const baseSummary = `${targetState === "paused" ? "Paused" : "Resumed"} ${successCount} agent${successCount === 1 ? "" : "s"}; skipped ${skippedCount}`; if (failureCount > 0) { const failureSummary = failedResults .slice(0, 3) .map(({ agent, result }) => `${agent.name || agent.id}: ${getErrorMessage(result.reason)}`) .join("; "); addToast(`${baseSummary}; failed ${failureCount}${failureSummary ? ` (${failureSummary})` : ""}`, "error"); } else { addToast(baseSummary, "success"); } await loadAgents(); } catch (err) { addToast(`Failed to ${targetState === "paused" ? "pause" : "resume"} agents: ${getErrorMessage(err)}`, "error"); } finally { setIsBulkActionRunning(false); } }; const handleStateChange = async (agentId: string, newState: AgentState) => { if (transitioningAgentIds.has(agentId)) return; setTransitioningAgentIds((prev) => new Set(prev).add(agentId)); setOptimisticStateOverrides((prev) => { const next = new Map(prev); next.set(agentId, newState); return next; }); try { await updateAgentState(agentId, newState, projectId); addToast(`Agent state updated to ${newState}`, "success"); await loadAgents(); setOptimisticStateOverrides((prev) => { const next = new Map(prev); next.delete(agentId); return next; }); } catch (err) { setOptimisticStateOverrides((prev) => { const next = new Map(prev); next.delete(agentId); return next; }); addToast(`Failed to update state: ${getErrorMessage(err)}`, "error"); } finally { setTransitioningAgentIds((prev) => { const next = new Set(prev); next.delete(agentId); return next; }); } }; const handleDelete = async (agentId: string, agentName: string) => { const shouldDelete = await confirm({ title: "Delete Agent", message: `Delete agent "${agentName}"? This cannot be undone.`, danger: true, }); if (!shouldDelete) return; try { await deleteAgent(agentId, projectId); addToast(`Agent "${agentName}" deleted`, "success"); await loadAgents(); } catch (err) { addToast(`Failed to delete agent: ${getErrorMessage(err)}`, "error"); } }; const handleRoleChange = async (agentId: string, newRole: AgentCapability) => { const agent = agents.find(a => a.id === agentId); if (!agent) return; // If same role, just cancel editing without API call if (agent.role === newRole) { setEditingRoleForAgent(null); return; } try { await updateAgent(agentId, { role: newRole }, projectId); addToast(`Agent role updated to ${AGENT_ROLES.find(r => r.value === newRole)?.label ?? newRole}`, "success"); setEditingRoleForAgent(null); void loadAgents(); } catch (err) { addToast(`Failed to update role: ${getErrorMessage(err)}`, "error"); } }; const handleRoleKeyDown = (e: React.KeyboardEvent, _agentId: string) => { if (e.key === "Escape") { setEditingRoleForAgent(null); } }; const handleHeartbeatIntervalChange = async (agent: Agent, newIntervalMs: number) => { // Clear custom input state when selecting a preset if (customHeartbeatAgentId === agent.id) { setCustomHeartbeatAgentId(null); setCustomHeartbeatMinutes((prev) => { const next = { ...prev }; delete next[agent.id]; return next; }); } setUpdatingHeartbeatAgentId(agent.id); try { await updateAgent( agent.id, { runtimeConfig: { ...(agent.runtimeConfig ?? {}), heartbeatIntervalMs: newIntervalMs, }, }, projectId, ); addToast(`Heartbeat interval updated to ${formatHeartbeatInterval(newIntervalMs)} for ${agent.name}`, "success"); void loadAgents(); } catch (err) { addToast(`Failed to update heartbeat interval: ${getErrorMessage(err)}`, "error"); } finally { setUpdatingHeartbeatAgentId(null); } }; /** * Handle saving custom heartbeat interval from typed minutes input. * Validation behavior: * - Empty value: do not save; show validation toast * - Non-numeric value: do not save; show validation toast * - Value <= 0: do not save; show validation toast * - Value 1-4: save as 5 minutes (300000 ms) and show clamp-info toast * - Value >= 5: save exact minute value converted to ms */ const handleCustomHeartbeatSave = async (agent: Agent) => { const inputValue = customHeartbeatMinutes[agent.id] ?? ""; // Validate: empty value if (inputValue.trim() === "") { addToast("Please enter a heartbeat interval in minutes", "error"); return; } // Validate: non-numeric value const minutes = Number(inputValue); if (isNaN(minutes)) { addToast("Heartbeat interval must be a valid number", "error"); return; } // Validate: zero or negative if (minutes <= 0) { addToast("Heartbeat interval must be greater than 0", "error"); return; } // Handle values 1-4: clamp to 5 minutes if (minutes >= 1 && minutes < 5) { setUpdatingHeartbeatAgentId(agent.id); try { await updateAgent( agent.id, { runtimeConfig: { ...(agent.runtimeConfig ?? {}), heartbeatIntervalMs: MIN_HEARTBEAT_INTERVAL_MS, }, }, projectId, ); addToast(`Heartbeat interval set to 5 minutes (minimum). ${minutes} minute${minutes !== 1 ? "s" : ""} was below the 5-minute minimum.`, "success"); setCustomHeartbeatAgentId(null); setCustomHeartbeatMinutes((prev) => { const next = { ...prev }; delete next[agent.id]; return next; }); void loadAgents(); } catch (err) { addToast(`Failed to update heartbeat interval: ${getErrorMessage(err)}`, "error"); } finally { setUpdatingHeartbeatAgentId(null); } return; } // Handle values >= 5: save exact minute value const intervalMs = Math.round(minutes * 60_000); setUpdatingHeartbeatAgentId(agent.id); try { await updateAgent( agent.id, { runtimeConfig: { ...(agent.runtimeConfig ?? {}), heartbeatIntervalMs: intervalMs, }, }, projectId, ); addToast(`Heartbeat interval updated to ${formatHeartbeatInterval(intervalMs)} for ${agent.name}`, "success"); setCustomHeartbeatAgentId(null); setCustomHeartbeatMinutes((prev) => { const next = { ...prev }; delete next[agent.id]; return next; }); void loadAgents(); } catch (err) { addToast(`Failed to update heartbeat interval: ${getErrorMessage(err)}`, "error"); } finally { setUpdatingHeartbeatAgentId(null); } }; /** Handle selecting custom option from dropdown */ const handleSelectCustomHeartbeat = (agent: Agent) => { const configuredIntervalMs = resolveHeartbeatIntervalMs(agent.runtimeConfig?.heartbeatIntervalMs); // Convert ms to minutes for the input field const currentMinutes = Math.round(configuredIntervalMs / 60_000); setCustomHeartbeatAgentId(agent.id); setCustomHeartbeatMinutes((prev) => ({ ...prev, [agent.id]: String(currentMinutes), })); }; const openAgentDetail = useCallback((agentId: string, options?: { initialTab?: "dashboard" | "runs"; initialRunId?: string | null; preferActiveRun?: boolean }) => { setSelectedAgentId(agentId); setSelectedAgentInitialTab(options?.initialTab ?? "dashboard"); setSelectedAgentInitialRunId(options?.initialRunId ?? null); setSelectedAgentPreferActiveRun(options?.preferActiveRun ?? false); }, []); const handleCloseDetail = useCallback(() => { setSelectedAgentId(null); setSelectedAgentInitialTab("dashboard"); setSelectedAgentInitialRunId(null); setSelectedAgentPreferActiveRun(false); }, []); const handleChildClick = useCallback((childId: string) => { openAgentDetail(childId); }, [openAgentDetail]); const handleOrgChartNodeSelect = useCallback((agentId: string) => { setSelectedOrgChartAgentId(agentId); openAgentDetail(agentId); }, [openAgentDetail]); const handleDetailMutationSuccess = useCallback(async ({ agentId, deleted }: { agentId: string; deleted?: boolean }) => { await refreshAgents(); if (deleted && selectedAgentId === agentId) { handleCloseDetail(); } }, [refreshAgents, selectedAgentId, handleCloseDetail]); const handleOverviewAgentSelect = useCallback((agentId: string) => { openAgentDetail(agentId); if (isMobileViewport) { setIsOverviewOpen(false); } }, [isMobileViewport, openAgentDetail]); const handleRunHeartbeat = async (agentId: string, agentName: string) => { // Optimistic state flip: the API call can take several seconds before the // backend transitions the agent to running, and the user clicking "Run // Now" reasonably expects the card to react immediately. We mirror the // pattern handleStateChange uses: stamp the override, await the API, // refetch on success, roll back on failure. setOptimisticStateOverrides((prev) => { const next = new Map(prev); next.set(agentId, "running"); return next; }); try { await startAgentRun(agentId, projectId, { source: "on_demand", triggerDetail: "Triggered from dashboard" }); addToast(`Heartbeat run started for ${agentName}`, "success"); await loadAgents(); setOptimisticStateOverrides((prev) => { const next = new Map(prev); next.delete(agentId); return next; }); } catch (err) { setOptimisticStateOverrides((prev) => { const next = new Map(prev); next.delete(agentId); return next; }); addToast(`Failed to start heartbeat run: ${getErrorMessage(err)}`, "error"); } }; 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") { setOrgChartTransform({ scale: 1, x: 0, y: 0 }); } if (isMobileViewport && selectedAgentId) { handleCloseDetail(); } }, [handleCloseDetail, isMobileViewport, selectedAgentId]); const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role; const orgChartLayoutMode: OrgChartLayoutMode = useMemo(() => resolveOrgChartLayoutMode({ tree: displayOrgTree, availableWidth: orgChartViewportWidth, preference: orgChartLayoutPreference, }), [displayOrgTree, orgChartLayoutPreference, orgChartViewportWidth]); const handleOrgChartLayoutPreferenceChange = useCallback((preference: OrgChartLayoutPreference) => { setOrgChartLayoutPreference(preference); 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); } }, []); 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 }> = [ { value: "horizontal", label: "Horizontal", icon: , ariaLabel: "Horizontal layout" }, { value: "vertical", label: "Vertical", icon: , ariaLabel: "Vertical layout" }, { value: "auto", label: "Auto", icon: , ariaLabel: "Automatic layout" }, ]; return (
{options.map((option) => { const isActive = orgChartLayoutPreference === option.value; return ( ); })}
); }, [handleOrgChartLayoutPreferenceChange, orgChartLayoutPreference]); /** Get skill badges from agent metadata */ const getSkillBadges = (agent: Agent): string[] => { if (Array.isArray(agent.metadata?.skills)) { return agent.metadata.skills as string[]; } return []; }; // Use centralized health status utility for consistent labels across all views const getHealthStatus = (agent: Agent): AgentHealthStatus => { return getAgentHealthStatus(agent); }; const isPauseAllDisabled = isBulkEligibilityLoading || isBulkActionRunning || bulkPauseEligibleCount === 0; const isResumeAllDisabled = isBulkEligibilityLoading || isBulkActionRunning || bulkResumeEligibleCount === 0; const showInitialAgentsLoading = isLoading && agents.length === 0; const handleOpenNewAgent = useCallback(() => { setIsCreating(true); }, []); return (

Agents

{!isMobileViewport && ( <> )} {isControlsPanelOpen && ( )}
{ setIsCreating(false); setOnboardingDraft(null); }} onCreated={() => { setIsCreating(false); setOnboardingDraft(null); void loadAgents(); }} projectId={projectId} prefillDraft={onboardingDraft} agentOnboardingEnabled={agentOnboardingEnabled} existingAgents={agents} onPrefillDraft={setOnboardingDraft} /> setIsImporting(false)} onImported={() => void loadAgents()} projectId={projectId} /> setIsOverviewOpen((open) => !open)} onSelectAgent={handleOverviewAgentSelect} onOpenTaskLogs={onOpenTaskLogs} /> {agentView === "org" ? (
{selectedAgentId ? (
) : showInitialAgentsLoading ? (
Loading agents...
) : (
{isMobileViewport ? (
{renderOrgChartLayoutToggle()} {renderOrgChartZoomControls()}
) : (
{renderOrgChartLayoutToggle()}
{renderOrgChartZoomControls()}
)}
{isOrgTreeLoading ? (
Loading org chart...
) : displayOrgTree.length === 0 ? ( ) : ( (() => { orgChartLinksRef.current = []; return displayOrgTree.map((node) => ( )); })() )}
)}
) : (
{/* Agent Collection */} {showInitialAgentsLoading ? (
Loading agents...
) : agentView === "board" ? (
{displayAgents.length === 0 ? ( ) : ( displayAgents.map((agent) => { const health = getHealthStatus(agent); const healthSummary = getHealthSummary(agent, health); const stateBadgeClass = getStateBadgeClass(agent.state); const stateCardClass = getStateCardClass("agent-board-card", agent.state); return (
openAgentDetail(agent.id)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { if (e.key === " ") { e.preventDefault(); } openAgentDetail(agent.id); } }} >
{getRoleLabel(agent.role)} {agent.state} {(agent.pendingApprovalCount ?? 0) > 0 ? ( {agent.pendingApprovalCount} ) : null}
{agent.name}
{agent.id}
{health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""}
); }) )}
) : (
{displayAgents.length === 0 ? ( ) : ( // List view: detailed card layout displayAgents.map(agent => { const health = getHealthStatus(agent); const healthSummary = getHealthSummary(agent, health); const stateBadgeClass = getStateBadgeClass(agent.state); const stateCardClass = getStateCardClass("agent-card", agent.state); const configuredIntervalMs = resolveHeartbeatIntervalMs(agent.runtimeConfig?.heartbeatIntervalMs); const heartbeatOptions = getHeartbeatIntervalOptions(configuredIntervalMs); const isUpdatingHeartbeat = updatingHeartbeatAgentId === agent.id; return (
{ // Open detail when the user clicks the card body, but // bail when the click landed on an interactive // descendant (action buttons, the role-edit select, // the role-icon button) so those keep their dedicated // behaviors instead of double-firing. Use currentTarget // as the boundary so the card's own role="button" is // not treated as an interactive descendant. const target = e.target as HTMLElement; if (target === e.currentTarget) { openAgentDetail(agent.id); return; } const interactive = target.closest('button, select, input, [role="button"]'); if (interactive && interactive !== e.currentTarget) return; openAgentDetail(agent.id); }} role="button" tabIndex={0} onKeyDown={(e) => { if (e.target !== e.currentTarget) return; if (e.key === "Enter" || e.key === " ") { if (e.key === " ") e.preventDefault(); openAgentDetail(agent.id); } }} aria-label={`Open details for ${agent.name}`} >
{editingRoleForAgent === agent.id ? ( ) : ( { e.stopPropagation(); setEditingRoleForAgent(agent.id); }} title="Click to change role" role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.stopPropagation(); setEditingRoleForAgent(agent.id); } }} > )}
{agent.name} {agent.id}
{agent.state} {health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""} {getRoleLabel(agent.role)} {(agent.pendingApprovalCount ?? 0) > 0 ? ( {agent.pendingApprovalCount} ) : null} {/* List view: up to 2 skill badges */} {(() => { const skills = getSkillBadges(agent); if (skills.length === 0) return null; const displaySkills = skills.slice(0, 2); const extraCount = skills.length - 2; return ( <> {displaySkills.map((skillId) => ( {formatAgentSkillBadgeLabel(skillId)} ))} {extraCount > 0 && +{extraCount}} ); })()}
{agent.state === "error" && agent.lastError ? ( ) : null} {agent.taskId && (
Working on: {agent.taskId}
)}
Heartbeat: {customHeartbeatAgentId === agent.id ? ( // Custom input mode <> setCustomHeartbeatMinutes((prev) => ({ ...prev, [agent.id]: e.target.value, }))} onKeyDown={(e) => { if (e.key === "Enter") { void handleCustomHeartbeatSave(agent); } else if (e.key === "Escape") { setCustomHeartbeatAgentId(null); setCustomHeartbeatMinutes((prev) => { const next = { ...prev }; delete next[agent.id]; return next; }); } }} disabled={isUpdatingHeartbeat} aria-label={`Custom heartbeat interval in minutes for ${agent.name}`} /> min ) : ( // Preset selection mode <> )} {isUpdatingHeartbeat && Saving…} {agent.lastHeartbeatAt && (() => { const lastAt = new Date(agent.lastHeartbeatAt); const nextAt = new Date(lastAt.getTime() + configuredIntervalMs); const isTicking = agent.state === "active" || agent.state === "running"; return ( <> Last: {lastAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} {isTicking && ( Next: {nextAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} )} ); })()}
{agent.state === "idle" && ( )} {agent.state === "active" && ( <> )} {agent.state === "paused" && ( )} {agent.state === "running" && ( <> )} {agent.state === "error" && ( )}
{(agent.state === "idle" || agent.state === "paused") && ( )}
); }) )}
)}
{selectedAgentId ? ( ) : (

Select an agent

Choose an agent from the sidebar to view details

)}
)}
); }