import "./AgentListModal.css"; // AgentListModal renders agent cards using .agent-board-*, .agent-icon, .agent-state-filter // rules that live in AgentsView.css. The modal is eager but AgentsView is lazy, so we // import the styles eagerly here to avoid the modal rendering unstyled. import "./AgentsView.css"; import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { Plus, Play, Pause, Square, Trash2, RefreshCw, Bot, LayoutGrid, List, Filter } from "lucide-react"; import type { Agent, AgentCapability, AgentState } from "../api"; import { fetchAgents, createAgent, updateAgent, updateAgentState, deleteAgent } from "../api"; import { getScopedItem, setScopedItem } from "../utils/projectStorage"; import { getAgentHealthStatus } from "../utils/agentHealth"; import { getErrorMessage } from "@fusion/core"; import type { AgentHealthStatus } from "../utils/agentHealth"; import { useConfirm } from "../hooks/useConfirm"; import { AgentAvatar } from "./AgentAvatar"; import { AgentErrorIndicator } from "./AgentErrorDetailsModal"; interface AgentListModalProps { isOpen: boolean; onClose: () => void; 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: "✦" }, ]; export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentListModalProps) { const [agents, setAgents] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isCreating, setIsCreating] = useState(false); const [newAgentName, setNewAgentName] = useState(""); const [newAgentRole, setNewAgentRole] = useState("custom"); const [filterState, setFilterState] = useState("all"); const [view, setView] = useState<"board" | "list">(() => { if (typeof window === "undefined") return "list"; const saved = getScopedItem("fn-agent-view", projectId); return (saved === "board" || saved === "list") ? saved : "list"; }); useEffect(() => { const saved = getScopedItem("fn-agent-view", projectId); if (saved === "board" || saved === "list") { setView(saved); return; } setView("list"); }, [projectId]); // Persist view preference to localStorage useEffect(() => { setScopedItem("fn-agent-view", view, projectId); }, [projectId, view]); const [editingRoleForAgent, setEditingRoleForAgent] = useState(null); const roleSelectRef = useRef(null); const [transitioningAgentIds, setTransitioningAgentIds] = useState>(new Set()); const [optimisticStateOverrides, setOptimisticStateOverrides] = useState>(new Map()); const { confirm } = useConfirm(); 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]); // Display ordering: paused agents accumulate over time and would crowd // active agents at the top; sort them to the bottom in the default // "All States" view, breaking ties by `updatedAt` desc. const displayAgents = useMemo(() => { if (filterState !== "all") return optimisticAgents; return [...optimisticAgents].sort((a, b) => { const aPaused = a.state === "paused" ? 1 : 0; const bPaused = b.state === "paused" ? 1 : 0; if (aPaused !== bPaused) return aPaused - bPaused; return (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""); }); }, [optimisticAgents, filterState]); const loadAgents = useCallback(async () => { setIsLoading(true); try { const filter = filterState !== "all" ? { state: filterState } : undefined; const data = await fetchAgents(filter, projectId); setAgents(data); } catch (err) { addToast(`Failed to load agents: ${getErrorMessage(err)}`, "error"); } finally { setIsLoading(false); } }, [filterState, addToast, projectId]); useEffect(() => { if (isOpen) { void loadAgents(); } }, [isOpen, loadAgents]); // Poll for agent updates to keep health statuses fresh (every 30 seconds) // This ensures health badges stay current while the modal is open useEffect(() => { if (!isOpen) return; const pollInterval = setInterval(() => { void loadAgents(); }, 30_000); return () => { clearInterval(pollInterval); }; }, [isOpen, loadAgents]); const handleCreate = async () => { if (!newAgentName.trim()) return; try { await createAgent({ name: newAgentName.trim(), role: newAgentRole }, projectId); addToast(`Agent "${newAgentName}" created`, "success"); setNewAgentName(""); setIsCreating(false); void loadAgents(); } catch (err) { addToast(`Failed to create agent: ${getErrorMessage(err)}`, "error"); } }; 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"); 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 getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role; // Use centralized health status utility for consistent labels across all views // This fixes the previous hardcoded 60s timeout that was inconsistent with other views const getHealthStatus = (agent: Agent): AgentHealthStatus => { return getAgentHealthStatus(agent); }; const getHealthTone = (health: AgentHealthStatus): "active" | "paused" | "error" | "muted" => { if (health.color === "var(--state-active-text)") return "active"; if (health.color === "var(--state-paused-text)") return "paused"; if (health.color === "var(--state-error-text)") return "error"; return "muted"; }; const 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, }; }; if (!isOpen) return null; return (
e.target === e.currentTarget && onClose()} role="dialog" aria-modal="true">

Agents

{/* Filter and Create Bar */}
{/* Create Form */} {isCreating && (
setNewAgentName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleCreate()} className="input" autoFocus />
)} {/* Agent List */}
{displayAgents.length === 0 ? (

No agents found

Create an agent to get started

) : view === "board" ? ( // Board view: compact grid layout displayAgents.map(agent => { const health = getHealthStatus(agent); const healthSummary = getHealthSummary(agent, health); const healthTone = getHealthTone(health); return (
{agent.state} {health.icon}
{agent.name}
{agent.id}
{agent.state === "idle" && ( <> )} {agent.state === "active" && ( <> )} {agent.state === "paused" && ( <> )} {agent.state === "running" && ( <> )} {agent.state === "error" && ( <> )}
); }) ) : ( // List view: detailed card layout displayAgents.map(agent => { const health = getHealthStatus(agent); const healthSummary = getHealthSummary(agent, health); const healthTone = getHealthTone(health); return (
{editingRoleForAgent === agent.id ? ( ) : ( setEditingRoleForAgent(agent.id)} title="Click to change role" role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { setEditingRoleForAgent(agent.id); } }} > )}
{agent.name} {agent.id}
{agent.state} {health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""} {getRoleLabel(agent.role)}
{agent.state === "error" && agent.lastError ? ( ) : null} {agent.taskId && (
Working on: {agent.taskId}
)} {agent.lastHeartbeatAt && (
Last heartbeat: {new Date(agent.lastHeartbeatAt).toLocaleString()}
)}
{agent.state === "idle" && ( <> )} {agent.state === "active" && ( <> )} {agent.state === "paused" && ( <> )} {agent.state === "running" && ( <> )} {agent.state === "error" && ( <> )}
); }) )}
); }