Files
fusion/packages/dashboard/app/components/AgentDetailView.tsx
gsxdsm ab16307750 FN-5669: unify agent model favorites with shared selector state
Use the shared favorites hook so agent model pickers stay in sync with project favorites.

- Switch AgentDetailView config tab model favorites to use useFavorites instead of local settings update logic.
- Switch NewAgentDialog model favorites to the same shared favorites flow and remove duplicated toggle handlers.
- Extend AgentDetailView/NewAgentDialog tests and dropdown test helpers to exercise provider/model favorite toggles through the shared global settings path.

Files changed:
 .../dashboard/app/components/AgentDetailView.tsx   | 44 +++------------------
 .../dashboard/app/components/NewAgentDialog.tsx    | 45 +++-------------------
 .../AgentDetailView.advanced-settings.test.tsx     | 41 ++++++++++++++++++++
 .../__tests__/AgentDetailView.test-helpers.ts      | 25 +++++++++++-
 .../components/__tests__/NewAgentDialog.test.tsx   | 36 +++++++++++++++++
 5 files changed, 111 insertions(+), 80 deletions(-)

Fusion-Task-Id: FN-5669

Fusion-Task-Lineage: 8ace8ea1-1c88-409c-a9ae-6b5deb391f46
2026-05-29 08:45:12 -07:00

