feat(FN-3122): redesign agents workspace with split-pane view and mobile dr

This merge delivers a major Agents workspace redesign (FN-3122) with split-pane layout, inline AgentDetailView, and mobile drill-in, along with a comprehensive design-system revision addressing token consistency and focus-visible states. Priority picker support was added to the quick-entry box (FN-3

Fusion-Task-Id: FN-3122
This commit is contained in:
Fusion
2026-05-02 03:50:11 -07:00
committed by gsxdsm
parent e69a0b76e5
commit 3fa96ca9c1
7 changed files with 1111 additions and 492 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -34,7 +34,7 @@ function cn(...classes: (string | boolean | undefined | null)[]): string {
/** /**
* Format an ISO timestamp to a relative time string. * Format an ISO timestamp to a relative time string.
*/ */
function relativeTime(iso: string): string { export function relativeTime(iso: string): string {
const now = Date.now(); const now = Date.now();
const then = new Date(iso).getTime(); const then = new Date(iso).getTime();
const diffMs = now - then; const diffMs = now - then;
@@ -61,6 +61,7 @@ interface AgentDetailViewProps {
onClose: () => void; onClose: () => void;
addToast: (message: string, type?: "success" | "error") => void; addToast: (message: string, type?: "success" | "error") => void;
onChildClick?: (childId: string) => void; onChildClick?: (childId: string) => void;
inline?: boolean;
} }
type TabId = "dashboard" | "logs" | "config" | "runs" | "tasks" | "employees" | "soul" | "instructions" | "memory" | "reflections"; type TabId = "dashboard" | "logs" | "config" | "runs" | "tasks" | "employees" | "soul" | "instructions" | "memory" | "reflections";
@@ -88,10 +89,10 @@ const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: strin
}; };
const RUN_STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string }> = { const RUN_STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string }> = {
completed: { icon: CheckCircle, color: "var(--color-success, #3fb950)" }, completed: { icon: CheckCircle, color: "var(--color-success)" },
failed: { icon: XCircle, color: "var(--color-error, #f85149)" }, failed: { icon: XCircle, color: "var(--color-error)" },
active: { icon: Loader2, color: "var(--in-progress, #bc8cff)" }, active: { icon: Loader2, color: "var(--in-progress)" },
terminated: { icon: Square, color: "var(--text-muted, #8b949e)" }, terminated: { icon: Square, color: "var(--text-muted)" },
}; };
const MEMORY_LAYER_NAMES: Record<MemoryFileInfo["layer"], string> = { const MEMORY_LAYER_NAMES: Record<MemoryFileInfo["layer"], string> = {
@@ -118,7 +119,7 @@ function pickDefaultAgentMemoryPath(files: MemoryFileInfo[], currentPath: string
?? ""; ?? "";
} }
export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick }: AgentDetailViewProps) { export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick, inline = false }: AgentDetailViewProps) {
const [agent, setAgent] = useState<AgentDetail | null>(null); const [agent, setAgent] = useState<AgentDetail | null>(null);
const { confirm } = useConfirm(); const { confirm } = useConfirm();
const [logs, setLogs] = useState<AgentLogEntry[]>([]); const [logs, setLogs] = useState<AgentLogEntry[]>([]);
@@ -130,7 +131,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
const logContainerRef = useRef<HTMLDivElement>(null); const logContainerRef = useRef<HTMLDivElement>(null);
const agentDetailModalRef = useRef<HTMLDivElement>(null); const agentDetailModalRef = useRef<HTMLDivElement>(null);
const overlayMouseDownRef = useRef(false); const overlayMouseDownRef = useRef(false);
useModalResizePersist(agentDetailModalRef, true, "fusion:agent-detail-modal-size"); useModalResizePersist(agentDetailModalRef, !inline, "fusion:agent-detail-modal-size");
const onCloseRef = useRef(onClose); const onCloseRef = useRef(onClose);
const addToastRef = useRef(addToast); const addToastRef = useRef(addToast);
const agentRef = useRef<AgentDetail | null>(null); const agentRef = useRef<AgentDetail | null>(null);
@@ -426,7 +427,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
return { return {
label: "Unknown", label: "Unknown",
icon: <Bot size={14} />, icon: <Bot size={14} />,
color: "var(--text-muted, #8b949e)", color: "var(--text-muted)",
stateDerived: false, stateDerived: false,
}; };
} }
@@ -442,6 +443,17 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
}; };
if (isLoading) { 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 ( return (
<div <div
className="agent-detail-overlay" className="agent-detail-overlay"
@@ -469,10 +481,17 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
const stateStyle = STATE_COLORS[agent.state]; const stateStyle = STATE_COLORS[agent.state];
const health = getHealthStatus(); const health = getHealthStatus();
const detailShellClassName = inline ? "agent-detail-inline" : "agent-detail-modal";
return ( return (
<div className="agent-detail-overlay" onClick={(e) => e.target === e.currentTarget && onClose()} role="dialog" aria-modal="true"> <div
<div className="agent-detail-modal"> 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 */} {/* Header */}
<div className="agent-detail-header"> <div className="agent-detail-header">
{/* Identity area: icon + name + badges */} {/* Identity area: icon + name + badges */}
@@ -567,9 +586,11 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
<button className="btn-icon" onClick={() => void loadAgent()} title="Refresh"> <button className="btn-icon" onClick={() => void loadAgent()} title="Refresh">
<RefreshCw size={16} /> <RefreshCw size={16} />
</button> </button>
<button className="btn-icon" onClick={onClose} aria-label="Close" title="Close"> {!inline && (
<X size={20} /> <button className="btn-icon" onClick={onClose} aria-label="Close" title="Close">
</button> <X size={20} />
</button>
)}
</div> </div>
</div> </div>
@@ -682,24 +703,26 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
</div> </div>
{/* Footer with agent ID */} {/* Footer with agent ID */}
<div className="agent-detail-footer"> {!inline && (
<button className="btn-icon" onClick={copyAgentId} title="Copy Agent ID"> <div className="agent-detail-footer">
<Copy /> <button className="btn-icon" onClick={copyAgentId} title="Copy Agent ID">
</button> <Copy />
<span className="agent-detail-id" onClick={copyAgentId}> </button>
{agent.id} <span className="agent-detail-id" onClick={copyAgentId}>
</span> {agent.id}
{agent.taskId && ( </span>
<> {agent.taskId && (
<span className="divider">|</span> <>
<span className="text-muted">Working on:</span> <span className="divider">|</span>
<a href={`/tasks/${agent.taskId}`} className="link"> <span className="text-muted">Working on:</span>
{agent.taskId} <a href={`/tasks/${agent.taskId}`} className="link">
<ExternalLink size={12} /> {agent.taskId}
</a> <ExternalLink size={12} />
</> </a>
)} </>
</div> )}
</div>
)}
</div> </div>
</div> </div>
); );
@@ -808,138 +831,113 @@ function DashboardTab({
}; };
}, [agent]); }, [agent]);
const recentRuns = (agent.completedRuns || []).slice(0, 5);
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 ( return (
<div className="dashboard-tab"> <div className="dashboard-tab dashboard-summary-layout">
{/* Budget Exhausted Warning */}
{budgetStatus?.isOverBudget && ( {budgetStatus?.isOverBudget && (
<div className="budget-warning-banner" role="alert"> <div className="budget-warning-banner" role="alert">
<span>⚠️</span> <span>⚠️</span>
<span><strong>Budget Exhausted:</strong> This agent has exceeded its token budget and may be operating with limited functionality.</span> <span><strong>Budget Exhausted:</strong> This agent has exceeded its token budget and may operate with limited functionality.</span>
</div> </div>
)} )}
{/* Agent Info Card */} <section className="dashboard-summary-card dashboard-summary-hero">
<div className="dashboard-section"> <div className="dashboard-summary-hero__heading">
<h3>Agent Information</h3> <Bot />
<div className="info-grid"> <h3>Overview</h3>
<div className="info-item"> <strong>{agent.name}</strong>
<span className="info-label">Name</span> <span className="inline-badge" style={{ background: stateStyle.bg, color: stateStyle.text }}>{agent.state}</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">Skills</span>
<span className="info-value">
{Array.isArray(agent.metadata?.skills) && (agent.metadata.skills as string[]).length > 0 ? (
<div className="skill-badge-row">
{(agent.metadata.skills as string[]).map((skillId: string) => (
<span key={skillId} className="skill-badge">{skillId}</span>
))}
</div>
) : (
"—"
)}
</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 }} title={health.label}>
{!health.stateDerived ? health.label : health.icon}
</span>
</div>
{modelDisplay && (
<div className="info-item">
<span className="info-label">{runtimeHint ? "Runtime" : "Model"}</span>
<span className="info-value">{modelDisplay}</span>
</div>
)}
{budgetStatus?.budgetLimit != null && (
<div className="info-item">
<span className="info-label">Budget</span>
<span className="info-value">
<span
className="budget-badge"
style={{
background: budgetStatus.isOverBudget
? "var(--state-error-bg, rgba(248,81,73,0.15))"
: budgetStatus.isOverThreshold
? "var(--state-paused-bg, rgba(227,181,65,0.15))"
: "var(--state-active-bg, rgba(63,185,80,0.15))",
color: budgetStatus.isOverBudget
? "var(--state-error-text, #f85149)"
: budgetStatus.isOverThreshold
? "var(--state-paused-text, #e3b541)"
: "var(--state-active-text, #3fb950)",
border: `1px solid ${budgetStatus.isOverBudget ? "var(--state-error-border, #f85149)" : budgetStatus.isOverThreshold ? "var(--state-paused-border, #e3b541)" : "var(--state-active-border, #3fb950)"}`,
}}
>
{budgetStatus.isOverBudget
? "⚠ Budget Exhausted"
: `${Math.round(budgetStatus.usagePercent ?? 0)}% used`}
</span>
</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>
{(() => {
// Next heartbeat is only meaningful while the agent is in a ticking
// state — paused/terminated/error agents have no scheduled next tick.
const isTicking = agent.state === "active" || agent.state === "running";
if (!isTicking || !agent.lastHeartbeatAt) return null;
const intervalMs = resolveHeartbeatIntervalMs(
agent.runtimeConfig?.heartbeatIntervalMs,
);
const nextAt = new Date(
new Date(agent.lastHeartbeatAt).getTime() + intervalMs,
);
return (
<div className="info-item">
<span className="info-label">Next Heartbeat</span>
<span className="info-value" title={nextAt.toLocaleString()}>
{relativeTime(nextAt.toISOString())}
</span>
</div>
);
})()}
</div> </div>
</div> <div className="dashboard-summary-hero__meta">
<span className="dashboard-summary-hero__health" title={health.label}>{health.icon} {health.label}</span>
<span>Role: {agent.role}</span>
<span>
<span className="dashboard-summary-label">{runtimeHint ? "Runtime" : "Model"}</span>
<span> {modelDisplay ?? "Auto"}</span>
</span>
{Array.isArray(agent.metadata?.skills) && (agent.metadata.skills as string[]).length > 0 ? (
<span>Skills: {(agent.metadata.skills as string[]).join(", ")}</span>
) : (
<span>Skills: —</span>
)}
</div>
</section>
<div className="dashboard-section"> <section className="dashboard-summary-card">
<h3> <h3>Heartbeat &amp; Health</h3>
<GitBranch size={16} style={{ marginRight: "6px", verticalAlign: "-2px" }} /> <div className="dashboard-summary-grid">
Chain of Command <div>
</h3> <p className="dashboard-summary-label">Last heartbeat</p>
{isLoadingChainOfCommand ? ( <p>{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "Never"}</p>
<div className="chain-of-command-loading" role="status" aria-live="polite">
<Loader2 size={14} className="animate-spin" />
<span>Loading reporting chain...</span>
</div> </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}</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.completedAt || 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 ? ( ) : chainOfCommand.length <= 1 ? (
<p className="text-muted">No reporting chain</p> <p className="text-muted">No reporting chain</p>
) : ( ) : (
@@ -949,66 +947,16 @@ function DashboardTab({
const isAncestor = !isCurrent; const isAncestor = !isCurrent;
return ( return (
<div key={chainAgent.id} className="chain-of-command-item"> <div key={chainAgent.id} className="chain-of-command-item">
<button <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}`}>
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} {chainAgent.name}
</button> </button>
{!isCurrent && ( {!isCurrent && <span className="chain-of-command-separator" aria-hidden="true">→</span>}
<span className="chain-of-command-separator" aria-hidden="true">→</span>
)}
</div> </div>
); );
})} })}
</div> </div>
)} )}
</div> </section>
{/* 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> </div>
); );
} }
@@ -1047,7 +995,7 @@ function LogsTab({
<div className="logs-header"> <div className="logs-header">
<span className="logs-count">{logs.length} entries</span> <span className="logs-count">{logs.length} entries</span>
{fallbackLabel && ( {fallbackLabel && (
<span className="text-muted" style={{ fontSize: "12px" }}>{fallbackLabel}</span> <span className="text-muted logs-fallback-label">{fallbackLabel}</span>
)} )}
{isStreaming && ( {isStreaming && (
<span className="streaming-indicator"> <span className="streaming-indicator">
@@ -1109,7 +1057,7 @@ function LogEntry({ entry, showTimestamp }: { entry: AgentLogEntry; showTimestam
}; };
default: default:
return { return {
color: "var(--text-primary)", color: "var(--text)",
}; };
} }
}; };
@@ -1282,7 +1230,7 @@ function RunsTab({
if (isLoadingRuns && runs.length === 0) { if (isLoadingRuns && runs.length === 0) {
return ( return (
<div className="runs-tab"> <div className="runs-tab">
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "24px", justifyContent: "center" }}> <div className="runs-loading-row">
<Loader2 size={16} className="animate-spin" /> <Loader2 size={16} className="animate-spin" />
<span className="text-muted">Loading runs...</span> <span className="text-muted">Loading runs...</span>
</div> </div>
@@ -1294,7 +1242,7 @@ function RunsTab({
return ( return (
<div className="runs-tab"> <div className="runs-tab">
{canRunHeartbeat && ( {canRunHeartbeat && (
<div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border-color)" }}> <div className="runs-toolbar">
<button <button
className="btn btn--sm btn-task-create" className="btn btn--sm btn-task-create"
onClick={() => void handleRunHeartbeat()} onClick={() => void handleRunHeartbeat()}
@@ -1323,7 +1271,7 @@ function RunsTab({
const renderUsage = (usage: { inputTokens: number; outputTokens: number; cachedTokens: number } | undefined) => { const renderUsage = (usage: { inputTokens: number; outputTokens: number; cachedTokens: number } | undefined) => {
if (!usage) return null; if (!usage) return null;
return ( return (
<div style={{ fontSize: "12px", color: "var(--text-secondary)", display: "flex", gap: "12px", flexWrap: "wrap" }}> <div className="run-usage">
<span>Input: {usage.inputTokens.toLocaleString()}</span> <span>Input: {usage.inputTokens.toLocaleString()}</span>
<span>Output: {usage.outputTokens.toLocaleString()}</span> <span>Output: {usage.outputTokens.toLocaleString()}</span>
{usage.cachedTokens > 0 && <span>Cached: {usage.cachedTokens.toLocaleString()}</span>} {usage.cachedTokens > 0 && <span>Cached: {usage.cachedTokens.toLocaleString()}</span>}
@@ -1342,9 +1290,8 @@ function RunsTab({
return ( return (
<div key={run.id}> <div key={run.id}>
<div <div
className={cn("run-card", isActive && "run-card--active", isSelected && "run-card--selected")} className={cn("run-card", isActive && "run-card--active", isSelected && "run-card--selected", "run-card--clickable")}
onClick={() => void handleRunClick(run.id)} onClick={() => void handleRunClick(run.id)}
style={{ cursor: "pointer" }}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-expanded={isSelected} aria-expanded={isSelected}
@@ -1357,7 +1304,7 @@ function RunsTab({
}} }}
> >
<div className="run-header"> <div className="run-header">
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}> <div className="run-header-group">
{isSelected ? <ChevronDown size={14} /> : <ChevronRight size={14} />} {isSelected ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{isActive ? ( {isActive ? (
<span className="run-live-indicator"> <span className="run-live-indicator">
@@ -1368,9 +1315,9 @@ function RunsTab({
<span className="run-id">#{index + 1} {run.id.slice(0, 8)}</span> <span className="run-id">#{index + 1} {run.id.slice(0, 8)}</span>
)} )}
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}> <div className="run-header-group">
{run.invocationSource && ( {run.invocationSource && (
<span className="badge" style={{ fontSize: "10px", padding: "1px 6px" }}> <span className="badge run-badge--compact">
{run.invocationSource} {run.invocationSource}
</span> </span>
)} )}
@@ -1388,11 +1335,11 @@ function RunsTab({
</button> </button>
)} )}
<span className={cn("run-status", run.status)}> <span className={cn("run-status", run.status)}>
<StatusIcon size={14} className={statusInfo.color} style={run.status === "active" ? { color: statusInfo.color } : undefined} /> <StatusIcon size={14} className={statusInfo.color} />
{run.status} {run.status}
</span> </span>
{run.heartbeatProcedureSource === "custom" && ( {run.heartbeatProcedureSource === "custom" && (
<span className="badge" style={{ fontSize: "10px", padding: "1px 6px" }}> <span className="badge run-badge--compact">
Heartbeat: custom Heartbeat: custom
</span> </span>
)} )}
@@ -1423,7 +1370,7 @@ function RunsTab({
{/* System Prompt */} {/* System Prompt */}
<div className="run-output-section"> <div className="run-output-section">
<details> <details>
<summary className="run-output-label" style={{ cursor: "pointer", userSelect: "none" }}>System Prompt</summary> <summary className="run-output-label run-output-summary">System Prompt</summary>
{detailRun.systemPrompt ? ( {detailRun.systemPrompt ? (
<pre className="run-output-panel">{detailRun.systemPrompt}</pre> <pre className="run-output-panel">{detailRun.systemPrompt}</pre>
) : ( ) : (
@@ -1435,7 +1382,7 @@ function RunsTab({
{/* Execution Prompt */} {/* Execution Prompt */}
<div className="run-output-section"> <div className="run-output-section">
<details> <details>
<summary className="run-output-label" style={{ cursor: "pointer", userSelect: "none" }}>Execution Prompt</summary> <summary className="run-output-label run-output-summary">Execution Prompt</summary>
{detailRun.executionPrompt ? ( {detailRun.executionPrompt ? (
<pre className="run-output-panel">{detailRun.executionPrompt}</pre> <pre className="run-output-panel">{detailRun.executionPrompt}</pre>
) : ( ) : (
@@ -1525,12 +1472,12 @@ function RunsTab({
return ( return (
<div className="runs-tab"> <div className="runs-tab">
{canRunHeartbeat && ( {canRunHeartbeat && (
<div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border-color)", display: "flex", justifyContent: "space-between", alignItems: "center" }}> <div className="runs-toolbar runs-toolbar--between">
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}> <span className="runs-toolbar-meta">
{runs.length} run{runs.length !== 1 ? "s" : ""} {runs.length} run{runs.length !== 1 ? "s" : ""}
{hasActiveRun && <span className="run-live-indicator" style={{ marginLeft: "8px" }}><span className="live-dot" />Live</span>} {hasActiveRun && <span className="run-live-indicator run-live-indicator--with-margin"><span className="live-dot" />Live</span>}
</span> </span>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}> <div className="run-header-group">
{hasActiveRun && ( {hasActiveRun && (
<button <button
className="btn btn--sm btn--danger" className="btn btn--sm btn--danger"
@@ -1848,7 +1795,7 @@ function SoulTab({
) : ( ) : (
<textarea <textarea
id="agent-soul" id="agent-soul"
className="input" className="input config-textarea-mono"
rows={12} rows={12}
placeholder="Describe this agent's personality, tone, and behavioral traits..." placeholder="Describe this agent's personality, tone, and behavioral traits..."
value={soul} value={soul}
@@ -1856,7 +1803,6 @@ function SoulTab({
setSoul(e.target.value); setSoul(e.target.value);
setJustSaved(false); setJustSaved(false);
}} }}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
/> />
)} )}
{!showPreview && ( {!showPreview && (
@@ -2066,7 +2012,7 @@ function MemoryTab({
Store context that belongs to this agent only. Workspace memory, daily notes, dreams, and qmd search live in project settings under Project Memory. Store context that belongs to this agent only. Workspace memory, daily notes, dreams, and qmd search live in project settings under Project Memory.
</p> </p>
{isReadOnly && ( {isReadOnly && (
<p className="config-hint" style={{ marginBottom: 12 }}> <p className="config-hint config-hint--block-spacing">
Read-only while this agent is running. Read-only while this agent is running.
</p> </p>
)} )}
@@ -2074,7 +2020,7 @@ function MemoryTab({
<div className="config-fields"> <div className="config-fields">
<div className="config-field"> <div className="config-field">
<label htmlFor="agent-memory">Inline Memory</label> <label htmlFor="agent-memory">Inline Memory</label>
<span className="config-hint" style={{ display: "block", marginBottom: 8 }}> <span className="config-hint config-hint--block">
Short-form memory stored directly on the agent record and injected into prompts. Short-form memory stored directly on the agent record and injected into prompts.
</span> </span>
<div className="agent-content-toolbar"> <div className="agent-content-toolbar">
@@ -2117,7 +2063,7 @@ function MemoryTab({
<textarea <textarea
id="agent-memory" id="agent-memory"
aria-label="Agent Memory" aria-label="Agent Memory"
className="input" className="input config-textarea-mono"
rows={10} rows={10}
placeholder="Durable preferences, operating habits, and context this agent should carry across tasks..." placeholder="Durable preferences, operating habits, and context this agent should carry across tasks..."
value={memory} value={memory}
@@ -2126,7 +2072,6 @@ function MemoryTab({
setMemory(e.target.value); setMemory(e.target.value);
setJustSaved(false); setJustSaved(false);
}} }}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
/> />
)} )}
{!showPreview && ( {!showPreview && (
@@ -2136,7 +2081,7 @@ function MemoryTab({
<div className="config-field"> <div className="config-field">
<label htmlFor="agent-memory-file-select">Memory Files</label> <label htmlFor="agent-memory-file-select">Memory Files</label>
<span className="config-hint" style={{ display: "block", marginBottom: 8 }}> <span className="config-hint config-hint--block">
Full OpenClaw memory files at <code>.fusion/agent-memory/{agent.id}/</code> (MEMORY.md, DREAMS.md, and daily notes). Full OpenClaw memory files at <code>.fusion/agent-memory/{agent.id}/</code> (MEMORY.md, DREAMS.md, and daily notes).
</span> </span>
@@ -2161,14 +2106,14 @@ function MemoryTab({
</select> </select>
{memoryFilesLoading && ( {memoryFilesLoading && (
<span className="config-hint" style={{ display: "inline-flex", gap: 6, marginTop: 8 }}> <span className="config-hint config-hint--inline-loader">
<Loader2 size={14} className="animate-spin" /> <Loader2 size={14} className="animate-spin" />
Loading memory files… Loading memory files…
</span> </span>
)} )}
{selectedMemoryFile && ( {selectedMemoryFile && (
<div className="config-hint" style={{ marginTop: 8 }}> <div className="config-hint config-hint--top-spacing">
<strong>{MEMORY_LAYER_NAMES[selectedMemoryFile.layer]}</strong> · {selectedLayerDescription} <strong>{MEMORY_LAYER_NAMES[selectedMemoryFile.layer]}</strong> · {selectedLayerDescription}
<br /> <br />
{selectedMemoryFile.size.toLocaleString()} bytes · Updated {relativeTime(selectedMemoryFile.updatedAt)} {selectedMemoryFile.size.toLocaleString()} bytes · Updated {relativeTime(selectedMemoryFile.updatedAt)}
@@ -2176,7 +2121,7 @@ function MemoryTab({
)} )}
<textarea <textarea
className="input" className="input config-textarea-mono config-textarea-top-spacing"
rows={14} rows={14}
placeholder="Select a memory file to view and edit its content..." placeholder="Select a memory file to view and edit its content..."
value={selectedFileContent} value={selectedFileContent}
@@ -2187,18 +2132,17 @@ function MemoryTab({
setSelectedFileJustSaved(false); setSelectedFileJustSaved(false);
setFileSwitchHint(""); setFileSwitchHint("");
}} }}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical", marginTop: 8 }}
/> />
{selectedFileLoading && ( {selectedFileLoading && (
<span className="config-hint" style={{ display: "inline-flex", gap: 6, marginTop: 8 }}> <span className="config-hint config-hint--inline-loader">
<Loader2 size={14} className="animate-spin" /> <Loader2 size={14} className="animate-spin" />
Loading file content… Loading file content…
</span> </span>
)} )}
{fileSwitchHint && ( {fileSwitchHint && (
<span className="config-hint" style={{ display: "block", marginTop: 8 }}> <span className="config-hint config-hint--top-spacing config-hint--block">
{fileSwitchHint} {fileSwitchHint}
</span> </span>
)} )}
@@ -2457,7 +2401,6 @@ function InstructionsTab({
setInstructionsText(e.target.value); setInstructionsText(e.target.value);
setJustSaved(false); setJustSaved(false);
}} }}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
/> />
)} )}
{!showPreview && ( {!showPreview && (
@@ -2520,23 +2463,23 @@ function InstructionsTab({
<div className="config-fields"> <div className="config-fields">
<div className="config-field"> <div className="config-field">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "8px" }}> <div className="config-inline-header">
<label htmlFor="instructions-file-content">File Content</label> <label htmlFor="instructions-file-content">File Content</label>
{isLoadingFile && ( {isLoadingFile && (
<span className="config-hint" style={{ display: "flex", alignItems: "center", gap: "4px" }}> <span className="config-hint config-hint--inline-tight">
<Loader2 size={12} className="animate-spin" /> <Loader2 size={12} className="animate-spin" />
Loading... Loading...
</span> </span>
)} )}
{fileContentDirty && !isLoadingFile && ( {fileContentDirty && !isLoadingFile && (
<span className="config-hint" style={{ color: "var(--color-warning, #e3b541)" }}> <span className="config-hint config-hint--warning">
Unsaved changes Unsaved changes
</span> </span>
)} )}
</div> </div>
<textarea <textarea
id="instructions-file-content" id="instructions-file-content"
className="input" className="input config-textarea-mono"
rows={20} rows={20}
placeholder="File content will appear here when loaded..." placeholder="File content will appear here when loaded..."
value={fileContent} value={fileContent}
@@ -2546,7 +2489,6 @@ function InstructionsTab({
setFileContentDirty(true); setFileContentDirty(true);
setJustSavedFile(false); setJustSavedFile(false);
}} }}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
/> />
<span className="config-hint">Edit the markdown file content directly. Save separately using the button below.</span> <span className="config-hint">Edit the markdown file content directly. Save separately using the button below.</span>
</div> </div>
@@ -4073,9 +4015,9 @@ function EmployeesTab({
<div className="detail-section-header"> <div className="detail-section-header">
<h3>Employees</h3> <h3>Employees</h3>
</div> </div>
<div className="detail-section-body" style={{ display: "flex", alignItems: "center", gap: 8, padding: 16 }}> <div className="detail-section-body detail-section-body--loading">
<Loader2 size={16} className="spin" /> <Loader2 size={16} className="spin" />
<span className="text-secondary">Loading employees...</span> <span className="text-muted">Loading employees...</span>
</div> </div>
</div> </div>
); );
@@ -4085,14 +4027,14 @@ function EmployeesTab({
<div className="detail-section"> <div className="detail-section">
<div className="detail-section-header"> <div className="detail-section-header">
<h3>Employees</h3> <h3>Employees</h3>
<span className="text-secondary">({children.length})</span> <span className="text-muted">({children.length})</span>
</div> </div>
<div className="detail-section-body"> <div className="detail-section-body">
{children.length === 0 ? ( {children.length === 0 ? (
<div className="agent-empty" style={{ padding: 24 }}> <div className="agent-empty agent-empty--padded">
<GitBranch size={32} opacity={0.3} /> <GitBranch size={32} opacity={0.3} />
<p>No employees</p> <p>No employees</p>
<p className="text-secondary">This agent has no employees</p> <p className="text-muted">This agent has no employees</p>
</div> </div>
) : ( ) : (
<div className="agent-tree__children"> <div className="agent-tree__children">
@@ -4105,7 +4047,14 @@ function EmployeesTab({
onClick={() => onChildClick?.(child.id)} onClick={() => onChildClick?.(child.id)}
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && onChildClick?.(child.id)} onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (e.key === " ") {
e.preventDefault();
}
onChildClick?.(child.id);
}
}}
style={{ cursor: onChildClick ? "pointer" : "default" }} style={{ cursor: onChildClick ? "pointer" : "default" }}
> >
<span className="agent-tree__icon">{child.icon ?? "🤖"}</span> <span className="agent-tree__icon">{child.icon ?? "🤖"}</span>

View File

@@ -34,7 +34,7 @@
} }
.agent-metric-value { .agent-metric-value {
font-size: 18px; font-size: calc(var(--space-lg) + var(--space-xs) * 0.5);
font-weight: 700; font-weight: 700;
font-family: var(--font-mono); font-family: var(--font-mono);
line-height: 1.2; line-height: 1.2;
@@ -60,7 +60,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: var(--space-lg) 20px; padding: var(--space-lg);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--surface); background: var(--surface);
} }
@@ -72,7 +72,7 @@
} }
.agents-view-title h2 { .agents-view-title h2 {
font-size: 18px; font-size: calc(var(--space-lg) + var(--space-xs) * 0.5);
font-weight: 600; font-weight: 600;
margin: 0; margin: 0;
} }
@@ -133,10 +133,88 @@
padding: var(--space-md); padding: var(--space-md);
} }
.agents-split-layout {
display: grid;
grid-template-columns: minmax(calc(var(--space-xl) * 11 + var(--space-xs)), calc(var(--space-xl) * 13 + var(--space-lg))) minmax(0, 1fr);
gap: 0;
height: 100%;
min-height: 0;
}
.agents-split-sidebar {
min-height: 0;
overflow: hidden;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.agents-split-detail {
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--surface);
}
.agents-view-content { .agents-view-content {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: 20px; padding: calc(var(--space-lg) + var(--space-xs));
}
.agents-detail-empty-state {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-md);
color: var(--text-muted);
text-align: center;
padding: var(--space-xl);
}
.agents-detail-empty-state h3,
.agents-detail-empty-state p {
margin: 0;
}
.agents-detail-empty-state svg {
opacity: 0.3;
}
.agents-sidebar-quick-controls {
border-top: 1px solid var(--border);
padding: var(--space-md);
background: var(--surface);
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.agents-sidebar-quick-controls__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.agents-sidebar-quick-controls__meta {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-size: calc(var(--space-sm) + var(--space-xs));
color: var(--text-muted);
}
.agents-sidebar-quick-controls__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
} }
.agents-view-loading { .agents-view-loading {
@@ -195,15 +273,15 @@
background: transparent; background: transparent;
border: none; border: none;
color: var(--text); color: var(--text);
font-size: 13px; font-size: calc(var(--space-sm) + var(--space-xs) + var(--space-xs) * 0.25);
font-family: var(--font-primary); font-family: var(--font-primary);
cursor: pointer; cursor: pointer;
outline: none; outline: none;
padding-right: 4px; padding-right: var(--space-xs);
} }
.agent-system-filter { .agent-system-filter {
font-size: 13px; font-size: calc(var(--space-sm) + var(--space-xs) + var(--space-xs) * 0.25);
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
@@ -220,8 +298,8 @@
} }
.agent-system-filter input[type="checkbox"] { .agent-system-filter input[type="checkbox"] {
width: 16px; width: calc(var(--space-sm) + var(--space-xs) * 2);
height: 16px; height: calc(var(--space-sm) + var(--space-xs) * 2);
accent-color: var(--todo); accent-color: var(--todo);
cursor: pointer; cursor: pointer;
} }
@@ -247,13 +325,13 @@
} }
.agent-tree__icon { .agent-tree__icon {
font-size: 16px; font-size: calc(var(--space-md) + var(--space-xs));
line-height: 1; line-height: 1;
flex-shrink: 0; flex-shrink: 0;
} }
.agent-tree__name { .agent-tree__name {
font-size: 13px; font-size: calc(var(--space-sm) + var(--space-xs) + var(--space-xs) * 0.25);
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -261,10 +339,10 @@
} }
.agent-tree__badge { .agent-tree__badge {
font-size: 10px; font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
padding: 2px 6px; padding: calc(var(--space-xs) * 0.5) calc(var(--space-xs) + var(--space-sm) * 0.25);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -296,7 +374,7 @@
.agent-board { .agent-board {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(calc(var(--space-xl) * 8 + var(--space-sm)), 1fr));
gap: var(--space-lg); gap: var(--space-lg);
} }
@@ -329,15 +407,15 @@
} }
.agent-board-icon { .agent-board-icon {
font-size: 20px; font-size: calc(var(--space-lg) + var(--space-xs));
line-height: 1; line-height: 1;
} }
.agent-board-badge { .agent-board-badge {
font-size: 10px; font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
padding: 2px 6px; padding: calc(var(--space-xs) * 0.5) calc(var(--space-sm) - var(--space-xs) * 0.25);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
margin-left: auto; margin-left: auto;
} }
@@ -349,14 +427,14 @@
.agent-board-name { .agent-board-name {
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.agent-board-id { .agent-board-id {
font-size: 11px; font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
font-family: var(--font-mono); font-family: var(--font-mono);
color: var(--text-muted); color: var(--text-muted);
} }
@@ -369,6 +447,17 @@
color: var(--todo); color: var(--todo);
} }
.agent-board-clickable:focus-visible {
outline: none;
border-radius: var(--radius-sm);
box-shadow: var(--focus-ring-strong);
background: color-mix(in srgb, var(--todo) 8%, transparent);
}
.agent-board-clickable:focus-visible .agent-board-name {
color: var(--todo);
}
.agent-board-actions { .agent-board-actions {
display: flex; display: flex;
gap: var(--space-xs); gap: var(--space-xs);
@@ -396,6 +485,11 @@
background: var(--surface); background: var(--surface);
} }
.agent-card--selected {
border-left-color: var(--todo) !important;
background: color-mix(in srgb, var(--todo) 8%, transparent);
}
.agent-card-header { .agent-card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -415,8 +509,8 @@
align-items: center; align-items: center;
gap: var(--space-md); gap: var(--space-md);
cursor: pointer; cursor: pointer;
padding: 4px; padding: var(--space-xs);
margin: -4px; margin: calc(var(--space-xs) * -1);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
transition: background var(--transition-fast); transition: background var(--transition-fast);
} }
@@ -425,12 +519,19 @@
background: var(--card-hover); background: var(--card-hover);
} }
.agent-info--clickable:hover .agent-name { .agent-info--clickable:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
background: color-mix(in srgb, var(--todo) 8%, transparent);
}
.agent-info--clickable:hover .agent-name,
.agent-info--clickable:focus-visible .agent-name {
color: var(--todo); color: var(--todo);
} }
.agent-icon { .agent-icon {
font-size: 24px; font-size: calc(var(--space-xl) + var(--space-xs));
} }
.agent-icon--clickable { .agent-icon--clickable {
@@ -444,16 +545,16 @@
transform: scale(1.1); transform: scale(1.1);
} }
.agent-icon--clickable:focus { .agent-icon--clickable:focus-visible {
outline: 2px solid var(--todo); outline: none;
outline-offset: 2px; box-shadow: var(--focus-ring-strong);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.agent-role-select { .agent-role-select {
font-size: 14px; font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
padding: 4px var(--space-sm); padding: var(--space-xs) var(--space-sm);
min-width: 120px; min-width: calc(var(--space-2xl) * 3 + var(--space-lg));
width: auto; width: auto;
cursor: pointer; cursor: pointer;
} }
@@ -465,11 +566,11 @@
.agent-name { .agent-name {
font-weight: 600; font-weight: 600;
font-size: 16px; font-size: var(--space-lg);
} }
.agent-id { .agent-id {
font-size: 12px; font-size: var(--space-md);
font-family: var(--font-mono); font-family: var(--font-mono);
} }
@@ -493,12 +594,12 @@
.agent-card-body { .agent-card-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: var(--space-xs);
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
padding: var(--space-sm); padding: var(--space-sm);
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 13px; font-size: calc(var(--space-md) + var(--space-xs) * 0.25);
} }
.agent-task, .agent-task,
@@ -666,12 +767,16 @@
.org-chart-node-card:hover, .org-chart-node-card:hover,
.org-chart-node-card:focus-visible { .org-chart-node-card:focus-visible {
border-color: var(--accent); border-color: var(--todo);
background: var(--card-hover); background: var(--card-hover);
transform: translateY(calc(var(--space-xs) * -0.25)); transform: translateY(calc(var(--space-xs) * -0.25));
outline: none; outline: none;
} }
.org-chart-node-card:focus-visible {
box-shadow: var(--focus-ring-strong);
}
.org-chart-node-card--active, .org-chart-node-card--active,
.org-chart-node-card--running { .org-chart-node-card--running {
background: var(--state-active-bg); background: var(--state-active-bg);
@@ -788,6 +893,13 @@
color: var(--text); color: var(--text);
} }
.chain-of-command-node:focus-visible {
outline: none;
border-color: var(--todo);
box-shadow: var(--focus-ring-strong);
color: var(--text);
}
.chain-of-command-node:disabled { .chain-of-command-node:disabled {
cursor: default; cursor: default;
opacity: 0.9; opacity: 0.9;
@@ -820,7 +932,7 @@
} }
.agent-metric-value { .agent-metric-value {
font-size: 16px; font-size: var(--space-lg);
} }
.agent-metric-label { .agent-metric-label {
@@ -876,7 +988,7 @@
} }
.agents-view-title h2 { .agents-view-title h2 {
font-size: 16px; font-size: var(--space-lg);
white-space: nowrap; white-space: nowrap;
} }
@@ -914,9 +1026,10 @@
/* Controls button: icon-only on mobile, sized to match view-toggle buttons */ /* Controls button: icon-only on mobile, sized to match view-toggle buttons */
.agent-controls-trigger { .agent-controls-trigger {
padding: 0; padding: 0;
width: 28px; width: calc(var(--space-lg) * 2 + var(--space-xs));
height: 28px; height: calc(var(--space-lg) * 2 + var(--space-xs));
min-width: 28px; min-width: calc(var(--space-lg) * 2 + var(--space-xs));
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
font-size: 0; font-size: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -926,9 +1039,10 @@
/* New Agent: icon-only on mobile, sized to match view-toggle buttons. /* New Agent: icon-only on mobile, sized to match view-toggle buttons.
Text is visually hidden via font-size: 0 (the SVG icon keeps its size). */ Text is visually hidden via font-size: 0 (the SVG icon keeps its size). */
.agents-view-primary-actions .btn-task-create { .agents-view-primary-actions .btn-task-create {
width: 28px; width: calc(var(--space-lg) * 2 + var(--space-xs));
height: 28px; height: calc(var(--space-lg) * 2 + var(--space-xs));
min-width: 28px; min-width: calc(var(--space-lg) * 2 + var(--space-xs));
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
padding: 0; padding: 0;
font-size: 0; font-size: 0;
gap: 0; gap: 0;
@@ -939,16 +1053,59 @@
} }
.agents-view-primary-actions .btn-icon { .agents-view-primary-actions .btn-icon {
width: 28px; width: calc(var(--space-lg) * 2 + var(--space-xs));
height: 28px; height: calc(var(--space-lg) * 2 + var(--space-xs));
min-width: 28px; min-width: calc(var(--space-lg) * 2 + var(--space-xs));
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
} }
/* Title row: keep Bot icon + "Agents" text on a single line so the /* Title row: keep Bot icon + "Agents" text on a single line so the
view-toggle and primary actions get pushed to the right edge. */ view-toggle and primary actions get pushed to the right edge. */
.agents-view-title { .agents-view-title {
height: 32px; height: calc(var(--space-lg) * 2);
align-items: center; align-items: center;
} }
.agents-split-layout {
display: block;
height: auto;
}
.agents-split-sidebar {
border-right: 0;
width: 100%;
}
.agents-split-sidebar--hidden-mobile {
display: none;
}
.agents-split-detail {
width: 100%;
height: 100%;
}
.agents-split-detail--hidden-mobile {
display: none;
}
.agents-sidebar-quick-controls {
display: none;
}
.agents-mobile-back-row {
padding: var(--space-md);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.agents-mobile-back-btn {
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
}
}
@media (min-width: 769px) and (max-width: 1024px) {
.agents-split-layout {
grid-template-columns: minmax(calc(var(--space-xl) * 10), calc(var(--space-xl) * 11 + var(--space-md))) minmax(0, 1fr);
}
} }

View File

@@ -1,6 +1,6 @@
import "./AgentsView.css"; import "./AgentsView.css";
import { useState, useEffect, useCallback, useRef, useMemo, useId, lazy, Suspense } from "react"; import { useState, useEffect, useCallback, useRef, useMemo, useId, lazy, Suspense } from "react";
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, Filter, Upload, Network, SlidersHorizontal } from "lucide-react"; import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, ChevronLeft, Filter, Upload, Network, SlidersHorizontal } from "lucide-react";
import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api"; import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api";
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api"; import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
@@ -15,6 +15,7 @@ import { NewAgentDialog } from "./NewAgentDialog";
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal"; import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
import { AgentImportModal } from "./AgentImportModal"; import { AgentImportModal } from "./AgentImportModal";
import { getScopedItem, setScopedItem } from "../utils/projectStorage"; import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { useViewportMode } from "../hooks/useViewportMode";
import { getAgentHealthStatus } from "../utils/agentHealth"; import { getAgentHealthStatus } from "../utils/agentHealth";
import type { AgentHealthStatus } from "../utils/agentHealth"; import type { AgentHealthStatus } from "../utils/agentHealth";
import { import {
@@ -25,6 +26,7 @@ import {
HEARTBEAT_INTERVAL_PRESETS, HEARTBEAT_INTERVAL_PRESETS,
} from "../utils/heartbeatIntervals"; } from "../utils/heartbeatIntervals";
import { isEphemeralAgent, getErrorMessage } from "@fusion/core"; import { isEphemeralAgent, getErrorMessage } from "@fusion/core";
import { relativeTime } from "./AgentDetailView";
export interface AgentsViewProps { export interface AgentsViewProps {
addToast: (message: string, type?: "success" | "error") => void; addToast: (message: string, type?: "success" | "error") => void;
@@ -91,12 +93,14 @@ function OrgChartNode({
getHealthStatus, getHealthStatus,
getRoleIcon, getRoleIcon,
getSkillBadges, getSkillBadges,
selectedAgentId,
}: { }: {
node: OrgTreeNode; node: OrgTreeNode;
onSelect: (id: string) => void; onSelect: (id: string) => void;
getHealthStatus: (agent: Agent) => AgentHealthStatus; getHealthStatus: (agent: Agent) => AgentHealthStatus;
getRoleIcon: (role: AgentCapability) => string; getRoleIcon: (role: AgentCapability) => string;
getSkillBadges: (agent: Agent) => string[]; getSkillBadges: (agent: Agent) => string[];
selectedAgentId: string | null;
}) { }) {
const { agent, children } = node; const { agent, children } = node;
const health = getHealthStatus(agent); const health = getHealthStatus(agent);
@@ -106,11 +110,18 @@ function OrgChartNode({
return ( return (
<div className={`org-chart-node${children.length > 0 ? " org-chart-node--has-children" : ""}`}> <div className={`org-chart-node${children.length > 0 ? " org-chart-node--has-children" : ""}`}>
<div <div
className={stateNodeClass} className={`${stateNodeClass}${selectedAgentId === agent.id ? " agent-card--selected" : ""}`}
onClick={() => onSelect(agent.id)} onClick={() => onSelect(agent.id)}
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && onSelect(agent.id)} onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (e.key === " ") {
e.preventDefault();
}
onSelect(agent.id);
}
}}
> >
<div className="org-chart-node__header"> <div className="org-chart-node__header">
<span className="org-chart-node__icon">{getRoleIcon(agent.role)}</span> <span className="org-chart-node__icon">{getRoleIcon(agent.role)}</span>
@@ -153,6 +164,7 @@ function OrgChartNode({
getHealthStatus={getHealthStatus} getHealthStatus={getHealthStatus}
getRoleIcon={getRoleIcon} getRoleIcon={getRoleIcon}
getSkillBadges={getSkillBadges} getSkillBadges={getSkillBadges}
selectedAgentId={selectedAgentId}
/> />
))} ))}
</div> </div>
@@ -163,6 +175,8 @@ function OrgChartNode({
export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardingEnabled = false }: AgentsViewProps) { export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardingEnabled = false }: AgentsViewProps) {
const [showSystemAgents, setShowSystemAgents] = useState(false); const [showSystemAgents, setShowSystemAgents] = useState(false);
const viewportMode = useViewportMode();
const isMobileViewport = viewportMode === "mobile";
const [filterState, setFilterState] = useState<AgentState | "all">("all"); const [filterState, setFilterState] = useState<AgentState | "all">("all");
const { agents, stats, isLoading, loadAgents } = useAgents(projectId, { const { agents, stats, isLoading, loadAgents } = useAgents(projectId, {
filterState, filterState,
@@ -173,6 +187,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const [onboardingDraft, setOnboardingDraft] = useState<AgentOnboardingSummary | null>(null); const [onboardingDraft, setOnboardingDraft] = useState<AgentOnboardingSummary | null>(null);
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null); const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const isMobileDetailOpen = isMobileViewport && !!selectedAgentId;
const [agentView, setAgentView] = useState<"list" | "board" | "org">(() => { const [agentView, setAgentView] = useState<"list" | "board" | "org">(() => {
if (typeof window === "undefined") return "list"; if (typeof window === "undefined") return "list";
const saved = getScopedItem("fn-agent-view", projectId); const saved = getScopedItem("fn-agent-view", projectId);
@@ -603,6 +618,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role; const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "◆"; const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "◆";
const selectedAgent = selectedAgentId ? displayAgents.find((agent) => agent.id === selectedAgentId) ?? null : null;
/** Get skill badges from agent metadata */ /** Get skill badges from agent metadata */
const getSkillBadges = (agent: Agent): string[] => { const getSkillBadges = (agent: Agent): string[] => {
@@ -805,8 +821,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
</div> </div>
)} )}
<div className="agents-view-content"> <div className="agents-split-layout">
<div className={`agents-split-sidebar${isMobileDetailOpen ? " agents-split-sidebar--hidden-mobile" : ""}`}>
<div className="agents-view-content">
<AgentMetricsBar stats={stats} /> <AgentMetricsBar stats={stats} />
<ActiveAgentsPanel agents={displayActiveAgents} projectId={projectId} onAgentSelect={setSelectedAgentId} onOpenTaskLogs={onOpenTaskLogs} /> <ActiveAgentsPanel agents={displayActiveAgents} projectId={projectId} onAgentSelect={setSelectedAgentId} onOpenTaskLogs={onOpenTaskLogs} />
@@ -865,6 +882,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
getHealthStatus={getHealthStatus} getHealthStatus={getHealthStatus}
getRoleIcon={getRoleIcon} getRoleIcon={getRoleIcon}
getSkillBadges={getSkillBadges} getSkillBadges={getSkillBadges}
selectedAgentId={selectedAgentId}
/> />
)) ))
)} )}
@@ -879,13 +897,20 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const stateBadgeClass = getStateBadgeClass(agent.state); const stateBadgeClass = getStateBadgeClass(agent.state);
const stateCardClass = getStateCardClass("agent-board-card", agent.state); const stateCardClass = getStateCardClass("agent-board-card", agent.state);
return ( return (
<div key={agent.id} className={`agent-board-card ${stateCardClass}`}> <div key={agent.id} className={`agent-board-card ${stateCardClass}${selectedAgentId === agent.id ? " agent-card--selected" : ""}`}>
<div <div
className="agent-board-clickable" className="agent-board-clickable"
onClick={() => setSelectedAgentId(agent.id)} onClick={() => setSelectedAgentId(agent.id)}
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && setSelectedAgentId(agent.id)} onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (e.key === " ") {
e.preventDefault();
}
setSelectedAgentId(agent.id);
}
}}
> >
<div className="agent-board-header"> <div className="agent-board-header">
<span className="agent-board-icon">{getRoleIcon(agent.role)}</span> <span className="agent-board-icon">{getRoleIcon(agent.role)}</span>
@@ -917,14 +942,21 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const heartbeatOptions = getHeartbeatIntervalOptions(configuredIntervalMs); const heartbeatOptions = getHeartbeatIntervalOptions(configuredIntervalMs);
const isUpdatingHeartbeat = updatingHeartbeatAgentId === agent.id; const isUpdatingHeartbeat = updatingHeartbeatAgentId === agent.id;
return ( return (
<div key={agent.id} className={`agent-card ${stateCardClass}`}> <div key={agent.id} className={`agent-card ${stateCardClass}${selectedAgentId === agent.id ? " agent-card--selected" : ""}`}>
<div className="agent-card-header"> <div className="agent-card-header">
<div <div
className="agent-info agent-info--clickable" className="agent-info agent-info--clickable"
onClick={() => setSelectedAgentId(agent.id)} onClick={() => setSelectedAgentId(agent.id)}
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && setSelectedAgentId(agent.id)} onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (e.key === " ") {
e.preventDefault();
}
setSelectedAgentId(agent.id);
}
}}
> >
{editingRoleForAgent === agent.id ? ( {editingRoleForAgent === agent.id ? (
<select <select
@@ -1214,22 +1246,93 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
)} )}
</div> </div>
)} )}
</div>
{!isMobileViewport && selectedAgent && (
<div className="agents-sidebar-quick-controls">
<div className="agents-sidebar-quick-controls__header">
<strong>{selectedAgent.name}</strong>
<span className={`badge ${getStateBadgeClass(selectedAgent.state)}`}>{selectedAgent.state}</span>
</div>
<div className="agents-sidebar-quick-controls__meta">
<span>{formatHeartbeatInterval(resolveHeartbeatIntervalMs(selectedAgent.runtimeConfig?.heartbeatIntervalMs))}</span>
{selectedAgent.lastHeartbeatAt && <span>Last {relativeTime(selectedAgent.lastHeartbeatAt)}</span>}
</div>
<div className="agents-sidebar-quick-controls__actions">
{selectedAgent.state === "idle" && (
<button className="btn btn-sm" onClick={() => void handleStateChange(selectedAgent.id, "active")}>
<Play size={14} /> Start
</button>
)}
{selectedAgent.state === "active" && (
<>
<button className="btn btn-sm" onClick={() => void handleRunHeartbeat(selectedAgent.id, selectedAgent.name)}>
<Activity size={14} /> Run Now
</button>
<button className="btn btn-sm" onClick={() => void handleStateChange(selectedAgent.id, "paused")}>
<Pause size={14} /> Pause
</button>
</>
)}
{selectedAgent.state === "running" && (
<button className="btn btn-sm" onClick={() => void handleStateChange(selectedAgent.id, "paused")}>
<Pause size={14} /> Pause
</button>
)}
{selectedAgent.state === "paused" && (
<button className="btn btn-sm" onClick={() => void handleStateChange(selectedAgent.id, "active")}>
<Play size={14} /> Resume
</button>
)}
{selectedAgent.state === "error" && (
<button className="btn btn-sm" onClick={() => void handleStateChange(selectedAgent.id, "active")}>
<Play size={14} /> Retry
</button>
)}
{selectedAgent.state === "terminated" && (
<>
<button className="btn btn-sm" onClick={() => void handleStateChange(selectedAgent.id, "active")}>
<Play size={14} /> Start
</button>
<button className="btn btn-danger btn-sm" onClick={() => void handleDelete(selectedAgent.id, selectedAgent.name)}>
<Trash2 size={14} /> Delete
</button>
</>
)}
</div>
</div>
)}
</div>
<div className={`agents-split-detail${isMobileViewport && !selectedAgentId ? " agents-split-detail--hidden-mobile" : ""}`}>
{isMobileDetailOpen && (
<div className="agents-mobile-back-row">
<button className="btn agents-mobile-back-btn" onClick={handleCloseDetail}>
<ChevronLeft size={16} />
Agents
</button>
</div>
)}
{selectedAgentId ? (
<Suspense fallback={null}>
<AgentDetailView
inline
agentId={selectedAgentId}
projectId={projectId}
onClose={handleCloseDetail}
addToast={addToast}
onChildClick={handleChildClick}
/>
</Suspense>
) : (
<div className="agents-detail-empty-state">
<Bot size={48} />
<h3>Select an agent</h3>
<p>Choose an agent from the sidebar to view details</p>
</div>
)}
</div>
</div> </div>
{/* Agent Detail Modal */}
{selectedAgentId && (
<Suspense fallback={null}>
<AgentDetailView
agentId={selectedAgentId}
projectId={projectId}
onClose={handleCloseDetail}
addToast={addToast}
onChildClick={handleChildClick}
/>
</Suspense>
)}
</div> </div>
); );
} }

View File

@@ -244,6 +244,42 @@ describe("AgentDetailView", () => {
expect(screen.getByText(/Loading agent/i)).toBeInTheDocument(); expect(screen.getByText(/Loading agent/i)).toBeInTheDocument();
}); });
it("renders inline mode as a region without overlay or close button", async () => {
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
inline
/>,
);
await waitFor(() => {
expect(screen.getByRole("region", { name: "Agent detail" })).toBeInTheDocument();
});
expect(document.querySelector(".agent-detail-overlay")).toBeNull();
expect(screen.queryByRole("button", { name: "Close" })).toBeNull();
expect(screen.getByRole("heading", { name: "Test Agent" })).toBeInTheDocument();
});
it("keeps modal mode as dialog with close button", async () => {
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
expect(document.querySelector(".agent-detail-overlay")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
});
it("defines CSS variables for agent state tokens in the global stylesheet", async () => { it("defines CSS variables for agent state tokens in the global stylesheet", async () => {
render( render(
<AgentDetailView <AgentDetailView
@@ -484,7 +520,7 @@ describe("AgentDetailView", () => {
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("executor")).toBeInTheDocument(); expect(screen.getByText("Role: executor")).toBeInTheDocument();
}); });
}); });
@@ -525,6 +561,25 @@ describe("AgentDetailView", () => {
}); });
}); });
it("renders redesigned dashboard summary sections", async () => {
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText("Overview")).toBeInTheDocument();
expect(screen.getByText("Heartbeat & Health")).toBeInTheDocument();
expect(screen.getByText("Current Work")).toBeInTheDocument();
expect(screen.getByText("Recent Runs")).toBeInTheDocument();
expect(screen.getByText("Throughput")).toBeInTheDocument();
expect(screen.getByText("Chain of Command")).toBeInTheDocument();
});
});
it("renders Employees tab empty state", async () => { it("renders Employees tab empty state", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
mockFetchAgentChildren.mockResolvedValue([]); mockFetchAgentChildren.mockResolvedValue([]);
@@ -3833,8 +3888,7 @@ describe("AgentDetailView", () => {
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByText("skill-1")).toBeTruthy(); expect(screen.getByText("Skills: skill-1, skill-2")).toBeInTheDocument();
expect(screen.getAllByText("skill-2")).toBeTruthy();
}); });
}); });
@@ -3853,11 +3907,7 @@ describe("AgentDetailView", () => {
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Skills")).toBeInTheDocument(); expect(screen.getByText("Skills: —")).toBeInTheDocument();
// Should show dash when no skills
const skillsLabel = screen.getByText("Skills");
const parent = skillsLabel.closest(".info-item");
expect(parent?.textContent).toContain("—");
}); });
}); });

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { AgentsView } from "../AgentsView"; import { AgentsView } from "../AgentsView";
import * as apiModule from "../../api"; import * as apiModule from "../../api";
import type { Agent, AgentState, AgentCapability, OrgTreeNode } from "../../api"; import type { Agent, AgentState, AgentCapability, OrgTreeNode } from "../../api";
@@ -31,7 +31,16 @@ vi.mock("../../api", async (importOriginal) => {
}); });
vi.mock("../AgentDetailView", () => ({ vi.mock("../AgentDetailView", () => ({
AgentDetailView: ({ agentId }: { agentId: string }) => <div data-testid="agent-detail-view">Agent detail: {agentId}</div>, AgentDetailView: ({ agentId, inline }: { agentId: string; inline?: boolean }) => (
<div data-testid="agent-detail-view" data-inline={inline ? "true" : "false"}>Agent detail: {agentId}</div>
),
relativeTime: () => "just now",
}));
const mockViewportMode = vi.fn<() => "mobile" | "tablet" | "desktop">(() => "desktop");
vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: () => mockViewportMode(),
})); }));
const mockConfirm = vi.fn(); const mockConfirm = vi.fn();
@@ -105,6 +114,7 @@ describe("AgentsView", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockViewportMode.mockReturnValue("desktop");
mockConfirm.mockReset(); mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true); mockConfirm.mockResolvedValue(true);
localStorage.clear(); localStorage.clear();
@@ -152,6 +162,131 @@ describe("AgentsView", () => {
}); });
}); });
it("renders split layout with sidebar and detail pane", async () => {
const { container } = render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(container.querySelector(".agents-split-layout")).toBeTruthy();
});
expect(container.querySelector(".agents-split-sidebar")).toBeTruthy();
expect(container.querySelector(".agents-split-detail")).toBeTruthy();
expect(screen.getByText("Select an agent")).toBeInTheDocument();
expect(screen.getByText("Choose an agent from the sidebar to view details")).toBeInTheDocument();
});
it("opens inline detail pane and marks selected card", async () => {
const { container } = render(<AgentsView addToast={mockAddToast} />);
const detailButton = await screen.findByRole("button", { name: "View details for Test Agent 1" });
fireEvent.click(detailButton);
await waitFor(() => {
expect(screen.getByTestId("agent-detail-view")).toHaveAttribute("data-inline", "true");
});
expect(container.querySelector(".agent-card--selected")).toBeTruthy();
});
it("renders desktop quick controls and run now starts heartbeat", async () => {
render(<AgentsView addToast={mockAddToast} />);
const detailButton = await screen.findByRole("button", { name: "View details for Test Agent 2" });
fireEvent.click(detailButton);
const runNowButtons = await screen.findAllByRole("button", { name: /Run Now/i });
fireEvent.click(runNowButtons[0]);
await waitFor(() => {
expect(mockStartAgentRun).toHaveBeenCalled();
});
});
it.each([
{ state: "idle", expected: ["Start"], unexpected: ["Run Now", "Pause", "Resume", "Retry", "Delete"] },
{ state: "active", expected: ["Run Now", "Pause"], unexpected: ["Resume", "Retry", "Delete"] },
{ state: "paused", expected: ["Resume"], unexpected: ["Run Now", "Pause", "Retry", "Delete"] },
{ state: "running", expected: ["Pause"], unexpected: ["Run Now", "Resume", "Retry", "Delete"] },
{ state: "error", expected: ["Retry"], unexpected: ["Run Now", "Pause", "Resume", "Delete"] },
{ state: "terminated", expected: ["Start", "Delete"], unexpected: ["Run Now", "Pause", "Resume", "Retry"] },
] as const)("shows correct quick-control buttons for $state state", async ({ state, expected, unexpected }) => {
const stateAgent = {
...mockAgents[0],
id: "state-agent",
name: `State ${state}`,
state,
} as Agent;
mockFetchAgents.mockResolvedValueOnce([stateAgent]);
mockFetchAgentStats.mockResolvedValueOnce({ total: 1, byState: {}, byRole: {} });
render(<AgentsView addToast={mockAddToast} />);
const detailButton = await screen.findByRole("button", { name: `View details for State ${state}` });
fireEvent.click(detailButton);
const quickControls = await screen.findByText(`State ${state}`, { selector: ".agents-sidebar-quick-controls strong" });
const quickControlsPanel = quickControls.closest(".agents-sidebar-quick-controls");
expect(quickControlsPanel).toBeTruthy();
for (const label of expected) {
expect(within(quickControlsPanel as HTMLElement).getByRole("button", { name: new RegExp(label, "i") })).toBeTruthy();
}
for (const label of unexpected) {
expect(within(quickControlsPanel as HTMLElement).queryByRole("button", { name: new RegExp(label, "i") })).toBeNull();
}
});
it("quick control start button triggers state update", async () => {
const idleAgent = { ...mockAgents[0], id: "idle-agent", name: "Idle Agent", state: "idle" as AgentState };
mockFetchAgents.mockResolvedValueOnce([idleAgent]);
mockFetchAgentStats.mockResolvedValueOnce({ total: 1, byState: {}, byRole: {} });
render(<AgentsView addToast={mockAddToast} />);
fireEvent.click(await screen.findByRole("button", { name: "View details for Idle Agent" }));
const quickControlsPanel = await screen.findByText("Idle Agent", { selector: ".agents-sidebar-quick-controls strong" });
fireEvent.click(within(quickControlsPanel.closest(".agents-sidebar-quick-controls") as HTMLElement).getByRole("button", { name: /Start/i }));
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("idle-agent", "active", undefined);
});
});
it("quick control delete button deletes terminated agent", async () => {
const terminatedAgent = { ...mockAgents[0], id: "terminated-agent", name: "Terminated Agent", state: "terminated" as AgentState };
mockFetchAgents.mockResolvedValueOnce([terminatedAgent]);
mockFetchAgentStats.mockResolvedValueOnce({ total: 1, byState: {}, byRole: {} });
render(<AgentsView addToast={mockAddToast} />);
fireEvent.click(await screen.findByRole("button", { name: "View details for Terminated Agent" }));
const quickControlsPanel = await screen.findByText("Terminated Agent", { selector: ".agents-sidebar-quick-controls strong" });
fireEvent.click(within(quickControlsPanel.closest(".agents-sidebar-quick-controls") as HTMLElement).getByRole("button", { name: /Delete/i }));
await waitFor(() => {
expect(mockDeleteAgent).toHaveBeenCalledWith("terminated-agent", undefined);
});
});
it("supports mobile drill-in detail with back navigation", async () => {
mockViewportMode.mockReturnValue("mobile");
const { container } = render(<AgentsView addToast={mockAddToast} />);
expect(container.querySelector(".agents-split-sidebar")).toBeTruthy();
expect(container.querySelector(".agents-split-detail--hidden-mobile")).toBeTruthy();
fireEvent.click(await screen.findByRole("button", { name: "View details for Test Agent 1" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Agents" })).toBeTruthy();
});
expect(container.querySelector(".agents-split-sidebar--hidden-mobile")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Agents" }));
await waitFor(() => {
expect(screen.getByText("Select an agent")).toBeInTheDocument();
});
});
it("shows a loading indicator while the initial agents fetch is pending", async () => { it("shows a loading indicator while the initial agents fetch is pending", async () => {
let resolveAgents: ((value: Agent[]) => void) | undefined; let resolveAgents: ((value: Agent[]) => void) | undefined;
mockFetchAgents.mockImplementationOnce( mockFetchAgents.mockImplementationOnce(

View File

@@ -213,10 +213,10 @@ describe("agents-view mobile CSS", () => {
expect(block).toMatch(/padding:\s*var\(--space-sm\)\s+var\(--space-md\)/); expect(block).toMatch(/padding:\s*var\(--space-sm\)\s+var\(--space-md\)/);
}); });
it("defines .agents-view-title h2 with 16px font on mobile", () => { it("defines .agents-view-title h2 with token font size on mobile", () => {
expect(mobileMediaBlock).toContain(".agents-view-title h2"); expect(mobileMediaBlock).toContain(".agents-view-title h2");
const block = extractRuleBlock(mobileMediaBlock, ".agents-view-title h2"); const block = extractRuleBlock(mobileMediaBlock, ".agents-view-title h2");
expect(block).toContain("font-size: 16px"); expect(block).toContain("font-size: var(--space-lg)");
}); });
it("defines .agents-view-controls with flex-wrap on mobile", () => { it("defines .agents-view-controls with flex-wrap on mobile", () => {