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 && (
)}
);
}