5334 lines
197 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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";
import { useFavorites } from "../hooks/useFavorites";
/**
* 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<void>;
}
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<AgentState, { bg: string; text: string; border: string }> = {
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<string, { icon: typeof CheckCircle; color: string }> = {
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<MemoryFileInfo["layer"], string> = {
"long-term": "Long-term",
daily: "Daily",
dreams: "Dreams",
};
const MEMORY_LAYER_DESCRIPTIONS: Record<MemoryFileInfo["layer"], string> = {
"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<AgentDetail | null>(null);
const { confirm } = useConfirm();
const [logs, setLogs] = useState<AgentLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabId>(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<AgentHeartbeatRun | null>(null);
const [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null);
const [isLoadingMailbox, setIsLoadingMailbox] = useState(false);
const [mailboxError, setMailboxError] = useState<string | null>(null);
const agentDetailModalRef = useRef<HTMLDivElement>(null);
const bulkMenuRef = useRef<HTMLDivElement | null>(null);
const overlayMouseDownRef = useRef(false);
useModalResizePersist(agentDetailModalRef, !inline, "fusion:agent-detail-modal-size");
const onCloseRef = useRef(onClose);
const addToastRef = useRef(addToast);
const agentRef = useRef<AgentDetail | null>(null);
const hasConfigChangesRef = useRef(false);
const loadedLatestRunLogsRef = useRef<string | null>(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: <Bot size={14} />,
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 (
<div className="agent-detail-inline-loading" role="region" aria-label="Agent detail loading">
<div className="agent-detail-loading">
<Loader2 className="animate-spin" size={24} />
<span>Loading agent...</span>
</div>
</div>
);
}
return (
<div
className="agent-detail-overlay"
onMouseDown={(e) => { 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"
>
<div className="agent-detail-modal" ref={agentDetailModalRef}>
<div className="agent-detail-loading">
<Loader2 className="animate-spin" size={24} />
<span>Loading agent...</span>
</div>
</div>
</div>
);
}
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 (
<div
className={inline ? "agent-detail-inline-shell" : "agent-detail-overlay"}
onClick={(e) => !inline && e.target === e.currentTarget && onClose()}
role={inline ? "region" : "dialog"}
aria-label={inline ? "Agent detail" : undefined}
aria-modal={inline ? undefined : "true"}
>
<div className={detailShellClassName} ref={agentDetailModalRef}>
{/* Header */}
<div className="agent-detail-header">
{/* Identity area: icon + name + badges */}
<div className="agent-detail-identity">
{inline && showInlineBackButton ? (
<button
type="button"
className="btn agent-detail-inline-back"
onClick={onClose}
aria-label="Back to agents"
>
<ChevronLeft size={16} />
Agents
</button>
) : null}
<div className="agent-detail-icon">
<AgentAvatar agent={agent} size={36} />
</div>
<div className="agent-detail-info">
<h2>{agent.name}</h2>
<div className="agent-detail-badges">
<span
className="badge"
style={{ background: stateStyle.bg, color: stateStyle.text, border: `1px solid ${stateStyle.border}` }}
>
{agent.state}
</span>
<span className="badge" style={{ color: health.color }} title={health.reason ?? health.label}>
{health.icon}
{!health.stateDerived && health.label}
</span>
</div>
</div>
</div>
<div className="agent-detail-header-actions">
{/* Lifecycle controls: compact action buttons */}
<div className="agent-detail-controls">
{/* State-dependent action buttons */}
{agent.state === "idle" && (
<>
<button className="btn btn-task-create btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
<Play size={14} />
Start
</button>
<button
className="btn btn-task-create btn--compact"
onClick={() => void handleRunHeartbeat()}
aria-label={`Run now for ${agent.name}`}
disabled={isStartingRun || isTransitioning}
>
<Activity size={14} />
Run Now
</button>
<button className="btn btn--danger btn--compact" onClick={handleDelete}>
<Trash2 size={14} />
Delete
</button>
</>
)}
{agent.state === "active" && (
<>
<button className="btn btn--compact agent-detail-mobile-icon-control" onClick={() => void handleStateChange("paused")} disabled={isTransitioning} aria-label="Pause">
<Pause size={14} />
<span className="agent-detail-control-label">Pause</span>
</button>
<button className="btn btn--danger btn--compact agent-detail-mobile-icon-control" onClick={() => void handleStateChange("paused")} disabled={isTransitioning} aria-label="Stop">
<Square size={14} />
<span className="agent-detail-control-label">Stop</span>
</button>
<button
className="btn btn-task-create btn--compact agent-detail-mobile-icon-control"
onClick={() => void handleRunHeartbeat()}
aria-label={`Run now for ${agent.name}`}
disabled={isStartingRun || isTransitioning}
>
<Activity size={14} />
<span className="agent-detail-control-label">Run Now</span>
</button>
</>
)}
{agent.state === "paused" && (
<>
<button className="btn btn-task-create btn--compact agent-detail-mobile-icon-control" onClick={() => void handleStateChange("active")} disabled={isTransitioning} aria-label="Resume">
<Play size={14} />
<span className="agent-detail-control-label">Resume</span>
</button>
<button className="btn btn--danger btn--compact" onClick={handleDelete}>
<Trash2 size={14} />
Delete
</button>
</>
)}
{agent.state === "running" && (
<>
<button className="btn btn--compact agent-detail-mobile-icon-control" onClick={() => void handleStateChange("paused")} disabled={isTransitioning} aria-label="Pause">
<Pause size={14} />
<span className="agent-detail-control-label">Pause</span>
</button>
<button className="btn btn--danger btn--compact agent-detail-mobile-icon-control" onClick={() => void handleStateChange("paused")} disabled={isTransitioning} aria-label="Stop">
<Square size={14} />
<span className="agent-detail-control-label">Stop</span>
</button>
</>
)}
{agent.state === "error" && (
<>
<button className="btn btn-task-create btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
<Play size={14} />
Retry
</button>
<button className="btn btn--danger btn--compact agent-detail-mobile-icon-control" onClick={() => void handleStateChange("paused")} disabled={isTransitioning} aria-label="Stop">
<Square size={14} />
<span className="agent-detail-control-label">Stop</span>
</button>
</>
)}
</div>
{/* Utility actions: refresh + close */}
<div className="agent-detail-utility-actions">
<div className="agent-detail-bulk-menu" ref={bulkMenuRef}>
<button
type="button"
className="btn-icon"
onClick={() => setIsBulkMenuOpen((open) => !open)}
aria-haspopup="menu"
aria-expanded={isBulkMenuOpen}
aria-label="Bulk agent actions"
title="Bulk agent actions"
disabled={isTransitioning || isBulkActionRunning}
>
<MoreVertical size={16} />
</button>
{isBulkMenuOpen && (
<div className="agent-detail-bulk-menu-popover" role="menu">
<button
type="button"
role="menuitem"
className="agent-detail-bulk-menu-item"
onClick={() => void handleBulkStateChange("paused")}
disabled={isPauseAllDisabled || isBulkActionRunning}
>
Pause All Agents
<span className="agent-detail-bulk-menu-item-hint">
{isBulkEligibilityLoading
? "Loading eligible agents..."
: isPauseAllDisabled
? "No active agents eligible"
: `Pause ${bulkPauseEligibleCount} active/running agent${bulkPauseEligibleCount === 1 ? "" : "s"}`}
</span>
</button>
<button
type="button"
role="menuitem"
className="agent-detail-bulk-menu-item"
onClick={() => void handleBulkStateChange("active")}
disabled={isResumeAllDisabled || isBulkActionRunning}
>
Resume All Agents
<span className="agent-detail-bulk-menu-item-hint">
{isBulkEligibilityLoading
? "Loading eligible agents..."
: isResumeAllDisabled
? "No paused agents eligible"
: `Resume ${bulkResumeEligibleCount} paused agent${bulkResumeEligibleCount === 1 ? "" : "s"}`}
</span>
</button>
</div>
)}
</div>
<button className="btn-icon" onClick={() => void loadAgent()} title="Refresh" aria-label="Refresh">
<RefreshCw size={16} />
</button>
{!inline && (
<button className="btn-icon" onClick={onClose} aria-label="Close" title="Close">
<X size={20} />
</button>
)}
</div>
</div>
</div>
{/* Tabs */}
<div className="agent-detail-tabs">
{TABS.map(tab => (
<button
key={tab.id}
className={cn("agent-detail-tab", activeTab === tab.id && "active")}
onClick={() => setActiveTab(tab.id)}
>
<tab.icon size={16} />
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
<div className="agent-detail-content">
{activeTab === "dashboard" && (
<DashboardTab
agent={agent}
health={health}
onChildClick={onChildClick}
projectId={projectId}
/>
)}
{activeTab === "logs" && (
<LogsTab
logs={logs}
isStreaming={isStreaming}
hasTask={!!agent.taskId || logs.length > 0 || latestRun !== null}
fallbackLabel={!agent.taskId && latestRun ? `Latest run · ${latestRun.id.slice(0, 8)}` : null}
/>
)}
{activeTab === "mail" && (
<MailTab
agent={agent}
mailbox={agentMailbox}
isLoading={isLoadingMailbox}
error={mailboxError}
projectId={projectId}
addToast={addToast}
onRefresh={() => void loadMailbox()}
/>
)}
{activeTab === "runs" && (
<RunsTab
addToast={addToast}
agentId={agent.id}
projectId={projectId}
agentState={agent.state}
agentName={agent.name}
initialRunId={initialRunId}
preferActiveRun={preferActiveRun}
runNowRefreshToken={runNowRefreshToken}
isEphemeral={isEphemeralAgent(agent)}
/>
)}
{activeTab === "tasks" && (
<TasksTab
agentId={agent.id}
projectId={projectId}
addToast={addToast}
/>
)}
{activeTab === "employees" && (
<EmployeesTab
agentId={agent.id}
projectId={projectId}
onChildClick={onChildClick}
/>
)}
{activeTab === "soul" && (
<SoulTab
agent={agent}
projectId={projectId}
addToast={addToast}
onSaved={handleSavedMutation}
/>
)}
{activeTab === "instructions" && (
<InstructionsTab
agent={agent}
projectId={projectId}
addToast={addToast}
onSaved={handleSavedMutation}
/>
)}
{activeTab === "memory" && (
<MemoryTab
agent={agent}
projectId={projectId}
addToast={addToast}
onSaved={handleSavedMutation}
/>
)}
{activeTab === "reflections" && (
<AgentReflectionsTab
agentId={agent.id}
projectId={projectId}
addToast={addToast}
/>
)}
{activeTab === "config" && (
<ConfigTab
key={agent.id}
agent={agent}
projectId={projectId}
addToast={addToast}
onSaved={handleSavedMutation}
onHasChangesChange={handleConfigChangesState}
onDelete={handleDelete}
onAgentDraftApplied={(updates) => {
setAgent((current) => (current ? { ...current, ...updates } : current));
}}
/>
)}
</div>
{/* Footer with agent ID */}
{!inline && (
<div className="agent-detail-footer">
<button className="btn-icon" onClick={copyAgentId} title="Copy Agent ID">
<Copy />
</button>
<span className="agent-detail-id" onClick={copyAgentId}>
{agent.id}
</span>
{agent.taskId && (
<>
<span className="divider">|</span>
<span className="text-muted">Working on:</span>
<a href={`/tasks/${agent.taskId}`} className="link">
{agent.taskId}
<ExternalLink size={12} />
</a>
</>
)}
</div>
)}
</div>
</div>
);
}
// ── 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<Agent[]>([]);
const [isLoadingChainOfCommand, setIsLoadingChainOfCommand] = useState(true);
const [budgetStatus, setBudgetStatus] = useState<AgentBudgetStatus | null>(null);
const [availableRuntimes, setAvailableRuntimes] = useState<PluginRuntimeInfo[]>([]);
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(null);
const [selectedSkillContent, setSelectedSkillContent] = useState<SkillContent | null>(null);
const [isLoadingSkillContent, setIsLoadingSkillContent] = useState(false);
const [skillContentError, setSkillContentError] = useState<string | null>(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 (
<div className="dashboard-tab dashboard-summary-layout">
{budgetStatus?.isOverBudget && (
<div className="budget-warning-banner" role="alert">
<span></span>
<span><strong>Budget Exhausted:</strong> This agent has exceeded its token budget and may operate with limited functionality.</span>
</div>
)}
<section className="dashboard-summary-card dashboard-summary-hero">
<div className="dashboard-summary-hero__heading">
<Bot />
<h3>Overview</h3>
<strong>{agent.name}</strong>
<span className="inline-badge" style={{ background: stateStyle.bg, color: stateStyle.text }}>{agent.state}</span>
</div>
<div className="dashboard-summary-hero__meta">
<span className="dashboard-summary-hero__health" title={health.reason ?? health.label}>{health.icon} {health.label}</span>
{(agent.pendingApprovalCount ?? 0) > 0 ? (
<span className="badge agent-detail-approval-badge" title="Pending approvals">
<span className="status-dot status-dot--pending" />
{agent.pendingApprovalCount} pending approvals
</span>
) : null}
<span>Role: {agent.role}</span>
<span>
<span className="dashboard-summary-label">{runtimeHint ? "Runtime" : "Model"}</span>
<span> {modelDisplay ?? "Auto"}</span>
</span>
{agentSkills.length > 0 ? (
<span className="dashboard-summary-skills">
<span className="dashboard-summary-label">Skills</span>
<span className="dashboard-summary-skill-badges" role="list" aria-label="Assigned skills">
{agentSkills.map((skillId) => {
const isSelected = selectedSkillId === skillId;
return (
<button
key={skillId}
type="button"
className={cn("badge", "badge-skill", "dashboard-summary-skill-badge", "dashboard-summary-skill-badge-btn", isSelected && "dashboard-summary-skill-badge--selected")}
title={skillId}
onClick={() => handleSkillBadgeClick(skillId)}
aria-expanded={isSelected}
aria-label={`View details for ${formatAgentSkillBadgeLabel(skillId)}`}
>
{formatAgentSkillBadgeLabel(skillId)}
</button>
);
})}
</span>
</span>
) : (
<span>Skills: </span>
)}
</div>
{selectedSkillId ? (
<div className="dashboard-summary-skill-detail" data-testid="agent-skill-detail">
<div className="dashboard-summary-skill-detail-header">
<span className="dashboard-summary-skill-detail-title">{selectedSkillLabel}</span>
<button
type="button"
className="btn btn-sm"
onClick={() => handleSkillBadgeClick(selectedSkillId)}
>
<X size={14} />
Close
</button>
</div>
{isLoadingSkillContent ? (
<div className="dashboard-summary-skill-detail-loading" role="status" aria-live="polite">
<Loader2 size={14} className="animate-spin" />
Loading skill content...
</div>
) : skillContentError ? (
<div className="dashboard-summary-skill-detail-error" role="alert">
<AlertCircle size={14} />
<span>{skillContentError}</span>
<button type="button" className="btn btn-sm" onClick={() => void loadSkillContent(selectedSkillId)}>
Retry
</button>
</div>
) : selectedSkillContent ? (
<pre className="dashboard-summary-skill-detail-content">{selectedSkillContent.skillMd || "(No SKILL.md found)"}</pre>
) : (
<div className="dashboard-summary-skill-detail-empty">No skill content available</div>
)}
</div>
) : null}
</section>
<section className="dashboard-summary-card">
<h3>Heartbeat &amp; Health</h3>
<div className="dashboard-summary-grid">
<div>
<p className="dashboard-summary-label">Last heartbeat</p>
<p>{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "Never"}</p>
</div>
<div>
<p className="dashboard-summary-label">Next expected</p>
<p>{nextHeartbeatAt ? relativeTime(nextHeartbeatAt) : "Not scheduled"}</p>
</div>
<div>
<p className="dashboard-summary-label">Interval</p>
<p>{formatHeartbeatInterval(heartbeatIntervalMs)}</p>
</div>
<div>
<p className="dashboard-summary-label">Status</p>
<p className="dashboard-summary-health-row"><span className={cn("status-dot", agent.state === "running" && "status-dot--running")} />{health.label}{health.reason && <span className="text-secondary dashboard-summary-health-reason" title={health.reason}>({health.reason})</span>}</p>
</div>
</div>
</section>
<section className="dashboard-summary-card">
<h3>Current Work</h3>
{agent.taskId ? (
<div className="current-task">
<a href={`/tasks/${agent.taskId}`} className="task-badge">{agent.taskId}</a>
<a href={`/tasks/${agent.taskId}`} className="btn btn-sm">View Task <ExternalLink size={14} /></a>
</div>
) : (
<p className="text-muted">No active assignment</p>
)}
</section>
<section className="dashboard-summary-card">
<h3>Recent Runs</h3>
<p className="dashboard-summary-label">{stats.successfulRuns}/{stats.totalRuns} successful ({stats.successRate}%)</p>
{recentRuns.length === 0 ? (
<p className="text-muted">No runs yet</p>
) : (
<div className="runs-list">
{recentRuns.map((run) => {
const statusSpec = RUN_STATUS_ICONS[run.status] || RUN_STATUS_ICONS.terminated;
const StatusIcon = statusSpec.icon;
return (
<div key={run.id} className="run-item">
<StatusIcon size={14} style={{ color: statusSpec.color }} />
<span>{relativeTime(run.startedAt)}</span>
<span className="text-muted">{Math.max(0, Math.round((new Date(run.endedAt || run.startedAt).getTime() - new Date(run.startedAt).getTime()) / 1000))}s</span>
</div>
);
})}
</div>
)}
</section>
<section className="dashboard-summary-card">
<h3>Throughput</h3>
<div className="stats-grid">
<div className="stat-card"><div className="stat-value">{stats.totalRuns}</div><div className="stat-label">Total Runs</div></div>
<div className="stat-card"><div className="stat-value">{stats.todayRuns}</div><div className="stat-label">Runs Today</div></div>
<div className="stat-card"><div className="stat-value">{stats.successRate}%</div><div className="stat-label">Success Rate</div></div>
</div>
</section>
<section className="dashboard-summary-card">
<h3>Chain of Command</h3>
{isLoadingChainOfCommand ? (
<div className="chain-of-command-loading" role="status" aria-live="polite"><Loader2 size={14} className="animate-spin" /><span>Loading reporting chain...</span></div>
) : chainOfCommand.length <= 1 ? (
<p className="text-muted">No reporting chain</p>
) : (
<div className="chain-of-command-path" aria-label="Chain of command">
{chainOfCommand.map((chainAgent, index) => {
const isCurrent = index === chainOfCommand.length - 1;
const isAncestor = !isCurrent;
return (
<div key={chainAgent.id} className="chain-of-command-item">
<button type="button" className={`chain-of-command-node${isCurrent ? " chain-of-command-node--current" : ""}`} onClick={() => isAncestor && onChildClick?.(chainAgent.id)} disabled={!isAncestor || !onChildClick} title={isCurrent ? "Current agent" : `View ${chainAgent.name}`}>
{chainAgent.name}
</button>
{!isCurrent && <span className="chain-of-command-separator" aria-hidden="true"></span>}
</div>
);
})}
</div>
)}
</section>
</div>
);
}
// ── Logs Tab ──────────────────────────────────────────────────────────────
function LogsTab({
logs,
isStreaming,
hasTask,
fallbackLabel,
}: {
logs: AgentLogEntry[];
isStreaming: boolean;
hasTask: boolean;
fallbackLabel?: string | null;
}) {
if (!hasTask) {
return (
<div className="logs-tab">
<div className="logs-empty">
<FileText size={48} opacity={0.3} />
<p>No activity yet</p>
<p className="text-muted">
Agent logs will appear here from the current task or most recent run
</p>
</div>
</div>
);
}
return (
<div className="logs-tab">
<div className="logs-header">
<span className="logs-count">{logs.length} entries</span>
{fallbackLabel && (
<span className="text-muted logs-fallback-label">{fallbackLabel}</span>
)}
{isStreaming && (
<span className="streaming-indicator">
<span className="streaming-dot" />
Live
</span>
)}
</div>
{logs.length === 0 ? (
<div className="logs-empty">
<FileText size={48} opacity={0.3} />
<p>No log entries yet</p>
<p className="text-muted">
{isStreaming ? "Waiting for activity..." : "Logs will appear here when the agent is active"}
</p>
</div>
) : (
<AgentLogViewer entries={logs} loading={false} />
)}
</div>
);
}
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, string>,
): 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<Agent[]>([]);
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<string, string>();
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<string | null>(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) => (
<button
key={message.id}
type="button"
className={cn("mailbox-item", "agent-mail-tab-message", activeSubtab === "inbox" && !message.read && "unread", selectedMessageId === message.id && "agent-mail-tab-message--selected")}
onClick={() => void handleMessageClick(message)}
aria-pressed={selectedMessageId === message.id}
>
<div className="mailbox-item-avatar">
{(activeSubtab === "inbox" ? message.fromType : message.toType) === "agent" ? <Bot size={16} /> : <User size={16} />}
</div>
<div className="mailbox-item-content">
<div className="mailbox-item-header">
{activeSubtab === "inbox" ? (
<span className="mailbox-item-from">{mailboxParticipantLabel(message.fromId, message.fromType, agentNamesById)}</span>
) : (
<span className="mailbox-item-to">To: {mailboxParticipantLabel(message.toId, message.toType, agentNamesById)}</span>
)}
<span className="mailbox-item-time">{formatMailboxTimestamp(message.createdAt)}</span>
</div>
<div className="mailbox-item-preview">{message.content.slice(0, 80)}{message.content.length > 80 ? "…" : ""}</div>
</div>
{activeSubtab === "inbox" && !message.read ? <div className="mailbox-item-unread-dot" aria-label="Unread message" /> : null}
</button>
);
return (
<div className="agent-mail-tab">
<div className="agent-mail-tab-header">
<h3>{agent.name} Mail</h3>
<button className="btn btn-sm" onClick={handleRefresh} disabled={isLoading}>
<RefreshCw size={14} />
Refresh
</button>
</div>
<div className="mailbox-agent-subtabs" data-testid="agent-detail-mail-subtabs">
<button
className={cn("btn", "btn-sm", "btn-secondary", "mailbox-agent-subtab", activeSubtab === "inbox" && "active")}
onClick={() => setActiveSubtab("inbox")}
>
<InboxIcon size={12} />
<span>Inbox</span>
{(mailbox?.unreadCount ?? 0) > 0 ? <span className="mailbox-tab-badge">{mailbox?.unreadCount}</span> : null}
</button>
<button
className={cn("btn", "btn-sm", "btn-secondary", "mailbox-agent-subtab", activeSubtab === "outbox" && "active")}
onClick={() => setActiveSubtab("outbox")}
>
<Send size={12} />
<span>Outbox</span>
</button>
</div>
{isLoading && !mailbox ? (
<div className="agent-detail-loading agent-detail-loading--inline" role="status" aria-live="polite">
<Loader2 className="animate-spin" size={16} />
<span>Loading mailbox...</span>
</div>
) : null}
{!isLoading && error ? (
<div className="agent-mail-tab-error" role="alert">
<AlertCircle size={16} />
<span>Failed to load mailbox: {error}</span>
</div>
) : null}
{!isLoading && !error ? (
selectedMessage ? (
<div className="agent-mail-tab-detail" data-testid="agent-detail-mail-message">
<button
type="button"
className="btn btn-sm agent-mail-tab-back"
data-testid="agent-detail-mail-back"
onClick={() => setSelectedMessageId(null)}
>
<ChevronLeft size={14} />
Back to {activeSubtab === "inbox" ? "Inbox" : "Outbox"}
</button>
<div className="agent-mail-tab-detail-meta">
<div className="agent-mail-tab-detail-row">
<span className="agent-mail-tab-detail-label">From</span>
<span>{mailboxParticipantLabel(selectedMessage.fromId, selectedMessage.fromType, agentNamesById)}</span>
</div>
<div className="agent-mail-tab-detail-row">
<span className="agent-mail-tab-detail-label">To</span>
<span>{mailboxParticipantLabel(selectedMessage.toId, selectedMessage.toType, agentNamesById)}</span>
</div>
<div className="agent-mail-tab-detail-row">
<span className="agent-mail-tab-detail-label">Type</span>
<span>{selectedMessage.type}</span>
</div>
<div className="agent-mail-tab-detail-row">
<span className="agent-mail-tab-detail-label">Sent</span>
<span>{new Date(selectedMessage.createdAt).toLocaleString()}</span>
</div>
{selectedMessage.metadata?.replyTo?.messageId ? (
<div className="agent-mail-tab-reply-context"> Replying to message {selectedMessage.metadata.replyTo.messageId}</div>
) : null}
</div>
<div className="agent-mail-tab-detail-body">{selectedMessage.content}</div>
</div>
) : (
<div className="mailbox-list" data-testid="agent-detail-mail-list">
{messages.length === 0 ? (
<div className="mailbox-empty" data-testid="agent-detail-mail-empty">
{activeSubtab === "inbox" ? <InboxIcon size={32} /> : <Send size={32} />}
<p>{activeSubtab === "inbox" ? "No received messages for this agent" : "No sent messages for this agent"}</p>
</div>
) : (
messages.map(renderMessage)
)}
</div>
)
) : null}
</div>
);
}
// ── 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<AgentHeartbeatRun[]>([]);
const { confirm } = useConfirm();
const [isLoadingRuns, setIsLoadingRuns] = useState(true);
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [runLogs, setRunLogs] = useState<AgentLogEntry[]>([]);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const [detailRun, setDetailRun] = useState<AgentHeartbeatRun | null>(null);
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
const [tokenUsageSummary, setTokenUsageSummary] = useState<AgentTokenUsageSummary | null>(null);
const [promptSizes, setPromptSizes] = useState<AgentPromptSizePoint[]>([]);
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 (
<div className="runs-tab">
<div className="runs-loading-row">
<Loader2 size={16} className="animate-spin" />
<span className="text-muted">Loading runs...</span>
</div>
</div>
);
}
if (runs.length === 0) {
return (
<div className="runs-tab">
<div className="runs-empty">
<Activity size={48} opacity={0.3} />
<p>No runs yet</p>
<p className="text-muted">Heartbeat runs will appear here</p>
</div>
</div>
);
}
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 (
<div className="run-usage">
<span>Input: {usage.inputTokens.toLocaleString()}</span>
<span>Output: {usage.outputTokens.toLocaleString()}</span>
{usage.cachedTokens > 0 && <span>Cache read: {usage.cachedTokens.toLocaleString()}</span>}
{(usage.cacheWriteTokens ?? 0) > 0 && <span>Cache write: {(usage.cacheWriteTokens ?? 0).toLocaleString()}</span>}
</div>
);
};
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 (
<div key={run.id}>
<div
className={cn("run-card", isActive && "run-card--active", isSelected && "run-card--selected", "run-card--clickable")}
onClick={() => 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);
}
}}
>
<div className="run-header">
<div className="run-header-group">
{isSelected ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{isActive ? (
<span className="run-live-indicator">
<span className="live-dot" />
Live Run
</span>
) : (
<span className="run-id">#{index + 1} {run.id.slice(0, 8)}</span>
)}
</div>
<div className="run-header-group">
{run.invocationSource && (
<span className="badge run-badge--compact">
{run.invocationSource}
</span>
)}
{isActive && (
<button
className="btn btn--sm btn--danger"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void handleStopRun();
}}
aria-label="Stop active run"
>
<Square size={12} /> Stop
</button>
)}
<span className={cn("run-status", run.status)}>
<StatusIcon size={14} style={{ color: statusInfo.color }} />
{run.status}
</span>
{run.heartbeatProcedureSource === "custom" && (
<span className="badge run-badge--compact">
Heartbeat: custom
</span>
)}
</div>
</div>
<div className="run-details">
<span>Started {relativeTime(run.startedAt)}</span>
<span></span>
<span>{duration}</span>
{run.triggerDetail && (
<>
<span></span>
<span className="text-muted">{run.triggerDetail}</span>
</>
)}
</div>
</div>
{isSelected && (
<div className="run-logs-container">
{/* Execution Details */}
{isLoadingDetail ? (
<div className="run-details-loading-state">
<Loader2 size={14} className="animate-spin" />
<span className="text-muted">Loading details...</span>
</div>
) : detailRun && (
<div className="run-output-sections">
{/* System Prompt */}
<div className="run-output-section">
<details>
<summary className="run-output-label run-output-summary">System Prompt</summary>
{detailRun.systemPrompt ? (
<pre className="run-output-panel">{detailRun.systemPrompt}</pre>
) : (
<div className="text-muted run-output-empty">System prompt not captured for this run</div>
)}
</details>
</div>
{/* Execution Prompt */}
<div className="run-output-section">
<details>
<summary className="run-output-label run-output-summary">Execution Prompt</summary>
{detailRun.executionPrompt ? (
<pre className="run-output-panel">{detailRun.executionPrompt}</pre>
) : (
<div className="text-muted run-output-empty">Execution prompt not captured for this run</div>
)}
</details>
</div>
{/* Token Usage */}
{detailRun.usageJson && (
<div className="run-output-section">
<div className="run-output-label">Token Usage</div>
{renderUsage(detailRun.usageJson)}
</div>
)}
{/* Output */}
{detailRun.stdoutExcerpt && (
<div className="run-output-section">
<div className="run-output-label">Output</div>
<pre className="run-output-panel">
{detailRun.stdoutExcerpt.length > 2000
? `${detailRun.stdoutExcerpt.slice(0, 2000)}\n\n... (truncated, ${detailRun.stdoutExcerpt.length} chars total)`
: detailRun.stdoutExcerpt}
</pre>
</div>
)}
{/* Errors */}
{detailRun.stderrExcerpt && (
<div className="run-output-section">
<div className="run-output-label run-output-label--error">Errors</div>
<AgentErrorIndicator
errorText={detailRun.stderrExcerpt}
summaryPrefix="Run error"
issueContext={{
surface: "AgentDetailView runs",
agentId,
agentName,
agentState,
runId: detailRun.id,
taskId: undefined,
timestamp: detailRun.startedAt,
}}
/>
</div>
)}
{/* Result */}
{detailRun.resultJson && (
<div className="run-output-section">
<div className="run-output-label">Result</div>
<pre className="run-output-panel">{JSON.stringify(detailRun.resultJson, null, 2)}</pre>
</div>
)}
{/* Context */}
{detailRun.contextSnapshot && Object.keys(detailRun.contextSnapshot).length > 0 && (
<div className="run-output-section">
<div className="run-output-label">Context</div>
<div className="run-context-grid">
{Object.entries(detailRun.contextSnapshot).map(([key, value]) => (
<span key={key} className="run-context-item">
<span className="text-muted">{key}:</span>{" "}
<span>{String(value)}</span>
</span>
))}
</div>
</div>
)}
{/* No output state */}
{!detailRun.stdoutExcerpt && !detailRun.stderrExcerpt && !detailRun.resultJson && (
<div className="text-muted run-output-empty">No output captured</div>
)}
</div>
)}
{/* Run Logs */}
<div className="run-agent-logs-section">
<div className="run-output-label">Agent Logs</div>
{isLoadingLogs ? (
<div className="run-details-loading-state">
<Loader2 size={14} className="animate-spin" />
<span className="text-muted">Loading logs...</span>
</div>
) : runLogs.length === 0 ? (
<div className="text-muted run-output-empty">No logs available for this run</div>
) : (
<AgentLogViewer entries={runLogs} loading={false} />
)}
</div>
</div>
)}
</div>
);
};
const renderCacheWindow = (label: string, window: AgentTokenUsageWindowSummary) => (
<div className="run-context-item" key={label}>
<span className="text-muted">{label}:</span>{" "}
<span>{(window.hitRatio * 100).toFixed(1)}% ({window.totalCachedTokens.toLocaleString()} / {window.totalCacheWriteTokens.toLocaleString()} / {window.totalInputTokens.toLocaleString()} / {window.nTasks.toLocaleString()})</span>
</div>
);
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 (
<div className="runs-tab">
{promptSizes.length > 0 && latestPrompt && (
<div className="run-output-section">
<div className="run-output-label">Prompt Size</div>
<div className="prompt-size-summary">
<svg className="prompt-size-sparkline" viewBox="0 0 100 100" role="img" aria-label="Execution prompt size over last 7 runs">
<polyline className="prompt-size-sparkline-grid" points="0,100 100,100" />
<polyline className="prompt-size-sparkline-line" points={promptPolyline} />
</svg>
<span className="prompt-size-values">
{latestPrompt.systemChars.toLocaleString()} / {latestPrompt.execChars.toLocaleString()} / {latestPrompt.totalChars.toLocaleString()}
</span>
</div>
</div>
)}
{tokenUsageSummary && (
<div className="run-output-section">
<div className="run-output-label">Cache hit ratio</div>
<div className="run-context-grid">
{renderCacheWindow("Last 24h", tokenUsageSummary.last24h)}
{renderCacheWindow("Last 7d", tokenUsageSummary.last7d)}
{renderCacheWindow("All time", tokenUsageSummary.allTime)}
</div>
</div>
)}
<div className="runs-toolbar runs-toolbar--between">
<span className="runs-toolbar-meta">
{runs.length} run{runs.length !== 1 ? "s" : ""}
{hasActiveRun && <span className="run-live-indicator run-live-indicator--with-margin"><span className="live-dot" />Live</span>}
</span>
<div className="run-header-group">
{hasActiveRun && (
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStopRun()}
aria-label={`Stop active run for ${agentName ?? agentId}`}
>
<Square size={14} /> Stop Run
</button>
)}
</div>
</div>
{activeRuns.map((run, i) => renderRunCard(run, i, true))}
{completedRuns.map((run, i) => renderRunCard(run, activeRuns.length + i, false))}
</div>
);
}
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<Task["column"], string> = {
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<Task[]>([]);
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 (
<div className="agent-tasks-empty">
<Loader2 size={16} className="animate-spin" />
<p>Loading assigned tasks...</p>
</div>
);
}
if (tasks.length === 0) {
return (
<div className="agent-tasks-empty">
<ListChecks size={18} />
<p>No tasks assigned to this agent</p>
</div>
);
}
return (
<div className="agent-tasks-list">
{tasks.map((task) => (
<a key={task.id} className="agent-task-item" href={`/tasks/${task.id}`}>
<div className="agent-task-row">
<span className="agent-task-id">{task.id}</span>
<span className={`agent-task-column column-${task.column}`}>{TASK_COLUMN_LABELS[task.column]}</span>
</div>
<div className="agent-task-title" title={task.title || task.description || task.id}>
{truncateTaskLabel(task)}
</div>
<div className="agent-task-status">
{task.status ?? "idle"} · Updated {relativeTime(task.updatedAt)}
</div>
</a>
))}
</div>
);
}
// ── 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 (010, 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<string, string>;
function validateAdvancedSettings(
values: Record<string, string>,
): 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<void>;
}) {
const [soul, setSoul] = useState(agent.soul ?? "");
const [isSaving, setIsSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="config-tab">
<div className="config-section">
<h3>Soul</h3>
<p className="config-description">
Define this agent&apos;s personality and identity.
</p>
<div className="config-fields">
<div className="config-field">
<label htmlFor="agent-soul">Agent Soul</label>
<div className="agent-content-toolbar">
<div className="agent-content-mode-toggle">
<button
className={`btn btn-sm ${!showPreview ? "btn-primary" : ""}`}
onClick={() => setShowPreview(false)}
disabled={!showPreview}
aria-label="Edit mode"
>
<FileEdit size={14} />
Edit
</button>
<button
className={`btn btn-sm ${showPreview ? "btn-primary" : ""}`}
onClick={() => setShowPreview(true)}
disabled={showPreview}
aria-label="Preview mode"
>
<Eye size={14} />
Preview
</button>
</div>
</div>
{showPreview ? (
soul.trim() ? (
<div className="agent-content-preview markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{soul}
</ReactMarkdown>
</div>
) : (
<div className="agent-content-preview agent-content-placeholder">
No soul defined yet. Switch to Edit mode to define the agent&apos;s personality.
</div>
)
) : (
<textarea
id="agent-soul"
className="input config-textarea-mono"
rows={12}
placeholder="Describe this agent's personality, tone, and behavioral traits..."
value={soul}
onChange={(e) => {
setSoul(e.target.value);
setJustSaved(false);
}}
/>
)}
{!showPreview && (
<span className="config-hint">Defines the agent&apos;s character and identity. Max 10,000 characters.</span>
)}
</div>
</div>
{!showPreview && (
<div className="config-actions">
<button
className="btn btn-task-create"
disabled={!hasChanges || isSaving}
onClick={() => void handleSave()}
>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving
</>
) : (
<>
<CheckCircle size={16} />
Save Soul
</>
)}
</button>
{!hasChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Soul saved
</span>
)}
</div>
)}
</div>
</div>
);
}
function MemoryTab({
agent,
projectId,
addToast,
onSaved,
}: {
agent: AgentDetail;
projectId?: string;
addToast: (message: string, type?: "success" | "error") => void;
onSaved: () => Promise<void>;
}) {
const [memory, setMemory] = useState(agent.memory ?? "");
const [isSaving, setIsSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [showFilePreview, setShowFilePreview] = useState(false);
const [memoryFiles, setMemoryFiles] = useState<MemoryFileInfo[]>([]);
const [memoryFilesLoading, setMemoryFilesLoading] = useState(false);
const [selectedFilePath, setSelectedFilePath] = useState("");
const [selectedFileContent, setSelectedFileContent] = useState("");
const [selectedFileDirty, setSelectedFileDirty] = useState(false);
const [selectedFileLoading, setSelectedFileLoading] = useState(false);
const [savingSelectedFile, setSavingSelectedFile] = useState(false);
const [selectedFileJustSaved, setSelectedFileJustSaved] = useState(false);
const [fileSwitchHint, setFileSwitchHint] = useState("");
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectedFileJustSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isReadOnly = agent.state === "running";
const hasInlineChanges = memory !== (agent.memory ?? "");
const selectedMemoryFile = useMemo(
() => memoryFiles.find((file) => file.path === selectedFilePath),
[memoryFiles, selectedFilePath],
);
const selectedLayerDescription = selectedMemoryFile
? MEMORY_LAYER_DESCRIPTIONS[selectedMemoryFile.layer]
: "Select a memory file to view or edit.";
const loadSelectedMemoryFile = useCallback(async (path: string) => {
setSelectedFileLoading(true);
try {
const result = await fetchAgentMemoryFile(agent.id, path, projectId);
setSelectedFilePath(result.path);
setSelectedFileContent(result.content);
setSelectedFileDirty(false);
setSelectedFileJustSaved(false);
} catch (err) {
addToast(`Failed to load agent memory file: ${getErrorMessage(err)}`, "error");
} finally {
setSelectedFileLoading(false);
}
}, [agent.id, projectId, addToast]);
const loadMemoryFiles = useCallback(async (preferredPath = "") => {
setMemoryFilesLoading(true);
try {
const { files } = await fetchAgentMemoryFiles(agent.id, projectId);
setMemoryFiles(files);
if (files.length === 0) {
setSelectedFilePath("");
setSelectedFileContent("");
setSelectedFileDirty(false);
return;
}
const nextPath = pickDefaultAgentMemoryPath(files, preferredPath);
await loadSelectedMemoryFile(nextPath);
} catch (err) {
addToast(`Failed to load memory files: ${getErrorMessage(err)}`, "error");
setMemoryFiles([]);
setSelectedFilePath("");
setSelectedFileContent("");
setSelectedFileDirty(false);
} finally {
setMemoryFilesLoading(false);
}
}, [agent.id, projectId, addToast, loadSelectedMemoryFile]);
useEffect(() => {
setMemory(agent.memory ?? "");
setJustSaved(false);
setShowPreview(false);
setShowFilePreview(false);
setFileSwitchHint("");
setSelectedFileJustSaved(false);
void loadMemoryFiles();
}, [agent.id, agent.memory, loadMemoryFiles]);
useEffect(() => {
return () => {
if (justSavedTimeoutRef.current) {
clearTimeout(justSavedTimeoutRef.current);
}
if (selectedFileJustSavedTimeoutRef.current) {
clearTimeout(selectedFileJustSavedTimeoutRef.current);
}
};
}, []);
const handleSaveInlineMemory = async () => {
if (memory.length > 50000) {
addToast("Memory must be at most 50,000 characters", "error");
return;
}
setIsSaving(true);
try {
await updateAgentMemory(agent.id, memory, projectId);
addToast("Memory saved", "success");
setJustSaved(true);
if (justSavedTimeoutRef.current) {
clearTimeout(justSavedTimeoutRef.current);
}
justSavedTimeoutRef.current = setTimeout(() => setJustSaved(false), 3000);
await onSaved();
} catch (err) {
addToast(`Failed to save memory: ${getErrorMessage(err)}`, "error");
} finally {
setIsSaving(false);
}
};
const handleSelectMemoryFile = async (path: string) => {
if (!path || path === selectedFilePath) {
return;
}
if (selectedFileDirty) {
setFileSwitchHint("Save the current file before switching to another file.");
return;
}
setFileSwitchHint("");
await loadSelectedMemoryFile(path);
};
const handleSaveSelectedMemoryFile = async () => {
if (!selectedFilePath) {
return;
}
setSavingSelectedFile(true);
try {
await saveAgentMemoryFile(agent.id, selectedFilePath, selectedFileContent, projectId);
setSelectedFileDirty(false);
setSelectedFileJustSaved(true);
if (selectedFileJustSavedTimeoutRef.current) {
clearTimeout(selectedFileJustSavedTimeoutRef.current);
}
selectedFileJustSavedTimeoutRef.current = setTimeout(() => setSelectedFileJustSaved(false), 3000);
setFileSwitchHint("");
await loadMemoryFiles(selectedFilePath);
addToast("Agent memory file saved", "success");
await onSaved();
} catch (err) {
addToast(`Failed to save agent memory file: ${getErrorMessage(err)}`, "error");
} finally {
setSavingSelectedFile(false);
}
};
return (
<div className="config-tab">
<div className="config-section">
<h3>Agent Memory</h3>
<p className="config-description">
Store context that belongs to this agent only. Workspace memory, daily notes, dreams, and qmd search live in project settings under Project Memory.
</p>
{isReadOnly && (
<p className="config-hint config-hint--block-spacing">
Read-only while this agent is running.
</p>
)}
<div className="config-fields">
<div className="config-field">
<label htmlFor="agent-memory">Inline Memory</label>
<span className="config-hint config-hint--block">
Short-form memory stored directly on the agent record and injected into prompts.
</span>
<div className="agent-content-toolbar">
<div className="agent-content-mode-toggle">
{!isReadOnly && (
<button
className={`btn btn-sm ${!showPreview ? "btn-primary" : ""}`}
onClick={() => setShowPreview(false)}
disabled={!showPreview}
aria-label="Edit mode"
>
<FileEdit size={14} />
Edit
</button>
)}
<button
className={`btn btn-sm ${showPreview ? "btn-primary" : ""}`}
onClick={() => setShowPreview(true)}
disabled={showPreview}
aria-label="Preview mode"
>
<Eye size={14} />
Preview
</button>
</div>
</div>
{showPreview ? (
memory.trim() ? (
<div className="agent-content-preview markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{memory}
</ReactMarkdown>
</div>
) : (
<div className="agent-content-preview agent-content-placeholder">
No agent memory defined yet. Switch to Edit mode to add memory content.
</div>
)
) : (
<textarea
id="agent-memory"
aria-label="Agent Memory"
className="input config-textarea-mono"
rows={10}
placeholder="Durable preferences, operating habits, and context this agent should carry across tasks..."
value={memory}
readOnly={isReadOnly}
onChange={(e) => {
setMemory(e.target.value);
setJustSaved(false);
}}
/>
)}
{!showPreview && (
<span className="config-hint">This is the inline memory field on the agent JSON record. Max 50,000 characters.</span>
)}
</div>
<div className="config-field">
<label htmlFor="agent-memory-file-select">Memory Files</label>
<span className="config-hint config-hint--block">
Full OpenClaw memory files at <code>agent/{agent.name || agent.id}/memory/</code> (MEMORY.md, DREAMS.md, and daily notes).
</span>
<select
id="agent-memory-file-select"
className="select"
value={selectedFilePath}
disabled={memoryFilesLoading || selectedFileLoading || savingSelectedFile || memoryFiles.length === 0}
onChange={(e) => {
void handleSelectMemoryFile(e.target.value);
}}
>
{memoryFiles.length === 0 ? (
<option value="">No memory files found</option>
) : (
memoryFiles.map((file) => (
<option key={file.path} value={file.path}>
{MEMORY_LAYER_NAMES[file.layer]} {file.label}
</option>
))
)}
</select>
{memoryFilesLoading && (
<span className="config-hint config-hint--inline-loader">
<Loader2 size={14} className="animate-spin" />
Loading memory files
</span>
)}
{selectedMemoryFile && (
<div className="config-hint config-hint--top-spacing">
<strong>{MEMORY_LAYER_NAMES[selectedMemoryFile.layer]}</strong> · {selectedLayerDescription}
<br />
{selectedMemoryFile.size.toLocaleString()} bytes · Updated {relativeTime(selectedMemoryFile.updatedAt)}
</div>
)}
<div className="agent-content-toolbar config-textarea-top-spacing">
<div className="agent-content-mode-toggle">
{!isReadOnly && (
<button
className={`btn btn-sm ${!showFilePreview ? "btn-primary" : ""}`}
onClick={() => setShowFilePreview(false)}
disabled={!showFilePreview}
aria-label="Memory file edit mode"
>
<FileEdit size={14} />
Edit
</button>
)}
<button
className={`btn btn-sm ${showFilePreview ? "btn-primary" : ""}`}
onClick={() => setShowFilePreview(true)}
disabled={showFilePreview}
aria-label="Memory file preview mode"
>
<Eye size={14} />
Preview
</button>
</div>
</div>
{showFilePreview ? (
selectedFileContent.trim() ? (
<div className="agent-content-preview markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{selectedFileContent}
</ReactMarkdown>
</div>
) : (
<div className="agent-content-preview agent-content-placeholder">
No memory file content yet. Switch to Edit mode to add content.
</div>
)
) : (
<textarea
className="input config-textarea-mono"
rows={14}
placeholder="Select a memory file to view and edit its content..."
value={selectedFileContent}
readOnly={isReadOnly || !selectedFilePath || selectedFileLoading}
onChange={(e) => {
setSelectedFileContent(e.target.value);
setSelectedFileDirty(true);
setSelectedFileJustSaved(false);
setFileSwitchHint("");
}}
/>
)}
{selectedFileLoading && (
<span className="config-hint config-hint--inline-loader">
<Loader2 size={14} className="animate-spin" />
Loading file content
</span>
)}
{fileSwitchHint && (
<span className="config-hint config-hint--top-spacing config-hint--block">
{fileSwitchHint}
</span>
)}
</div>
</div>
<div className="config-actions">
{!showPreview && (
<button
className="btn btn-task-create"
disabled={!hasInlineChanges || isSaving || isReadOnly}
onClick={() => void handleSaveInlineMemory()}
>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving
</>
) : (
<>
<CheckCircle size={16} />
Save Memory
</>
)}
</button>
)}
{!showFilePreview && (
<button
className="btn"
disabled={!selectedFileDirty || savingSelectedFile || !selectedFilePath || isReadOnly}
onClick={() => void handleSaveSelectedMemoryFile()}
>
{savingSelectedFile ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving file
</>
) : (
<>
<CheckCircle size={16} />
Save Memory File
</>
)}
</button>
)}
{!hasInlineChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Memory saved
</span>
)}
{!selectedFileDirty && selectedFileJustSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Memory file saved
</span>
)}
</div>
</div>
</div>
);
}
function InstructionsTab({
agent,
projectId,
addToast,
onSaved,
}: {
agent: AgentDetail;
projectId?: string;
addToast: (message: string, type?: "success" | "error") => void;
onSaved: () => Promise<void>;
}) {
// Inline instructions state
const [instructionsText, setInstructionsText] = useState(agent.instructionsText ?? "");
const [instructionsPath, setInstructionsPath] = useState(agent.instructionsPath ?? "");
const [showPreview, setShowPreview] = useState(false);
// File content state (when instructionsPath is set)
const [fileContent, setFileContent] = useState("");
const [isLoadingFile, setIsLoadingFile] = useState(false);
const [fileContentDirty, setFileContentDirty] = useState(false);
// Save state
const [isSaving, setIsSaving] = useState(false);
const [isSavingFile, setIsSavingFile] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const [justSavedFile, setJustSavedFile] = useState(false);
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const justSavedFileTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load file content when instructionsPath changes
useEffect(() => {
const path = instructionsPath.trim();
if (!path) {
setFileContent("");
setFileContentDirty(false);
return;
}
setIsLoadingFile(true);
fetchWorkspaceFileContent("project", path)
.then((data) => {
setFileContent(data.content);
setFileContentDirty(false);
})
.catch((err) => {
// ENOENT means file doesn't exist yet - treat as empty "new file" state
const msg = getErrorMessage(err);
if (msg.includes("ENOENT") || msg.includes("Not found") || msg.includes("not found")) {
setFileContent("");
setFileContentDirty(false);
} else {
addToast(`Failed to load instructions file: ${msg}`, "error");
setFileContent("");
}
})
.finally(() => {
setIsLoadingFile(false);
});
}, [instructionsPath, addToast]);
// Sync with agent data changes
useEffect(() => {
setInstructionsText(agent.instructionsText ?? "");
setInstructionsPath(agent.instructionsPath ?? "");
setJustSaved(false);
setJustSavedFile(false);
setShowPreview(false);
}, [agent.id, agent.instructionsText, agent.instructionsPath]);
useEffect(() => {
return () => {
if (justSavedTimeoutRef.current) {
clearTimeout(justSavedTimeoutRef.current);
}
if (justSavedFileTimeoutRef.current) {
clearTimeout(justSavedFileTimeoutRef.current);
}
};
}, []);
const hasInstructionsChanges = (() => {
const currentText = instructionsText ?? "";
const persistedText = agent.instructionsText ?? "";
const currentPath = instructionsPath?.trim() ?? "";
const persistedPath = agent.instructionsPath?.trim() ?? "";
return currentText !== persistedText || currentPath !== persistedPath;
})();
const handleSaveInstructions = async () => {
setIsSaving(true);
try {
await updateAgentInstructions(
agent.id,
{
instructionsText: instructionsText || undefined,
instructionsPath: instructionsPath.trim() || undefined,
},
projectId,
);
addToast("Instructions saved", "success");
setJustSaved(true);
if (justSavedTimeoutRef.current) {
clearTimeout(justSavedTimeoutRef.current);
}
justSavedTimeoutRef.current = setTimeout(() => setJustSaved(false), 3000);
await onSaved();
} catch (err) {
addToast(`Failed to save instructions: ${getErrorMessage(err)}`, "error");
} finally {
setIsSaving(false);
}
};
const handleSaveFile = async () => {
const path = instructionsPath.trim();
if (!path) {
addToast("No instructions file path set", "error");
return;
}
setIsSavingFile(true);
try {
await saveWorkspaceFileContent("project", path, fileContent);
addToast("Instructions file saved", "success");
setFileContentDirty(false);
setJustSavedFile(true);
if (justSavedFileTimeoutRef.current) {
clearTimeout(justSavedFileTimeoutRef.current);
}
justSavedFileTimeoutRef.current = setTimeout(() => setJustSavedFile(false), 3000);
await onSaved();
} catch (err) {
addToast(`Failed to save instructions file: ${getErrorMessage(err)}`, "error");
} finally {
setIsSavingFile(false);
}
};
const hasFilePath = !!instructionsPath.trim();
return (
<div className="config-tab">
<div className="config-section">
<h3>Custom Instructions</h3>
<p className="config-description">
Append custom instructions to this agent&apos;s system prompt at execution time. Use this to customize behavior, coding style, or project conventions without modifying built-in prompts.
</p>
<div className="config-fields">
<div className="config-field">
<label htmlFor="instructions-text">Inline Instructions</label>
<div className="agent-content-toolbar">
<div className="agent-content-mode-toggle">
<button
className={`btn btn-sm ${!showPreview ? "btn-primary" : ""}`}
onClick={() => setShowPreview(false)}
disabled={!showPreview}
aria-label="Edit mode"
data-testid="instructions-edit-toggle"
>
<FileEdit size={14} />
Edit
</button>
<button
className={`btn btn-sm ${showPreview ? "btn-primary" : ""}`}
onClick={() => setShowPreview(true)}
disabled={showPreview}
aria-label="Preview mode"
data-testid="instructions-preview-toggle"
>
<Eye size={14} />
Preview
</button>
</div>
</div>
{showPreview ? (
instructionsText.trim() ? (
<div className="agent-content-preview markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{instructionsText}
</ReactMarkdown>
</div>
) : (
<div className="agent-content-preview agent-content-placeholder">
No inline instructions defined yet. Switch to Edit mode to add instructions.
</div>
)
) : (
<textarea
id="instructions-text"
className="input"
rows={10}
placeholder="Enter custom instructions to append to this agent's system prompt..."
value={instructionsText}
onChange={(e) => {
setInstructionsText(e.target.value);
setJustSaved(false);
}}
/>
)}
{!showPreview && (
<span className="config-hint">Markdown formatting supported. Max 50,000 characters.</span>
)}
</div>
<div className="config-field">
<label htmlFor="instructions-path">Instructions File Path</label>
<input
id="instructions-path"
type="text"
className="input"
placeholder="e.g., .fusion/agents/my-agent-instructions.md"
value={instructionsPath}
onChange={(e) => {
setInstructionsPath(e.target.value);
setJustSaved(false);
}}
/>
<span className="config-hint">Path to a .md file (relative to project root). Contents are read and appended at execution time.</span>
</div>
</div>
{!showPreview && (
<div className="config-actions">
<button
className="btn btn-task-create"
disabled={!hasInstructionsChanges || isSaving}
onClick={() => void handleSaveInstructions()}
>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving
</>
) : (
<>
<CheckCircle size={16} />
Save Instructions
</>
)}
</button>
{!hasInstructionsChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Instructions saved
</span>
)}
</div>
)}
</div>
{hasFilePath && (
<div className="config-section">
<h3>Instructions File Editor</h3>
<p className="config-description">
Edit the instructions file directly. Changes are saved separately from the path configuration.
</p>
<div className="config-fields">
<div className="config-field">
<div className="config-inline-header">
<label htmlFor="instructions-file-content">File Content</label>
{isLoadingFile && (
<span className="config-hint config-hint--inline-tight">
<Loader2 size={12} className="animate-spin" />
Loading...
</span>
)}
{fileContentDirty && !isLoadingFile && (
<span className="config-hint config-hint--warning">
Unsaved changes
</span>
)}
</div>
<textarea
id="instructions-file-content"
className="input config-textarea-mono"
rows={20}
placeholder="File content will appear here when loaded..."
value={fileContent}
readOnly={isLoadingFile}
onChange={(e) => {
setFileContent(e.target.value);
setFileContentDirty(true);
setJustSavedFile(false);
}}
/>
<span className="config-hint">Edit the markdown file content directly. Save separately using the button below.</span>
</div>
</div>
<div className="config-actions">
<button
className="btn btn-task-create"
disabled={!fileContentDirty || isSavingFile}
onClick={() => void handleSaveFile()}
>
{isSavingFile ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving
</>
) : (
<>
<CheckCircle size={16} />
Save File
</>
)}
</button>
{!fileContentDirty && justSavedFile && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
File saved
</span>
)}
</div>
</div>
)}
</div>
);
}
function deriveHeartbeatValues(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): Record<string, string> {
const rc = runtimeConfig ?? {};
const nextValues: Record<string, string> = {};
if (rc.heartbeatIntervalMs !== undefined && rc.heartbeatIntervalMs !== null) {
nextValues.heartbeatIntervalMs = String(Number(rc.heartbeatIntervalMs) / 1000);
}
if (rc.heartbeatTimeoutMs !== undefined && rc.heartbeatTimeoutMs !== null) {
nextValues.heartbeatTimeoutMs = String(Number(rc.heartbeatTimeoutMs) / 1000);
}
if (rc.maxConcurrentRuns !== undefined && rc.maxConcurrentRuns !== null) {
nextValues.maxConcurrentRuns = String(rc.maxConcurrentRuns);
}
if (rc.messageResponseMode === "immediate" || rc.messageResponseMode === "on-heartbeat") {
nextValues.messageResponseMode = rc.messageResponseMode;
}
if (rc.autoClaimCandidatesInPrompt !== undefined && rc.autoClaimCandidatesInPrompt !== null) {
nextValues.autoClaimCandidatesInPrompt = String(rc.autoClaimCandidatesInPrompt);
}
return nextValues;
}
function deriveHeartbeatEnabled(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean {
return runtimeConfig?.enabled !== false;
}
function deriveAutoClaimRelevantTasksEnabled(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean {
return runtimeConfig?.autoClaimRelevantTasks !== false;
}
function deriveRunMissedHeartbeatOnStartup(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean {
return runtimeConfig?.runMissedHeartbeatOnStartup === true;
}
function deriveAllowParallelExecution(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean {
return runtimeConfig?.allowParallelExecution !== false;
}
function deriveSkipHeartbeatWhenIdle(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean {
return runtimeConfig?.skipHeartbeatWhenIdle === true;
}
function deriveHeartbeatScopeDiscipline(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): "strict" | "lite" | "off" | "" {
const mode = runtimeConfig?.heartbeatScopeDiscipline;
return mode === "strict" || mode === "lite" || mode === "off" ? mode : "";
}
function deriveHeartbeatPromptTemplate(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): "default" | "compact" | "" {
const template = runtimeConfig?.heartbeatPromptTemplate;
return template === "default" || template === "compact" ? template : "";
}
function deriveBudgetValues(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): Record<string, string> {
const bc = (runtimeConfig ?? {}).budgetConfig as Record<string, unknown> | undefined;
const nextValues: Record<string, string> = {};
if (!bc) {
return nextValues;
}
if (bc.tokenBudget !== undefined && bc.tokenBudget !== null) {
nextValues.tokenBudget = String(bc.tokenBudget);
}
if (bc.usageThreshold !== undefined && bc.usageThreshold !== null) {
// Convert fraction (0-1) to percentage (0-100) for display
nextValues.usageThreshold = String(Number(bc.usageThreshold) * 100);
}
if (bc.budgetPeriod !== undefined && bc.budgetPeriod !== null) {
nextValues.budgetPeriod = String(bc.budgetPeriod);
}
if (bc.resetDay !== undefined && bc.resetDay !== null) {
nextValues.resetDay = String(bc.resetDay);
}
return nextValues;
}
function HeartbeatProcedureSection({
agent,
projectId,
addToast,
onSaved,
}: {
agent: AgentDetail;
projectId?: string;
addToast: (message: string, type?: "success" | "error") => void;
onSaved: () => Promise<void>;
}) {
const [isUpgrading, setIsUpgrading] = useState(false);
const [showFileViewer, setShowFileViewer] = useState(false);
const [isLoadingFile, setIsLoadingFile] = useState(false);
const [isSavingFile, setIsSavingFile] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [fileContent, setFileContent] = useState("");
const [fileContentDirty, setFileContentDirty] = useState(false);
const [fileLoadError, setFileLoadError] = useState<string | null>(null);
const [justSavedFile, setJustSavedFile] = useState(false);
const justSavedFileTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentPath = agent.heartbeatProcedurePath?.trim();
const canonicalDefaultPath = `.fusion/agents/${agent.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || agent.id.toLowerCase().replace(/[^a-z0-9]+/g, "-") || "agent"}-${agent.id
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "agent"}/HEARTBEAT.md`;
const legacyDefaultPath = `.fusion/agents/${agent.id}/HEARTBEAT.md`;
const safeId = agent.id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
const onDefault = Boolean(
currentPath
&& (currentPath === canonicalDefaultPath
|| currentPath === legacyDefaultPath
|| new RegExp(`^\\.fusion/agents/[^/]+-${safeId}/HEARTBEAT\\.md$`).test(currentPath)),
);
const hasFilePath = Boolean(currentPath);
const loadHeartbeatFile = useCallback(async (path: string) => {
setIsLoadingFile(true);
setFileLoadError(null);
try {
const data = await fetchWorkspaceFileContent("project", path, projectId);
setFileContent(data.content);
setFileContentDirty(false);
} catch (err) {
const message = getErrorMessage(err);
setFileLoadError(message);
addToast(`Failed to load heartbeat procedure file: ${message}`, "error");
} finally {
setIsLoadingFile(false);
}
}, [addToast, projectId]);
useEffect(() => {
setShowFileViewer(false);
setShowPreview(false);
setFileContent("");
setFileContentDirty(false);
setFileLoadError(null);
setIsLoadingFile(false);
setIsSavingFile(false);
setJustSavedFile(false);
}, [agent.id, currentPath]);
useEffect(() => {
return () => {
if (justSavedFileTimeoutRef.current) {
clearTimeout(justSavedFileTimeoutRef.current);
}
};
}, []);
const handleOpenViewer = async () => {
if (!currentPath) return;
setShowFileViewer(true);
await loadHeartbeatFile(currentPath);
};
const handleSaveFile = async () => {
if (!currentPath) return;
setIsSavingFile(true);
try {
await saveWorkspaceFileContent("project", currentPath, fileContent, projectId);
setFileContentDirty(false);
setJustSavedFile(true);
addToast("Heartbeat procedure file saved", "success");
if (justSavedFileTimeoutRef.current) {
clearTimeout(justSavedFileTimeoutRef.current);
}
justSavedFileTimeoutRef.current = setTimeout(() => setJustSavedFile(false), 3000);
await onSaved();
} catch (err) {
addToast(`Failed to save heartbeat procedure file: ${getErrorMessage(err)}`, "error");
} finally {
setIsSavingFile(false);
}
};
const handleUpgrade = async () => {
setIsUpgrading(true);
try {
const result = await upgradeAgentHeartbeatProcedure(agent.id, projectId);
addToast(
result.procedureFileSeeded
? `Heartbeat procedure file ready at ${result.heartbeatProcedurePath}`
: `Heartbeat procedure path set to ${result.heartbeatProcedurePath}`,
"success",
);
await onSaved();
} catch (err) {
addToast(`Failed to upgrade heartbeat procedure: ${getErrorMessage(err)}`, "error");
} finally {
setIsUpgrading(false);
}
};
return (
<div className="config-section">
<h3>Heartbeat Procedure</h3>
<p className="config-description">
The per-tick procedure this agent runs every wake. Defaults to a per-agent
markdown file (for example <code>.fusion/agents/ceo-agent2736/HEARTBEAT.md</code>)
that you can edit. Legacy id-only default paths remain valid. Resets on every tick
no need to restart the agent after editing.
</p>
<div className="config-fields">
<div className="config-field">
<span className="config-hint">
Current path: <code>{currentPath || "(none — using built-in default)"}</code>
</span>
{hasFilePath && (
<div className="heartbeat-procedure-actions">
<button
className="btn btn-sm"
onClick={() => void handleOpenViewer()}
disabled={isLoadingFile}
>
{isLoadingFile ? (
<>
<Loader2 size={16} className="animate-spin" />
Loading file
</>
) : (
<>
<FileText size={16} />
View Heartbeat Markdown
</>
)}
</button>
</div>
)}
</div>
<div className="config-field">
<button
className="btn"
disabled={isUpgrading || onDefault}
onClick={() => void handleUpgrade()}
aria-label="Upgrade agent to default heartbeat procedure file"
>
{isUpgrading ? (
<>
<Loader2 size={16} className="animate-spin" />
Upgrading
</>
) : onDefault ? (
<>
<CheckCircle size={16} />
Already on default
</>
) : (
"Upgrade to Default Heartbeat Procedure"
)}
</button>
<span className="config-hint">
Sets <code>heartbeatProcedurePath</code> to{" "}
<code>{canonicalDefaultPath}</code>
{" "}and seeds the file from the built-in template if it doesn't exist.
Each agent gets its own per-agent file, so edits stay scoped to this agent.
Operator edits to the file are preserved.
</span>
</div>
</div>
{showFileViewer && hasFilePath && currentPath && (
<div className="config-fields heartbeat-procedure-viewer">
<div className="config-field">
<label htmlFor="heartbeat-procedure-file-content">Heartbeat Procedure File</label>
<div className="agent-content-toolbar">
<div className="agent-content-mode-toggle">
<button
className={`btn btn-sm ${!showPreview ? "btn-primary" : ""}`}
onClick={() => setShowPreview(false)}
disabled={!showPreview}
aria-label="Heartbeat file edit mode"
>
<FileEdit size={14} />
Edit
</button>
<button
className={`btn btn-sm ${showPreview ? "btn-primary" : ""}`}
onClick={() => setShowPreview(true)}
disabled={showPreview}
aria-label="Heartbeat file preview mode"
>
<Eye size={14} />
Preview
</button>
</div>
{isLoadingFile && (
<span className="config-hint heartbeat-procedure-status">
<Loader2 size={12} className="animate-spin" />
Loading...
</span>
)}
{fileContentDirty && !isLoadingFile && (
<span className="config-hint heartbeat-procedure-status heartbeat-procedure-status--warning">
Unsaved changes
</span>
)}
</div>
{showPreview ? (
fileContent.trim() ? (
<div className="agent-content-preview markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{fileContent}</ReactMarkdown>
</div>
) : (
<div className="agent-content-preview agent-content-placeholder">
No heartbeat procedure markdown content yet.
</div>
)
) : (
<textarea
id="heartbeat-procedure-file-content"
className="input"
rows={16}
value={fileContent}
readOnly={isLoadingFile}
placeholder="Heartbeat procedure markdown file content will appear here..."
onChange={(e) => {
setFileContent(e.target.value);
setFileContentDirty(true);
setJustSavedFile(false);
}}
/>
)}
{fileLoadError && (
<span className="config-error">Failed to load file: {fileLoadError}</span>
)}
<span className="config-hint">
This editor writes directly to <code>{currentPath}</code>.
</span>
</div>
{!showPreview && (
<div className="config-actions">
<button
className="btn btn-task-create"
disabled={!fileContentDirty || isSavingFile || isLoadingFile}
onClick={() => void handleSaveFile()}
>
{isSavingFile ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving…
</>
) : (
<>
<CheckCircle size={16} />
Save Heartbeat File
</>
)}
</button>
{!fileContentDirty && justSavedFile && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
File saved
</span>
)}
</div>
)}
</div>
)}
</div>
);
}
function ConfigTab({
agent,
projectId,
addToast,
onSaved,
onHasChangesChange,
onDelete,
onAgentDraftApplied,
}: {
agent: AgentDetail;
projectId?: string;
addToast: (message: string, type?: "success" | "error") => void;
onSaved: () => Promise<void>;
onHasChangesChange?: (hasChanges: boolean) => void;
onDelete?: () => Promise<void> | void;
onAgentDraftApplied?: (updates: Partial<AgentDetail>) => void;
}) {
// Identity field state
const [nameValue, setNameValue] = useState(agent.name);
const [roleValue, setRoleValue] = useState(agent.role);
const [titleValue, setTitleValue] = useState(agent.title ?? "");
const [iconValue, setIconValue] = useState(agent.icon ?? "");
const [reportsToValue, setReportsToValue] = useState(agent.reportsTo ?? "");
const [managerOptions, setManagerOptions] = useState<Agent[]>([]);
const [isLoadingManagers, setIsLoadingManagers] = useState(false);
const [isAvatarPending, setIsAvatarPending] = useState(false);
const [isAiInterviewOpen, setIsAiInterviewOpen] = useState(false);
const avatarInputRef = useRef<HTMLInputElement>(null);
// Local form state initialised from agent.metadata
const [formValues, setFormValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
for (const field of ADVANCED_SETTINGS) {
const raw = agent.metadata[field.key];
if (raw !== undefined && raw !== null) {
initial[field.key] = String(raw);
}
}
return initial;
});
// Heartbeat config state initialised from agent.runtimeConfig
const [heartbeatValues, setHeartbeatValues] = useState<Record<string, string>>(
() => deriveHeartbeatValues(agent.runtimeConfig),
);
const [heartbeatEnabled, setHeartbeatEnabled] = useState<boolean>(
() => deriveHeartbeatEnabled(agent.runtimeConfig),
);
const [autoClaimRelevantTasksEnabled, setAutoClaimRelevantTasksEnabled] = useState<boolean>(
() => deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig),
);
const [runMissedHeartbeatOnStartup, setRunMissedHeartbeatOnStartup] = useState<boolean>(
() => deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig),
);
const [allowParallelExecution, setAllowParallelExecution] = useState<boolean>(
() => deriveAllowParallelExecution(agent.runtimeConfig),
);
const [skipHeartbeatWhenIdle, setSkipHeartbeatWhenIdle] = useState<boolean>(
() => deriveSkipHeartbeatWhenIdle(agent.runtimeConfig),
);
const [heartbeatScopeDiscipline, setHeartbeatScopeDiscipline] = useState<"strict" | "lite" | "off" | "">(
() => deriveHeartbeatScopeDiscipline(agent.runtimeConfig),
);
const [heartbeatPromptTemplate, setHeartbeatPromptTemplate] = useState<"default" | "compact" | "">(
() => deriveHeartbeatPromptTemplate(agent.runtimeConfig),
);
// Budget config state initialised from agent.runtimeConfig.budgetConfig
const [budgetValues, setBudgetValues] = useState<Record<string, string>>(
() => deriveBudgetValues(agent.runtimeConfig),
);
// Bundle config state
const [bundleMode, setBundleMode] = useState<string>(agent.bundleConfig?.mode ?? "");
const [bundleEntryFile, setBundleEntryFile] = useState(agent.bundleConfig?.entryFile ?? "AGENTS.md");
const [bundleExternalPath, setBundleExternalPath] = useState(agent.bundleConfig?.externalPath ?? "");
const [bundleFiles, setBundleFiles] = useState<string[]>(agent.bundleConfig?.files ?? []);
// Skills state initialized from agent.metadata.skills
const [selectedSkills, setSelectedSkills] = useState<string[]>(
Array.isArray(agent.metadata?.skills) ? agent.metadata.skills as string[] : []
);
// Model/runtime selector state
const { favoriteProviders, favoriteModels, toggleFavoriteProvider, toggleFavoriteModel } = useFavorites();
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [availableRuntimes, setAvailableRuntimes] = useState<PluginRuntimeInfo[]>([]);
const [runtimesLoading, setRuntimesLoading] = useState(false);
const initialModelValue = (() => {
const rc = agent.runtimeConfig ?? {};
if (rc.modelProvider && rc.modelId) {
return `${rc.modelProvider}/${rc.modelId}`;
}
if (typeof rc.model === "string" && rc.model.includes("/")) {
return rc.model;
}
return "";
})();
const initialRuntimeHint = typeof agent.runtimeConfig?.runtimeHint === "string"
? agent.runtimeConfig.runtimeHint
: "";
const [runtimeMode, setRuntimeMode] = useState<"model" | "runtime">(initialRuntimeHint ? "runtime" : "model");
const [modelValue, setModelValue] = useState(initialModelValue);
const [selectedRuntimeId, setSelectedRuntimeId] = useState(initialRuntimeHint);
const [permissionPolicyValue, setPermissionPolicyValue] = useState<AgentPermissionPolicy | undefined>(agent.permissionPolicy);
const [projectDefaultPermissionPolicy, setProjectDefaultPermissionPolicy] = useState<Partial<AgentPermissionPolicyRules> | undefined>(undefined);
const managerSelection = reportsToValue.trim();
const availableManagers = useMemo(
() => managerOptions.filter((candidate) => candidate.id !== agent.id),
[managerOptions, agent.id],
);
const hasMissingManagerSelection = !!managerSelection
&& !availableManagers.some((candidate) => candidate.id === managerSelection);
const existingAgentConfig = useMemo(() => ({
name: nameValue,
role: roleValue,
title: titleValue || undefined,
instructionsText: agent.instructionsText,
soul: agent.soul,
memory: agent.memory,
reportsTo: reportsToValue || undefined,
skills: selectedSkills,
model: modelValue || undefined,
runtimeHint: runtimeMode === "runtime" ? selectedRuntimeId || undefined : undefined,
thinkingLevel: (formValues.thinkingLevel as "off" | "minimal" | "low" | "medium" | "high" | undefined) ?? undefined,
maxTurns: formValues.maxTurns ? Number(formValues.maxTurns) : undefined,
heartbeatIntervalMs: heartbeatValues.heartbeatIntervalMs ? Number(heartbeatValues.heartbeatIntervalMs) * 1000 : undefined,
heartbeatTimeoutMs: heartbeatValues.heartbeatTimeoutMs ? Number(heartbeatValues.heartbeatTimeoutMs) * 1000 : undefined,
maxConcurrentRuns: heartbeatValues.maxConcurrentRuns ? Number(heartbeatValues.maxConcurrentRuns) : undefined,
messageResponseMode: heartbeatValues.messageResponseMode as "immediate" | "on-heartbeat" | undefined,
}), [
agent.instructionsText,
agent.memory,
agent.soul,
formValues.maxTurns,
formValues.thinkingLevel,
heartbeatValues.heartbeatIntervalMs,
heartbeatValues.heartbeatTimeoutMs,
heartbeatValues.maxConcurrentRuns,
heartbeatValues.messageResponseMode,
modelValue,
nameValue,
reportsToValue,
roleValue,
runtimeMode,
selectedRuntimeId,
selectedSkills,
titleValue,
]);
const applyInterviewDraft = useCallback((summary: AgentOnboardingSummary) => {
setNameValue(summary.name);
setRoleValue(summary.role);
setTitleValue(summary.title ?? "");
setIconValue(summary.icon ?? "");
setReportsToValue(summary.reportsTo ?? "");
if (summary.skills) {
setSelectedSkills(summary.skills);
}
if (summary.thinkingLevel) {
setFormValues((prev) => ({ ...prev, thinkingLevel: summary.thinkingLevel }));
}
if (summary.maxTurns !== undefined) {
setFormValues((prev) => ({ ...prev, maxTurns: String(summary.maxTurns) }));
}
if (summary.runtimeHint !== undefined) {
setRuntimeMode("runtime");
setSelectedRuntimeId(summary.runtimeHint ?? "");
if (summary.runtimeHint) {
setModelValue("");
}
} else if (summary.model !== undefined) {
setRuntimeMode("model");
setModelValue(summary.model ?? "");
setSelectedRuntimeId("");
}
const draftUpdates = Object.fromEntries(
Object.entries({
name: summary.name,
role: summary.role,
title: summary.title,
icon: summary.icon,
reportsTo: summary.reportsTo,
instructionsText: summary.instructionsText,
soul: summary.soul,
memory: summary.memory,
}).filter(([, value]) => value !== undefined),
) as Partial<AgentDetail>;
onAgentDraftApplied?.(draftUpdates);
setIsAiInterviewOpen(false);
addToast("Interview draft applied. Review and save when ready.", "success");
}, [addToast, onAgentDraftApplied]);
useEffect(() => {
setPermissionPolicyValue(agent.permissionPolicy);
}, [agent.permissionPolicy]);
useEffect(() => {
fetchSettingsByScope(projectId)
.then((scoped) => setProjectDefaultPermissionPolicy(scoped.project?.defaultAgentPermissionPolicy?.rules))
.catch(() => setProjectDefaultPermissionPolicy(undefined));
}, [projectId]);
const handlePermissionPolicyChange = async (next: AgentPermissionPolicy | undefined) => {
setPermissionPolicyValue(next);
try {
await updateAgent(agent.id, { permissionPolicy: next }, projectId);
await onSaved();
addToast("Permission policy updated", "success");
} catch (err) {
addToast(`Failed to update permission policy: ${getErrorMessage(err)}`, "error");
}
};
// Load candidate managers for reports-to dropdown
useEffect(() => {
let cancelled = false;
setIsLoadingManagers(true);
fetchAgents(undefined, projectId)
.then((agents) => {
if (cancelled) return;
setManagerOptions(agents);
})
.catch(() => {
if (!cancelled) {
setManagerOptions([]);
}
})
.finally(() => {
if (!cancelled) {
setIsLoadingManagers(false);
}
});
return () => {
cancelled = true;
};
}, [projectId]);
// Load available models on mount
useEffect(() => {
setModelsLoading(true);
fetchModels()
.then((response) => {
setAvailableModels(response.models);
})
.catch(() => {
// Gracefully handle unavailable models endpoint
})
.finally(() => setModelsLoading(false));
}, []);
useEffect(() => {
setRuntimesLoading(true);
fetchPluginRuntimes(projectId)
.then(setAvailableRuntimes)
.catch(() => setAvailableRuntimes([]))
.finally(() => setRuntimesLoading(false));
}, [projectId]);
// Budget status for progress bar display
const [budgetStatus, setBudgetStatus] = useState<AgentBudgetStatus | null>(null);
const [isResettingBudget, setIsResettingBudget] = useState(false);
// Fetch budget status on mount
useEffect(() => {
fetchAgentBudgetStatus(agent.id, projectId)
.then(setBudgetStatus)
.catch(() => setBudgetStatus(null));
}, [agent.id, projectId]);
const handleResetBudget = async () => {
setIsResettingBudget(true);
try {
await resetAgentBudget(agent.id, projectId);
addToast("Budget usage reset successfully", "success");
// Refresh budget status
const status = await fetchAgentBudgetStatus(agent.id, projectId);
setBudgetStatus(status);
} catch (err) {
addToast(`Failed to reset budget: ${getErrorMessage(err)}`, "error");
} finally {
setIsResettingBudget(false);
}
};
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<ValidationErrors>({});
const [justSaved, setJustSaved] = useState(false);
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
const isDeletableState = agent.state === "idle" || agent.state === "paused";
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const previousAgentRuntimeSyncRef = useRef<{ id: string; updatedAt: string } | null>(null);
const lastSavedSignatureRef = useRef<string | null>(null);
const saveRevisionRef = useRef(0);
useEffect(() => {
return () => {
if (justSavedTimeoutRef.current) {
clearTimeout(justSavedTimeoutRef.current);
}
};
}, []);
/** Detect whether any local value differs from the persisted metadata */
const hasChanges = (() => {
// Check identity fields
if (nameValue !== agent.name) return true;
if (roleValue !== agent.role) return true;
if (titleValue !== (agent.title ?? "")) return true;
if (iconValue !== (agent.icon ?? "")) return true;
if (reportsToValue !== (agent.reportsTo ?? "")) return true;
// Check bundle config
if (bundleMode !== (agent.bundleConfig?.mode ?? "")) return true;
if (bundleEntryFile !== (agent.bundleConfig?.entryFile ?? "AGENTS.md")) return true;
if (bundleExternalPath !== (agent.bundleConfig?.externalPath ?? "")) return true;
if (JSON.stringify(bundleFiles) !== JSON.stringify(agent.bundleConfig?.files ?? [])) return true;
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;
}
// Check heartbeat values
const rc = agent.runtimeConfig ?? {};
if (heartbeatEnabled !== deriveHeartbeatEnabled(agent.runtimeConfig)) return true;
if (autoClaimRelevantTasksEnabled !== deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig)) return true;
if (runMissedHeartbeatOnStartup !== deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig)) return true;
if (allowParallelExecution !== deriveAllowParallelExecution(agent.runtimeConfig)) return true;
if (skipHeartbeatWhenIdle !== deriveSkipHeartbeatWhenIdle(agent.runtimeConfig)) return true;
if (heartbeatScopeDiscipline !== deriveHeartbeatScopeDiscipline(agent.runtimeConfig)) return true;
if (heartbeatPromptTemplate !== deriveHeartbeatPromptTemplate(agent.runtimeConfig)) return true;
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns", "messageResponseMode", "autoClaimCandidatesInPrompt"] as const) {
const current = heartbeatValues[key]?.trim() ?? "";
let persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
if ((key === "heartbeatIntervalMs" || key === "heartbeatTimeoutMs") && persisted) {
persisted = String(Number(persisted) / 1000);
}
if (current !== persisted) return true;
}
// Check budget config values
const persistedBc = rc.budgetConfig as Record<string, unknown> | undefined;
for (const key of ["tokenBudget", "budgetPeriod", "resetDay"] as const) {
const current = budgetValues[key]?.trim() ?? "";
const persisted = persistedBc?.[key] !== undefined && persistedBc?.[key] !== null
? String(persistedBc[key])
: "";
if (current !== persisted) return true;
}
// usageThreshold: compare percentage (UI) against fraction * 100 (persisted)
const currentThreshold = budgetValues.usageThreshold?.trim() ?? "";
const persistedThreshold = persistedBc?.usageThreshold !== undefined && persistedBc?.usageThreshold !== null
? String(Number(persistedBc.usageThreshold) * 100)
: "";
if (currentThreshold !== persistedThreshold) return true;
// Check skills
const persistedSkills = Array.isArray(agent.metadata?.skills) ? agent.metadata.skills as string[] : [];
if (JSON.stringify(selectedSkills) !== JSON.stringify(persistedSkills)) return true;
// Check model/runtime override
if (runtimeMode !== (initialRuntimeHint ? "runtime" : "model")) return true;
if (modelValue !== initialModelValue) return true;
if (selectedRuntimeId !== initialRuntimeHint) return true;
return false;
})();
const previousHasChangesRef = useRef<boolean | null>(null);
useEffect(() => {
if (!onHasChangesChange) return;
if (previousHasChangesRef.current === hasChanges) return;
previousHasChangesRef.current = hasChanges;
onHasChangesChange(hasChanges);
}, [hasChanges, onHasChangesChange]);
useEffect(() => {
return () => {
onHasChangesChange?.(false);
};
}, [onHasChangesChange]);
useEffect(() => {
const nextSnapshot = { id: agent.id, updatedAt: agent.updatedAt };
const previousSnapshot = previousAgentRuntimeSyncRef.current;
const hasNewAgentData =
!previousSnapshot
|| previousSnapshot.id !== nextSnapshot.id
|| previousSnapshot.updatedAt !== nextSnapshot.updatedAt;
if (!hasNewAgentData) {
return;
}
if (hasChanges) {
return;
}
previousAgentRuntimeSyncRef.current = nextSnapshot;
setHeartbeatValues(deriveHeartbeatValues(agent.runtimeConfig));
setHeartbeatEnabled(deriveHeartbeatEnabled(agent.runtimeConfig));
setAutoClaimRelevantTasksEnabled(deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig));
setRunMissedHeartbeatOnStartup(deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig));
setAllowParallelExecution(deriveAllowParallelExecution(agent.runtimeConfig));
setSkipHeartbeatWhenIdle(deriveSkipHeartbeatWhenIdle(agent.runtimeConfig));
setHeartbeatScopeDiscipline(deriveHeartbeatScopeDiscipline(agent.runtimeConfig));
setBudgetValues(deriveBudgetValues(agent.runtimeConfig));
setModelValue(initialModelValue);
setSelectedRuntimeId(initialRuntimeHint);
setRuntimeMode(initialRuntimeHint ? "runtime" : "model");
}, [agent, hasChanges, initialModelValue, initialRuntimeHint]);
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 handleHeartbeatFieldChange = (key: string, value: string) => {
setHeartbeatValues((prev) => ({ ...prev, [key]: value }));
setJustSaved(false);
if (errors[key]) {
setErrors((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}
};
const handleHeartbeatEnabledChange = (enabled: boolean) => {
setHeartbeatEnabled(enabled);
setJustSaved(false);
};
const handleBudgetFieldChange = (key: string, value: string) => {
setBudgetValues((prev) => ({ ...prev, [key]: value }));
setJustSaved(false);
if (errors[key]) {
setErrors((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}
};
const validationErrors = useMemo(() => {
const nextErrors = validateAdvancedSettings(formValues);
for (const [key, config] of Object.entries({
heartbeatIntervalMs: { label: "Heartbeat Interval", min: 1 },
heartbeatTimeoutMs: { label: "Heartbeat Timeout", min: 5 },
maxConcurrentRuns: { label: "Max Concurrent Runs", min: 1 },
})) {
const raw = heartbeatValues[key]?.trim();
if (!raw) continue;
const num = Number(raw);
if (Number.isNaN(num) || !Number.isFinite(num)) {
nextErrors[key] = `"${config.label}" must be a valid number`;
} else if (num < config.min) {
nextErrors[key] = `"${config.label}" must be at least ${config.min.toLocaleString()}`;
}
}
const autoClaimCandidatesInPromptRaw = heartbeatValues.autoClaimCandidatesInPrompt?.trim();
if (autoClaimCandidatesInPromptRaw) {
const num = Number(autoClaimCandidatesInPromptRaw);
if (!Number.isInteger(num) || num < 0 || num > 10) {
nextErrors.autoClaimCandidatesInPrompt = "\"Auto-claim candidates in prompt\" must be an integer between 0 and 10";
}
}
const messageResponseModeForValidation = heartbeatValues.messageResponseMode?.trim();
if (messageResponseModeForValidation && !["immediate", "on-heartbeat"].includes(messageResponseModeForValidation)) {
nextErrors.messageResponseMode = "\"Message Response Mode\" must be either immediate or on-heartbeat";
}
const tokenBudgetRaw = budgetValues.tokenBudget?.trim();
if (tokenBudgetRaw) {
const num = Number(tokenBudgetRaw);
if (Number.isNaN(num) || !Number.isFinite(num)) {
nextErrors.tokenBudget = "\"Token Budget\" must be a valid number";
} else if (num <= 0) {
nextErrors.tokenBudget = "\"Token Budget\" must be greater than 0";
}
}
const usageThresholdRaw = budgetValues.usageThreshold?.trim();
if (usageThresholdRaw) {
const num = Number(usageThresholdRaw);
if (Number.isNaN(num) || !Number.isFinite(num)) {
nextErrors.usageThreshold = "\"Usage Threshold\" must be a valid number";
} else if (num < 1 || num > 100) {
nextErrors.usageThreshold = "\"Usage Threshold\" must be between 1 and 100";
}
}
const budgetPeriodRaw = budgetValues.budgetPeriod?.trim();
if (budgetPeriodRaw && !["daily", "weekly", "monthly", "lifetime"].includes(budgetPeriodRaw)) {
nextErrors.budgetPeriod = "\"Budget Period\" must be one of: daily, weekly, monthly, lifetime";
}
const resetDayRaw = budgetValues.resetDay?.trim();
const periodForResetDay = budgetPeriodRaw || "lifetime";
if (resetDayRaw) {
const num = Number(resetDayRaw);
if (Number.isNaN(num) || !Number.isFinite(num)) {
nextErrors.resetDay = "\"Reset Day\" must be a valid number";
} else if (periodForResetDay === "weekly") {
if (num < 0 || num > 6 || !Number.isInteger(num)) {
nextErrors.resetDay = "\"Reset Day\" must be between 0 (Sunday) and 6 (Saturday) for weekly period";
}
} else if (periodForResetDay === "monthly") {
if (num < 1 || num > 31 || !Number.isInteger(num)) {
nextErrors.resetDay = "\"Reset Day\" must be between 1 and 31 for monthly period";
}
}
}
return nextErrors;
}, [formValues, heartbeatValues, budgetValues]);
const buildSavePayload = useCallback(() => {
if (Object.keys(validationErrors).length > 0) {
return null;
}
// Build the metadata payload — only include non-empty values
const newMetadata: Record<string, unknown> = { ...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;
}
}
// Handle skills in metadata
if (selectedSkills.length > 0) {
newMetadata.skills = selectedSkills;
} else {
delete newMetadata.skills;
}
// Build the runtimeConfig payload — only include non-empty values
const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig };
newRuntimeConfig.enabled = heartbeatEnabled;
newRuntimeConfig.autoClaimRelevantTasks = autoClaimRelevantTasksEnabled;
newRuntimeConfig.runMissedHeartbeatOnStartup = runMissedHeartbeatOnStartup;
newRuntimeConfig.allowParallelExecution = allowParallelExecution;
newRuntimeConfig.skipHeartbeatWhenIdle = skipHeartbeatWhenIdle;
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns", "autoClaimCandidatesInPrompt"] as const) {
const raw = heartbeatValues[key]?.trim();
if (!raw) {
delete newRuntimeConfig[key];
} else {
const num = Number(raw);
newRuntimeConfig[key] = key === "maxConcurrentRuns" || key === "autoClaimCandidatesInPrompt" ? num : num * 1000;
}
}
const messageResponseMode = heartbeatValues.messageResponseMode?.trim();
if (!messageResponseMode) {
delete newRuntimeConfig.messageResponseMode;
} else {
newRuntimeConfig.messageResponseMode = messageResponseMode;
}
if (!heartbeatScopeDiscipline) {
delete newRuntimeConfig.heartbeatScopeDiscipline;
} else {
newRuntimeConfig.heartbeatScopeDiscipline = heartbeatScopeDiscipline;
}
if (!heartbeatPromptTemplate) {
delete newRuntimeConfig.heartbeatPromptTemplate;
} else {
newRuntimeConfig.heartbeatPromptTemplate = heartbeatPromptTemplate;
}
if (runtimeMode === "runtime") {
if (selectedRuntimeId.trim()) {
newRuntimeConfig.runtimeHint = selectedRuntimeId.trim();
} else {
delete newRuntimeConfig.runtimeHint;
}
delete newRuntimeConfig.modelProvider;
delete newRuntimeConfig.modelId;
delete newRuntimeConfig.model;
} else {
delete newRuntimeConfig.runtimeHint;
// Model override: parse "provider/modelId" into separate fields
if (modelValue.trim()) {
const slashIdx = modelValue.indexOf("/");
if (slashIdx !== -1) {
newRuntimeConfig.modelProvider = modelValue.slice(0, slashIdx);
newRuntimeConfig.modelId = modelValue.slice(slashIdx + 1);
newRuntimeConfig.model = modelValue.trim();
}
} else {
delete newRuntimeConfig.modelProvider;
delete newRuntimeConfig.modelId;
delete newRuntimeConfig.model;
}
}
// Build budgetConfig payload — only include non-empty values
const newBudgetConfig: Record<string, unknown> = {};
const tokenBudget = budgetValues.tokenBudget?.trim();
const usageThreshold = budgetValues.usageThreshold?.trim();
const budgetPeriod = budgetValues.budgetPeriod?.trim();
const resetDay = budgetValues.resetDay?.trim();
if (tokenBudget) {
newBudgetConfig.tokenBudget = Number(tokenBudget);
}
if (usageThreshold) {
// Convert percentage (UI) to fraction (storage)
newBudgetConfig.usageThreshold = Number(usageThreshold) / 100;
}
if (budgetPeriod) {
newBudgetConfig.budgetPeriod = budgetPeriod;
}
if (resetDay) {
newBudgetConfig.resetDay = Number(resetDay);
}
// Only persist budgetConfig if it has any values
if (Object.keys(newBudgetConfig).length > 0) {
newRuntimeConfig.budgetConfig = newBudgetConfig;
} else {
delete newRuntimeConfig.budgetConfig;
}
// Build bundleConfig payload — only include if mode is set
let newBundleConfig: { mode: "managed" | "external"; entryFile: string; files: string[]; externalPath?: string } | undefined;
if (bundleMode) {
newBundleConfig = {
mode: bundleMode as "managed" | "external",
entryFile: bundleEntryFile || "AGENTS.md",
files: bundleFiles.length > 0 ? bundleFiles : ["AGENTS.md"],
};
if (bundleMode === "external" && bundleExternalPath.trim()) {
newBundleConfig.externalPath = bundleExternalPath.trim();
}
}
return {
name: nameValue.trim() || undefined,
role: roleValue,
title: titleValue.trim() || undefined,
icon: iconValue.trim() || undefined,
reportsTo: reportsToValue.trim() || undefined,
metadata: newMetadata,
runtimeConfig: newRuntimeConfig,
bundleConfig: newBundleConfig,
};
}, [agent.metadata, agent.runtimeConfig, allowParallelExecution, autoClaimRelevantTasksEnabled, budgetValues, bundleEntryFile, bundleExternalPath, bundleFiles, bundleMode, formValues, heartbeatEnabled, heartbeatPromptTemplate, heartbeatScopeDiscipline, heartbeatValues, iconValue, modelValue, nameValue, reportsToValue, roleValue, runMissedHeartbeatOnStartup, runtimeMode, selectedRuntimeId, selectedSkills, skipHeartbeatWhenIdle, titleValue, validationErrors]);
const persistSettings = useCallback(async (showValidationToast: boolean, source: "auto" | "manual") => {
const payload = buildSavePayload();
if (!payload) {
setErrors(validationErrors);
if (showValidationToast) {
addToast("Please fix validation errors before saving", "error");
}
if (source === "auto") {
setAutoSaveError("Fix validation errors to save changes");
}
return false;
}
const signature = JSON.stringify(payload);
if (signature === lastSavedSignatureRef.current) {
return false;
}
const revision = ++saveRevisionRef.current;
setErrors({});
setAutoSaveError(null);
setIsSaving(true);
try {
await updateAgent(agent.id, payload, projectId);
if (revision !== saveRevisionRef.current) {
return false;
}
lastSavedSignatureRef.current = signature;
if (source === "manual") {
addToast("Settings saved", "success");
}
setAutoSaveError(null);
setJustSaved(true);
if (justSavedTimeoutRef.current) {
clearTimeout(justSavedTimeoutRef.current);
}
justSavedTimeoutRef.current = setTimeout(() => setJustSaved(false), 3000);
await onSaved();
return true;
} catch (err) {
if (revision === saveRevisionRef.current) {
const message = getErrorMessage(err);
setAutoSaveError(message);
addToast(`Failed to save settings: ${message}`, "error");
}
return false;
} finally {
if (revision === saveRevisionRef.current) {
setIsSaving(false);
}
}
}, [addToast, agent.id, buildSavePayload, onSaved, projectId, validationErrors]);
const handleSave = async () => {
await persistSettings(true, "manual");
};
const scheduleAutoSave = useCallback(() => {
if (!hasChanges || isSaving) {
return;
}
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
void persistSettings(false, "auto");
}, [hasChanges, isSaving, persistSettings, validationErrors]);
useEffect(() => {
if (!hasChanges || isSaving) {
return;
}
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
const timeout = setTimeout(() => {
void persistSettings(false, "auto");
}, CONFIG_AUTOSAVE_DEBOUNCE_MS);
return () => clearTimeout(timeout);
}, [hasChanges, isSaving, persistSettings, validationErrors]);
const handleAvatarUpload = useCallback(async (file: File) => {
setIsAvatarPending(true);
try {
await uploadAgentAvatar(agent.id, file, projectId);
await onSaved();
addToast("Avatar uploaded", "success");
} catch (error: unknown) {
addToast(getErrorMessage(error), "error");
} finally {
setIsAvatarPending(false);
if (avatarInputRef.current) {
avatarInputRef.current.value = "";
}
}
}, [addToast, agent.id, onSaved, projectId]);
const handleAvatarDelete = useCallback(async () => {
setIsAvatarPending(true);
try {
await deleteAgentAvatar(agent.id, projectId);
await onSaved();
addToast("Avatar removed", "success");
} catch (error: unknown) {
addToast(getErrorMessage(error), "error");
} finally {
setIsAvatarPending(false);
}
}, [addToast, agent.id, onSaved, projectId]);
const saveStatusLabel = isSaving
? "Saving changes…"
: autoSaveError
? `Save failed: ${autoSaveError}`
: !hasChanges && justSaved
? "All changes saved"
: null;
return (
<div className="config-tab">
<div className="config-section">
<h3>Agent Configuration</h3>
<p className="config-description">
Configure agent settings and behavior.
</p>
<div className="config-actions-row">
<button type="button" className="btn btn-sm" onClick={() => setIsAiInterviewOpen(true)}>
AI Interview
</button>
</div>
<div className="config-fields">
<div className="config-field">
<label htmlFor="agent-name">Name</label>
<input
id="agent-name"
type="text"
className="input"
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
onBlur={() => { void scheduleAutoSave(); }}
/>
</div>
<div className="config-field">
<label htmlFor="agent-role">Role</label>
<select
id="agent-role"
className="select"
value={roleValue}
onChange={(e) => {
setRoleValue(e.target.value as AgentCapability);
void scheduleAutoSave();
}}
>
<option value="triage">Triage</option>
<option value="executor">Executor</option>
<option value="reviewer">Reviewer</option>
<option value="merger">Merger</option>
<option value="scheduler">Scheduler</option>
<option value="custom">Custom</option>
</select>
</div>
<div className="config-field">
<label htmlFor="agent-title">Title</label>
<input
id="agent-title"
type="text"
className="input"
placeholder="e.g. Senior Code Reviewer"
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onBlur={() => { void scheduleAutoSave(); }}
/>
</div>
<div className="config-field">
<label>Avatar</label>
<div className="agent-avatar-editor">
<AgentAvatar agent={agent} size={64} className="agent-avatar-editor-preview" />
<div className="agent-avatar-editor-actions">
<input
ref={avatarInputRef}
id="agent-avatar-upload"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
className="visually-hidden"
disabled={isAvatarPending}
onChange={(event) => {
const selectedFile = event.target.files?.[0];
if (selectedFile) {
void handleAvatarUpload(selectedFile);
}
}}
/>
<button
type="button"
className="btn btn-sm"
disabled={isAvatarPending}
onClick={() => avatarInputRef.current?.click()}
>
Upload Avatar
</button>
{agent.imageUrl ? (
<button type="button" className="btn btn-sm" onClick={() => void handleAvatarDelete()} disabled={isAvatarPending}>
Remove Avatar
</button>
) : null}
</div>
</div>
</div>
<div className="config-field">
<label htmlFor="agent-icon">Icon</label>
<input
id="agent-icon"
type="text"
className="input"
placeholder="e.g. 🤖"
value={iconValue}
onChange={(e) => setIconValue(e.target.value)}
onBlur={() => { void scheduleAutoSave(); }}
/>
</div>
<div className="config-field">
<label htmlFor="agent-reports-to">Reports To</label>
<select
id="agent-reports-to"
className="select"
value={reportsToValue}
onChange={(e) => {
setReportsToValue(e.target.value);
void scheduleAutoSave();
}}
disabled={isLoadingManagers}
>
<option value="">No manager</option>
{hasMissingManagerSelection && (
<option value={managerSelection}>Unknown manager ({managerSelection})</option>
)}
{availableManagers.map((manager) => (
<option key={manager.id} value={manager.id}>
{manager.name} ({manager.id})
</option>
))}
</select>
</div>
</div>
</div>
<div className="config-section">
<h3>Skills</h3>
<p className="config-description">
Assign skills to this agent for specialized behavior.
</p>
<div className="config-fields">
<div className="config-field">
<SkillMultiselect
id="agent-skills"
label="Skills"
value={selectedSkills}
onChange={(nextSkills) => {
setSelectedSkills(nextSkills);
void scheduleAutoSave();
}}
projectId={projectId}
/>
</div>
</div>
</div>
<div className="config-section">
<h3>Model</h3>
<p className="config-description">
Choose either a built-in model or a plugin runtime for this agent. These options are mutually exclusive.
</p>
<div className="config-fields">
<div className="config-field">
<label>Runtime Source</label>
<div className="config-runtime-tabs" role="tablist" aria-label="Runtime source">
<button
type="button"
className={`config-runtime-tab${runtimeMode === "model" ? " active" : ""}`}
role="tab"
aria-selected={runtimeMode === "model"}
tabIndex={runtimeMode === "model" ? 0 : -1}
onClick={() => {
setRuntimeMode("model");
setSelectedRuntimeId("");
void scheduleAutoSave();
}}
>
Built-in Model
</button>
<button
type="button"
className={`config-runtime-tab${runtimeMode === "runtime" ? " active" : ""}`}
role="tab"
aria-selected={runtimeMode === "runtime"}
tabIndex={runtimeMode === "runtime" ? 0 : -1}
onClick={() => {
setRuntimeMode("runtime");
void scheduleAutoSave();
}}
>
Plugin Runtime
</button>
</div>
</div>
{runtimeMode === "model" ? (
<div className="config-field">
<CustomModelDropdown
models={availableModels}
value={modelValue}
onChange={(value) => {
setModelValue(value);
void scheduleAutoSave();
}}
placeholder="Use global default"
label="Agent Model"
disabled={modelsLoading}
favoriteProviders={favoriteProviders}
onToggleFavorite={toggleFavoriteProvider}
favoriteModels={favoriteModels}
onToggleModelFavorite={toggleFavoriteModel}
/>
</div>
) : (
<div className="config-field">
<label htmlFor="agent-runtime-hint">Runtime</label>
{runtimesLoading ? (
<span className="config-hint">Loading runtimes…</span>
) : (
<select
id="agent-runtime-hint"
className="select"
value={selectedRuntimeId}
onChange={(e) => {
setSelectedRuntimeId(e.target.value);
void scheduleAutoSave();
}}
>
<option value="">
{availableRuntimes.length > 0 ? "Select a plugin runtime…" : "No plugin runtimes available"}
</option>
{availableRuntimes.map((runtime) => (
<option key={`${runtime.pluginId}:${runtime.runtimeId}`} value={runtime.runtimeId}>
{runtime.description ? `${runtime.name} — ${runtime.description}` : runtime.name}
</option>
))}
</select>
)}
</div>
)}
</div>
</div>
<div className="config-section">
<h3>Permissions</h3>
<p className="config-description">
Per-agent settings override project defaults. Each category controls a separate approval gate.
</p>
{permissionPolicyValue === undefined ? (
<div className="agent-permission-inherit-banner">
<span>Inheriting project default — no per-agent override set</span>
<button
type="button"
className="btn btn-sm"
onClick={() => void handlePermissionPolicyChange({
presetId: "custom",
rules: {
git_write: projectDefaultPermissionPolicy?.git_write ?? "allow",
file_write_delete: projectDefaultPermissionPolicy?.file_write_delete ?? "allow",
command_execution: projectDefaultPermissionPolicy?.command_execution ?? "allow",
network_api: projectDefaultPermissionPolicy?.network_api ?? "allow",
task_agent_mutation: projectDefaultPermissionPolicy?.task_agent_mutation ?? "allow",
},
})}
>
Customize for this agent
</button>
</div>
) : null}
{/* Provisioning policy (fn_agent_create/fn_agent_delete) is project-scoped only; no per-agent override UI here by design. */}
<AgentPermissionPolicyEditor
mode="agent-override"
value={permissionPolicyValue}
projectDefault={projectDefaultPermissionPolicy}
onChange={(next) => { void handlePermissionPolicyChange(next); }}
/>
</div>
<div className="config-section">
<h3>Heartbeat Settings</h3>
<p className="config-description">
Configure how this agent's heartbeat is monitored. Leave a field empty to use system defaults.
</p>
<div className="config-fields">
<div className="config-field agent-heartbeat-auto-claim-card">
<div className="agent-heartbeat-preset-row">
<div>
<label className="agent-heartbeat-preset-label">Coordination-only agent</label>
<span className="config-hint">Disables auto-claim and removes the candidate section from heartbeat prompts. Recommended for routing/CEO-style agents.</span>
</div>
<button
type="button"
className="btn btn-sm agent-heartbeat-preset-btn"
onClick={() => {
setAutoClaimRelevantTasksEnabled(false);
setHeartbeatValues((prev) => ({ ...prev, autoClaimCandidatesInPrompt: "0" }));
void scheduleAutoSave();
}}
>
Apply preset
</button>
</div>
<label className="checkbox-label" htmlFor="hb-autoClaimRelevantTasks">
<input
id="hb-autoClaimRelevantTasks"
type="checkbox"
checked={autoClaimRelevantTasksEnabled}
onChange={(e) => {
setAutoClaimRelevantTasksEnabled(e.target.checked);
void scheduleAutoSave();
}}
/>
Auto-Claim Relevant Tasks
</label>
<span className="config-hint">When enabled (default), no-task heartbeats scan open unowned work and auto-claim tasks aligned with this agent&apos;s role and soul.</span>
</div>
<div className="config-field">
<label className="checkbox-label" htmlFor="hb-enabled">
<input
id="hb-enabled"
type="checkbox"
checked={heartbeatEnabled}
onChange={(e) => {
handleHeartbeatEnabledChange(e.target.checked);
void scheduleAutoSave();
}}
/>
Heartbeat Enabled
</label>
<span className="config-hint">When enabled, this agent receives scheduled heartbeat runs based on its interval.</span>
</div>
<div className="config-field">
<label className="checkbox-label" htmlFor="hb-runMissedHeartbeatOnStartup">
<input
id="hb-runMissedHeartbeatOnStartup"
type="checkbox"
checked={runMissedHeartbeatOnStartup}
onChange={(e) => {
setRunMissedHeartbeatOnStartup(e.target.checked);
void scheduleAutoSave();
}}
/>
Run Missed Heartbeat On Startup
</label>
<span className="config-hint">When enabled, if the server was down across this agent&apos;s scheduled heartbeat tick, fire a single catch-up heartbeat at startup. Default: off.</span>
</div>
<div className="config-field">
<label className="checkbox-label" htmlFor="hb-allowParallelExecution">
<input
id="hb-allowParallelExecution"
type="checkbox"
checked={allowParallelExecution}
onChange={(e) => {
setAllowParallelExecution(e.target.checked);
void scheduleAutoSave();
}}
/>
Allow Parallel Execution
</label>
<span className="config-hint">When disabled, the heartbeat and task execution paths serialize for this agent (heartbeat will not start while the agent&apos;s task is executing, and vice versa). Permanent agents only.</span>
</div>
<div className="config-field">
<label className="checkbox-label" htmlFor="hb-skipHeartbeatWhenIdle">
<input
id="hb-skipHeartbeatWhenIdle"
type="checkbox"
checked={skipHeartbeatWhenIdle}
onChange={(e) => {
setSkipHeartbeatWhenIdle(e.target.checked);
void scheduleAutoSave();
}}
/>
Skip heartbeat when idle
</label>
<span className="config-hint">When enabled, scheduled (timer) heartbeats are skipped while this agent has no assigned task. The agent still wakes immediately when a task is assigned or you trigger a run manually. Default: off.</span>
</div>
<div className="config-field">
<label htmlFor="hb-heartbeatScopeDiscipline">Heartbeat Scope Discipline</label>
<select
id="hb-heartbeatScopeDiscipline"
className="select"
value={heartbeatScopeDiscipline}
onChange={(e) => {
const value = e.target.value;
setHeartbeatScopeDiscipline(value === "strict" || value === "lite" || value === "off" ? value : "");
void scheduleAutoSave();
}}
>
<option value="">Inherit project default</option>
<option value="strict">Strict</option>
<option value="lite">Lite</option>
<option value="off">Off</option>
</select>
<span className="config-hint">Strict coordination-focused; higher per-tick tokens. Lite pre-2026-05-11 behavior. Off minimal procedure.</span>
</div>
<div className="config-field">
<label htmlFor="hb-heartbeatPromptTemplate">Heartbeat Prompt Template</label>
<select
id="hb-heartbeatPromptTemplate"
className="select"
value={heartbeatPromptTemplate}
onChange={(e) => {
const value = e.target.value;
setHeartbeatPromptTemplate(value === "default" || value === "compact" ? value : "");
void scheduleAutoSave();
}}
>
<option value="">Inherit project default</option>
<option value="default">Default</option>
<option value="compact">Compact</option>
</select>
</div>
<div className="config-field">
<label htmlFor="hb-heartbeatIntervalMs">Heartbeat Interval (s)</label>
<input
id="hb-heartbeatIntervalMs"
type="text"
inputMode="numeric"
className={cn("input", !!errors.heartbeatIntervalMs && "input--error")}
placeholder={String(DEFAULT_HEARTBEAT_INTERVAL_MS / 1000)}
value={heartbeatValues.heartbeatIntervalMs ?? ""}
onChange={(e) => handleHeartbeatFieldChange("heartbeatIntervalMs", e.target.value)}
/>
{errors.heartbeatIntervalMs ? (
<span className="config-error">{errors.heartbeatIntervalMs}</span>
) : (
<span className="config-hint">
How often heartbeats are checked. Leave empty for system default ({DEFAULT_HEARTBEAT_INTERVAL_MS / 1000}s / {DEFAULT_HEARTBEAT_INTERVAL_LABEL}).
</span>
)}
</div>
<div className="config-field">
<label htmlFor="hb-heartbeatTimeoutMs">Heartbeat Timeout (s)</label>
<input
id="hb-heartbeatTimeoutMs"
type="text"
inputMode="numeric"
className={cn("input", !!errors.heartbeatTimeoutMs && "input--error")}
placeholder="60"
value={heartbeatValues.heartbeatTimeoutMs ?? ""}
onChange={(e) => handleHeartbeatFieldChange("heartbeatTimeoutMs", e.target.value)}
/>
{errors.heartbeatTimeoutMs ? (
<span className="config-error">{errors.heartbeatTimeoutMs}</span>
) : (
<span className="config-hint">Time without heartbeat before agent is considered unresponsive. Leave empty for system default (60s)</span>
)}
</div>
<div className="config-field">
<label htmlFor="hb-maxConcurrentRuns">Max Concurrent Runs</label>
<input
id="hb-maxConcurrentRuns"
type="text"
inputMode="numeric"
className={cn("input", !!errors.maxConcurrentRuns && "input--error")}
placeholder="1"
value={heartbeatValues.maxConcurrentRuns ?? ""}
onChange={(e) => handleHeartbeatFieldChange("maxConcurrentRuns", e.target.value)}
/>
{errors.maxConcurrentRuns ? (
<span className="config-error">{errors.maxConcurrentRuns}</span>
) : (
<span className="config-hint">Maximum simultaneous heartbeat runs for this agent. Leave empty for system default (1).</span>
)}
</div>
<div className="config-field">
<label htmlFor="hb-messageResponseMode">Message Response Mode</label>
<select
id="hb-messageResponseMode"
className={cn("select", !!errors.messageResponseMode && "input--error")}
value={heartbeatValues.messageResponseMode ?? ""}
onChange={(e) => handleHeartbeatFieldChange("messageResponseMode", e.target.value)}
>
<option value="">System Default (On Heartbeat)</option>
<option value="on-heartbeat">On Heartbeat</option>
<option value="immediate">Immediate</option>
</select>
{errors.messageResponseMode ? (
<span className="config-error">{errors.messageResponseMode}</span>
) : (
<span className="config-hint">How this agent responds to incoming messages. &apos;Immediate&apos; wakes the agent as soon as a message arrives. &apos;On Heartbeat&apos; defers processing to the next scheduled heartbeat.</span>
)}
</div>
</div>
</div>
<div className="config-section">
<h3>Budget Settings</h3>
<p className="config-description">
Configure token budget limits for this agent. Leave all fields empty to disable budget tracking.
</p>
<div className="config-fields">
<div className="config-field">
<label htmlFor="budget-tokenBudget">Token Budget</label>
<input
id="budget-tokenBudget"
type="text"
inputMode="numeric"
className={cn("input", !!errors.tokenBudget && "input--error")}
placeholder="No limit"
value={budgetValues.tokenBudget ?? ""}
onChange={(e) => handleBudgetFieldChange("tokenBudget", e.target.value)}
/>
{errors.tokenBudget ? (
<span className="config-error">{errors.tokenBudget}</span>
) : (
<span className="config-hint">Total token cap (input + output) for this agent. Leave empty for no limit.</span>
)}
</div>
<div className="config-field">
<label htmlFor="budget-usageThreshold">Usage Threshold (%)</label>
<input
id="budget-usageThreshold"
type="text"
inputMode="numeric"
className={cn("input", !!errors.usageThreshold && "input--error")}
placeholder="80"
value={budgetValues.usageThreshold ?? ""}
onChange={(e) => handleBudgetFieldChange("usageThreshold", e.target.value)}
/>
{errors.usageThreshold ? (
<span className="config-error">{errors.usageThreshold}</span>
) : (
<span className="config-hint">Warning threshold as a percentage. Agent warns when usage reaches this level. Default: 80%.</span>
)}
</div>
<div className="config-field">
<label htmlFor="budget-budgetPeriod">Budget Period</label>
<select
id="budget-budgetPeriod"
className={cn("select", !!errors.budgetPeriod && "input--error")}
value={budgetValues.budgetPeriod ?? ""}
onChange={(e) => handleBudgetFieldChange("budgetPeriod", e.target.value)}
>
<option value="">No reset (lifetime)</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
{errors.budgetPeriod ? (
<span className="config-error">{errors.budgetPeriod}</span>
) : (
<span className="config-hint">How often the budget counter resets. Leave empty for lifetime budget.</span>
)}
</div>
<div className="config-field">
<label htmlFor="budget-resetDay">Reset Day</label>
<input
id="budget-resetDay"
type="text"
inputMode="numeric"
className={cn("input", !!errors.resetDay && "input--error")}
placeholder="Auto"
value={budgetValues.resetDay ?? ""}
onChange={(e) => handleBudgetFieldChange("resetDay", e.target.value)}
/>
{errors.resetDay ? (
<span className="config-error">{errors.resetDay}</span>
) : (
<span className="config-hint">
{budgetValues.budgetPeriod === "weekly"
? "Day of week (0=Sunday to 6=Saturday) for reset."
: budgetValues.budgetPeriod === "monthly"
? "Day of month (1-31) for reset."
: "Day for reset (weekly: 0-6, monthly: 1-31). Leave empty for automatic."}
</span>
)}
</div>
{/* Budget Usage Progress Bar */}
{budgetStatus?.budgetLimit != null && (
<div className="config-field">
<label>Current Usage</label>
<div className="budget-progress-container">
<div className="budget-progress-bar">
<div
className={cn(
"budget-progress-bar__fill",
(budgetStatus.usagePercent ?? 0) >= 100
? "budget-progress-bar__fill--red"
: (budgetStatus.usagePercent ?? 0) >= 80
? "budget-progress-bar__fill--amber"
: "budget-progress-bar__fill--green"
)}
style={{ width: `${Math.min(budgetStatus.usagePercent ?? 0, 100)}%` }}
/>
</div>
<span className="budget-progress-label">
{(budgetStatus.currentUsage ?? 0).toLocaleString()} / {(budgetStatus.budgetLimit ?? 0).toLocaleString()} tokens ({Math.round(budgetStatus.usagePercent ?? 0)}% used)
</span>
</div>
</div>
)}
{/* Reset Budget Button */}
{budgetStatus?.budgetLimit != null && (
<div className="config-field">
<button
className="btn btn-reset-budget"
onClick={() => void handleResetBudget()}
disabled={isResettingBudget}
>
{isResettingBudget ? (
<>
<Loader2 size={14} className="animate-spin" />
Resetting
</>
) : (
<>
<RefreshCw size={14} />
Reset Budget Usage
</>
)}
</button>
</div>
)}
</div>
</div>
<div className="config-section">
<h3>Instruction Bundle</h3>
<p className="config-description">
Configure the agent's instruction bundle. Leave empty to use inline instructions only.
</p>
<div className="config-fields">
<div className="config-field">
<label htmlFor="bundle-mode">Bundle Mode</label>
<select
id="bundle-mode"
className="select"
value={bundleMode}
onChange={(e) => setBundleMode(e.target.value)}
>
<option value="">None (use inline instructions)</option>
<option value="managed">Managed (system-managed directory)</option>
<option value="external">External (user-specified path)</option>
</select>
<span className="config-hint">
{bundleMode === "managed" && "Files will be stored in a system-managed directory within .fusion/agents/"}
{bundleMode === "external" && "Specify an external directory path for the instruction files"}
{!bundleMode && "Select a mode to enable instruction bundling"}
</span>
</div>
{bundleMode && (
<>
<div className="config-field">
<label htmlFor="bundle-entry-file">Entry File</label>
<input
id="bundle-entry-file"
type="text"
className="input"
placeholder="AGENTS.md"
value={bundleEntryFile}
onChange={(e) => setBundleEntryFile(e.target.value)}
/>
<span className="config-hint">Primary instructions file name (default: AGENTS.md)</span>
</div>
{bundleMode === "external" && (
<div className="config-field">
<label htmlFor="bundle-external-path">External Path</label>
<input
id="bundle-external-path"
type="text"
className="input"
placeholder="e.g. .fusion/agents/my-agent"
value={bundleExternalPath}
onChange={(e) => setBundleExternalPath(e.target.value)}
/>
<span className="config-hint">Absolute or relative path to the external directory</span>
</div>
)}
<div className="config-field">
<label htmlFor="bundle-files">Files (comma-separated)</label>
<input
id="bundle-files"
type="text"
className="input"
placeholder="AGENTS.md, PROMPTS.md"
value={bundleFiles.join(", ")}
onChange={(e) => setBundleFiles(
e.target.value.split(",").map(f => f.trim()).filter(Boolean)
)}
/>
<span className="config-hint">List of file names in the bundle directory</span>
</div>
</>
)}
</div>
</div>
<div className="config-section">
<h3>Advanced Settings</h3>
<p className="config-description">
Advanced configuration options for this agent. Leave a field empty to use system defaults.
</p>
<div className="config-fields">
{ADVANCED_SETTINGS.map((field) => {
const hasError = !!errors[field.key];
return (
<div className="config-field" key={field.key}>
<label htmlFor={`adv-${field.key}`}>{field.label}</label>
{field.type === "select" ? (
<select
id={`adv-${field.key}`}
className={cn("select", hasError && "input--error")}
value={formValues[field.key] ?? ""}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
>
<option value="">System Default</option>
{field.options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : (
<input
id={`adv-${field.key}`}
type="text"
inputMode={field.type === "number" ? "numeric" : undefined}
className={cn("input", hasError && "input--error")}
placeholder={field.placeholder}
value={formValues[field.key] ?? ""}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
)}
{hasError && (
<span className="config-error">{errors[field.key]}</span>
)}
{!hasError && field.hint && (
<span className="config-hint">{field.hint}</span>
)}
</div>
);
})}
</div>
<div className="config-actions">
<button
className="btn btn-task-create"
disabled={!hasChanges || isSaving}
onClick={() => void handleSave()}
>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving
</>
) : (
<>
<CheckCircle size={16} />
Save Settings
</>
)}
</button>
{saveStatusLabel && (
<span className={cn("config-saved-indicator", autoSaveError && "config-saved-indicator--error")}>
{isSaving ? <Loader2 size={14} className="animate-spin" /> : <CheckCircle size={14} />}
{saveStatusLabel}
</span>
)}
</div>
</div>
<HeartbeatProcedureSection
agent={agent}
projectId={projectId}
addToast={addToast}
onSaved={onSaved}
/>
<div className="config-section config-section--danger">
<h3>Danger Zone</h3>
<p className="config-description">
Permanently delete this agent from the project.
</p>
<div className="config-fields">
<div className="config-field">
<button
className="btn btn--danger"
disabled={!isDeletableState || !onDelete}
onClick={() => void onDelete?.()}
>
<Trash2 size={16} />
Delete Agent
</button>
<span className="config-danger-note">
{isDeletableState
? "Deletion is permanent and cannot be undone."
: `Agent deletion is only available when state is idle or paused (current state: ${agent.state}).`}
</span>
</div>
</div>
</div>
<ExperimentalAgentOnboardingModal
isOpen={isAiInterviewOpen}
onClose={() => setIsAiInterviewOpen(false)}
onUseDraft={applyInterviewDraft}
projectId={projectId}
existingAgents={managerOptions}
mode="edit"
existingAgentConfig={existingAgentConfig}
/>
</div>
);
}
// ── Employees Tab ───────────────────────────────────────────────────────────
function EmployeesTab({
agentId,
projectId,
onChildClick,
}: {
agentId: string;
projectId?: string;
onChildClick?: (childId: string) => void;
}) {
const [children, setChildren] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
fetchAgentChildren(agentId, projectId)
.then(setChildren)
.finally(() => setIsLoading(false));
}, [agentId, projectId]);
if (isLoading) {
return (
<div className="detail-section">
<div className="detail-section-header">
<h3>Employees</h3>
</div>
<div className="detail-section-body detail-section-body--loading">
<Loader2 size={16} className="spin" />
<span className="text-muted">Loading employees...</span>
</div>
</div>
);
}
return (
<div className="detail-section">
<div className="detail-section-header">
<h3>Employees</h3>
<span className="text-muted">({children.length})</span>
</div>
<div className="detail-section-body">
{children.length === 0 ? (
<div className="agent-empty agent-empty--padded">
<GitBranch size={32} opacity={0.3} />
<p>No employees</p>
<p className="text-muted">This agent has no employees</p>
</div>
) : (
<div className="agent-tree__children">
{children.map((child) => {
const stateStyle = STATE_COLORS[child.state as AgentState];
return (
<div
key={child.id}
className={`agent-tree__node agent-is-child`}
onClick={() => onChildClick?.(child.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (e.key === " ") {
e.preventDefault();
}
onChildClick?.(child.id);
}
}}
style={{ cursor: onChildClick ? "pointer" : "default" }}
>
<span className="agent-tree__icon">{child.icon ?? "🤖"}</span>
<span className="agent-tree__name">{child.name}</span>
<span
className="agent-tree__badge"
style={{
background: stateStyle?.bg ?? "var(--state-idle-bg)",
color: stateStyle?.text ?? "var(--state-idle-text)",
border: `1px solid ${stateStyle?.border ?? "var(--state-idle-border)"}`,
}}
>
{child.state}
</span>
</div>
);
})}
</div>
)}
</div>
</div>
);
}