import "./AgentDetailView.css"; import "./MailboxModal.css"; 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, GitBranch, ListChecks, AlertCircle, ChevronDown, ChevronRight, ChevronLeft, BarChart3, BookOpen, Eye, FileEdit, Mail, Send, Inbox as InboxIcon, User, MoreVertical } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse, AgentPromptSizePoint } from "../api"; import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, fetchSettingsByScope, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead, fetchAgentPromptSizes } from "../api"; import type { Agent } from "../api"; import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules } from "@fusion/core"; import { getErrorMessage, isEphemeralAgent } from "@fusion/core"; import { AgentLogViewer } from "./AgentLogViewer"; import { AgentReflectionsTab } from "./AgentReflectionsTab"; import { getAgentHealthStatus } from "../utils/agentHealth"; import type { AgentHealthStatus } from "../utils/agentHealth"; import { SkillMultiselect } from "./SkillMultiselect"; import { subscribeSse } from "../sse-bus"; import { DEFAULT_HEARTBEAT_INTERVAL_MS, formatHeartbeatInterval, resolveHeartbeatIntervalMs } from "../utils/heartbeatIntervals"; import { formatAgentSkillBadgeLabel } from "../utils/agentSkills"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { useConfirm } from "../hooks/useConfirm"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { AgentAvatar } from "./AgentAvatar"; import { AgentErrorIndicator } from "./AgentErrorDetailsModal"; import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal"; import { AgentPermissionPolicyEditor } from "./AgentPermissionPolicyEditor"; /** * 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. */ export 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; onChildClick?: (childId: string) => void; inline?: boolean; showInlineBackButton?: boolean; initialTab?: TabId; initialRunId?: string | null; preferActiveRun?: boolean; onMutationSuccess?: (context: { agentId: string; deleted?: boolean }) => void | Promise; } type TabId = "dashboard" | "logs" | "mail" | "config" | "runs" | "tasks" | "employees" | "soul" | "instructions" | "memory" | "reflections"; const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [ { id: "dashboard", label: "Dashboard", icon: ActivitySquare }, { id: "logs", label: "Logs", icon: FileText }, { id: "mail", label: "Mail", icon: Mail }, { id: "runs", label: "Runs", icon: Activity }, { id: "tasks", label: "Tasks", icon: ListChecks }, { id: "employees", label: "Employees", icon: GitBranch }, { id: "soul", label: "Soul", icon: Heart }, { id: "instructions", label: "Instructions", icon: BookOpen }, { id: "memory", label: "Agent Memory", icon: FileText }, { id: "reflections", label: "Evaluation", icon: BarChart3 }, { 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)" }, }; const RUN_STATUS_ICONS: Record = { completed: { icon: CheckCircle, color: "var(--color-success)" }, failed: { icon: XCircle, color: "var(--color-error)" }, active: { icon: Loader2, color: "var(--in-progress)" }, terminated: { icon: Square, color: "var(--text-muted)" }, }; const MEMORY_LAYER_NAMES: Record = { "long-term": "Long-term", daily: "Daily", dreams: "Dreams", }; const MEMORY_LAYER_DESCRIPTIONS: Record = { "long-term": "Curated durable decisions, conventions, constraints, and pitfalls for this specific agent.", daily: "Raw daily observations and open loops recorded by this agent.", dreams: "Synthesized patterns and emerging themes distilled from this agent's daily memory.", }; const DEFAULT_HEARTBEAT_INTERVAL_LABEL = formatHeartbeatInterval(DEFAULT_HEARTBEAT_INTERVAL_MS); const CONFIG_AUTOSAVE_DEBOUNCE_MS = 700; function pickDefaultAgentMemoryPath(files: MemoryFileInfo[], currentPath: string): string { if (files.some((file) => file.path === currentPath)) { return currentPath; } return files.find((file) => file.layer === "long-term")?.path ?? files[0]?.path ?? ""; } export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick, inline = false, showInlineBackButton = false, initialTab, initialRunId, preferActiveRun = false, onMutationSuccess }: AgentDetailViewProps) { const [agent, setAgent] = useState(null); const { confirm } = useConfirm(); const [logs, setLogs] = useState([]); const [isLoading, setIsLoading] = useState(true); const [activeTab, setActiveTab] = useState(initialTab ?? "dashboard"); const [isStreaming, setIsStreaming] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false); const [isStartingRun, setIsStartingRun] = useState(false); const [isBulkMenuOpen, setIsBulkMenuOpen] = useState(false); const [isBulkActionRunning, setIsBulkActionRunning] = useState(false); const [isBulkEligibilityLoading, setIsBulkEligibilityLoading] = useState(false); const [bulkPauseEligibleCount, setBulkPauseEligibleCount] = useState(0); const [bulkResumeEligibleCount, setBulkResumeEligibleCount] = useState(0); const [runNowRefreshToken, setRunNowRefreshToken] = useState(0); const [latestRun, setLatestRun] = useState(null); const [agentMailbox, setAgentMailbox] = useState(null); const [isLoadingMailbox, setIsLoadingMailbox] = useState(false); const [mailboxError, setMailboxError] = useState(null); const agentDetailModalRef = useRef(null); const bulkMenuRef = useRef(null); const overlayMouseDownRef = useRef(false); useModalResizePersist(agentDetailModalRef, !inline, "fusion:agent-detail-modal-size"); const onCloseRef = useRef(onClose); const addToastRef = useRef(addToast); const agentRef = useRef(null); const hasConfigChangesRef = useRef(false); const loadedLatestRunLogsRef = useRef(null); // Track the context version to detect stale events after project/agent switches. // Incremented whenever agentId or projectId changes, invalidating any in-flight SSE handlers. const contextVersionRef = useRef(0); const previousAgentIdRef = useRef(agentId); const previousProjectIdRef = useRef(projectId); onCloseRef.current = onClose; addToastRef.current = addToast; agentRef.current = agent; const loadAgent = useCallback(async () => { const showLoadingSpinner = agentRef.current === null; if (showLoadingSpinner) { setIsLoading(true); } try { const data = await fetchAgent(agentId, projectId); setAgent(data); } catch (err) { addToastRef.current(`Failed to load agent: ${getErrorMessage(err)}`, "error"); onCloseRef.current(); } finally { setIsLoading(false); } }, [agentId, projectId]); const loadLogs = useCallback(async () => { // Capture context version at callback creation - stale responses will be rejected const contextVersionAtCapture = contextVersionRef.current; const currentAgentId = agentId; const currentProjectId = projectId; const isStale = () => contextVersionRef.current !== contextVersionAtCapture || agentId !== currentAgentId || projectId !== currentProjectId; try { if (agent?.taskId) { setLatestRun(null); loadedLatestRunLogsRef.current = null; const result = await fetchAgentLogsWithMeta(agent.taskId, currentProjectId, { limit: 100 }); if (isStale()) return; setLogs(result.entries); return; } // Fallback: show the latest run's logs so the Logs tab is populated even // when no task is currently assigned. const runs = await fetchAgentRuns(currentAgentId, 1, currentProjectId); if (isStale()) return; const latest = runs[0] ?? null; setLatestRun(latest); if (!latest) { loadedLatestRunLogsRef.current = null; setLogs([]); return; } if (loadedLatestRunLogsRef.current === latest.id) { return; } const entries = await fetchAgentRunLogs(currentAgentId, latest.id, currentProjectId); if (isStale()) return; setLogs(entries); loadedLatestRunLogsRef.current = latest.id; } catch (err) { if (isStale()) return; console.error("Failed to load agent logs:", err); } }, [agent?.taskId, agentId, projectId]); const loadMailbox = useCallback(async () => { setIsLoadingMailbox(true); setMailboxError(null); try { const mailbox = await fetchAgentMailbox(agentId, projectId); setAgentMailbox(mailbox); } catch (err) { setMailboxError(getErrorMessage(err)); setAgentMailbox(null); } finally { setIsLoadingMailbox(false); } }, [agentId, projectId]); const handleConfigChangesState = useCallback((hasChanges: boolean) => { hasConfigChangesRef.current = hasChanges; }, []); const notifyMutationSuccess = useCallback(async (deleted = false) => { await onMutationSuccess?.({ agentId, deleted }); }, [agentId, onMutationSuccess]); const handleSavedMutation = useCallback(async () => { await loadAgent(); await notifyMutationSuccess(false); }, [loadAgent, notifyMutationSuccess]); useEffect(() => { void loadAgent(); }, [loadAgent]); // Poll for agent updates to keep health status fresh (every 30 seconds) // This ensures health badges stay current while the detail view is open useEffect(() => { const pollInterval = setInterval(() => { void loadAgent(); }, 30_000); return () => { clearInterval(pollInterval); }; }, [loadAgent]); useEffect(() => { if (agent && activeTab === "logs") { void loadLogs(); } }, [agent, activeTab, loadLogs]); useEffect(() => { if (activeTab !== "logs") { loadedLatestRunLogsRef.current = null; } }, [activeTab]); useEffect(() => { if (agent && activeTab === "mail") { void loadMailbox(); } }, [agent, activeTab, loadMailbox]); useEffect(() => { if (!isBulkMenuOpen) return; const onDocumentMouseDown = (event: MouseEvent) => { if (!bulkMenuRef.current?.contains(event.target as Node)) { setIsBulkMenuOpen(false); } }; const onDocumentKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setIsBulkMenuOpen(false); } }; document.addEventListener("mousedown", onDocumentMouseDown); document.addEventListener("keydown", onDocumentKeyDown); return () => { document.removeEventListener("mousedown", onDocumentMouseDown); document.removeEventListener("keydown", onDocumentKeyDown); }; }, [isBulkMenuOpen]); useEffect(() => { if (!isBulkMenuOpen) return; let cancelled = false; setIsBulkEligibilityLoading(true); 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(() => { if (cancelled) return; setBulkPauseEligibleCount(0); setBulkResumeEligibleCount(0); }) .finally(() => { if (!cancelled) { setIsBulkEligibilityLoading(false); } }); return () => { cancelled = true; }; }, [isBulkMenuOpen, projectId]); // When falling back to latest-run logs (no taskId) and that run is active, // subscribe to the run-scoped SSE stream so the Logs tab tails updates. useEffect(() => { if (activeTab !== "logs" || agent?.taskId) return; if (!latestRun || latestRun.status !== "active") return; const contextVersionAtStart = contextVersionRef.current; const currentAgentId = agentId; const currentRunId = latestRun.id; const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const unsubscribe = subscribeSse( `/api/agents/${encodeURIComponent(currentAgentId)}/runs/${encodeURIComponent(currentRunId)}/logs/stream${query}`, { events: { "agent:log": (e) => { if (contextVersionRef.current !== contextVersionAtStart) return; try { const entry: AgentLogEntry = JSON.parse(e.data); setLogs(prev => [...prev, entry]); } catch { // ignore malformed events } }, }, onOpen: () => { if (contextVersionRef.current === contextVersionAtStart) { setIsStreaming(true); } }, onError: () => { if (contextVersionRef.current === contextVersionAtStart) { setIsStreaming(false); } }, }, ); return () => { unsubscribe(); if (contextVersionRef.current === contextVersionAtStart) { setIsStreaming(false); } }; }, [activeTab, agent?.taskId, agentId, projectId, latestRun]); // Detect context changes (agentId or projectId) and invalidate stale handlers useEffect(() => { if (previousAgentIdRef.current !== agentId || previousProjectIdRef.current !== projectId) { previousAgentIdRef.current = agentId; previousProjectIdRef.current = projectId; contextVersionRef.current++; // Clear stale logs and streaming state immediately setLogs([]); setIsStreaming(false); setLatestRun(null); setAgentMailbox(null); setMailboxError(null); loadedLatestRunLogsRef.current = null; hasConfigChangesRef.current = false; } }, [agentId, projectId]); // Refresh this view when the current agent is updated elsewhere, unless there are unsaved edits. useEffect(() => { const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const contextVersionAtStart = contextVersionRef.current; const refreshAgentForApprovalEvent = (event: MessageEvent) => { if (contextVersionRef.current !== contextVersionAtStart) return; try { const payload: unknown = JSON.parse(event.data); if (!payload || typeof payload !== "object") return; const approvalAgentId = (payload as { agentId?: unknown }).agentId; if (approvalAgentId !== agentId) return; void loadAgent(); } catch { // Ignore malformed events } }; return subscribeSse(`/api/events${query}`, { events: { "agent:updated": (event) => { if (contextVersionRef.current !== contextVersionAtStart) return; try { const payload: unknown = JSON.parse(event.data); if (!payload || typeof payload !== "object") return; const updatedId = (payload as { id?: unknown }).id; if (updatedId !== agentId) return; if (hasConfigChangesRef.current) return; void loadAgent(); } catch { // Ignore malformed events } }, "approval:requested": refreshAgentForApprovalEvent, "approval:updated": refreshAgentForApprovalEvent, "approval:decided": refreshAgentForApprovalEvent, }, }); }, [agentId, projectId, loadAgent]); // Set up SSE for live log streaming when viewing logs tab with a task useEffect(() => { if (activeTab !== "logs" || !agent?.taskId) { setIsStreaming(false); return; } // Capture context version at effect start - stale events will be rejected const contextVersionAtStart = contextVersionRef.current; const currentTaskId = agent.taskId; const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const unsubscribe = subscribeSse( `/api/tasks/${encodeURIComponent(currentTaskId)}/logs/stream${query}`, { events: { "agent:log": (e) => { if (contextVersionRef.current !== contextVersionAtStart) return; try { const entry: AgentLogEntry = JSON.parse(e.data); setLogs(prev => [...prev, entry]); } catch { // Ignore parse errors } }, }, onOpen: () => { if (contextVersionRef.current === contextVersionAtStart) { setIsStreaming(true); } }, onError: () => { if (contextVersionRef.current === contextVersionAtStart) { setIsStreaming(false); } }, }, ); return () => { unsubscribe(); if (contextVersionRef.current === contextVersionAtStart) { setIsStreaming(false); } }; }, [agent?.taskId, activeTab, projectId]); const handleStateChange = async (newState: AgentState) => { if (isTransitioning || !agentRef.current) return; const previousState = agentRef.current.state; if (previousState === newState) return; setIsTransitioning(true); setAgent((prev) => (prev ? { ...prev, state: newState } : prev)); try { await updateAgentState(agentId, newState, projectId); addToast(`Agent state updated to ${newState}`, "success"); await handleSavedMutation(); } catch (err) { setAgent((prev) => (prev ? { ...prev, state: previousState } : prev)); addToast(`Failed to update state: ${getErrorMessage(err)}`, "error"); } finally { setIsTransitioning(false); } }; const handleBulkStateChange = async (targetState: "paused" | "active") => { if (isBulkActionRunning) return; setIsBulkMenuOpen(false); 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 handleSavedMutation(); } catch (err) { addToast(`Failed to ${targetState === "paused" ? "pause" : "resume"} agents: ${getErrorMessage(err)}`, "error"); } finally { setIsBulkActionRunning(false); } }; const handleRunHeartbeat = async () => { if (isStartingRun) return; setIsStartingRun(true); try { await startAgentRun(agentId, projectId, { source: "on_demand", triggerDetail: "Triggered from dashboard" }); addToast(`Heartbeat run started for ${agent?.name ?? agentId}`, "success"); setRunNowRefreshToken((prev) => prev + 1); } catch (err) { addToast(`Failed to start heartbeat run: ${getErrorMessage(err)}`, "error"); } finally { setIsStartingRun(false); } }; const handleDelete = async () => { if (!agent) return; const shouldDelete = await confirm({ title: "Delete Agent", message: `Delete agent "${agent.name}"? This cannot be undone.`, danger: true, }); if (!shouldDelete) return; try { await deleteAgent(agentId, projectId); addToast(`Agent "${agent.name}" deleted`, "success"); await notifyMutationSuccess(true); onClose(); } catch (err) { addToast(`Failed to delete agent: ${getErrorMessage(err)}`, "error"); } }; // Use centralized health status utility for consistent labels across all views const getHealthStatus = (): AgentHealthStatus => { if (!agent) { return { label: "Unknown", icon: , color: "var(--text-muted)", stateDerived: false, }; } return getAgentHealthStatus(agent); }; const copyAgentId = () => { if (agent) { navigator.clipboard.writeText(agent.id); addToast("Agent ID copied to clipboard", "success"); } }; if (isLoading) { if (inline) { return (
Loading agent...
); } return (
{ if (e.target === e.currentTarget) overlayMouseDownRef.current = true; }} onMouseUp={(e) => { if (overlayMouseDownRef.current && e.target === e.currentTarget) onClose(); overlayMouseDownRef.current = false; }} role="dialog" aria-modal="true" >
Loading agent...
); } if (!agent) { return null; } const stateStyle = STATE_COLORS[agent.state]; const health = getHealthStatus(); const detailShellClassName = inline ? "agent-detail-inline" : "agent-detail-modal"; const isPauseAllDisabled = isBulkEligibilityLoading || bulkPauseEligibleCount === 0; const isResumeAllDisabled = isBulkEligibilityLoading || bulkResumeEligibleCount === 0; return (
!inline && e.target === e.currentTarget && onClose()} role={inline ? "region" : "dialog"} aria-label={inline ? "Agent detail" : undefined} aria-modal={inline ? undefined : "true"} >
{/* Header */}
{/* Identity area: icon + name + badges */}
{inline && showInlineBackButton ? ( ) : null}

