Files
fusion/packages/dashboard/app/components/AgentDetailView.tsx
gsxdsm c18a92cee4 feat(FN-1035): replace hardcoded colors with theme-aware CSS variables in agent UI
- Replace hardcoded color values in AgentDetailView with --in-progress CSS variable
- Fix card size badges to use theme-aware CSS variables
- Replace hardcoded log entry colors with CSS variables in AgentLogViewer
- Remove duplicate CSS variable definitions from AgentListModal
- Update central styles.css with proper CSS variable declarations for agent UI
- Update AgentListModal tests to reflect removed duplicate CSS variables
2026-04-06 16:12:11 -07:00

1136 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { 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
} from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentChildren } from "../api";
import type { Agent } from "../api";
import type { AgentLogEntry } from "@fusion/core";
/**
* Simple className utility - joins class names conditionally
*/
function cn(...classes: (string | boolean | undefined | null)[]): string {
return classes.filter(Boolean).join(" ");
}
/**
* Format an ISO timestamp to a relative time string.
*/
function relativeTime(iso: string): string {
const now = Date.now();
const then = new Date(iso).getTime();
const diffMs = now - then;
// Future
if (diffMs < 0) {
const absDiff = Math.abs(diffMs);
if (absDiff < 60_000) return "in a moment";
if (absDiff < 3_600_000) return `in ${Math.floor(absDiff / 60_000)}m`;
if (absDiff < 86_400_000) return `in ${Math.floor(absDiff / 3_600_000)}h`;
return `in ${Math.floor(absDiff / 86_400_000)}d`;
}
// Past
if (diffMs < 60_000) return "just now";
if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`;
return `${Math.floor(diffMs / 86_400_000)}d ago`;
}
interface AgentDetailViewProps {
agentId: string;
projectId?: string;
onClose: () => void;
addToast: (message: string, type?: "success" | "error") => void;
onChildClick?: (childId: string) => void;
}
type TabId = "dashboard" | "logs" | "config" | "runs" | "children";
const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
{ id: "dashboard", label: "Dashboard", icon: ActivitySquare },
{ id: "logs", label: "Logs", icon: FileText },
{ id: "runs", label: "Runs", icon: Activity },
{ id: "children", label: "Children", icon: GitBranch },
{ 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)" },
terminated: { 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, #3fb950)" },
failed: { icon: XCircle, color: "var(--color-error, #f85149)" },
active: { icon: Loader2, color: "var(--in-progress, #bc8cff)" },
terminated: { icon: Square, color: "var(--text-muted, #8b949e)" },
};
export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick }: AgentDetailViewProps) {
const [agent, setAgent] = useState<AgentDetail | null>(null);
const [logs, setLogs] = useState<AgentLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabId>("dashboard");
const [isStreaming, setIsStreaming] = useState(false);
const logContainerRef = useRef<HTMLDivElement>(null);
const loadAgent = useCallback(async () => {
setIsLoading(true);
try {
const data = await fetchAgent(agentId, projectId);
setAgent(data);
} catch (err: any) {
addToast(`Failed to load agent: ${err.message}`, "error");
onClose();
} finally {
setIsLoading(false);
}
}, [agentId, addToast, onClose, projectId]);
const loadLogs = useCallback(async () => {
// Agent logs are tied to tasks, not agents directly.
// If the agent has a current task, we could show those logs.
// For now, we'll show heartbeat runs as the "activity" for the agent.
// If the agent is working on a task, we could show task logs.
if (agent?.taskId) {
try {
const data = await fetchAgentLogs(agent.taskId, projectId);
setLogs(data);
} catch (err: any) {
console.error("Failed to load task logs:", err);
}
}
}, [agent?.taskId, projectId]);
useEffect(() => {
void loadAgent();
}, [loadAgent]);
useEffect(() => {
if (agent?.taskId) {
void loadLogs();
}
}, [agent?.taskId, loadLogs]);
// Set up SSE for live log streaming when viewing logs tab with a task
useEffect(() => {
if (activeTab !== "logs" || !agent?.taskId) {
setIsStreaming(false);
return;
}
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const es = new EventSource(`/api/tasks/${encodeURIComponent(agent.taskId)}/logs/stream${query}`);
const handleAgentLog = (e: MessageEvent) => {
try {
const entry: AgentLogEntry = JSON.parse(e.data);
setLogs(prev => [entry, ...prev]);
// Auto-scroll to top for new entries
const container = logContainerRef.current;
if (container && container.scrollTop < 50) {
container.scrollTop = 0;
}
} catch {
// Ignore parse errors
}
};
es.addEventListener("agent:log", handleAgentLog as EventListener);
es.onerror = () => {
setIsStreaming(false);
};
es.onopen = () => {
setIsStreaming(true);
};
return () => {
es.removeEventListener("agent:log", handleAgentLog as EventListener);
es.close();
setIsStreaming(false);
};
}, [agent?.taskId, activeTab, projectId]);
const handleStateChange = async (newState: AgentState) => {
try {
await updateAgentState(agentId, newState, projectId);
addToast(`Agent state updated to ${newState}`, "success");
void loadAgent();
} catch (err: any) {
addToast(`Failed to update state: ${err.message}`, "error");
}
};
const handleDelete = async () => {
if (!agent || !confirm(`Delete agent "${agent.name}"? This cannot be undone.`)) return;
try {
await deleteAgent(agentId, projectId);
addToast(`Agent "${agent.name}" deleted`, "success");
onClose();
} catch (err: any) {
addToast(`Failed to delete agent: ${err.message}`, "error");
}
};
const getHealthStatus = () => {
if (!agent) return { label: "Unknown", color: "var(--text-muted, #8b949e)" };
if (agent.state === "terminated") {
return { label: "Terminated", color: "var(--state-error-text, #f85149)" };
}
if (agent.state === "error") {
return { label: agent.lastError ?? "Error", color: "var(--state-error-text, #f85149)" };
}
if (agent.state === "paused") {
return { label: agent.pauseReason ? `Paused: ${agent.pauseReason}` : "Paused", color: "var(--state-paused-text, #e3b541)" };
}
if (agent.state === "running") {
return { label: "Running", color: "var(--state-active-text, #3fb950)" };
}
if (!agent.lastHeartbeatAt) {
return { label: agent.state === "active" ? "Starting..." : "Idle", color: "var(--state-idle-text, #8b949e)" };
}
const lastHeartbeat = new Date(agent.lastHeartbeatAt).getTime();
const elapsed = Date.now() - lastHeartbeat;
const timeoutMs = 60000;
if (elapsed > timeoutMs) {
return { label: "Unresponsive", color: "var(--state-error-text, #f85149)" };
}
return { label: "Healthy", color: "var(--state-active-text, #3fb950)" };
};
const copyAgentId = () => {
if (agent) {
navigator.clipboard.writeText(agent.id);
addToast("Agent ID copied to clipboard", "success");
}
};
if (isLoading) {
return (
<div className="agent-detail-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="agent-detail-modal">
<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 runs = (agent as any).completedRuns || [];
const activeRun = (agent as any).activeRun;
return (
<div className="agent-detail-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="agent-detail-modal">
{/* Header */}
<div className="agent-detail-header">
<div className="agent-detail-title">
<div className="agent-detail-icon">
<Bot size={24} />
</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 }}>
{health.label === "Healthy" && <Heart size={12} />}
{health.label === "Unresponsive" && <Activity size={12} />}
{health.label}
</span>
</div>
</div>
</div>
<div className="agent-detail-actions">
{/* State-dependent action buttons */}
{agent.state === "idle" && (
<>
<button className="btn btn--primary" onClick={() => void handleStateChange("active")}>
<Play size={16} />
Start
</button>
<button className="btn btn--danger" onClick={handleDelete}>
<Trash2 size={16} />
Delete
</button>
</>
)}
{agent.state === "active" && (
<>
<button className="btn" onClick={() => void handleStateChange("paused")}>
<Pause size={16} />
Pause
</button>
<button className="btn btn--danger" onClick={() => void handleStateChange("terminated")}>
<Square size={16} />
Stop
</button>
</>
)}
{agent.state === "paused" && (
<>
<button className="btn btn--primary" onClick={() => void handleStateChange("active")}>
<Play size={16} />
Resume
</button>
<button className="btn btn--danger" onClick={() => void handleStateChange("terminated")}>
<Square size={16} />
Stop
</button>
</>
)}
{agent.state === "running" && (
<>
<button className="btn" onClick={() => void handleStateChange("paused")}>
<Pause size={16} />
Pause
</button>
<button className="btn btn--danger" onClick={() => void handleStateChange("terminated")}>
<Square size={16} />
Stop
</button>
</>
)}
{agent.state === "error" && (
<>
<button className="btn btn--primary" onClick={() => void handleStateChange("active")}>
<Play size={16} />
Retry
</button>
<button className="btn btn--danger" onClick={() => void handleStateChange("terminated")}>
<Square size={16} />
Stop
</button>
</>
)}
{agent.state === "terminated" && (
<button className="btn btn--danger" onClick={handleDelete}>
<Trash2 size={16} />
Delete
</button>
)}
<button className="btn-icon" onClick={() => void loadAgent()} title="Refresh">
<RefreshCw size={16} />
</button>
<button className="btn-icon" onClick={onClose} title="Close">
<X size={20} />
</button>
</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} />
)}
{activeTab === "logs" && (
<LogsTab
logs={logs}
isStreaming={isStreaming}
containerRef={logContainerRef}
hasTask={!!agent.taskId}
/>
)}
{activeTab === "runs" && (
<RunsTab
runs={runs}
activeRun={activeRun}
addToast={addToast}
/>
)}
{activeTab === "config" && (
<ConfigTab
agent={agent}
projectId={projectId}
addToast={addToast}
onSaved={loadAgent}
/>
)}
{activeTab === "children" && (
<ChildrenTab
agentId={agent.id}
projectId={projectId}
onChildClick={onChildClick}
/>
)}
</div>
{/* Footer with agent ID */}
<div className="agent-detail-footer">
<button className="btn-icon" onClick={copyAgentId} title="Copy Agent ID">
<Copy size={14} />
</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
}: {
agent: AgentDetail;
health: { label: string; color: string };
}) {
const stateStyle = STATE_COLORS[agent.state];
const stats = useMemo(() => {
const runs = (agent as any).completedRuns || [];
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayRuns = runs.filter((r: AgentHeartbeatRun) =>
new Date(r.startedAt) >= today
);
const successfulRuns = runs.filter((r: AgentHeartbeatRun) =>
r.status === "completed"
);
return {
totalRuns: runs.length,
todayRuns: todayRuns.length,
successfulRuns: successfulRuns.length,
successRate: runs.length > 0
? Math.round((successfulRuns.length / runs.length) * 100)
: 0,
};
}, [agent]);
return (
<div className="dashboard-tab">
{/* Agent Info Card */}
<div className="dashboard-section">
<h3>Agent Information</h3>
<div className="info-grid">
<div className="info-item">
<span className="info-label">Name</span>
<span className="info-value">{agent.name}</span>
</div>
<div className="info-item">
<span className="info-label">Role</span>
<span className="info-value">{agent.role}</span>
</div>
<div className="info-item">
<span className="info-label">State</span>
<span className="info-value">
<span
className="inline-badge"
style={{ background: stateStyle.bg, color: stateStyle.text }}
>
{agent.state}
</span>
</span>
</div>
<div className="info-item">
<span className="info-label">Health</span>
<span className="info-value" style={{ color: health.color }}>
{health.label}
</span>
</div>
<div className="info-item">
<span className="info-label">Created</span>
<span className="info-value">{new Date(agent.createdAt).toLocaleDateString()}</span>
</div>
<div className="info-item">
<span className="info-label">Last Heartbeat</span>
<span className="info-value">
{agent.lastHeartbeatAt
? relativeTime(agent.lastHeartbeatAt)
: "Never"
}
</span>
</div>
</div>
</div>
{/* Stats Cards */}
<div className="dashboard-section">
<h3>Statistics</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>
</div>
{/* Current Task */}
{agent.taskId && (
<div className="dashboard-section">
<h3>Current Task</h3>
<div className="current-task">
<span className="task-badge">{agent.taskId}</span>
<a href={`/tasks/${agent.taskId}`} className="btn btn--sm">
View Task <ExternalLink size={14} />
</a>
</div>
</div>
)}
{/* Metadata */}
{agent.metadata && Object.keys(agent.metadata).length > 0 && (
<div className="dashboard-section">
<h3>Metadata</h3>
<pre className="metadata-json">
{JSON.stringify(agent.metadata, null, 2)}
</pre>
</div>
)}
</div>
);
}
// ── Logs Tab ──────────────────────────────────────────────────────────────
function LogsTab({
logs,
isStreaming,
containerRef,
hasTask
}: {
logs: AgentLogEntry[];
isStreaming: boolean;
containerRef: React.RefObject<HTMLDivElement | null>;
hasTask: boolean;
}) {
if (!hasTask) {
return (
<div className="logs-tab">
<div className="logs-empty">
<FileText size={48} opacity={0.3} />
<p>No task assigned</p>
<p className="text-muted">
Agent logs are available when the agent is assigned to a task
</p>
</div>
</div>
);
}
return (
<div className="logs-tab">
<div className="logs-header">
<span className="logs-count">{logs.length} entries</span>
{isStreaming && (
<span className="streaming-indicator">
<span className="streaming-dot" />
Live
</span>
)}
</div>
<div ref={containerRef} className="logs-container">
{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>
) : (
logs.map((entry, i) => {
const prevEntry = i > 0 ? logs[i - 1] : undefined;
const showTimestamp = !prevEntry || prevEntry.agent !== entry.agent;
return (
<LogEntry key={`${entry.timestamp}-${i}`} entry={entry} showTimestamp={showTimestamp} />
);
})
)}
</div>
</div>
);
}
function LogEntry({ entry, showTimestamp }: { entry: AgentLogEntry; showTimestamp: boolean }) {
const getEntryStyles = () => {
switch (entry.type) {
case "tool":
return {
color: "var(--accent)",
borderLeft: "3px solid var(--accent)",
background: "var(--log-tool-bg)",
};
case "tool_result":
return {
color: "var(--color-success)",
borderLeft: "3px solid var(--color-success)",
background: "var(--log-success-bg)",
};
case "tool_error":
return {
color: "var(--color-error)",
borderLeft: "3px solid var(--color-error)",
background: "var(--log-error-bg)",
};
case "thinking":
return {
color: "var(--text-muted)",
fontStyle: "italic" as const,
opacity: 0.7,
};
default:
return {
color: "var(--text-primary)",
};
}
};
const styles = getEntryStyles();
const timestamp = new Date(entry.timestamp).toLocaleTimeString();
return (
<div className="log-entry" style={styles}>
{showTimestamp && (
<span className="log-timestamp">[{timestamp}]</span>
)}
{entry.agent && (
<span className="log-agent">[{entry.agent}]</span>
)}
{entry.type === "tool" && <span className="log-icon"></span>}
{entry.type === "tool_result" && <span className="log-icon"></span>}
{entry.type === "tool_error" && <span className="log-icon"></span>}
<span className="log-text">
{entry.text}
{entry.detail && (
<span className="log-detail"> {entry.detail}</span>
)}
</span>
</div>
);
}
// ── Runs Tab ───────────────────────────────────────────────────────────────
function RunsTab({
runs,
activeRun,
addToast
}: {
runs: AgentHeartbeatRun[];
activeRun?: AgentHeartbeatRun;
addToast: (msg: string, type?: "success" | "error") => void;
}) {
if (runs.length === 0 && !activeRun) {
return (
<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>
);
}
const sortedRuns = [...runs].sort(
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
);
return (
<div className="runs-tab">
{activeRun && (
<div className="run-card run-card--active">
<div className="run-header">
<span className="run-live-indicator">
<span className="live-dot" />
Live Run
</span>
<span className="run-status active">
<Loader2 size={14} className="animate-spin" />
Active
</span>
</div>
<div className="run-details">
<span>Started {relativeTime(activeRun.startedAt)}</span>
</div>
</div>
)}
{sortedRuns.map((run, i) => {
const statusInfo = RUN_STATUS_ICONS[run.status] || RUN_STATUS_ICONS.completed;
const StatusIcon = statusInfo.icon;
const duration = run.endedAt
? formatDuration(new Date(run.startedAt), new Date(run.endedAt))
: "In progress";
return (
<div key={run.id} className="run-card">
<div className="run-header">
<span className="run-id">#{i + 1} {run.id.slice(0, 8)}</span>
<span className={cn("run-status", run.status)}>
<StatusIcon size={14} className={statusInfo.color} />
{run.status}
</span>
</div>
<div className="run-details">
<span>Started {relativeTime(run.startedAt)}</span>
<span></span>
<span>{duration}</span>
</div>
</div>
);
})}
</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`;
}
// ── Config Tab ─────────────────────────────────────────────────────────────
/** Shape of a single advanced setting field stored in agent.metadata */
interface AdvancedSettingField {
key: string;
label: string;
type: "text" | "number" | "select";
placeholder?: string;
hint?: string;
options?: Array<{ value: string; label: string }>;
/** Minimum value for number fields */
min?: number;
/** Maximum value for number fields */
max?: number;
}
/** Well-known advanced setting definitions backed by agent.metadata */
const ADVANCED_SETTINGS: AdvancedSettingField[] = [
{
key: "heartbeatIntervalMs",
label: "Heartbeat Interval (ms)",
type: "number",
placeholder: "30000",
hint: "How often the agent sends heartbeats (minimum 1000ms, default 30000ms)",
min: 1000,
max: 600000,
},
{
key: "maxRetries",
label: "Max Retries",
type: "number",
placeholder: "3",
hint: "Maximum number of automatic retries on task failure (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 ConfigTab({
agent,
projectId,
addToast,
onSaved,
}: {
agent: AgentDetail;
projectId?: string;
addToast: (message: string, type?: "success" | "error") => void;
onSaved: () => Promise<void>;
}) {
// 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;
});
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<ValidationErrors>({});
const [justSaved, setJustSaved] = useState(false);
/** Detect whether any local value differs from the persisted metadata */
const hasChanges = (() => {
for (const field of ADVANCED_SETTINGS) {
const current = formValues[field.key]?.trim() ?? "";
const persisted = agent.metadata[field.key] !== undefined && agent.metadata[field.key] !== null
? String(agent.metadata[field.key])
: "";
if (current !== persisted) return true;
}
return false;
})();
const handleFieldChange = (key: string, value: string) => {
setFormValues((prev) => ({ ...prev, [key]: value }));
setJustSaved(false);
// Clear individual field error on change
if (errors[key]) {
setErrors((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}
};
const handleSave = async () => {
// Validate before save
const validationErrors = validateAdvancedSettings(formValues);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
addToast("Please fix validation errors before saving", "error");
return;
}
// Build the metadata payload — only include non-empty values
const newMetadata: Record<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;
}
}
setIsSaving(true);
try {
await updateAgent(agent.id, { metadata: newMetadata }, projectId);
addToast("Advanced settings saved", "success");
setJustSaved(true);
// Auto-hide the saved indicator after 3 seconds
setTimeout(() => setJustSaved(false), 3000);
await onSaved();
} catch (err: any) {
addToast(`Failed to save settings: ${err.message}`, "error");
} finally {
setIsSaving(false);
}
};
return (
<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-fields">
<div className="config-field">
<label>Name</label>
<input
type="text"
className="input"
defaultValue={agent.name}
disabled
/>
<span className="config-hint">Name changes coming soon</span>
</div>
<div className="config-field">
<label>Role</label>
<select className="select" defaultValue={agent.role} disabled>
<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>
<span className="config-hint">Role changes coming soon</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--primary"
disabled={!hasChanges || isSaving}
onClick={() => void handleSave()}
>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving
</>
) : (
<>
<CheckCircle size={16} />
Save Settings
</>
)}
</button>
{!hasChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Settings saved
</span>
)}
</div>
</div>
</div>
);
}
// ── Children Tab ────────────────────────────────────────────────────────────
function ChildrenTab({
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>Child Agents</h3>
</div>
<div className="detail-section-body" style={{ display: "flex", alignItems: "center", gap: 8, padding: 16 }}>
<Loader2 size={16} className="spin" />
<span className="text-secondary">Loading children...</span>
</div>
</div>
);
}
return (
<div className="detail-section">
<div className="detail-section-header">
<h3>Child Agents</h3>
<span className="text-secondary">({children.length})</span>
</div>
<div className="detail-section-body">
{children.length === 0 ? (
<div className="agent-empty" style={{ padding: 24 }}>
<GitBranch size={32} opacity={0.3} />
<p>No child agents</p>
<p className="text-secondary">This agent has no spawned children</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) => e.key === "Enter" && 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>
);
}