import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import type { JSX } from "react"; import { Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, 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 { 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"; 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 STATE_COLORS: Record = { idle: { bg: "var(--state-idle-bg)", text: "var(--state-idle-text)", border: "var(--state-idle-border)" }, active: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" }, running: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" }, paused: { bg: "var(--state-paused-bg)", text: "var(--state-paused-text)", border: "var(--state-paused-border)" }, error: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" }, terminated: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" }, }; /** Recursive tree node component for agent hierarchy */ function AgentTreeNode({ node, onSelect, onToggle, isExpanded, getChildCount, getHealthStatus, getRoleIcon, }: { node: AgentNode; onSelect: (id: string) => void; onToggle: (id: string) => void; isExpanded: (id: string) => boolean; getChildCount: (id: string) => number; getHealthStatus: (agent: Agent) => { label: string; icon: JSX.Element; color: string }; getRoleIcon: (role: AgentCapability) => string; }) { const { agent, children, depth } = node; const childCount = getChildCount(agent.id); const expanded = isExpanded(agent.id); const health = getHealthStatus(agent); const stateStyle = STATE_COLORS[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}) )}
{expanded && children.length > 0 && (
{children.map((child) => ( ))}
)} ); } function OrgChartNode({ node, onSelect, getHealthStatus, getRoleIcon, }: { node: OrgTreeNode; onSelect: (id: string) => void; getHealthStatus: (agent: Agent) => { label: string; icon: JSX.Element; color: string }; getRoleIcon: (role: AgentCapability) => string; }) { const { agent, children } = node; const health = getHealthStatus(agent); const stateStyle = STATE_COLORS[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.label}
{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<"board" | "list" | "tree" | "org">(() => { if (typeof window === "undefined") return "list"; const saved = getScopedItem("kb-agent-view", projectId); return (saved === "board" || saved === "list" || saved === "tree" || saved === "org") ? saved : "list"; }); const [orgTree, setOrgTree] = useState([]); const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false); useEffect(() => { const saved = getScopedItem("kb-agent-view", projectId); if (saved === "board" || saved === "list" || saved === "tree" || saved === "org") { setAgentView(saved); return; } setAgentView("list"); }, [projectId]); // Persist view preference to localStorage useEffect(() => { setScopedItem("kb-agent-view", agentView, projectId); }, [agentView, projectId]); const [editingRoleForAgent, setEditingRoleForAgent] = useState(null); const roleSelectRef = useRef(null); const hierarchy = useAgentHierarchy(agents, projectId); // Filter agents for display: hide terminated agents in default "All States" view // but show them when the user explicitly filters to "terminated" const displayAgents = useMemo(() => { if (filterState === "all") { return agents.filter(a => a.state !== "terminated"); } return agents; }, [agents, filterState]); // Filter org tree to exclude terminated agents in default view const displayOrgTree = useMemo(() => { if (filterState === "all") { // Recursively filter out terminated agents from the org tree const filterNode = (node: OrgTreeNode): OrgTreeNode | null => { if (node.agent.state === "terminated") 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); } return orgTree; }, [orgTree, 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: any) { addToast(`Failed to load agents: ${err.message}`, "error"); } finally { setIsLoading(false); } }, [filterState, addToast, projectId]); useEffect(() => { void loadAgents(); }, [loadAgents]); useEffect(() => { if (agentView !== "org") return; let cancelled = false; setIsOrgTreeLoading(true); fetchOrgTree(projectId) .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, addToast]); // Refresh agent list on SSE events (independent from useAgents hook state) useEffect(() => { const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const es = new EventSource(`/api/events${query}`); const refresh = () => { void loadAgents(); }; es.addEventListener("agent:created", refresh); es.addEventListener("agent:updated", refresh); es.addEventListener("agent:deleted", refresh); es.addEventListener("agent:stateChanged", refresh); return () => { es.close(); }; }, [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 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 ?? "🤖"; // Use centralized health status utility for consistent labels across all views const getHealthStatus = (agent: Agent): { label: string; icon: JSX.Element; color: string } => { 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 ? (

No agents found

Create an agent to get started

) : ( hierarchy.rootNodes.map((node) => ( hierarchy.getChildren(id).length} getHealthStatus={getHealthStatus} getRoleIcon={getRoleIcon} /> )) )}
) : agentView === "org" ? (
{isOrgTreeLoading ? (
Loading org chart...
) : displayOrgTree.length === 0 ? (

No agents found

Create an agent to get started

) : ( displayOrgTree.map((node) => ( )) )}
) : (
{displayAgents.length === 0 ? (

No agents found

Create an agent to get started

) : agentView === "board" ? ( // Board view: compact grid layout displayAgents.map(agent => { const health = getHealthStatus(agent); const stateStyle = STATE_COLORS[agent.state]; return (
setSelectedAgentId(agent.id)} role="button" tabIndex={0} onKeyDown={(e) => e.key === "Enter" && setSelectedAgentId(agent.id)} >
{getRoleIcon(agent.role)} {agent.state} {health.icon}
{agent.name}
{agent.id}
{agent.state === "idle" && ( <> )} {agent.state === "active" && ( <> {agent.taskId && ( )} )} {agent.state === "paused" && ( <> )} {agent.state === "running" && ( <> {agent.taskId && ( )} )} {agent.state === "error" && ( <> )} {agent.state === "terminated" && ( )}
); }) ) : ( // List view: detailed card layout displayAgents.map(agent => { const health = getHealthStatus(agent); const stateStyle = STATE_COLORS[agent.state]; 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.label} {getRoleLabel(agent.role)}
{agent.taskId && (
Working on: {agent.taskId}
)} {agent.lastHeartbeatAt && (
Last heartbeat: {new Date(agent.lastHeartbeatAt).toLocaleString()}
)}
{agent.state === "idle" && ( <> )} {agent.state === "active" && ( <> {agent.taskId && ( )} )} {agent.state === "paused" && ( <> )} {agent.state === "running" && ( <> {agent.taskId && ( )} )} {agent.state === "error" && ( <> )} {agent.state === "terminated" && ( )}
); }) )}
)}
{/* Agent Detail Modal */} {selectedAgentId && ( )}
); }