{agent.name}

{agent.state} {health.icon} {!health.stateDerived && health.label}
{/* Lifecycle controls: compact action buttons */}
{/* State-dependent action buttons */} {agent.state === "idle" && ( <> )} {agent.state === "active" && ( <> )} {agent.state === "paused" && ( <> )} {agent.state === "running" && ( <> )} {agent.state === "error" && ( <> )}
{/* Utility actions: refresh + close */}
{isBulkMenuOpen && (
)}
{!inline && ( )}
{/* Tabs */}
{TABS.map(tab => ( ))}
{/* Tab Content */}
{activeTab === "dashboard" && ( )} {activeTab === "logs" && ( 0 || latestRun !== null} fallbackLabel={!agent.taskId && latestRun ? `Latest run · ${latestRun.id.slice(0, 8)}` : null} /> )} {activeTab === "mail" && ( void loadMailbox()} /> )} {activeTab === "runs" && ( )} {activeTab === "tasks" && ( )} {activeTab === "employees" && ( )} {activeTab === "soul" && ( )} {activeTab === "instructions" && ( )} {activeTab === "memory" && ( )} {activeTab === "reflections" && ( )} {activeTab === "config" && ( { setAgent((current) => (current ? { ...current, ...updates } : current)); }} /> )}
{/* Footer with agent ID */} {!inline && (
{agent.id} {agent.taskId && ( <> | Working on: {agent.taskId} )}
)}
); } // ── Dashboard Tab ─────────────────────────────────────────────────────────── function DashboardTab({ agent, health, onChildClick, projectId, }: { agent: AgentDetail; health: AgentHealthStatus; onChildClick?: (childId: string) => void; projectId?: string; }) { const stateStyle = STATE_COLORS[agent.state]; const [chainOfCommand, setChainOfCommand] = useState([]); const [isLoadingChainOfCommand, setIsLoadingChainOfCommand] = useState(true); const [budgetStatus, setBudgetStatus] = useState(null); const [availableRuntimes, setAvailableRuntimes] = useState([]); const [selectedSkillId, setSelectedSkillId] = useState(null); const [selectedSkillContent, setSelectedSkillContent] = useState(null); const [isLoadingSkillContent, setIsLoadingSkillContent] = useState(false); const [skillContentError, setSkillContentError] = useState(null); const runtimeHint = typeof agent.runtimeConfig?.runtimeHint === "string" ? agent.runtimeConfig.runtimeHint : ""; const modelDisplay = (() => { const rc = agent.runtimeConfig ?? {}; if (runtimeHint) { const selectedRuntime = availableRuntimes.find((runtime) => runtime.runtimeId === runtimeHint); return selectedRuntime ? selectedRuntime.name : runtimeHint; } if (rc.modelProvider && rc.modelId) { return `${rc.modelProvider}/${rc.modelId}`; } if (typeof rc.model === "string" && rc.model.includes("/")) { const slashIdx = rc.model.indexOf("/"); return rc.model.slice(slashIdx + 1); } return null; })(); // Fetch budget status on mount useEffect(() => { fetchAgentBudgetStatus(agent.id, projectId) .then(setBudgetStatus) .catch(() => setBudgetStatus(null)); }, [agent.id, projectId]); useEffect(() => { fetchPluginRuntimes(projectId) .then(setAvailableRuntimes) .catch(() => setAvailableRuntimes([])); }, [projectId]); useEffect(() => { let cancelled = false; setIsLoadingChainOfCommand(true); void fetchChainOfCommand(agent.id, projectId) .then((chain) => { if (cancelled) return; const normalized = chain.length > 0 && chain[0]?.id === agent.id ? [...chain].reverse() : chain; setChainOfCommand(normalized); }) .catch(() => { if (!cancelled) { setChainOfCommand([]); } }) .finally(() => { if (!cancelled) { setIsLoadingChainOfCommand(false); } }); return () => { cancelled = true; }; }, [agent.id, projectId]); const stats = useMemo(() => { const runs = agent.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]); const recentRuns = (agent.completedRuns || []).slice(0, 5); const agentSkills = Array.isArray(agent.metadata?.skills) ? (agent.metadata.skills as string[]) : []; const selectedSkillLabel = selectedSkillId ? formatAgentSkillBadgeLabel(selectedSkillId) : null; const loadSkillContent = useCallback(async (skillId: string) => { setIsLoadingSkillContent(true); setSkillContentError(null); setSelectedSkillContent(null); try { const content = await fetchSkillContent(skillId, projectId); setSelectedSkillContent(content); } catch (err) { setSkillContentError(getErrorMessage(err)); } finally { setIsLoadingSkillContent(false); } }, [projectId]); const handleSkillBadgeClick = useCallback((skillId: string) => { if (selectedSkillId === skillId) { setSelectedSkillId(null); setSelectedSkillContent(null); setSkillContentError(null); setIsLoadingSkillContent(false); return; } setSelectedSkillId(skillId); void loadSkillContent(skillId); }, [loadSkillContent, selectedSkillId]); const isTicking = agent.state === "active" || agent.state === "running"; const heartbeatIntervalMs = resolveHeartbeatIntervalMs(agent.runtimeConfig?.heartbeatIntervalMs); const nextHeartbeatAt = isTicking && agent.lastHeartbeatAt ? new Date(new Date(agent.lastHeartbeatAt).getTime() + heartbeatIntervalMs).toISOString() : null; return (
{budgetStatus?.isOverBudget && (
⚠️ Budget Exhausted: This agent has exceeded its token budget and may operate with limited functionality.
)}

