- Add project-scoped Pause All Agents and Resume All Agents actions to the AgentsView controls menu - Load bulk-action eligibility on menu open and skip ephemeral or ineligible agents - Surface bulk action confirmations, success/error toasts, and refresh agent state after updates - Add dashboard tests and agent docs coverage for the new bulk controls Fusion-Task-Id: FN-5227
1971 lines
84 KiB
TypeScript
1971 lines
84 KiB
TypeScript
import "./AgentsView.css";
|
||
import { useState, useEffect, useCallback, useRef, useMemo, useId, useLayoutEffect, lazy, Suspense, type CSSProperties, type ReactNode, type MutableRefObject, type RefObject, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, Filter, Upload, Network, SlidersHorizontal, ZoomIn, ZoomOut, Minimize2, Move, Info } from "lucide-react";
|
||
import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api";
|
||
import { fetchAgents, 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_SCALE_MIN = 0.25;
|
||
const ORG_CHART_SCALE_MAX = 3;
|
||
const ORG_CHART_KEYBOARD_PAN_STEP = 16;
|
||
const ORG_CHART_OVERSCROLL = 32;
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
type OrgChartLink = { parentId: string; childId: string };
|
||
type OrgChartTransform = { scale: number; x: number; y: number };
|
||
|
||
type OrgChartNodeProps = {
|
||
node: OrgTreeNode;
|
||
onSelect: (id: string) => void;
|
||
getHealthStatus: (agent: Agent) => AgentHealthStatus;
|
||
selectedAgentId: string | null;
|
||
registerNodeElement: (id: string, element: HTMLDivElement | null) => void;
|
||
linksRef: MutableRefObject<OrgChartLink[]>;
|
||
};
|
||
|
||
function OrgChartNode({ node, onSelect, getHealthStatus, selectedAgentId, registerNodeElement, linksRef }: OrgChartNodeProps) {
|
||
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;
|
||
|
||
return (
|
||
<div className={`org-chart-node${children.length > 0 ? " org-chart-node--has-children" : ""}`} style={nodeStyle}>
|
||
<div
|
||
ref={(element) => registerNodeElement(agent.id, element)}
|
||
data-agent-id={agent.id}
|
||
className={`org-chart-node-card ${stateNodeClass}${selectedAgentId === agent.id ? " agent-card--selected" : ""}`}
|
||
onClick={() => onSelect(agent.id)}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
if (e.key === " ") {
|
||
e.preventDefault();
|
||
}
|
||
onSelect(agent.id);
|
||
}
|
||
}}
|
||
>
|
||
<div className="org-chart-node__header">
|
||
<span className="org-chart-node__icon"><AgentAvatar agent={agent} size={20} /></span>
|
||
<span className="org-chart-node__name">{agent.name}</span>
|
||
</div>
|
||
<div className="org-chart-node__meta">
|
||
<span className={`org-chart-node__badge ${stateBadgeClass}`}>{agent.state}</span>
|
||
<span className="org-chart-node__health" style={{ color: health.color }} title={healthSummary.title}>
|
||
{health.icon}
|
||
{healthSummary.label && <span className="text-secondary">{healthSummary.label}</span>}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{children.length > 0 && (
|
||
<div className="org-chart-children" role="group" aria-label={`${agent.name} employees`}>
|
||
{children.map((child) => {
|
||
linksRef.current.push({ parentId: agent.id, childId: child.agent.id });
|
||
return (
|
||
<OrgChartNode
|
||
key={child.agent.id}
|
||
node={child}
|
||
onSelect={onSelect}
|
||
getHealthStatus={getHealthStatus}
|
||
selectedAgentId={selectedAgentId}
|
||
registerNodeElement={registerNodeElement}
|
||
linksRef={linksRef}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function OrgChartConnectors({
|
||
links,
|
||
nodeElements,
|
||
canvasRef,
|
||
viewportRef,
|
||
layoutMode,
|
||
transform,
|
||
}: {
|
||
links: OrgChartLink[];
|
||
nodeElements: Map<string, HTMLDivElement>;
|
||
canvasRef: RefObject<HTMLDivElement | null>;
|
||
viewportRef: RefObject<HTMLDivElement | null>;
|
||
layoutMode: OrgChartLayoutMode;
|
||
transform: OrgChartTransform;
|
||
}) {
|
||
const [paths, setPaths] = useState<string[]>([]);
|
||
|
||
useLayoutEffect(() => {
|
||
const canvas = canvasRef.current;
|
||
const viewport = viewportRef.current;
|
||
if (!canvas || !viewport || links.length === 0) {
|
||
setPaths([]);
|
||
return;
|
||
}
|
||
|
||
const recompute = () => {
|
||
const canvasRect = canvas.getBoundingClientRect();
|
||
const next = links.flatMap(({ parentId, childId }) => {
|
||
const parent = nodeElements.get(parentId);
|
||
const child = nodeElements.get(childId);
|
||
if (!parent || !child) return [];
|
||
const parentRect = parent.getBoundingClientRect();
|
||
const childRect = child.getBoundingClientRect();
|
||
const pLeft = (parentRect.left - canvasRect.left) / transform.scale;
|
||
const pTop = (parentRect.top - canvasRect.top) / transform.scale;
|
||
const cLeft = (childRect.left - canvasRect.left) / transform.scale;
|
||
const cTop = (childRect.top - canvasRect.top) / transform.scale;
|
||
|
||
if (layoutMode === "vertical") {
|
||
const startX = pLeft;
|
||
const startY = pTop + parentRect.height / transform.scale / 2;
|
||
const endX = cLeft;
|
||
const endY = cTop + childRect.height / transform.scale / 2;
|
||
const midX = startX - (startX - endX) / 2;
|
||
return [`M ${startX} ${startY} L ${midX} ${startY} L ${midX} ${endY} L ${endX} ${endY}`];
|
||
}
|
||
|
||
const startX = pLeft + parentRect.width / transform.scale / 2;
|
||
const startY = pTop + parentRect.height / transform.scale;
|
||
const endX = cLeft + childRect.width / transform.scale / 2;
|
||
const endY = cTop;
|
||
const midY = startY + (endY - startY) / 2;
|
||
return [`M ${startX} ${startY} L ${startX} ${midY} L ${endX} ${midY} L ${endX} ${endY}`];
|
||
});
|
||
setPaths(next);
|
||
};
|
||
|
||
recompute();
|
||
const resizeObserver = new ResizeObserver(recompute);
|
||
resizeObserver.observe(canvas);
|
||
resizeObserver.observe(viewport);
|
||
nodeElements.forEach((node) => resizeObserver.observe(node));
|
||
return () => resizeObserver.disconnect();
|
||
}, [canvasRef, layoutMode, links, nodeElements, transform.scale, viewportRef]);
|
||
|
||
return (
|
||
<svg className="agent-org-chart-connectors" aria-hidden="true">
|
||
{paths.map((d, index) => (
|
||
<path key={`${index}-${d}`} d={d} />
|
||
))}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
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<AgentState | "all">("all");
|
||
const { agents, stats, isLoading, loadAgents, refreshAgents } = useAgents(projectId, {
|
||
filterState,
|
||
showSystemAgents,
|
||
});
|
||
const [isCreating, setIsCreating] = useState(false);
|
||
const [onboardingDraft, setOnboardingDraft] = useState<AgentOnboardingSummary | null>(null);
|
||
const [isImporting, setIsImporting] = useState(false);
|
||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||
const [selectedOrgChartAgentId, setSelectedOrgChartAgentId] = useState<string | null>(null);
|
||
const isMobileDetailOpen = isMobileViewport && !!selectedAgentId;
|
||
const [selectedAgentInitialTab, setSelectedAgentInitialTab] = useState<"dashboard" | "runs">("dashboard");
|
||
const [selectedAgentInitialRunId, setSelectedAgentInitialRunId] = useState<string | null>(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<OrgChartLayoutPreference>(() => {
|
||
if (typeof window === "undefined") return "auto";
|
||
const saved = getScopedItem(ORG_CHART_LAYOUT_STORAGE_KEY, projectId);
|
||
return isOrgChartLayoutPreference(saved) ? saved : "auto";
|
||
});
|
||
const [orgTree, setOrgTree] = useState<OrgTreeNode[]>([]);
|
||
const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false);
|
||
const [orgChartViewportWidth, setOrgChartViewportWidth] = useState(0);
|
||
const [isControlsPanelOpen, setIsControlsPanelOpen] = useState(false);
|
||
const [isOverviewOpen, setIsOverviewOpen] = useState(false);
|
||
const [isBulkActionRunning, setIsBulkActionRunning] = useState(false);
|
||
const [isBulkEligibilityLoading, setIsBulkEligibilityLoading] = useState(false);
|
||
const [bulkPauseEligibleCount, setBulkPauseEligibleCount] = useState(0);
|
||
const [bulkResumeEligibleCount, setBulkResumeEligibleCount] = useState(0);
|
||
const [orgChartTransform, setOrgChartTransform] = useState<OrgChartTransform>({ scale: 1, x: 0, y: 0 });
|
||
const [isOrgChartPanning, setIsOrgChartPanning] = useState(false);
|
||
const controlsPanelRef = useRef<HTMLDivElement>(null);
|
||
const orgChartViewportRef = useRef<HTMLDivElement>(null);
|
||
const orgChartCanvasRef = useRef<HTMLDivElement>(null);
|
||
const orgChartNodeRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||
const orgChartLinksRef = useRef<OrgChartLink[]>([]);
|
||
const activePointersRef = useRef<Map<number, { x: number; y: number }>>(new Map());
|
||
const panStartRef = useRef<{ x: number; y: number; pointerId: number } | null>(null);
|
||
const pinchStartRef = useRef<{ distance: number; scale: number; centerX: number; centerY: number } | null>(null);
|
||
const orgChartFitDoneRef = useRef(false);
|
||
const { confirm } = useConfirm();
|
||
const controlsTriggerRef = useRef<HTMLButtonElement>(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<string | null>(null);
|
||
const roleSelectRef = useRef<HTMLSelectElement>(null);
|
||
const [updatingHeartbeatAgentId, setUpdatingHeartbeatAgentId] = useState<string | null>(null);
|
||
/** Agent ID currently showing custom heartbeat input */
|
||
const [customHeartbeatAgentId, setCustomHeartbeatAgentId] = useState<string | null>(null);
|
||
/** Custom minutes input value for each agent */
|
||
const [customHeartbeatMinutes, setCustomHeartbeatMinutes] = useState<Record<string, string>>({});
|
||
/** Global heartbeat multiplier loaded from project settings */
|
||
const [heartbeatMultiplier, setHeartbeatMultiplier] = useState<number>(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<Set<string>>(new Set());
|
||
/** Optimistic state overrides keyed by agent ID while pause/resume/start API call is in-flight */
|
||
const [optimisticStateOverrides, setOptimisticStateOverrides] = useState<Map<string, AgentState>>(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;
|
||
|
||
let cancelled = false;
|
||
setIsBulkEligibilityLoading(true);
|
||
void fetchAgents(undefined, projectId)
|
||
.then((projectAgents) => {
|
||
if (cancelled) return;
|
||
const nonEphemeralAgents = projectAgents.filter((projectAgent) => !isEphemeralAgent(projectAgent));
|
||
setBulkPauseEligibleCount(
|
||
nonEphemeralAgents.filter((projectAgent) => projectAgent.state === "active" || projectAgent.state === "running").length,
|
||
);
|
||
setBulkResumeEligibleCount(nonEphemeralAgents.filter((projectAgent) => projectAgent.state === "paused").length);
|
||
})
|
||
.catch((err) => {
|
||
if (cancelled) return;
|
||
setBulkPauseEligibleCount(0);
|
||
setBulkResumeEligibleCount(0);
|
||
addToast(`Failed to load bulk agent actions: ${getErrorMessage(err)}`, "error");
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) {
|
||
setIsBulkEligibilityLoading(false);
|
||
}
|
||
});
|
||
|
||
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 () => {
|
||
cancelled = true;
|
||
document.removeEventListener("mousedown", handlePointerDown);
|
||
document.removeEventListener("touchstart", handlePointerDown);
|
||
document.removeEventListener("keydown", handleKeyDown);
|
||
};
|
||
}, [addToast, isControlsPanelOpen, projectId]);
|
||
|
||
const handleBulkStateChange = async (targetState: "paused" | "active") => {
|
||
if (isBulkActionRunning) return;
|
||
setIsBulkActionRunning(true);
|
||
|
||
try {
|
||
const projectAgents = await fetchAgents(undefined, projectId);
|
||
const nonEphemeralAgents = projectAgents.filter((projectAgent) => !isEphemeralAgent(projectAgent));
|
||
const eligibleAgents = nonEphemeralAgents.filter((projectAgent) => (
|
||
targetState === "paused"
|
||
? projectAgent.state === "active" || projectAgent.state === "running"
|
||
: projectAgent.state === "paused"
|
||
));
|
||
const skippedCount = nonEphemeralAgents.length - eligibleAgents.length;
|
||
|
||
if (eligibleAgents.length === 0) {
|
||
addToast(`No agents eligible to ${targetState === "paused" ? "pause" : "resume"}`, "error");
|
||
return;
|
||
}
|
||
|
||
const confirmed = await confirm({
|
||
title: targetState === "paused" ? "Pause All Agents" : "Resume All Agents",
|
||
message: `${targetState === "paused" ? "Pause" : "Resume"} ${eligibleAgents.length} agent${eligibleAgents.length === 1 ? "" : "s"} in this project?`,
|
||
danger: targetState === "paused",
|
||
});
|
||
if (!confirmed) return;
|
||
|
||
const results = await Promise.allSettled(
|
||
eligibleAgents.map((projectAgent) => updateAgentState(projectAgent.id, targetState, projectId)),
|
||
);
|
||
const failedResults = results
|
||
.map((result, index) => ({ result, agent: eligibleAgents[index] }))
|
||
.filter((entry): entry is { result: PromiseRejectedResult; agent: Agent } => entry.result.status === "rejected");
|
||
const successCount = results.length - failedResults.length;
|
||
const failureCount = failedResults.length;
|
||
const baseSummary = `${targetState === "paused" ? "Paused" : "Resumed"} ${successCount} agent${successCount === 1 ? "" : "s"}; skipped ${skippedCount}`;
|
||
|
||
if (failureCount > 0) {
|
||
const failureSummary = failedResults
|
||
.slice(0, 3)
|
||
.map(({ agent, result }) => `${agent.name || agent.id}: ${getErrorMessage(result.reason)}`)
|
||
.join("; ");
|
||
addToast(`${baseSummary}; failed ${failureCount}${failureSummary ? ` (${failureSummary})` : ""}`, "error");
|
||
} else {
|
||
addToast(baseSummary, "success");
|
||
}
|
||
|
||
await loadAgents();
|
||
} catch (err) {
|
||
addToast(`Failed to ${targetState === "paused" ? "pause" : "resume"} agents: ${getErrorMessage(err)}`, "error");
|
||
} finally {
|
||
setIsBulkActionRunning(false);
|
||
}
|
||
};
|
||
|
||
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 clampScale = useCallback((scale: number) => Math.min(ORG_CHART_SCALE_MAX, Math.max(ORG_CHART_SCALE_MIN, scale)), []);
|
||
|
||
const clampTransform = useCallback((next: OrgChartTransform): OrgChartTransform => {
|
||
const viewport = orgChartViewportRef.current;
|
||
const chart = orgChartCanvasRef.current?.querySelector(".agent-org-chart") as HTMLDivElement | null;
|
||
if (!viewport || !chart) return { ...next, scale: clampScale(next.scale) };
|
||
const scale = clampScale(next.scale);
|
||
const contentWidth = chart.scrollWidth * scale;
|
||
const contentHeight = chart.scrollHeight * scale;
|
||
const viewportWidth = viewport.clientWidth;
|
||
const viewportHeight = viewport.clientHeight;
|
||
const minX = Math.min(ORG_CHART_OVERSCROLL, viewportWidth - contentWidth - ORG_CHART_OVERSCROLL);
|
||
const maxX = ORG_CHART_OVERSCROLL;
|
||
const minY = Math.min(ORG_CHART_OVERSCROLL, viewportHeight - contentHeight - ORG_CHART_OVERSCROLL);
|
||
const maxY = ORG_CHART_OVERSCROLL;
|
||
return {
|
||
scale,
|
||
x: Math.max(minX, Math.min(maxX, next.x)),
|
||
y: Math.max(minY, Math.min(maxY, next.y)),
|
||
};
|
||
}, [clampScale]);
|
||
|
||
const zoomAtPoint = useCallback((nextScale: number, clientX: number, clientY: number) => {
|
||
const viewport = orgChartViewportRef.current;
|
||
if (!viewport) return;
|
||
setOrgChartTransform((current) => {
|
||
const scale = clampScale(nextScale);
|
||
const viewportRect = viewport.getBoundingClientRect();
|
||
const offsetX = clientX - viewportRect.left;
|
||
const offsetY = clientY - viewportRect.top;
|
||
const worldX = (offsetX - current.x) / current.scale;
|
||
const worldY = (offsetY - current.y) / current.scale;
|
||
return clampTransform({
|
||
scale,
|
||
x: offsetX - worldX * scale,
|
||
y: offsetY - worldY * scale,
|
||
});
|
||
});
|
||
}, [clampScale, clampTransform]);
|
||
|
||
const fitToViewport = useCallback(() => {
|
||
const viewport = orgChartViewportRef.current;
|
||
const chart = orgChartCanvasRef.current?.querySelector(".agent-org-chart") as HTMLDivElement | null;
|
||
if (!viewport || !chart) return;
|
||
const viewportWidth = viewport.clientWidth;
|
||
const viewportHeight = viewport.clientHeight;
|
||
const contentWidth = chart.scrollWidth;
|
||
const contentHeight = chart.scrollHeight;
|
||
if (contentWidth <= 0 || contentHeight <= 0) return;
|
||
const scale = clampScale(Math.min(viewportWidth / contentWidth, viewportHeight / contentHeight, 1));
|
||
const x = (viewportWidth - contentWidth * scale) / 2;
|
||
const y = (viewportHeight - contentHeight * scale) / 2;
|
||
setOrgChartTransform(clampTransform({ scale, x, y }));
|
||
}, [clampScale, clampTransform]);
|
||
|
||
const handleAgentViewChange = useCallback((nextView: "list" | "board" | "org") => {
|
||
setAgentView(nextView);
|
||
if (nextView !== "org") {
|
||
setOrgChartTransform({ scale: 1, x: 0, y: 0 });
|
||
}
|
||
if (isMobileViewport && selectedAgentId) {
|
||
handleCloseDetail();
|
||
}
|
||
}, [handleCloseDetail, isMobileViewport, selectedAgentId]);
|
||
|
||
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
|
||
const orgChartLayoutMode: OrgChartLayoutMode = useMemo(() => resolveOrgChartLayoutMode({
|
||
tree: displayOrgTree,
|
||
availableWidth: orgChartViewportWidth,
|
||
preference: orgChartLayoutPreference,
|
||
}), [displayOrgTree, orgChartLayoutPreference, orgChartViewportWidth]);
|
||
const handleOrgChartLayoutPreferenceChange = useCallback((preference: OrgChartLayoutPreference) => {
|
||
setOrgChartLayoutPreference(preference);
|
||
setOrgChartTransform({ scale: 1, x: 0, y: 0 });
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
setOrgChartTransform({ scale: 1, x: 0, y: 0 });
|
||
orgChartFitDoneRef.current = false;
|
||
}, [projectId, orgChartLayoutMode]);
|
||
|
||
useLayoutEffect(() => {
|
||
if (agentView !== "org" || isOrgTreeLoading || displayOrgTree.length === 0 || orgChartFitDoneRef.current) return;
|
||
fitToViewport();
|
||
orgChartFitDoneRef.current = true;
|
||
}, [agentView, displayOrgTree.length, fitToViewport, isOrgTreeLoading]);
|
||
|
||
const registerOrgChartNodeElement = useCallback((id: string, element: HTMLDivElement | null) => {
|
||
if (element) {
|
||
orgChartNodeRefs.current.set(id, element);
|
||
} else {
|
||
orgChartNodeRefs.current.delete(id);
|
||
}
|
||
}, []);
|
||
|
||
const renderOrgChartZoomControls = useCallback(() => (
|
||
<>
|
||
<button type="button" className="btn-icon touch-target" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, scale: current.scale * 0.9 }))} aria-label="Zoom out org chart" title="Zoom out">
|
||
<ZoomOut size={16} />
|
||
</button>
|
||
<span className="agent-org-chart-controls__zoom-label" aria-live="polite">{Math.round(orgChartTransform.scale * 100)}%</span>
|
||
<button type="button" className="btn-icon touch-target" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, scale: current.scale * 1.1 }))} aria-label="Zoom in org chart" title="Zoom in">
|
||
<ZoomIn size={16} />
|
||
</button>
|
||
<button type="button" className="btn touch-target btn-sm agent-org-chart-controls__fit-btn" onClick={fitToViewport} aria-label="Fit org chart" title="Fit org chart">
|
||
<Minimize2 size={16} />
|
||
Fit
|
||
</button>
|
||
<button type="button" className="btn touch-target btn-sm" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, x: 0, y: 0 }))} aria-label="Center org chart" title="Center org chart">
|
||
<Move size={16} />
|
||
Center
|
||
</button>
|
||
</>
|
||
), [clampTransform, fitToViewport, orgChartTransform.scale]);
|
||
|
||
const handleOrgChartWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
|
||
if (!(event.ctrlKey || event.metaKey)) return;
|
||
event.preventDefault();
|
||
const factor = event.deltaY > 0 ? 0.9 : 1.1;
|
||
zoomAtPoint(orgChartTransform.scale * factor, event.clientX, event.clientY);
|
||
}, [orgChartTransform.scale, zoomAtPoint]);
|
||
|
||
const handleOrgChartPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||
if ((event.target as HTMLElement).closest(".org-chart-node-card")) return;
|
||
const viewport = event.currentTarget;
|
||
viewport.setPointerCapture?.(event.pointerId);
|
||
activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||
if (activePointersRef.current.size === 1) {
|
||
panStartRef.current = { x: event.clientX - orgChartTransform.x, y: event.clientY - orgChartTransform.y, pointerId: event.pointerId };
|
||
setIsOrgChartPanning(true);
|
||
return;
|
||
}
|
||
if (activePointersRef.current.size === 2) {
|
||
const points = Array.from(activePointersRef.current.values());
|
||
const dx = points[0].x - points[1].x;
|
||
const dy = points[0].y - points[1].y;
|
||
pinchStartRef.current = {
|
||
distance: Math.hypot(dx, dy),
|
||
scale: orgChartTransform.scale,
|
||
centerX: (points[0].x + points[1].x) / 2,
|
||
centerY: (points[0].y + points[1].y) / 2,
|
||
};
|
||
panStartRef.current = null;
|
||
}
|
||
}, [orgChartTransform.scale, orgChartTransform.x, orgChartTransform.y]);
|
||
|
||
const handleOrgChartPointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||
if (!activePointersRef.current.has(event.pointerId)) return;
|
||
activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||
if (activePointersRef.current.size >= 2) {
|
||
const points = Array.from(activePointersRef.current.values());
|
||
const start = pinchStartRef.current;
|
||
if (!start) return;
|
||
const dx = points[0].x - points[1].x;
|
||
const dy = points[0].y - points[1].y;
|
||
const nextDistance = Math.hypot(dx, dy);
|
||
const nextScale = start.scale * (nextDistance / start.distance);
|
||
zoomAtPoint(nextScale, start.centerX, start.centerY);
|
||
return;
|
||
}
|
||
if (!panStartRef.current || panStartRef.current.pointerId !== event.pointerId) return;
|
||
setOrgChartTransform((current) => clampTransform({ ...current, x: event.clientX - panStartRef.current!.x, y: event.clientY - panStartRef.current!.y }));
|
||
}, [clampTransform, zoomAtPoint]);
|
||
|
||
const handleOrgChartPointerEnd = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||
activePointersRef.current.delete(event.pointerId);
|
||
if (activePointersRef.current.size < 2) {
|
||
pinchStartRef.current = null;
|
||
}
|
||
if (activePointersRef.current.size === 0) {
|
||
panStartRef.current = null;
|
||
setIsOrgChartPanning(false);
|
||
}
|
||
}, []);
|
||
|
||
const handleOrgChartKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||
if (event.key === "ArrowLeft") setOrgChartTransform((current) => clampTransform({ ...current, x: current.x + ORG_CHART_KEYBOARD_PAN_STEP }));
|
||
else if (event.key === "ArrowRight") setOrgChartTransform((current) => clampTransform({ ...current, x: current.x - ORG_CHART_KEYBOARD_PAN_STEP }));
|
||
else if (event.key === "ArrowUp") setOrgChartTransform((current) => clampTransform({ ...current, y: current.y + ORG_CHART_KEYBOARD_PAN_STEP }));
|
||
else if (event.key === "ArrowDown") setOrgChartTransform((current) => clampTransform({ ...current, y: current.y - ORG_CHART_KEYBOARD_PAN_STEP }));
|
||
else if (event.key === "+" || event.key === "=") setOrgChartTransform((current) => clampTransform({ ...current, scale: current.scale * 1.1 }));
|
||
else if (event.key === "-") setOrgChartTransform((current) => clampTransform({ ...current, scale: current.scale * 0.9 }));
|
||
else if (event.key === "0") fitToViewport();
|
||
else if (event.key === "Home") setOrgChartTransform({ scale: 1, x: 0, y: 0 });
|
||
else return;
|
||
event.preventDefault();
|
||
}, [clampTransform, fitToViewport]);
|
||
|
||
const renderOrgChartLayoutToggle = useCallback(() => {
|
||
const options: Array<{ value: OrgChartLayoutPreference; label: string; icon: ReactNode; ariaLabel: string }> = [
|
||
{ value: "horizontal", label: "Horizontal", icon: <Network size={16} />, ariaLabel: "Horizontal layout" },
|
||
{ value: "vertical", label: "Vertical", icon: <List size={16} />, ariaLabel: "Vertical layout" },
|
||
{ value: "auto", label: "Auto", icon: <RefreshCw size={16} />, ariaLabel: "Automatic layout" },
|
||
];
|
||
|
||
return (
|
||
<div className="agent-org-chart-layout-toggle" data-testid="agent-org-chart-layout-toggle">
|
||
{options.map((option) => {
|
||
const isActive = orgChartLayoutPreference === option.value;
|
||
return (
|
||
<button
|
||
key={option.value}
|
||
type="button"
|
||
className={`btn-icon touch-target agent-org-chart-layout-toggle__button${isActive ? " btn-icon--active" : ""}`}
|
||
onClick={() => handleOrgChartLayoutPreferenceChange(option.value)}
|
||
aria-pressed={isActive}
|
||
aria-label={option.ariaLabel}
|
||
title={option.ariaLabel}
|
||
data-layout-value={option.value}
|
||
>
|
||
{option.icon}
|
||
<span className="agent-org-chart-layout-toggle__text">{option.label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}, [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 isPauseAllDisabled = isBulkEligibilityLoading || isBulkActionRunning || bulkPauseEligibleCount === 0;
|
||
const isResumeAllDisabled = isBulkEligibilityLoading || isBulkActionRunning || bulkResumeEligibleCount === 0;
|
||
const showInitialAgentsLoading = isLoading && agents.length === 0;
|
||
|
||
const handleOpenNewAgent = useCallback(() => {
|
||
setIsCreating(true);
|
||
}, []);
|
||
|
||
return (
|
||
<div className="agents-view">
|
||
<div className="agents-view-header">
|
||
<div className="agents-view-title">
|
||
<Bot size={24} />
|
||
<h2>Agents</h2>
|
||
</div>
|
||
<div className="agents-view-controls">
|
||
<div className="view-toggle">
|
||
<button
|
||
className={`view-toggle-btn${agentView === "list" ? " active" : ""}`}
|
||
onClick={() => handleAgentViewChange("list")}
|
||
title="List view"
|
||
aria-label="List view"
|
||
aria-pressed={agentView === "list"}
|
||
>
|
||
<List size={16} />
|
||
</button>
|
||
<button
|
||
className={`view-toggle-btn${agentView === "board" ? " active" : ""}`}
|
||
onClick={() => handleAgentViewChange("board")}
|
||
title="Board view"
|
||
aria-label="Board view"
|
||
aria-pressed={agentView === "board"}
|
||
>
|
||
<Activity size={16} />
|
||
</button>
|
||
<button
|
||
className={`view-toggle-btn${agentView === "org" ? " active" : ""}`}
|
||
onClick={() => handleAgentViewChange("org")}
|
||
title="Org Chart view"
|
||
aria-label="Org Chart view"
|
||
aria-pressed={agentView === "org"}
|
||
>
|
||
<Network size={16} />
|
||
</button>
|
||
</div>
|
||
<div className="agents-view-primary-actions">
|
||
<button
|
||
ref={controlsTriggerRef}
|
||
className={`btn-icon agent-controls-trigger${isControlsPanelOpen ? " agent-controls-trigger--active" : ""}`}
|
||
onClick={() => setIsControlsPanelOpen((open) => !open)}
|
||
title="Controls"
|
||
aria-label="Controls"
|
||
aria-haspopup="dialog"
|
||
aria-expanded={isControlsPanelOpen}
|
||
aria-controls={controlsPanelId}
|
||
>
|
||
<SlidersHorizontal size={16} />
|
||
</button>
|
||
<button
|
||
className="btn-icon"
|
||
onClick={() => void loadAgents()}
|
||
title="Refresh"
|
||
aria-label="Refresh"
|
||
>
|
||
<RefreshCw size={16} className={isLoading ? "spin" : undefined} />
|
||
</button>
|
||
{!isMobileViewport && (
|
||
<>
|
||
<button
|
||
className="btn btn-sm agent-import-trigger"
|
||
onClick={() => {
|
||
setIsImporting(true);
|
||
setIsControlsPanelOpen(false);
|
||
}}
|
||
aria-label="Import"
|
||
title="Import"
|
||
>
|
||
<Upload size={16} />
|
||
Import
|
||
</button>
|
||
<button
|
||
className="btn btn-task-create btn-sm"
|
||
onClick={() => {
|
||
handleOpenNewAgent();
|
||
setIsControlsPanelOpen(false);
|
||
}}
|
||
aria-label="New Agent"
|
||
title="New Agent"
|
||
>
|
||
<Plus size={16} />
|
||
New Agent
|
||
</button>
|
||
</>
|
||
)}
|
||
{isControlsPanelOpen && (
|
||
<div
|
||
ref={controlsPanelRef}
|
||
id={controlsPanelId}
|
||
className="agent-controls-panel agent-controls-panel--scrollable"
|
||
role="dialog"
|
||
aria-label="Agent controls"
|
||
aria-modal="false"
|
||
>
|
||
<div className="agent-controls">
|
||
<div className="agent-controls-filters">
|
||
<div className="agent-state-filter">
|
||
<Filter size={14} />
|
||
<select
|
||
className="agent-state-filter-select"
|
||
value={filterState}
|
||
onChange={(e) => setFilterState(e.target.value as AgentState | "all")}
|
||
aria-label="Filter agents by state"
|
||
>
|
||
<option value="all">All States</option>
|
||
<option value="idle">Idle</option>
|
||
<option value="active">Active</option>
|
||
<option value="running">Running</option>
|
||
<option value="paused">Paused</option>
|
||
<option value="error">Error</option>
|
||
</select>
|
||
</div>
|
||
|
||
<label className="checkbox-label agent-system-filter">
|
||
<input
|
||
type="checkbox"
|
||
checked={showSystemAgents}
|
||
onChange={(e) => setShowSystemAgents(e.target.checked)}
|
||
aria-label="Show system agents"
|
||
/>
|
||
Show system agents
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{isMobileViewport && (
|
||
<div className="agent-controls-mobile-actions">
|
||
<button
|
||
className="btn btn-sm agent-import-trigger"
|
||
onClick={() => {
|
||
setIsImporting(true);
|
||
setIsControlsPanelOpen(false);
|
||
}}
|
||
aria-label="Import"
|
||
title="Import"
|
||
>
|
||
<Upload size={16} />
|
||
Import
|
||
</button>
|
||
<button
|
||
className="btn btn-task-create btn-sm"
|
||
onClick={() => {
|
||
handleOpenNewAgent();
|
||
setIsControlsPanelOpen(false);
|
||
}}
|
||
aria-label="New Agent"
|
||
title="New Agent"
|
||
>
|
||
<Plus size={16} />
|
||
New Agent
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="agent-controls-bulk-actions" role="menu" aria-label="Bulk agent actions">
|
||
<button
|
||
type="button"
|
||
className="agent-detail-bulk-menu-item"
|
||
role="menuitem"
|
||
disabled={isPauseAllDisabled}
|
||
onClick={() => {
|
||
setIsControlsPanelOpen(false);
|
||
void handleBulkStateChange("paused");
|
||
}}
|
||
>
|
||
<span className="agent-controls-bulk-actions__label">
|
||
<Pause />
|
||
<span>Pause All Agents</span>
|
||
</span>
|
||
<span className="agent-detail-bulk-menu-item-hint">
|
||
{isBulkEligibilityLoading
|
||
? "Loading eligibility…"
|
||
: bulkPauseEligibleCount === 0
|
||
? "No active or running project agents to pause"
|
||
: `Pause ${bulkPauseEligibleCount} active/running agent${bulkPauseEligibleCount === 1 ? "" : "s"}`}
|
||
</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="agent-detail-bulk-menu-item"
|
||
role="menuitem"
|
||
disabled={isResumeAllDisabled}
|
||
onClick={() => {
|
||
setIsControlsPanelOpen(false);
|
||
void handleBulkStateChange("active");
|
||
}}
|
||
>
|
||
<span className="agent-controls-bulk-actions__label">
|
||
<Play />
|
||
<span>Resume All Agents</span>
|
||
</span>
|
||
<span className="agent-detail-bulk-menu-item-hint">
|
||
{isBulkEligibilityLoading
|
||
? "Loading eligibility…"
|
||
: bulkResumeEligibleCount === 0
|
||
? "No paused project agents to resume"
|
||
: `Resume ${bulkResumeEligibleCount} paused agent${bulkResumeEligibleCount === 1 ? "" : "s"}`}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="agent-global-controls agent-controls-actions">
|
||
<div className="heartbeat-multiplier-group">
|
||
<div className="heartbeat-multiplier-controls">
|
||
<label htmlFor="globalHeartbeatMultiplier" className="heartbeat-multiplier-label">
|
||
Heartbeat Speed
|
||
</label>
|
||
<input
|
||
id="globalHeartbeatMultiplier"
|
||
className="heartbeat-multiplier-slider touch-target"
|
||
type="range"
|
||
min={0.1}
|
||
max={10}
|
||
step={0.1}
|
||
value={heartbeatMultiplier}
|
||
onChange={(e) => {
|
||
const val = Number(e.target.value);
|
||
void handleHeartbeatMultiplierChange(Number.isFinite(val) && val > 0 ? val : 1);
|
||
}}
|
||
disabled={isSavingMultiplier}
|
||
/>
|
||
<span className="heartbeat-multiplier-value">×{heartbeatMultiplier.toFixed(1)}</span>
|
||
<select
|
||
className="heartbeat-multiplier-preset"
|
||
value={String(
|
||
HEARTBEAT_MULTIPLIER_PRESETS.reduce((closest, candidate) => {
|
||
return Math.abs(candidate - heartbeatMultiplier) < Math.abs(closest - heartbeatMultiplier) ? candidate : closest;
|
||
}, HEARTBEAT_MULTIPLIER_PRESETS[0])
|
||
)}
|
||
onChange={(e) => {
|
||
const val = Number(e.target.value);
|
||
void handleHeartbeatMultiplierChange(Number.isFinite(val) && val > 0 ? val : 1);
|
||
}}
|
||
disabled={isSavingMultiplier}
|
||
aria-label="Heartbeat speed preset"
|
||
>
|
||
{HEARTBEAT_MULTIPLIER_PRESETS.map((multiplier) => (
|
||
<option key={multiplier} value={String(multiplier)}>
|
||
×{multiplier}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<small className="text-secondary">
|
||
Scales all agent heartbeat intervals. ×0.5 = twice as fast, ×2.0 = twice as slow. Default: ×1.0
|
||
</small>
|
||
</div>
|
||
</div>
|
||
|
||
<AgentTokenStatsPanel agents={displayAgents} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<NewAgentDialog
|
||
isOpen={isCreating}
|
||
onClose={() => {
|
||
setIsCreating(false);
|
||
setOnboardingDraft(null);
|
||
}}
|
||
onCreated={() => { setIsCreating(false); setOnboardingDraft(null); void loadAgents(); }}
|
||
projectId={projectId}
|
||
prefillDraft={onboardingDraft}
|
||
agentOnboardingEnabled={agentOnboardingEnabled}
|
||
existingAgents={agents}
|
||
onPrefillDraft={setOnboardingDraft}
|
||
/>
|
||
|
||
<AgentImportModal
|
||
isOpen={isImporting}
|
||
onClose={() => setIsImporting(false)}
|
||
onImported={() => void loadAgents()}
|
||
projectId={projectId}
|
||
/>
|
||
|
||
<AgentsOverviewBar
|
||
stats={stats}
|
||
activeAgents={displayActiveAgents}
|
||
projectId={projectId}
|
||
isOpen={isOverviewOpen}
|
||
onToggle={() => setIsOverviewOpen((open) => !open)}
|
||
onSelectAgent={handleOverviewAgentSelect}
|
||
onOpenTaskLogs={onOpenTaskLogs}
|
||
/>
|
||
|
||
{agentView === "org" ? (
|
||
<div className="agents-org-full-view">
|
||
<div className="agents-view-content agents-view-content--org-full">
|
||
{selectedAgentId ? (
|
||
<div className="agents-org-detail-view" data-testid="agents-org-detail-view">
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm agents-org-detail-back"
|
||
onClick={handleCloseDetail}
|
||
aria-label="Back to org chart"
|
||
>
|
||
Back to org chart
|
||
</button>
|
||
<Suspense fallback={null}>
|
||
<AgentDetailView
|
||
key={selectedAgentId}
|
||
inline
|
||
showInlineBackButton={false}
|
||
agentId={selectedAgentId}
|
||
projectId={projectId}
|
||
onClose={handleCloseDetail}
|
||
addToast={addToast}
|
||
onChildClick={handleChildClick}
|
||
initialTab={selectedAgentInitialTab}
|
||
initialRunId={selectedAgentInitialRunId}
|
||
preferActiveRun={selectedAgentPreferActiveRun}
|
||
/>
|
||
</Suspense>
|
||
</div>
|
||
) : showInitialAgentsLoading ? (
|
||
<div className="agents-view-loading" role="status" aria-live="polite">
|
||
<RefreshCw size={18} className="spin" />
|
||
<span>Loading agents...</span>
|
||
</div>
|
||
) : (
|
||
<div className="agent-org-chart-shell" data-testid="agent-org-chart-shell">
|
||
{isMobileViewport ? (
|
||
<div className="agent-org-chart-controls" data-testid="agent-org-chart-controls">
|
||
{renderOrgChartLayoutToggle()}
|
||
{renderOrgChartZoomControls()}
|
||
</div>
|
||
) : (
|
||
<div className="agent-org-chart-toolbar">
|
||
{renderOrgChartLayoutToggle()}
|
||
<div className="agent-org-chart-controls" data-testid="agent-org-chart-controls">
|
||
{renderOrgChartZoomControls()}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div
|
||
ref={orgChartViewportRef}
|
||
className="agent-org-chart-viewport"
|
||
data-testid="agent-org-chart-viewport"
|
||
tabIndex={0}
|
||
role="region"
|
||
aria-label="Org chart canvas"
|
||
onWheel={handleOrgChartWheel}
|
||
onPointerDown={handleOrgChartPointerDown}
|
||
onPointerMove={handleOrgChartPointerMove}
|
||
onPointerUp={handleOrgChartPointerEnd}
|
||
onPointerCancel={handleOrgChartPointerEnd}
|
||
onKeyDown={handleOrgChartKeyDown}
|
||
data-panning={isOrgChartPanning ? "true" : "false"}
|
||
>
|
||
<div
|
||
ref={orgChartCanvasRef}
|
||
className={`agent-org-chart-canvas agent-org-chart-canvas--zoom-${Math.round((orgChartTransform.scale ?? 1) * 100)}`}
|
||
data-panning={isOrgChartPanning ? "true" : "false"}
|
||
style={{ transform: `translate(${orgChartTransform.x}px, ${orgChartTransform.y}px) scale(${orgChartTransform.scale})` }}
|
||
>
|
||
<div
|
||
className={`agent-org-chart${orgChartLayoutMode === "vertical" ? " agent-org-chart--vertical" : ""}`}
|
||
data-testid="agent-org-chart"
|
||
data-layout-mode={orgChartLayoutMode}
|
||
>
|
||
{isOrgTreeLoading ? (
|
||
<div className="agent-org-chart__loading" role="status" aria-live="polite">
|
||
<RefreshCw size={18} className="spin" />
|
||
<span>Loading org chart...</span>
|
||
</div>
|
||
) : displayOrgTree.length === 0 ? (
|
||
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
|
||
) : (
|
||
(() => {
|
||
orgChartLinksRef.current = [];
|
||
return displayOrgTree.map((node) => (
|
||
<OrgChartNode
|
||
key={node.agent.id}
|
||
node={node}
|
||
onSelect={handleOrgChartNodeSelect}
|
||
getHealthStatus={getHealthStatus}
|
||
selectedAgentId={selectedOrgChartAgentId}
|
||
registerNodeElement={registerOrgChartNodeElement}
|
||
linksRef={orgChartLinksRef}
|
||
/>
|
||
));
|
||
})()
|
||
)}
|
||
</div>
|
||
<OrgChartConnectors
|
||
links={orgChartLinksRef.current}
|
||
nodeElements={orgChartNodeRefs.current}
|
||
canvasRef={orgChartCanvasRef}
|
||
viewportRef={orgChartViewportRef}
|
||
layoutMode={orgChartLayoutMode}
|
||
transform={orgChartTransform}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="agents-split-layout">
|
||
<div className={`agents-split-sidebar${isMobileDetailOpen ? " agents-split-sidebar--hidden-mobile" : ""}`}>
|
||
<div className="agents-view-content">
|
||
{/* Agent Collection */}
|
||
{showInitialAgentsLoading ? (
|
||
<div className="agents-view-loading" role="status" aria-live="polite">
|
||
<RefreshCw size={18} className="spin" />
|
||
<span>Loading agents...</span>
|
||
</div>
|
||
) : agentView === "board" ? (
|
||
<div className="agent-board">
|
||
{displayAgents.length === 0 ? (
|
||
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
|
||
) : (
|
||
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 (
|
||
<div key={agent.id} className={`agent-board-card ${stateCardClass}${selectedAgentId === agent.id ? " agent-card--selected" : ""}`}>
|
||
<div
|
||
className="agent-board-clickable"
|
||
onClick={() => openAgentDetail(agent.id)}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
if (e.key === " ") {
|
||
e.preventDefault();
|
||
}
|
||
openAgentDetail(agent.id);
|
||
}
|
||
}}
|
||
>
|
||
<div className="agent-board-header">
|
||
<span className="agent-board-icon"><AgentAvatar agent={agent} size={20} /></span>
|
||
<span className="agent-board-badge badge text-secondary">{getRoleLabel(agent.role)}</span>
|
||
<span className={`agent-board-badge badge ${stateBadgeClass}`}>{agent.state}</span>
|
||
{(agent.pendingApprovalCount ?? 0) > 0 ? (
|
||
<span className="agent-board-badge badge agent-approval-badge" title="Pending approvals">
|
||
<span className="status-dot status-dot--pending" />
|
||
{agent.pendingApprovalCount}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="agent-board-name">{agent.name}</div>
|
||
<div className="agent-board-id">{agent.id}</div>
|
||
<div className="agent-board-health" style={{ color: health.color }} title={healthSummary.title}>
|
||
{health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="agent-list">
|
||
{displayAgents.length === 0 ? (
|
||
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
|
||
) : (
|
||
// 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 (
|
||
<div
|
||
key={agent.id}
|
||
className={`agent-card agent-card--clickable ${stateCardClass}${selectedAgentId === agent.id ? " agent-card--selected" : ""}`}
|
||
onClick={(e) => {
|
||
// 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}`}
|
||
>
|
||
<div className="agent-card-header">
|
||
<div className="agent-info">
|
||
{editingRoleForAgent === agent.id ? (
|
||
<select
|
||
ref={roleSelectRef}
|
||
className="select agent-role-select"
|
||
value={agent.role}
|
||
onChange={(e) => void handleRoleChange(agent.id, e.target.value as AgentCapability)}
|
||
onKeyDown={(e) => handleRoleKeyDown(e, agent.id)}
|
||
onBlur={() => setEditingRoleForAgent(null)}
|
||
autoFocus
|
||
>
|
||
{AGENT_ROLES.map(role => (
|
||
<option key={role.value} value={role.value}>
|
||
{role.icon} {role.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : (
|
||
<span
|
||
className="agent-icon agent-icon--clickable"
|
||
onClick={(e) => {
|
||
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);
|
||
}
|
||
}}
|
||
>
|
||
<AgentAvatar agent={agent} size={20} />
|
||
</span>
|
||
)}
|
||
<div className="agent-meta">
|
||
<span className="agent-name">{agent.name}</span>
|
||
<span className="agent-id text-secondary">{agent.id}</span>
|
||
</div>
|
||
<ChevronRight size={20} className="agent-card-chevron" />
|
||
</div>
|
||
<div className="agent-badges">
|
||
<span
|
||
className={`badge ${stateBadgeClass}`}
|
||
>
|
||
{agent.state}
|
||
</span>
|
||
<span className="badge" style={{ color: health.color }} title={healthSummary.title}>
|
||
{health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""}
|
||
</span>
|
||
<span className="badge text-secondary">
|
||
{getRoleLabel(agent.role)}
|
||
</span>
|
||
{(agent.pendingApprovalCount ?? 0) > 0 ? (
|
||
<span className="badge agent-approval-badge" title="Pending approvals">
|
||
<span className="status-dot status-dot--pending" />
|
||
{agent.pendingApprovalCount}
|
||
</span>
|
||
) : 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) => (
|
||
<span key={skillId} className="badge badge-skill" title={skillId}>{formatAgentSkillBadgeLabel(skillId)}</span>
|
||
))}
|
||
{extraCount > 0 && <span className="badge badge-skill">+{extraCount}</span>}
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="agent-card-body">
|
||
{agent.state === "error" && agent.lastError ? (
|
||
<AgentErrorIndicator
|
||
errorText={agent.lastError}
|
||
issueContext={{
|
||
surface: "AgentsView list",
|
||
agentId: agent.id,
|
||
agentName: agent.name,
|
||
agentState: agent.state,
|
||
taskId: agent.taskId,
|
||
}}
|
||
/>
|
||
) : null}
|
||
{agent.taskId && (
|
||
<div className="agent-task">
|
||
<span className="text-secondary">Working on:</span>
|
||
<span className="badge">{agent.taskId}</span>
|
||
</div>
|
||
)}
|
||
<div className="agent-heartbeat-control">
|
||
<span className="text-secondary">Heartbeat:</span>
|
||
{customHeartbeatAgentId === agent.id ? (
|
||
// Custom input mode
|
||
<>
|
||
<input
|
||
type="text"
|
||
inputMode="numeric"
|
||
pattern="[0-9]*"
|
||
className="input agent-heartbeat-custom-input"
|
||
value={customHeartbeatMinutes[agent.id] ?? ""}
|
||
onChange={(e) => 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}`}
|
||
/>
|
||
<span className="text-secondary">min</span>
|
||
<button
|
||
className="btn btn-sm"
|
||
onClick={() => void handleCustomHeartbeatSave(agent)}
|
||
disabled={isUpdatingHeartbeat}
|
||
title="Save custom interval"
|
||
>
|
||
Save
|
||
</button>
|
||
<button
|
||
className="btn btn-sm"
|
||
onClick={() => {
|
||
setCustomHeartbeatAgentId(null);
|
||
setCustomHeartbeatMinutes((prev) => {
|
||
const next = { ...prev };
|
||
delete next[agent.id];
|
||
return next;
|
||
});
|
||
}}
|
||
disabled={isUpdatingHeartbeat}
|
||
title="Cancel custom interval"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</>
|
||
) : (
|
||
// Preset selection mode
|
||
<>
|
||
<select
|
||
className="select agent-heartbeat-select"
|
||
value={configuredIntervalMs}
|
||
onChange={(e) => {
|
||
const value = e.target.value;
|
||
if (value === "__custom__") {
|
||
handleSelectCustomHeartbeat(agent);
|
||
} else {
|
||
void handleHeartbeatIntervalChange(agent, Number(value));
|
||
}
|
||
}}
|
||
disabled={isUpdatingHeartbeat}
|
||
aria-label={`Set heartbeat interval for ${agent.name}`}
|
||
>
|
||
{heartbeatOptions.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
{/* Only show "Custom..." if current value is a preset; if it's already custom, it's already in the list */}
|
||
{HEARTBEAT_INTERVAL_PRESETS.some((p) => p.value === configuredIntervalMs) && (
|
||
<option value="__custom__">Custom...</option>
|
||
)}
|
||
</select>
|
||
</>
|
||
)}
|
||
{isUpdatingHeartbeat && <span className="agent-heartbeat-saving text-secondary">Saving…</span>}
|
||
{agent.lastHeartbeatAt && (() => {
|
||
const lastAt = new Date(agent.lastHeartbeatAt);
|
||
const nextAt = new Date(lastAt.getTime() + configuredIntervalMs);
|
||
const isTicking = agent.state === "active" || agent.state === "running";
|
||
return (
|
||
<>
|
||
<span className="agent-heartbeat-last text-secondary" title={lastAt.toLocaleString()}>
|
||
Last: {lastAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
|
||
</span>
|
||
{isTicking && (
|
||
<span className="agent-heartbeat-next text-secondary" title={nextAt.toLocaleString()}>
|
||
Next: {nextAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
|
||
</span>
|
||
)}
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="agent-card-actions">
|
||
<div className="agent-card-actions-group agent-card-actions-group--primary">
|
||
{agent.state === "idle" && (
|
||
<button
|
||
className="btn btn-sm"
|
||
onClick={() => void handleStateChange(agent.id, "active")}
|
||
disabled={transitioningAgentIds.has(agent.id)}
|
||
title="Activate"
|
||
>
|
||
<Play size={14} /> <span className="agent-card-action-label">Start</span>
|
||
</button>
|
||
)}
|
||
{agent.state === "active" && (
|
||
<>
|
||
<button
|
||
className="btn btn-sm"
|
||
onClick={() => void handleRunHeartbeat(agent.id, agent.name)}
|
||
disabled={transitioningAgentIds.has(agent.id)}
|
||
title="Run Now"
|
||
aria-label={`Run now for ${agent.name}`}
|
||
>
|
||
<Activity size={14} /> <span className="agent-card-action-label">Run Now</span>
|
||
</button>
|
||
<button
|
||
className="btn btn-sm"
|
||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||
disabled={transitioningAgentIds.has(agent.id)}
|
||
title="Pause"
|
||
>
|
||
<Pause size={14} /> <span className="agent-card-action-label">Pause</span>
|
||
</button>
|
||
</>
|
||
)}
|
||
{agent.state === "paused" && (
|
||
<button
|
||
className="btn btn-sm"
|
||
onClick={() => void handleStateChange(agent.id, "active")}
|
||
disabled={transitioningAgentIds.has(agent.id)}
|
||
title="Resume"
|
||
>
|
||
<Play size={14} /> <span className="agent-card-action-label">Resume</span>
|
||
</button>
|
||
)}
|
||
{agent.state === "running" && (
|
||
<>
|
||
<button
|
||
className="btn btn-sm"
|
||
onClick={() => openAgentDetail(agent.id, { initialTab: "runs", initialRunId: null, preferActiveRun: true })}
|
||
title="View live run details"
|
||
aria-label={`View live run details for ${agent.name}`}
|
||
>
|
||
<Activity size={14} /> <span className="agent-card-action-label">Running</span>
|
||
</button>
|
||
<button
|
||
className="btn btn-sm"
|
||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||
disabled={transitioningAgentIds.has(agent.id)}
|
||
title="Pause"
|
||
>
|
||
<Pause size={14} /> <span className="agent-card-action-label">Pause</span>
|
||
</button>
|
||
</>
|
||
)}
|
||
{agent.state === "error" && (
|
||
<button
|
||
className="btn btn-sm"
|
||
onClick={() => void handleStateChange(agent.id, "active")}
|
||
disabled={transitioningAgentIds.has(agent.id)}
|
||
title="Retry"
|
||
>
|
||
<Play size={14} /> <span className="agent-card-action-label">Retry</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="agent-card-actions-group agent-card-actions-group--secondary">
|
||
<button
|
||
className="btn btn-sm agent-card-details-btn"
|
||
onClick={() => openAgentDetail(agent.id)}
|
||
title={`View details for ${agent.name}`}
|
||
aria-label={`View details for ${agent.name}`}
|
||
>
|
||
<Info size={14} /> <span className="agent-card-action-label">Details</span>
|
||
</button>
|
||
{(agent.state === "idle" || agent.state === "paused") && (
|
||
<button
|
||
className="btn btn-sm btn-danger"
|
||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||
title="Delete"
|
||
>
|
||
<Trash2 size={14} /> <span className="agent-card-action-label">Delete</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div className={`agents-split-detail${isMobileViewport && !selectedAgentId ? " agents-split-detail--hidden-mobile" : ""}`}>
|
||
{selectedAgentId ? (
|
||
<Suspense fallback={null}>
|
||
<AgentDetailView
|
||
key={selectedAgentId}
|
||
inline
|
||
showInlineBackButton={isMobileViewport}
|
||
agentId={selectedAgentId}
|
||
projectId={projectId}
|
||
onClose={handleCloseDetail}
|
||
addToast={addToast}
|
||
onChildClick={handleChildClick}
|
||
initialTab={selectedAgentInitialTab}
|
||
initialRunId={selectedAgentInitialRunId}
|
||
preferActiveRun={selectedAgentPreferActiveRun}
|
||
onMutationSuccess={handleDetailMutationSuccess}
|
||
/>
|
||
</Suspense>
|
||
) : (
|
||
<div className="agents-detail-empty-state">
|
||
<Bot size={48} />
|
||
<h3>Select an agent</h3>
|
||
<p>Choose an agent from the sidebar to view details</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|