import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw, Settings, FileText, ActivitySquare, X, Copy, ExternalLink, CheckCircle, XCircle, Loader2 } from "lucide-react"; import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api"; import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs } from "../api"; import type { AgentLogEntry } from "@fusion/core"; /** * Simple className utility - joins class names conditionally */ function cn(...classes: (string | boolean | undefined | null)[]): string { return classes.filter(Boolean).join(" "); } /** * Format an ISO timestamp to a relative time string. */ function relativeTime(iso: string): string { const now = Date.now(); const then = new Date(iso).getTime(); const diffMs = now - then; // Future if (diffMs < 0) { const absDiff = Math.abs(diffMs); if (absDiff < 60_000) return "in a moment"; if (absDiff < 3_600_000) return `in ${Math.floor(absDiff / 60_000)}m`; if (absDiff < 86_400_000) return `in ${Math.floor(absDiff / 3_600_000)}h`; return `in ${Math.floor(absDiff / 86_400_000)}d`; } // Past if (diffMs < 60_000) return "just now"; if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`; if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`; return `${Math.floor(diffMs / 86_400_000)}d ago`; } interface AgentDetailViewProps { agentId: string; projectId?: string; onClose: () => void; addToast: (message: string, type?: "success" | "error") => void; } type TabId = "dashboard" | "logs" | "config" | "runs"; const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [ { id: "dashboard", label: "Dashboard", icon: ActivitySquare }, { id: "logs", label: "Logs", icon: FileText }, { id: "runs", label: "Runs", icon: Activity }, { id: "config", label: "Settings", icon: Settings }, ]; const STATE_COLORS: Record = { idle: { bg: "var(--state-idle-bg)", text: "var(--state-idle-text)", border: "var(--state-idle-border)" }, active: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" }, running: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" }, paused: { bg: "var(--state-paused-bg)", text: "var(--state-paused-text)", border: "var(--state-paused-border)" }, error: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" }, terminated: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" }, }; const RUN_STATUS_ICONS: Record = { completed: { icon: CheckCircle, color: "var(--color-success, #3fb950)" }, failed: { icon: XCircle, color: "var(--color-error, #f85149)" }, active: { icon: Loader2, color: "var(--in-progress, #bc8cff)" }, terminated: { icon: Square, color: "var(--text-muted, #8b949e)" }, }; export function AgentDetailView({ agentId, projectId, onClose, addToast }: AgentDetailViewProps) { const [agent, setAgent] = useState(null); const [logs, setLogs] = useState([]); const [isLoading, setIsLoading] = useState(true); const [activeTab, setActiveTab] = useState("dashboard"); const [isStreaming, setIsStreaming] = useState(false); const logContainerRef = useRef(null); const loadAgent = useCallback(async () => { setIsLoading(true); try { const data = await fetchAgent(agentId, projectId); setAgent(data); } catch (err: any) { addToast(`Failed to load agent: ${err.message}`, "error"); onClose(); } finally { setIsLoading(false); } }, [agentId, addToast, onClose, projectId]); const loadLogs = useCallback(async () => { // Agent logs are tied to tasks, not agents directly. // If the agent has a current task, we could show those logs. // For now, we'll show heartbeat runs as the "activity" for the agent. // If the agent is working on a task, we could show task logs. if (agent?.taskId) { try { const data = await fetchAgentLogs(agent.taskId, projectId); setLogs(data); } catch (err: any) { console.error("Failed to load task logs:", err); } } }, [agent?.taskId, projectId]); useEffect(() => { void loadAgent(); }, [loadAgent]); useEffect(() => { if (agent?.taskId) { void loadLogs(); } }, [agent?.taskId, loadLogs]); // Set up SSE for live log streaming when viewing logs tab with a task useEffect(() => { if (activeTab !== "logs" || !agent?.taskId) { setIsStreaming(false); return; } const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const es = new EventSource(`/api/tasks/${encodeURIComponent(agent.taskId)}/logs/stream${query}`); const handleAgentLog = (e: MessageEvent) => { try { const entry: AgentLogEntry = JSON.parse(e.data); setLogs(prev => [entry, ...prev]); // Auto-scroll to top for new entries const container = logContainerRef.current; if (container && container.scrollTop < 50) { container.scrollTop = 0; } } catch { // Ignore parse errors } }; es.addEventListener("agent:log", handleAgentLog as EventListener); es.onerror = () => { setIsStreaming(false); }; es.onopen = () => { setIsStreaming(true); }; return () => { es.removeEventListener("agent:log", handleAgentLog as EventListener); es.close(); setIsStreaming(false); }; }, [agent?.taskId, activeTab, projectId]); const handleStateChange = async (newState: AgentState) => { try { await updateAgentState(agentId, newState, projectId); addToast(`Agent state updated to ${newState}`, "success"); void loadAgent(); } catch (err: any) { addToast(`Failed to update state: ${err.message}`, "error"); } }; const handleDelete = async () => { if (!agent || !confirm(`Delete agent "${agent.name}"? This cannot be undone.`)) return; try { await deleteAgent(agentId, projectId); addToast(`Agent "${agent.name}" deleted`, "success"); onClose(); } catch (err: any) { addToast(`Failed to delete agent: ${err.message}`, "error"); } }; const getHealthStatus = () => { if (!agent) return { label: "Unknown", color: "var(--text-muted, #8b949e)" }; if (agent.state === "terminated") { return { label: "Terminated", color: "var(--state-error-text, #f85149)" }; } if (agent.state === "error") { return { label: agent.lastError ?? "Error", color: "var(--state-error-text, #f85149)" }; } if (agent.state === "paused") { return { label: agent.pauseReason ? `Paused: ${agent.pauseReason}` : "Paused", color: "var(--state-paused-text, #e3b541)" }; } if (agent.state === "running") { return { label: "Running", color: "var(--state-active-text, #3fb950)" }; } if (!agent.lastHeartbeatAt) { return { label: agent.state === "active" ? "Starting..." : "Idle", color: "var(--state-idle-text, #8b949e)" }; } const lastHeartbeat = new Date(agent.lastHeartbeatAt).getTime(); const elapsed = Date.now() - lastHeartbeat; const timeoutMs = 60000; if (elapsed > timeoutMs) { return { label: "Unresponsive", color: "var(--state-error-text, #f85149)" }; } return { label: "Healthy", color: "var(--state-active-text, #3fb950)" }; }; const copyAgentId = () => { if (agent) { navigator.clipboard.writeText(agent.id); addToast("Agent ID copied to clipboard", "success"); } }; if (isLoading) { return (
e.target === e.currentTarget && onClose()}>
Loading agent...
); } if (!agent) { return null; } const stateStyle = STATE_COLORS[agent.state]; const health = getHealthStatus(); const runs = (agent as any).completedRuns || []; const activeRun = (agent as any).activeRun; return (
e.target === e.currentTarget && onClose()}>
{/* Header */}