Overview

{agent.name} {agent.state}
{health.icon} {health.label} {(agent.pendingApprovalCount ?? 0) > 0 ? ( {agent.pendingApprovalCount} pending approvals ) : null} Role: {agent.role} {runtimeHint ? "Runtime" : "Model"} {modelDisplay ?? "Auto"} {agentSkills.length > 0 ? ( Skills {agentSkills.map((skillId) => { const isSelected = selectedSkillId === skillId; return ( ); })} ) : ( Skills: — )}
{selectedSkillId ? (
{selectedSkillLabel}
{isLoadingSkillContent ? (
Loading skill content...
) : skillContentError ? (
{skillContentError}
) : selectedSkillContent ? (
{selectedSkillContent.skillMd || "(No SKILL.md found)"}
) : (
No skill content available
)}
) : null}

Heartbeat & Health

Last heartbeat

{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "Never"}

Next expected

{nextHeartbeatAt ? relativeTime(nextHeartbeatAt) : "Not scheduled"}

Interval

{formatHeartbeatInterval(heartbeatIntervalMs)}

Status

{health.label}{health.reason && ({health.reason})}

Current Work

{agent.taskId ? ( ) : (

No active assignment

)}

Recent Runs

{stats.successfulRuns}/{stats.totalRuns} successful ({stats.successRate}%)

