import "./AgentsView.css"; import { useState, useEffect, useCallback, useRef, useMemo, useId, lazy, Suspense } from "react"; import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, ChevronDown, GitBranch, Filter, Upload, Network, SlidersHorizontal } from "lucide-react"; import type { Agent, AgentCapability, AgentState, OrgTreeNode } from "../api"; import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api"; const AgentDetailView = lazy(() => import("./AgentDetailView").then((m) => ({ default: m.AgentDetailView }))); import { ActiveAgentsPanel } from "./ActiveAgentsPanel"; import { AgentMetricsBar } from "./AgentMetricsBar"; import { AgentTokenStatsPanel } from "./AgentTokenStatsPanel"; import { AgentEmptyState } from "./AgentEmptyState"; import { useAgents } from "../hooks/useAgents"; import { useAgentHierarchy } from "../hooks/useAgentHierarchy"; import type { AgentNode } from "../hooks/useAgentHierarchy"; import { NewAgentDialog } from "./NewAgentDialog"; import { AgentImportModal } from "./AgentImportModal"; import { getScopedItem, setScopedItem } from "../utils/projectStorage"; 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"; export interface AgentsViewProps { addToast: (message: string, type?: "success" | "error") => void; projectId?: string; } 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; 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 "terminated": return "agent-badge--terminated"; case "idle": default: return "agent-badge--idle"; } } function getStateCardClass(prefix: "agent-card" | "agent-board-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 "terminated": return `${prefix}--terminated`; case "idle": default: return `${prefix}--idle`; } } /** Recursive tree node component for agent hierarchy */ function AgentTreeNode({ node, onSelect, onToggle, isExpanded, getChildCount, getHealthStatus, getRoleIcon, getSkillBadges, }: { node: AgentNode; onSelect: (id: string) => void; onToggle: (id: string) => void; isExpanded: (id: string) => boolean; getChildCount: (id: string) => number; getHealthStatus: (agent: Agent) => AgentHealthStatus; getRoleIcon: (role: AgentCapability) => string; getSkillBadges: (agent: Agent) => string[]; }) { const { agent, children, depth } = node; const childCount = getChildCount(agent.id); const expanded = isExpanded(agent.id); const health = getHealthStatus(agent); const stateBadgeClass = getStateBadgeClass(agent.state); return ( <>
onSelect(agent.id)} role="button" tabIndex={0} onKeyDown={(e) => e.key === "Enter" && onSelect(agent.id)} > {getRoleIcon(agent.role)} {agent.name} {agent.state} {health.icon} {childCount > 0 && ( ({childCount}) )} {/* Tree view: up to 1 skill badge */} {(() => { const skills = getSkillBadges(agent); if (skills.length === 0) return null; return ( {skills[0]}{skills.length > 1 && ` +${skills.length - 1}`} ); })()}
{expanded && children.length > 0 && (
{children.map((child) => ( ))}
)} ); } function OrgChartNode({ node, onSelect, getHealthStatus, getRoleIcon, getSkillBadges, }: { node: OrgTreeNode; onSelect: (id: string) => void; getHealthStatus: (agent: Agent) => AgentHealthStatus; getRoleIcon: (role: AgentCapability) => string; getSkillBadges: (agent: Agent) => string[]; }) { const { agent, children } = node; const health = getHealthStatus(agent); const stateBadgeClass = getStateBadgeClass(agent.state); return (
0 ? " org-chart-node--has-children" : ""}`}>
onSelect(agent.id)} role="button" tabIndex={0} onKeyDown={(e) => e.key === "Enter" && onSelect(agent.id)} >
{getRoleIcon(agent.role)} {agent.name}
{agent.state} {health.icon} {!health.stateDerived && {health.label}} {/* Org chart: 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) => ( {skillId} ))} {extraCount > 0 && +{extraCount}} ); })()}
{children.length > 0 && (
{children.map((child) => ( ))}
)}
); } export function AgentsView({ addToast, projectId }: AgentsViewProps) { const [showSystemAgents, setShowSystemAgents] = useState(false); const [filterState, setFilterState] = useState("all"); const { agents, activeAgents, stats, isLoading, loadAgents } = useAgents(projectId, { filterState, showSystemAgents, }); const [isCreating, setIsCreating] = useState(false); const [isImporting, setIsImporting] = useState(false); const [selectedAgentId, setSelectedAgentId] = useState(null); const [agentView, setAgentView] = useState<"list" | "board" | "tree" | "org">(() => { if (typeof window === "undefined") return "list"; const saved = getScopedItem("fn-agent-view", projectId); return (saved === "list" || saved === "board" || saved === "tree" || saved === "org") ? saved : "list"; }); const [orgTree, setOrgTree] = useState([]); const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false); const [isControlsPanelOpen, setIsControlsPanelOpen] = useState(false); const controlsPanelRef = useRef(null); const controlsTriggerRef = useRef(null); const controlsPanelId = useId(); useEffect(() => { const saved = getScopedItem("fn-agent-view", projectId); if (saved === "list" || saved === "board" || saved === "tree" || saved === "org") { setAgentView(saved); return; } setAgentView("list"); }, [projectId]); // Persist view preference to localStorage useEffect(() => { setScopedItem("fn-agent-view", agentView, projectId); }, [agentView, 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()); 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 hierarchy = useAgentHierarchy(agents, projectId); // Filter agents for display. "All States" means all non-ephemeral agents, // including disabled/terminated agents that still carry configuration. // When "Show system agents" is enabled, include ephemeral/internal agents. const displayAgents = useMemo(() => { return agents.filter((agent) => showSystemAgents || !isEphemeralAgent(agent)); }, [agents, 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; 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; 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 () => { document.removeEventListener("mousedown", handlePointerDown); document.removeEventListener("touchstart", handlePointerDown); document.removeEventListener("keydown", handleKeyDown); }; }, [isControlsPanelOpen]); const handleStateChange = async (agentId: string, newState: AgentState) => { if (transitioningAgentIds.has(agentId)) return; setTransitioningAgentIds(prev => new Set(prev).add(agentId)); try { await updateAgentState(agentId, newState, projectId); addToast(`Agent state updated to ${newState}`, "success"); void loadAgents(); } catch (err) { 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) => { if (!confirm(`Delete agent "${agentName}"? This cannot be undone.`)) return; try { await deleteAgent(agentId, projectId); addToast(`Agent "${agentName}" deleted`, "success"); void 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 handleCloseDetail = useCallback(() => { setSelectedAgentId(null); }, []); const handleChildClick = useCallback((childId: string) => { setSelectedAgentId(childId); }, []); const handleRunHeartbeat = async (agentId: string, agentName: string) => { try { await startAgentRun(agentId, projectId, { source: "on_demand", triggerDetail: "Triggered from dashboard" }); addToast(`Heartbeat run started for ${agentName}`, "success"); void loadAgents(); } catch (err) { addToast(`Failed to start heartbeat run: ${getErrorMessage(err)}`, "error"); } }; 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 ?? "◆"; /** 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); }; return (

Agents

{isControlsPanelOpen && ( )}
setIsCreating(false)} onCreated={() => { setIsCreating(false); void loadAgents(); }} projectId={projectId} /> setIsImporting(false)} onImported={() => void loadAgents()} projectId={projectId} /> {/* Agent Collection */} {agentView === "tree" ? (
{displayAgents.length === 0 ? ( setIsCreating(true)} /> ) : ( hierarchy.rootNodes.map((node) => ( hierarchy.getChildren(id).length} getHealthStatus={getHealthStatus} getRoleIcon={getRoleIcon} getSkillBadges={getSkillBadges} /> )) )}
) : agentView === "org" ? (
{isOrgTreeLoading ? (
Loading org chart...
) : displayOrgTree.length === 0 ? ( setIsCreating(true)} /> ) : ( displayOrgTree.map((node) => ( )) )}
) : agentView === "board" ? (
{displayAgents.length === 0 ? ( setIsCreating(true)} /> ) : ( displayAgents.map((agent) => { const health = getHealthStatus(agent); const stateBadgeClass = getStateBadgeClass(agent.state); const stateCardClass = getStateCardClass("agent-board-card", agent.state); return (
setSelectedAgentId(agent.id)} role="button" tabIndex={0} onKeyDown={(e) => e.key === "Enter" && setSelectedAgentId(agent.id)} >
{getRoleIcon(agent.role)} {getRoleLabel(agent.role)} {agent.state}
{agent.name}
{agent.id}
{health.icon}{!health.stateDerived && ` ${health.label}`}
); }) )}
) : (
{displayAgents.length === 0 ? ( setIsCreating(true)} /> ) : ( // List view: detailed card layout displayAgents.map(agent => { const health = getHealthStatus(agent); 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 (
setSelectedAgentId(agent.id)} role="button" tabIndex={0} onKeyDown={(e) => e.key === "Enter" && setSelectedAgentId(agent.id)} > {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); } }} > {getRoleIcon(agent.role)} )}
{agent.name} {agent.id}
{agent.state} {health.icon}{!health.stateDerived && ` ${health.label}`} {getRoleLabel(agent.role)} {/* 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) => ( {skillId} ))} {extraCount > 0 && +{extraCount}} ); })()}
{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()} {isTicking && ( Next: {nextAt.toLocaleTimeString()} )} ); })()}
{agent.state === "idle" && ( <> )} {agent.state === "active" && ( <> )} {agent.state === "paused" && ( )} {agent.state === "running" && ( <> )} {agent.state === "error" && ( )} {agent.state === "terminated" && ( <> )}
); }) )}
)} {/* Secondary sections after the main collection */}
{/* Agent Detail Modal */} {selectedAgentId && ( )}
); }