{agent.name}

{agent.state} {health.label === "Healthy" && } {health.label === "Unresponsive" && } {health.label}
{/* State-dependent action buttons */} {agent.state === "idle" && ( <> )} {agent.state === "active" && ( <> )} {agent.state === "paused" && ( <> )} {agent.state === "running" && ( <> )} {agent.state === "error" && ( <> )} {agent.state === "terminated" && ( )}
{/* Tabs */}
{TABS.map(tab => ( ))}
{/* Tab Content */}
{activeTab === "dashboard" && ( )} {activeTab === "logs" && ( )} {activeTab === "runs" && ( )} {activeTab === "config" && ( )}
{/* Footer with agent ID */}
{agent.id} {agent.taskId && ( <> | Working on: {agent.taskId} )}
); } // ── Dashboard Tab ─────────────────────────────────────────────────────────── function DashboardTab({ agent, health }: { agent: AgentDetail; health: { label: string; color: string }; }) { const stateStyle = STATE_COLORS[agent.state]; const stats = useMemo(() => { const runs = (agent as any).completedRuns || []; const today = new Date(); today.setHours(0, 0, 0, 0); const todayRuns = runs.filter((r: AgentHeartbeatRun) => new Date(r.startedAt) >= today ); const successfulRuns = runs.filter((r: AgentHeartbeatRun) => r.status === "completed" ); return { totalRuns: runs.length, todayRuns: todayRuns.length, successfulRuns: successfulRuns.length, successRate: runs.length > 0 ? Math.round((successfulRuns.length / runs.length) * 100) : 0, }; }, [agent]); return (
{/* Agent Info Card */}

Agent Information

Name {agent.name}
Role {agent.role}
State {agent.state}
Health {health.label}
Created {new Date(agent.createdAt).toLocaleDateString()}
Last Heartbeat {agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "Never" }
{/* Stats Cards */}

Statistics

