import { useEffect, useState } from "react"; import { Activity, FileText } from "lucide-react"; import type { Agent } from "../api"; import type { TaskDetail } from "@fusion/core"; import { fetchTaskDetail } from "../api"; import "./ActiveAgentsPanel.css"; import { useLiveTranscript } from "../hooks/useLiveTranscript"; import { resolveHeartbeatIntervalMs } from "../utils/heartbeatIntervals"; interface LiveAgentCardProps { agent: Agent; projectId?: string; onSelect?: (agentId: string) => void; onOpenTaskLogs?: (taskId: string) => void; } const TASK_STATUS_POLL_MS = 5000; function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgentCardProps) { const { entries, isConnected } = useLiveTranscript(agent.taskId, projectId); const [task, setTask] = useState(null); // Poll the agent's task so the empty state can show real run progress // (current step, executor model) instead of just "Connecting..." while the // SSE log stream is still warming up. useEffect(() => { if (!agent.taskId) { setTask(null); return; } let cancelled = false; let timer: ReturnType | undefined; const load = async () => { try { const data = await fetchTaskDetail(agent.taskId!, projectId); if (!cancelled) setTask(data); } catch { // best-effort; leave previous value in place } finally { if (!cancelled) { timer = setTimeout(load, TASK_STATUS_POLL_MS); } } }; void load(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [agent.taskId, projectId]); const elapsed = agent.lastHeartbeatAt ? Math.floor((Date.now() - new Date(agent.lastHeartbeatAt).getTime()) / 1000) : 0; // Compute next heartbeat ETA from last + interval. Negative deltas mean the // beat is overdue — surface that explicitly rather than rendering a stale // future time. const nextHeartbeatLabel = (() => { if (!agent.lastHeartbeatAt) return null; const intervalMs = resolveHeartbeatIntervalMs( (agent.runtimeConfig as { heartbeatIntervalMs?: number } | undefined)?.heartbeatIntervalMs, ); const nextMs = new Date(agent.lastHeartbeatAt).getTime() + intervalMs; const deltaSec = Math.round((nextMs - Date.now()) / 1000); if (!Number.isFinite(deltaSec)) return null; if (deltaSec <= 0) return `Heartbeat overdue ${formatElapsed(-deltaSec)}`; return `Next heartbeat in ${formatElapsed(deltaSec)}`; })(); const currentStep = task?.steps?.[task.currentStep ?? 0]; const totalSteps = task?.steps?.length ?? 0; const stepNumber = (task?.currentStep ?? 0) + 1; const executorModel = task?.modelId; const handleSelect = () => { if (onSelect) { onSelect(agent.id); } }; const handleViewLogs = (e: React.MouseEvent) => { e.stopPropagation(); if (agent.taskId && onOpenTaskLogs) { onOpenTaskLogs(agent.taskId); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleSelect(); } }; return (
{agent.taskId && ( {agent.taskId} )}
{entries.length === 0 ? (
{!agent.taskId ? ( // "active" agents that aren't currently working a task have no // SSE stream to attach to; useLiveTranscript bails out with // isConnected=false. Showing "Connecting..." here is misleading // — the agent is just idle. {agent.state === "running" ? "Starting..." : "Idle — no task assigned"} ) : currentStep ? ( <>
Step {stepNumber} {totalSteps ? `/${totalSteps}` : ""}: {currentStep.name}
{executorModel && (
{executorModel}
)}
{isConnected ? "Waiting for output..." : "Connecting to log stream..."}
) : ( {isConnected ? "Waiting for output..." : "Connecting..."} )}
) : ( entries.slice(0, 20).map((entry, i) => (
{entry.text}
)) )}
{formatElapsed(elapsed)} {nextHeartbeatLabel && ( {nextHeartbeatLabel} )}
{agent.taskId && onOpenTaskLogs && ( )} {isConnected && }
); } function formatElapsed(seconds: number): string { if (seconds < 60) return `${seconds}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; } interface ActiveAgentsPanelProps { agents: Agent[]; projectId?: string; onAgentSelect?: (agentId: string) => void; onOpenTaskLogs?: (taskId: string) => void; className?: string; } export function ActiveAgentsPanel({ agents, projectId, onAgentSelect, onOpenTaskLogs, className = "" }: ActiveAgentsPanelProps) { // Dedupe by id defensively. The store should return unique agents but a race // between the initial fetch and an SSE refresh can briefly surface the same // agent twice — without this guard React floods the console with duplicate // key warnings (which previously snowballed into OOM). const uniqueAgents = Array.from(new Map(agents.map((a) => [a.id, a])).values()); if (uniqueAgents.length === 0) return null; return (
Active Agents ({uniqueAgents.length})
{uniqueAgents.map(agent => ( ))}
); }