{recentRuns.length === 0 ? (

No runs yet

) : (
{recentRuns.map((run) => { const statusSpec = RUN_STATUS_ICONS[run.status] || RUN_STATUS_ICONS.terminated; const StatusIcon = statusSpec.icon; return (
{relativeTime(run.startedAt)} {Math.max(0, Math.round((new Date(run.endedAt || run.startedAt).getTime() - new Date(run.startedAt).getTime()) / 1000))}s
); })}
)}

Throughput

{stats.totalRuns}
Total Runs
{stats.todayRuns}
Runs Today
{stats.successRate}%
Success Rate

Chain of Command

{isLoadingChainOfCommand ? (
Loading reporting chain...
) : chainOfCommand.length <= 1 ? (

No reporting chain

) : (
{chainOfCommand.map((chainAgent, index) => { const isCurrent = index === chainOfCommand.length - 1; const isAncestor = !isCurrent; return (
{!isCurrent && }
); })}
)}
); } // ── Logs Tab ────────────────────────────────────────────────────────────── function LogsTab({ logs, isStreaming, hasTask, fallbackLabel, }: { logs: AgentLogEntry[]; isStreaming: boolean; hasTask: boolean; fallbackLabel?: string | null; }) { if (!hasTask) { return (

No activity yet

Agent logs will appear here from the current task or most recent run

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