{stats.totalRuns}
Total Runs
{stats.todayRuns}
Runs Today
{stats.successRate}%
Success Rate
{/* Current Task */} {agent.taskId && (

Current Task

{agent.taskId} View Task
)} {/* Metadata */} {agent.metadata && Object.keys(agent.metadata).length > 0 && (

Metadata

            {JSON.stringify(agent.metadata, null, 2)}
          
)}
); } // ── Logs Tab ────────────────────────────────────────────────────────────── function LogsTab({ logs, isStreaming, containerRef, hasTask }: { logs: AgentLogEntry[]; isStreaming: boolean; containerRef: React.RefObject; hasTask: boolean; }) { if (!hasTask) { return (

No task assigned

Agent logs are available when the agent is assigned to a task

); } return (
{logs.length} entries {isStreaming && ( Live )}
{logs.length === 0 ? (

No log entries yet

{isStreaming ? "Waiting for activity..." : "Logs will appear here when the agent is active"}

) : ( logs.map((entry, i) => ( )) )}
); } function LogEntry({ entry }: { entry: AgentLogEntry }) { const getEntryStyles = () => { switch (entry.type) { case "tool": return { color: "var(--accent)", borderLeft: "3px solid var(--accent)", background: "rgba(124, 92, 191, 0.08)", }; case "tool_result": return { color: "var(--color-success, #3fb950)", borderLeft: "3px solid var(--color-success, #3fb950)", background: "rgba(76, 175, 80, 0.06)", }; case "tool_error": return { color: "var(--color-error, #f85149)", borderLeft: "3px solid var(--color-error, #f85149)", background: "rgba(229, 57, 53, 0.06)", }; case "thinking": return { color: "var(--text-muted)", fontStyle: "italic" as const, opacity: 0.7, }; default: return { color: "var(--text-primary)", }; } }; const styles = getEntryStyles(); const timestamp = new Date(entry.timestamp).toLocaleTimeString(); return (
[{timestamp}] {entry.agent && ( [{entry.agent}] )} {entry.type === "tool" && } {entry.type === "tool_result" && } {entry.type === "tool_error" && } {entry.text} {entry.detail && ( — {entry.detail} )}
); } // ── Runs Tab ─────────────────────────────────────────────────────────────── function RunsTab({ runs, activeRun, addToast }: { runs: AgentHeartbeatRun[]; activeRun?: AgentHeartbeatRun; addToast: (msg: string, type?: "success" | "error") => void; }) { if (runs.length === 0 && !activeRun) { return (

No runs yet

