import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, ChevronDown, GitBranch, Filter, Upload, Network } from "lucide-react"; import type { Agent, AgentCapability, AgentState, OrgTreeNode } from "../api"; import { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree } from "../api"; import { AgentDetailView } from "./AgentDetailView"; import { ActiveAgentsPanel } from "./ActiveAgentsPanel"; import { AgentMetricsBar } from "./AgentMetricsBar"; import { AgentEmptyState } from "./AgentEmptyState"; import { useAgents } from "../hooks/useAgents"; import { subscribeSse } from "../sse-bus"; 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, } from "../utils/heartbeatIntervals"; import { isEphemeralAgent } 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: "✦" }, ]; 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 { activeAgents, stats } = useAgents(projectId); const [agents, setAgents] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isCreating, setIsCreating] = useState(false); const [isImporting, setIsImporting] = useState(false); const [filterState, setFilterState] = useState("all"); 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); 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 [showSystemAgents, setShowSystemAgents] = useState(false); const [updatingHeartbeatAgentId, setUpdatingHeartbeatAgentId] = useState(null); 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]); const loadAgents = useCallback(async () => { setIsLoading(true); try { const filter = filterState !== "all" ? { state: filterState } : undefined; const data = await fetchAgents({ ...filter, includeEphemeral: showSystemAgents }, projectId); setAgents(data); } catch (err: any) { addToast(`Failed to load agents: ${err.message}`, "error"); } finally { setIsLoading(false); } }, [filterState, showSystemAgents, addToast, projectId]); useEffect(() => { void loadAgents(); }, [loadAgents]); useEffect(() => { if (agentView !== "org") return; let cancelled = false; setIsOrgTreeLoading(true); fetchOrgTree(projectId, { includeEphemeral: showSystemAgents }) .then((data) => { if (!cancelled) { setOrgTree(data); } }) .catch((err: any) => { if (!cancelled) { addToast(`Failed to load org chart: ${err.message}`, "error"); setOrgTree([]); } }) .finally(() => { if (!cancelled) { setIsOrgTreeLoading(false); } }); return () => { cancelled = true; }; }, [agentView, projectId, showSystemAgents, addToast]); // Refresh agent list on SSE events (independent from useAgents hook state) useEffect(() => { const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const refresh = () => { void loadAgents(); }; return subscribeSse(`/api/events${query}`, { events: { "agent:created": refresh, "agent:updated": refresh, "agent:deleted": refresh, "agent:stateChanged": refresh, }, }); }, [projectId, loadAgents]); // Poll for agent updates to keep health statuses fresh (every 30 seconds) // This ensures health badges stay current while the view is open useEffect(() => { const pollInterval = setInterval(() => { void loadAgents(); }, 30_000); return () => { clearInterval(pollInterval); }; }, [loadAgents]); const handleStateChange = async (agentId: string, newState: AgentState) => { try { await updateAgentState(agentId, newState, projectId); addToast(`Agent state updated to ${newState}`, "success"); // When activating an agent, also start a heartbeat run so it shows activity if (newState === "active") { try { await startAgentRun(agentId, projectId); } catch (runErr: any) { addToast(`Agent activated, but failed to start run: ${runErr.message}`, "error"); } } void loadAgents(); } catch (err: any) { addToast(`Failed to update state: ${err.message}`, "error"); } }; 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: any) { addToast(`Failed to delete agent: ${err.message}`, "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: any) { addToast(`Failed to update role: ${err.message}`, "error"); } }; const handleRoleKeyDown = (e: React.KeyboardEvent, _agentId: string) => { if (e.key === "Escape") { setEditingRoleForAgent(null); } }; const handleHeartbeatIntervalChange = async (agent: Agent, newIntervalMs: number) => { 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: any) { addToast(`Failed to update heartbeat interval: ${err.message}`, "error"); } finally { setUpdatingHeartbeatAgentId(null); } }; 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: any) { addToast(`Failed to start heartbeat run: ${err.message}`, "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

{/* Filter and Create Bar */}
setIsCreating(false)} onCreated={() => { setIsCreating(false); void loadAgents(); }} projectId={projectId} /> setIsImporting(false)} onImported={() => void loadAgents()} projectId={projectId} /> {/* Metrics Bar */} {/* Active Agents Panel - Live streaming cards */} {/* Agent List */} {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: {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" && ( <> )}
); }) )}
)}
{/* Agent Detail Modal */} {selectedAgentId && ( )}
); }