No log entries yet

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

) : ( )}
); } function formatMailboxTimestamp(ts: string): string { const date = new Date(ts); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return "Just now"; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } function mailboxParticipantLabel( id: string, type: ParticipantType, agentNamesById?: ReadonlyMap, ): string { if (type === "user") return id === "dashboard" ? "You" : `User: ${id}`; if (type === "agent") { const name = agentNamesById?.get(id)?.trim(); if (!name || name === id) return `Agent: ${id}`; return `Agent: ${name}`; } return "System"; } function MailTab({ agent, mailbox, isLoading, error, projectId, addToast, onRefresh, }: { agent: AgentDetail; mailbox: AgentMailboxResponse | null; isLoading: boolean; error: string | null; projectId?: string; addToast?: (message: string, type?: "success" | "error") => void; onRefresh: () => void; }) { const [activeSubtab, setActiveSubtab] = useState<"inbox" | "outbox">("inbox"); const [knownAgents, setKnownAgents] = useState([]); useEffect(() => { let cancelled = false; fetchAgents(undefined, projectId) .then((agents) => { if (!cancelled) { setKnownAgents(agents); } }) .catch(() => { if (!cancelled) { setKnownAgents([]); } }); return () => { cancelled = true; }; }, [projectId]); const agentNamesById = useMemo(() => { const map = new Map(); for (const knownAgent of knownAgents) { if (!knownAgent.id) continue; const name = typeof knownAgent.name === "string" ? knownAgent.name.trim() : ""; if (name.length > 0) { map.set(knownAgent.id, name); } } const currentAgentName = typeof agent.name === "string" ? agent.name.trim() : ""; if (currentAgentName.length > 0) { map.set(agent.id, currentAgentName); } return map; }, [knownAgents, agent.id, agent.name]); const [selectedMessageId, setSelectedMessageId] = useState(null); const messages = activeSubtab === "inbox" ? (mailbox?.inbox ?? []) : (mailbox?.outbox ?? []); const selectedMessage = selectedMessageId ? messages.find((message) => message.id === selectedMessageId) ?? null : null; useEffect(() => { setSelectedMessageId(null); }, [activeSubtab, agent.id]); const handleMessageClick = async (message: Message) => { setSelectedMessageId(message.id); if (activeSubtab !== "inbox" || message.read) { return; } try { await markMessageRead(message.id, projectId); onRefresh(); } catch (err) { const errorMessage = `Failed to mark message as read: ${getErrorMessage(err)}`; if (addToast) { addToast(errorMessage, "error"); } else { console.warn(errorMessage); } } }; const handleRefresh = () => { setSelectedMessageId(null); onRefresh(); }; const renderMessage = (message: Message) => ( ); return (

{agent.name} Mail

{isLoading && !mailbox ? (
Loading mailbox...
) : null} {!isLoading && error ? (
Failed to load mailbox: {error}
) : null} {!isLoading && !error ? ( selectedMessage ? (
From {mailboxParticipantLabel(selectedMessage.fromId, selectedMessage.fromType, agentNamesById)}
To {mailboxParticipantLabel(selectedMessage.toId, selectedMessage.toType, agentNamesById)}
Type {selectedMessage.type}
Sent {new Date(selectedMessage.createdAt).toLocaleString()}
{selectedMessage.metadata?.replyTo?.messageId ? (
↪ Replying to message {selectedMessage.metadata.replyTo.messageId}
) : null}
{selectedMessage.content}
) : (
{messages.length === 0 ? (
{activeSubtab === "inbox" ? : }

{activeSubtab === "inbox" ? "No received messages for this agent" : "No sent messages for this agent"}

) : ( messages.map(renderMessage) )}
) ) : null}
); } // ── Runs Tab ─────────────────────────────────────────────────────────────── interface AgentTokenUsageWindowSummary { totalInputTokens: number; totalCachedTokens: number; totalCacheWriteTokens: number; totalOutputTokens: number; nTasks: number; hitRatio: number; } interface AgentTokenUsageSummary { last24h: AgentTokenUsageWindowSummary; last7d: AgentTokenUsageWindowSummary; allTime: AgentTokenUsageWindowSummary; } function RunsTab({ addToast, agentId, projectId, agentState, agentName, initialRunId, preferActiveRun, runNowRefreshToken, isEphemeral, }: { addToast: (msg: string, type?: "success" | "error") => void; agentId: string; projectId?: string; agentState?: AgentState; agentName?: string; initialRunId?: string | null; preferActiveRun?: boolean; runNowRefreshToken: number; isEphemeral: boolean; }) { const [runs, setRuns] = useState([]); const { confirm } = useConfirm(); const [isLoadingRuns, setIsLoadingRuns] = useState(true); const [selectedRunId, setSelectedRunId] = useState(null); const [runLogs, setRunLogs] = useState([]); const [isLoadingLogs, setIsLoadingLogs] = useState(false); const [detailRun, setDetailRun] = useState(null); const [isLoadingDetail, setIsLoadingDetail] = useState(false); const [tokenUsageSummary, setTokenUsageSummary] = useState(null); const [promptSizes, setPromptSizes] = useState([]); const hasAutoExpandedInitialRunRef = useRef(false); const didMountRunNowRefreshRef = useRef(false); // Load runs on mount const loadRuns = useCallback(async () => { try { const data = await fetchAgentRuns(agentId, 50, projectId); setRuns(data); } catch (err) { addToast(`Failed to load runs: ${getErrorMessage(err)}`, "error"); } finally { setIsLoadingRuns(false); } }, [agentId, projectId, addToast]); useEffect(() => { void loadRuns(); }, [loadRuns]); useEffect(() => { if (isEphemeral) { setTokenUsageSummary(null); setPromptSizes([]); return; } const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; void fetch(`/api/agents/${encodeURIComponent(agentId)}/token-usage${query}`) .then(async (res) => { if (!res.ok) { if (res.status === 400) { setTokenUsageSummary(null); return; } throw new Error(`Request failed: ${res.status}`); } const data = (await res.json()) as AgentTokenUsageSummary; setTokenUsageSummary(data); }) .catch((err) => { addToast(`Failed to load cache hit ratio: ${getErrorMessage(err)}`, "error"); }); void fetchAgentPromptSizes(agentId, 7, projectId) .then((data) => setPromptSizes(data)) .catch((err) => { const message = getErrorMessage(err).toLowerCase(); if (message.includes("ephemeral") || message.includes("400")) { setPromptSizes([]); return; } addToast(`Failed to load prompt sizes: ${getErrorMessage(err)}`, "error"); }); }, [agentId, projectId, addToast, isEphemeral]); useEffect(() => { if (!didMountRunNowRefreshRef.current) { didMountRunNowRefreshRef.current = true; return; } setIsLoadingRuns(true); void loadRuns(); }, [loadRuns, runNowRefreshToken]); // Poll for active runs const hasActiveRun = runs.some(r => r.status === "active"); const selectedRunStatus = selectedRunId ? runs.find((run) => run.id === selectedRunId)?.status : undefined; useEffect(() => { if (!hasActiveRun) return; const interval = setInterval(() => { void loadRuns(); }, 5000); return () => clearInterval(interval); }, [hasActiveRun, loadRuns]); // While a selected run is still active, subscribe to its log stream so the // expanded view tails updates without a refresh. Mirrors the per-task log // SSE pattern in useAgentLogs. useEffect(() => { if (!selectedRunId) return; if (selectedRunStatus !== "active") return; const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; return subscribeSse( `/api/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(selectedRunId)}/logs/stream${query}`, { events: { "agent:log": (e) => { try { const entry: AgentLogEntry = JSON.parse(e.data); setRunLogs(prev => [...prev, entry]); } catch { // ignore malformed events } }, }, }, ); }, [selectedRunId, selectedRunStatus, agentId, projectId]); // Load run detail when a run is selected const handleRunClick = useCallback(async (runId: string) => { if (selectedRunId === runId) { setSelectedRunId(null); setRunLogs([]); setDetailRun(null); return; } setSelectedRunId(runId); setIsLoadingLogs(true); setIsLoadingDetail(true); setRunLogs([]); setDetailRun(null); try { const [logs, detail] = await Promise.all([ fetchAgentRunLogs(agentId, runId, projectId), fetchAgentRunDetail(agentId, runId, projectId), ]); setRunLogs(logs); setDetailRun(detail); } catch (err) { addToast(`Failed to load run details: ${getErrorMessage(err)}`, "error"); setRunLogs([]); setDetailRun(null); } finally { setIsLoadingLogs(false); setIsLoadingDetail(false); } }, [selectedRunId, agentId, projectId, addToast]); useEffect(() => { hasAutoExpandedInitialRunRef.current = false; }, [agentId, initialRunId, preferActiveRun]); useEffect(() => { if (runs.length === 0 || isLoadingRuns || hasAutoExpandedInitialRunRef.current) { return; } const runToExpand = initialRunId ? runs.find((run) => run.id === initialRunId) : (preferActiveRun ? runs.find((run) => run.status === "active") : null); hasAutoExpandedInitialRunRef.current = true; if (runToExpand) { void handleRunClick(runToExpand.id); } }, [initialRunId, preferActiveRun, runs, isLoadingRuns, handleRunClick]); const handleStopRun = async () => { const shouldStop = await confirm({ title: "Stop Active Run", message: "Stop the active run? The agent's work will be interrupted.", danger: true, }); if (!shouldStop) { return; } try { await stopAgentRun(agentId, projectId); addToast("Run stopped", "success"); setIsLoadingRuns(true); void loadRuns(); } catch (err) { addToast(`Failed to stop run: ${getErrorMessage(err)}`, "error"); } }; if (isLoadingRuns && runs.length === 0) { return (
Loading runs...
); } if (runs.length === 0) { 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() ); const activeRuns = sortedRuns.filter(r => r.status === "active"); const completedRuns = sortedRuns.filter(r => r.status !== "active"); const renderUsage = (usage: { inputTokens: number; outputTokens: number; cachedTokens: number; cacheWriteTokens?: number } | undefined) => { if (!usage) return null; return (
Input: {usage.inputTokens.toLocaleString()} Output: {usage.outputTokens.toLocaleString()} {usage.cachedTokens > 0 && Cache read: {usage.cachedTokens.toLocaleString()}} {(usage.cacheWriteTokens ?? 0) > 0 && Cache write: {(usage.cacheWriteTokens ?? 0).toLocaleString()}}
); }; const renderRunCard = (run: AgentHeartbeatRun, index: number, isActive: boolean) => { 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"; const isSelected = selectedRunId === run.id; return (
void handleRunClick(run.id)} role="button" tabIndex={0} aria-expanded={isSelected} aria-label={`${isActive ? "Active" : ""} run ${run.id.slice(0, 8)}, ${run.status}`} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); void handleRunClick(run.id); } }} >
{isSelected ? : } {isActive ? ( Live Run ) : ( #{index + 1} {run.id.slice(0, 8)} )}
{run.invocationSource && ( {run.invocationSource} )} {isActive && ( )} {run.status} {run.heartbeatProcedureSource === "custom" && ( Heartbeat: custom )}
Started {relativeTime(run.startedAt)} {duration} {run.triggerDetail && ( <> {run.triggerDetail} )}
{isSelected && (
{/* Execution Details */} {isLoadingDetail ? (
Loading details...
) : detailRun && (
{/* System Prompt */}
System Prompt {detailRun.systemPrompt ? (
{detailRun.systemPrompt}
) : (
System prompt not captured for this run
)}
{/* Execution Prompt */}
Execution Prompt {detailRun.executionPrompt ? (
{detailRun.executionPrompt}
) : (
Execution prompt not captured for this run
)}
{/* Token Usage */} {detailRun.usageJson && (
Token Usage
{renderUsage(detailRun.usageJson)}
)} {/* Output */} {detailRun.stdoutExcerpt && (
Output
                      {detailRun.stdoutExcerpt.length > 2000
                        ? `${detailRun.stdoutExcerpt.slice(0, 2000)}\n\n... (truncated, ${detailRun.stdoutExcerpt.length} chars total)`
                        : detailRun.stdoutExcerpt}
                    
)} {/* Errors */} {detailRun.stderrExcerpt && (
Errors
)} {/* Result */} {detailRun.resultJson && (
Result
{JSON.stringify(detailRun.resultJson, null, 2)}
)} {/* Context */} {detailRun.contextSnapshot && Object.keys(detailRun.contextSnapshot).length > 0 && (
Context
{Object.entries(detailRun.contextSnapshot).map(([key, value]) => ( {key}:{" "} {String(value)} ))}
)} {/* No output state */} {!detailRun.stdoutExcerpt && !detailRun.stderrExcerpt && !detailRun.resultJson && (
No output captured
)}
)} {/* Run Logs */}
Agent Logs
{isLoadingLogs ? (
Loading logs...
) : runLogs.length === 0 ? (
No logs available for this run
) : ( )}
)}
); }; const renderCacheWindow = (label: string, window: AgentTokenUsageWindowSummary) => (
{label}:{" "} {(window.hitRatio * 100).toFixed(1)}% ({window.totalCachedTokens.toLocaleString()} / {window.totalCacheWriteTokens.toLocaleString()} / {window.totalInputTokens.toLocaleString()} / {window.nTasks.toLocaleString()})
); const latestPrompt = promptSizes[0]; const promptPoints = [...promptSizes].reverse(); const maxExecChars = Math.max(1, ...promptPoints.map((point) => point.execChars)); const promptPolyline = promptPoints .map((point, index) => { const x = promptPoints.length <= 1 ? 0 : (index / (promptPoints.length - 1)) * 100; const y = 100 - Math.round((point.execChars / maxExecChars) * 100); return `${x},${y}`; }) .join(" "); return (
{promptSizes.length > 0 && latestPrompt && (
Prompt Size
{latestPrompt.systemChars.toLocaleString()} / {latestPrompt.execChars.toLocaleString()} / {latestPrompt.totalChars.toLocaleString()}
)} {tokenUsageSummary && (
Cache hit ratio
{renderCacheWindow("Last 24h", tokenUsageSummary.last24h)} {renderCacheWindow("Last 7d", tokenUsageSummary.last7d)} {renderCacheWindow("All time", tokenUsageSummary.allTime)}
)}
{runs.length} run{runs.length !== 1 ? "s" : ""} {hasActiveRun && Live}
{hasActiveRun && ( )}
{activeRuns.map((run, i) => renderRunCard(run, i, true))} {completedRuns.map((run, i) => renderRunCard(run, activeRuns.length + i, false))}
); } 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`; } const TASK_COLUMN_LABELS: Record = { triage: "Triage", todo: "Todo", "in-progress": "In Progress", "in-review": "In Review", done: "Done", archived: "Archived", }; function truncateTaskLabel(task: Task): string { const source = task.title?.trim() || task.description?.trim() || task.id; return source.length > 80 ? `${source.slice(0, 77)}...` : source; } function TasksTab({ agentId, projectId, addToast, }: { agentId: string; projectId?: string; addToast: (msg: string, type?: "success" | "error") => void; }) { const [tasks, setTasks] = useState([]); const [isLoading, setIsLoading] = useState(true); useEffect(() => { let cancelled = false; setIsLoading(true); void fetchAgentTasks(agentId, projectId) .then((assignedTasks) => { if (!cancelled) { setTasks(assignedTasks); } }) .catch((err) => { if (!cancelled) { setTasks([]); addToast(`Failed to load assigned tasks: ${getErrorMessage(err)}`, "error"); } }) .finally(() => { if (!cancelled) { setIsLoading(false); } }); return () => { cancelled = true; }; }, [agentId, projectId, addToast]); if (isLoading) { return (

Loading assigned tasks...

); } if (tasks.length === 0) { return (

No tasks assigned to this agent

); } return ( ); } // ── 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: "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 SoulTab({ agent, projectId, addToast, onSaved, }: { agent: AgentDetail; projectId?: string; addToast: (message: string, type?: "success" | "error") => void; onSaved: () => Promise; }) { const [soul, setSoul] = useState(agent.soul ?? ""); const [isSaving, setIsSaving] = useState(false); const [justSaved, setJustSaved] = useState(false); const [showPreview, setShowPreview] = useState(false); const justSavedTimeoutRef = useRef | null>(null); useEffect(() => { setSoul(agent.soul ?? ""); setJustSaved(false); setShowPreview(false); }, [agent.id, agent.soul]); useEffect(() => { return () => { if (justSavedTimeoutRef.current) { clearTimeout(justSavedTimeoutRef.current); } }; }, []); const hasChanges = soul !== (agent.soul ?? ""); const handleSave = async () => { if (soul.length > 10000) { addToast("Soul must be at most 10,000 characters", "error"); return; } setIsSaving(true); try { await updateAgentSoul(agent.id, soul, projectId); addToast("Soul saved", "success"); setJustSaved(true); if (justSavedTimeoutRef.current) { clearTimeout(justSavedTimeoutRef.current); } justSavedTimeoutRef.current = setTimeout(() => setJustSaved(false), 3000); await onSaved(); } catch (err) { addToast(`Failed to save soul: ${getErrorMessage(err)}`, "error"); } finally { setIsSaving(false); } }; return (

Soul

Define this agent's personality and identity.

{showPreview ? ( soul.trim() ? (
{soul}
) : (
No soul defined yet. Switch to Edit mode to define the agent's personality.
) ) : (