Heartbeat runs will appear here

); } const sortedRuns = [...runs].sort( (a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime() ); return (
{activeRun && (
Live Run Active
Started {relativeTime(activeRun.startedAt)}
)} {sortedRuns.map((run, i) => { const statusInfo = RUN_STATUS_ICONS[run.status] || RUN_STATUS_ICONS.completed; const StatusIcon = statusInfo.icon; const duration = run.endedAt ? formatDuration(new Date(run.startedAt), new Date(run.endedAt)) : "In progress"; return (
#{i + 1} {run.id.slice(0, 8)} {run.status}
Started {relativeTime(run.startedAt)} {duration}
); })}
); } function formatDuration(start: Date, end: Date): string { const diff = Math.floor((end.getTime() - start.getTime()) / 1000); if (diff < 60) return `${diff}s`; if (diff < 3600) return `${Math.floor(diff / 60)}m ${diff % 60}s`; return `${Math.floor(diff / 3600)}h ${Math.floor((diff % 3600) / 60)}m`; } // ── Config Tab ───────────────────────────────────────────────────────────── /** Shape of a single advanced setting field stored in agent.metadata */ interface AdvancedSettingField { key: string; label: string; type: "text" | "number" | "select"; placeholder?: string; hint?: string; options?: Array<{ value: string; label: string }>; /** Minimum value for number fields */ min?: number; /** Maximum value for number fields */ max?: number; } /** Well-known advanced setting definitions backed by agent.metadata */ const ADVANCED_SETTINGS: AdvancedSettingField[] = [ { key: "heartbeatIntervalMs", label: "Heartbeat Interval (ms)", type: "number", placeholder: "30000", hint: "How often the agent sends heartbeats (minimum 1000ms, default 30000ms)", min: 1000, max: 600000, }, { key: "maxRetries", label: "Max Retries", type: "number", placeholder: "3", hint: "Maximum number of automatic retries on task failure (0–10, default 3)", min: 0, max: 10, }, { key: "timeoutMs", label: "Task Timeout (ms)", type: "number", placeholder: "600000", hint: "Maximum time in ms before a task is considered timed out (minimum 60000ms, default 600000ms)", min: 60000, max: 86400000, }, { key: "logLevel", label: "Log Level", type: "select", hint: "Verbosity of agent log output", options: [ { value: "debug", label: "Debug" }, { value: "info", label: "Info" }, { value: "warn", label: "Warning" }, { value: "error", label: "Error" }, ], }, ]; /** Validation errors keyed by setting key */ type ValidationErrors = Record; function validateAdvancedSettings( values: Record, ): ValidationErrors { const errors: ValidationErrors = {}; for (const field of ADVANCED_SETTINGS) { const raw = values[field.key]?.trim(); // Empty is fine — it means "use default" if (!raw) continue; if (field.type === "number") { const num = Number(raw); if (Number.isNaN(num) || !Number.isFinite(num)) { errors[field.key] = `"${field.label}" must be a valid number`; continue; } if (field.min !== undefined && num < field.min) { errors[field.key] = `"${field.label}" must be at least ${field.min.toLocaleString()}`; } if (field.max !== undefined && num > field.max) { errors[field.key] = `"${field.label}" must be at most ${field.max.toLocaleString()}`; } } if (field.type === "select") { const validOptions = field.options?.map((o) => o.value) ?? []; if (validOptions.length > 0 && !validOptions.includes(raw)) { errors[field.key] = `"${field.label}" must be one of: ${validOptions.join(", ")}`; } } } return errors; } function ConfigTab({ agent, projectId, addToast, onSaved, }: { agent: AgentDetail; projectId?: string; addToast: (message: string, type?: "success" | "error") => void; onSaved: () => Promise; }) { // Local form state initialised from agent.metadata const [formValues, setFormValues] = useState>(() => { const initial: Record = {}; for (const field of ADVANCED_SETTINGS) { const raw = agent.metadata[field.key]; if (raw !== undefined && raw !== null) { initial[field.key] = String(raw); } } return initial; }); const [isSaving, setIsSaving] = useState(false); const [errors, setErrors] = useState({}); const [justSaved, setJustSaved] = useState(false); /** Detect whether any local value differs from the persisted metadata */ const hasChanges = (() => { for (const field of ADVANCED_SETTINGS) { const current = formValues[field.key]?.trim() ?? ""; const persisted = agent.metadata[field.key] !== undefined && agent.metadata[field.key] !== null ? String(agent.metadata[field.key]) : ""; if (current !== persisted) return true; } return false; })(); const handleFieldChange = (key: string, value: string) => { setFormValues((prev) => ({ ...prev, [key]: value })); setJustSaved(false); // Clear individual field error on change if (errors[key]) { setErrors((prev) => { const next = { ...prev }; delete next[key]; return next; }); } }; const handleSave = async () => { // Validate before save const validationErrors = validateAdvancedSettings(formValues); if (Object.keys(validationErrors).length > 0) { setErrors(validationErrors); addToast("Please fix validation errors before saving", "error"); return; } // Build the metadata payload — only include non-empty values const newMetadata: Record = { ...agent.metadata }; for (const field of ADVANCED_SETTINGS) { const raw = formValues[field.key]?.trim(); if (!raw) { // Remove the key to use system default delete newMetadata[field.key]; } else if (field.type === "number") { newMetadata[field.key] = Number(raw); } else { newMetadata[field.key] = raw; } } setIsSaving(true); try { await updateAgent(agent.id, { metadata: newMetadata }, projectId); addToast("Advanced settings saved", "success"); setJustSaved(true); // Auto-hide the saved indicator after 3 seconds setTimeout(() => setJustSaved(false), 3000); await onSaved(); } catch (err: any) { addToast(`Failed to save settings: ${err.message}`, "error"); } finally { setIsSaving(false); } }; return (

Agent Configuration

Configure agent settings and behavior.

Name changes coming soon
Role changes coming soon

Advanced Settings

Advanced configuration options for this agent. Leave a field empty to use system defaults.

{ADVANCED_SETTINGS.map((field) => { const hasError = !!errors[field.key]; return (
{field.type === "select" ? ( ) : ( handleFieldChange(field.key, e.target.value)} /> )} {hasError && ( {errors[field.key]} )} {!hasError && field.hint && ( {field.hint} )}
); })}
{!hasChanges && justSaved && ( Settings saved )}
); }