import "./AgentsView.css"; import { useState, useEffect, useCallback, useRef, useMemo, useId, lazy, Suspense, type CSSProperties, type ReactNode } from "react"; import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, Filter, Upload, Network, SlidersHorizontal, ZoomIn, ZoomOut, Minimize2, Info } from "lucide-react"; import type { Agent, AgentCapability, AgentOnboardingSummary, 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 { 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_ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] 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 "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, }; } function OrgChartNode({ node, onSelect, getHealthStatus, getRoleIcon, selectedAgentId, }: { node: OrgTreeNode; onSelect: (id: string) => void; getHealthStatus: (agent: Agent) => AgentHealthStatus; getRoleIcon: (role: AgentCapability) => string; selectedAgentId: string | null; }) { 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; const firstChildLeafCount = children.length > 0 ? getOrgChartLeafCount(children[0]) : 1; const lastChildLeafCount = children.length > 0 ? getOrgChartLeafCount(children[children.length - 1]) : 1; const childrenStyle = { "--org-chart-first-child-leaves": String(firstChildLeafCount), "--org-chart-last-child-leaves": String(lastChildLeafCount), } as CSSProperties; return (
0 ? " org-chart-node--has-children" : ""}`} style={nodeStyle} >
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) => ( ))}
)}
); } 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 [orgChartZoomIndex, setOrgChartZoomIndex] = useState(1); const controlsPanelRef = useRef(null); const orgChartViewportRef = useRef(null); 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; 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)); 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 handleAgentViewChange = useCallback((nextView: "list" | "board" | "org") => { setAgentView(nextView); if (nextView !== "org") { setOrgChartZoomIndex(1); } if (isMobileViewport && selectedAgentId) { handleCloseDetail(); } }, [handleCloseDetail, isMobileViewport, selectedAgentId]); 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 ?? "◆"; const orgChartZoom = ORG_CHART_ZOOM_LEVELS[orgChartZoomIndex]; const orgChartLayoutMode: OrgChartLayoutMode = useMemo(() => resolveOrgChartLayoutMode({ tree: displayOrgTree, availableWidth: orgChartViewportWidth, preference: orgChartLayoutPreference, }), [displayOrgTree, orgChartLayoutPreference, orgChartViewportWidth]); const handleOrgChartLayoutPreferenceChange = useCallback((preference: OrgChartLayoutPreference) => { setOrgChartLayoutPreference(preference); const isEdgeZoom = orgChartZoomIndex === 0 || orgChartZoomIndex === ORG_CHART_ZOOM_LEVELS.length - 1; const nextLayoutMode = resolveOrgChartLayoutMode({ tree: displayOrgTree, availableWidth: orgChartViewportWidth, preference, }); if (isEdgeZoom && nextLayoutMode !== orgChartLayoutMode) { setOrgChartZoomIndex(1); } }, [displayOrgTree, orgChartLayoutMode, orgChartViewportWidth, orgChartZoomIndex]); 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 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()} {Math.round(orgChartZoom * 100)}%
) : (
{renderOrgChartLayoutToggle()}
)}
{isOrgTreeLoading ? (
Loading org chart...
) : displayOrgTree.length === 0 ? ( ) : ( 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

)}
)}
); }