feat(KB-659): add Agents view toggle to main view selector
- Add AgentsView as a third view option alongside Board and List - Add view toggle button with board/list/agents options in Header - Integrate agents monitoring directly into main dashboard navigation - Remove separate Agents modal button from header - Update App.tsx to handle agents view state - Add comprehensive tests for AgentsView component and Header view toggle
This commit is contained in:
@@ -21,6 +21,7 @@ import { ScheduledTasksModal } from "./components/ScheduledTasksModal";
|
||||
import { ActivityLogModal } from "./components/ActivityLogModal";
|
||||
import { WorkflowStepManager } from "./components/WorkflowStepManager";
|
||||
import { AgentListModal } from "./components/AgentListModal";
|
||||
import { AgentsView } from "./components/AgentsView";
|
||||
import { useTasks } from "./hooks/useTasks";
|
||||
import { ToastProvider, useToast } from "./hooks/useToast";
|
||||
import { useTheme } from "./hooks/useTheme";
|
||||
@@ -49,11 +50,11 @@ function AppInner() {
|
||||
const [autoMerge, setAutoMerge] = useState(true);
|
||||
const [globalPaused, setGlobalPaused] = useState(false);
|
||||
const [enginePaused, setEnginePaused] = useState(false);
|
||||
const [view, setView] = useState<"board" | "list">(() => {
|
||||
const [view, setView] = useState<"board" | "list" | "agents">(() => {
|
||||
// Initialize from localStorage if available
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("kb-dashboard-view");
|
||||
if (saved === "list" || saved === "board") {
|
||||
if (saved === "list" || saved === "board" || saved === "agents") {
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
@@ -113,7 +114,7 @@ function AppInner() {
|
||||
localStorage.setItem("kb-dashboard-view", view);
|
||||
}, [view]);
|
||||
|
||||
const handleChangeView = useCallback((newView: "board" | "list") => {
|
||||
const handleChangeView = useCallback((newView: "board" | "list" | "agents") => {
|
||||
setView(newView);
|
||||
}, []);
|
||||
|
||||
@@ -276,7 +277,9 @@ function AppInner() {
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
{view === "board" ? (
|
||||
{view === "agents" ? (
|
||||
<AgentsView addToast={addToast} />
|
||||
) : view === "board" ? (
|
||||
<Board
|
||||
tasks={tasks}
|
||||
maxConcurrent={maxConcurrent}
|
||||
|
||||
753
packages/dashboard/app/components/AgentsView.tsx
Normal file
753
packages/dashboard/app/components/AgentsView.tsx
Normal file
@@ -0,0 +1,753 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentState } from "../api";
|
||||
import { fetchAgents, createAgent, updateAgent, updateAgentState, deleteAgent } from "../api";
|
||||
|
||||
export interface AgentsViewProps {
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
}
|
||||
|
||||
const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
|
||||
{ value: "triage", label: "Triage", icon: "🔍" },
|
||||
{ value: "executor", label: "Executor", icon: "⚡" },
|
||||
{ value: "reviewer", label: "Reviewer", icon: "👁" },
|
||||
{ value: "merger", label: "Merger", icon: "🔀" },
|
||||
{ value: "scheduler", label: "Scheduler", icon: "⏰" },
|
||||
{ value: "custom", label: "Custom", icon: "🔧" },
|
||||
];
|
||||
|
||||
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)" },
|
||||
paused: { bg: "var(--state-paused-bg)", text: "var(--state-paused-text)", border: "var(--state-paused-border)" },
|
||||
terminated: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
|
||||
};
|
||||
|
||||
export function AgentsView({ addToast }: AgentsViewProps) {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newAgentName, setNewAgentName] = useState("");
|
||||
const [newAgentRole, setNewAgentRole] = useState<AgentCapability>("custom");
|
||||
const [filterState, setFilterState] = useState<AgentState | "all">("all");
|
||||
const [agentView, setAgentView] = useState<"board" | "list">(() => {
|
||||
if (typeof window === "undefined") return "list";
|
||||
const saved = localStorage.getItem("kb-agent-view");
|
||||
return (saved === "board" || saved === "list") ? saved : "list";
|
||||
});
|
||||
|
||||
// Persist view preference to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("kb-agent-view", agentView);
|
||||
}, [agentView]);
|
||||
|
||||
const [editingRoleForAgent, setEditingRoleForAgent] = useState<string | null>(null);
|
||||
const roleSelectRef = useRef<HTMLSelectElement>(null);
|
||||
|
||||
const loadAgents = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const filter = filterState !== "all" ? { state: filterState } : undefined;
|
||||
const data = await fetchAgents(filter);
|
||||
setAgents(data);
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to load agents: ${err.message}`, "error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filterState, addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAgents();
|
||||
}, [loadAgents]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newAgentName.trim()) return;
|
||||
try {
|
||||
await createAgent({ name: newAgentName.trim(), role: newAgentRole });
|
||||
addToast(`Agent "${newAgentName}" created`, "success");
|
||||
setNewAgentName("");
|
||||
setIsCreating(false);
|
||||
void loadAgents();
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to create agent: ${err.message}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStateChange = async (agentId: string, newState: AgentState) => {
|
||||
try {
|
||||
await updateAgentState(agentId, newState);
|
||||
addToast(`Agent state updated to ${newState}`, "success");
|
||||
void loadAgents();
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to update state: ${err.message}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (agentId: string, agentName: string) => {
|
||||
if (!confirm(`Delete agent "${agentName}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteAgent(agentId);
|
||||
addToast(`Agent "${agentName}" deleted`, "success");
|
||||
void loadAgents();
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to delete agent: ${err.message}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (agentId: string, newRole: AgentCapability) => {
|
||||
const agent = agents.find(a => a.id === agentId);
|
||||
if (!agent) return;
|
||||
|
||||
// If same role, just cancel editing without API call
|
||||
if (agent.role === newRole) {
|
||||
setEditingRoleForAgent(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateAgent(agentId, { role: newRole });
|
||||
addToast(`Agent role updated to ${AGENT_ROLES.find(r => r.value === newRole)?.label ?? newRole}`, "success");
|
||||
setEditingRoleForAgent(null);
|
||||
void loadAgents();
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to update role: ${err.message}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleKeyDown = (e: React.KeyboardEvent, _agentId: string) => {
|
||||
if (e.key === "Escape") {
|
||||
setEditingRoleForAgent(null);
|
||||
}
|
||||
};
|
||||
|
||||
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 getHealthStatus = (agent: Agent): { label: string; icon: JSX.Element; color: string } => {
|
||||
if (agent.state === "terminated") {
|
||||
return { label: "Terminated", icon: <Square size={14} />, color: "var(--state-error-text)" };
|
||||
}
|
||||
if (agent.state === "paused") {
|
||||
return { label: "Paused", icon: <Pause size={14} />, color: "var(--state-paused-text)" };
|
||||
}
|
||||
if (!agent.lastHeartbeatAt) {
|
||||
return { label: agent.state === "active" ? "Starting..." : "Idle", icon: <Bot size={14} />, color: "var(--text-secondary)" };
|
||||
}
|
||||
const lastHeartbeat = new Date(agent.lastHeartbeatAt).getTime();
|
||||
const elapsed = Date.now() - lastHeartbeat;
|
||||
const timeoutMs = 60000; // 60 second timeout
|
||||
if (elapsed > timeoutMs) {
|
||||
return { label: "Unresponsive", icon: <Activity size={14} />, color: "var(--state-error-text)" };
|
||||
}
|
||||
return { label: "Healthy", icon: <Heart size={14} />, color: "var(--state-active-text)" };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="agents-view">
|
||||
<div className="agents-view-header">
|
||||
<div className="agents-view-title">
|
||||
<Bot size={20} />
|
||||
<h2>Agents</h2>
|
||||
</div>
|
||||
<div className="agents-view-controls">
|
||||
<div className="view-toggle">
|
||||
<button
|
||||
className={`view-toggle-btn${agentView === "board" ? " active" : ""}`}
|
||||
onClick={() => setAgentView("board")}
|
||||
title="Board view"
|
||||
aria-label="Board view"
|
||||
aria-pressed={agentView === "board"}
|
||||
>
|
||||
<LayoutGrid size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`view-toggle-btn${agentView === "list" ? " active" : ""}`}
|
||||
onClick={() => setAgentView("list")}
|
||||
title="List view"
|
||||
aria-label="List view"
|
||||
aria-pressed={agentView === "list"}
|
||||
>
|
||||
<List size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => void loadAgents()}
|
||||
title="Refresh"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw size={16} className={isLoading ? "spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="agents-view-content">
|
||||
{/* Filter and Create Bar */}
|
||||
<div className="agent-controls">
|
||||
<select
|
||||
className="select"
|
||||
value={filterState}
|
||||
onChange={(e) => setFilterState(e.target.value as AgentState | "all")}
|
||||
>
|
||||
<option value="all">All States</option>
|
||||
<option value="idle">Idle</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="terminated">Terminated</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
onClick={() => setIsCreating(!isCreating)}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{isCreating ? "Cancel" : "New Agent"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{isCreating && (
|
||||
<div className="agent-create-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Agent name..."
|
||||
value={newAgentName}
|
||||
onChange={(e) => setNewAgentName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && void handleCreate()}
|
||||
className="input"
|
||||
autoFocus
|
||||
/>
|
||||
<select
|
||||
className="select"
|
||||
value={newAgentRole}
|
||||
onChange={(e) => setNewAgentRole(e.target.value as AgentCapability)}
|
||||
>
|
||||
{AGENT_ROLES.map(role => (
|
||||
<option key={role.value} value={role.value}>
|
||||
{role.icon} {role.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn--primary" onClick={() => void handleCreate()}>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent List */}
|
||||
<div className={agentView === "board" ? "agent-board" : "agent-list"}>
|
||||
{agents.length === 0 ? (
|
||||
<div className="agent-empty">
|
||||
<Bot size={48} opacity={0.3} />
|
||||
<p>No agents found</p>
|
||||
<p className="text-secondary">Create an agent to get started</p>
|
||||
</div>
|
||||
) : agentView === "board" ? (
|
||||
// Board view: compact grid layout
|
||||
agents.map(agent => {
|
||||
const health = getHealthStatus(agent);
|
||||
const stateStyle = STATE_COLORS[agent.state];
|
||||
return (
|
||||
<div key={agent.id} className="agent-board-card" style={{ borderColor: stateStyle.border }}>
|
||||
<div className="agent-board-header">
|
||||
<span className="agent-board-icon">{getRoleIcon(agent.role)}</span>
|
||||
<span
|
||||
className="agent-board-badge"
|
||||
style={{
|
||||
background: stateStyle.bg,
|
||||
color: stateStyle.text,
|
||||
border: `1px solid ${stateStyle.border}`,
|
||||
}}
|
||||
>
|
||||
{agent.state}
|
||||
</span>
|
||||
<span className="agent-board-health" style={{ color: health.color }} title={health.label}>
|
||||
{health.icon}
|
||||
</span>
|
||||
</div>
|
||||
<div className="agent-board-name" title={agent.name}>
|
||||
{agent.name}
|
||||
</div>
|
||||
<div className="agent-board-id">{agent.id}</div>
|
||||
<div className="agent-board-actions">
|
||||
{agent.state === "idle" && (
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
title="Activate"
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
)}
|
||||
{agent.state === "active" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
title="Pause"
|
||||
>
|
||||
<Pause size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "paused" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
title="Resume"
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// List view: detailed card layout
|
||||
agents.map(agent => {
|
||||
const health = getHealthStatus(agent);
|
||||
const stateStyle = STATE_COLORS[agent.state];
|
||||
return (
|
||||
<div key={agent.id} className="agent-card" style={{ borderLeftColor: stateStyle.border }}>
|
||||
<div className="agent-card-header">
|
||||
<div className="agent-info">
|
||||
{editingRoleForAgent === agent.id ? (
|
||||
<select
|
||||
ref={roleSelectRef}
|
||||
className="select agent-role-select"
|
||||
value={agent.role}
|
||||
onChange={(e) => void handleRoleChange(agent.id, e.target.value as AgentCapability)}
|
||||
onKeyDown={(e) => handleRoleKeyDown(e, agent.id)}
|
||||
onBlur={() => setEditingRoleForAgent(null)}
|
||||
autoFocus
|
||||
>
|
||||
{AGENT_ROLES.map(role => (
|
||||
<option key={role.value} value={role.value}>
|
||||
{role.icon} {role.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span
|
||||
className="agent-icon agent-icon--clickable"
|
||||
onClick={() => setEditingRoleForAgent(agent.id)}
|
||||
title="Click to change role"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
setEditingRoleForAgent(agent.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{getRoleIcon(agent.role)}
|
||||
</span>
|
||||
)}
|
||||
<div className="agent-meta">
|
||||
<span className="agent-name">{agent.name}</span>
|
||||
<span className="agent-id text-secondary">{agent.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agent-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.icon} {health.label}
|
||||
</span>
|
||||
<span className="badge text-secondary">
|
||||
{getRoleLabel(agent.role)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="agent-card-body">
|
||||
{agent.taskId && (
|
||||
<div className="agent-task">
|
||||
<span className="text-secondary">Working on:</span>
|
||||
<span className="badge">{agent.taskId}</span>
|
||||
</div>
|
||||
)}
|
||||
{agent.lastHeartbeatAt && (
|
||||
<div className="agent-heartbeat">
|
||||
<span className="text-secondary">Last heartbeat:</span>
|
||||
<span>{new Date(agent.lastHeartbeatAt).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="agent-card-actions">
|
||||
{agent.state === "idle" && (
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
title="Activate"
|
||||
>
|
||||
<Play size={14} /> Start
|
||||
</button>
|
||||
)}
|
||||
{agent.state === "active" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
title="Pause"
|
||||
>
|
||||
<Pause size={14} /> Pause
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} /> Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "paused" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
title="Resume"
|
||||
>
|
||||
<Play size={14} /> Resume
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} /> Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.agents-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
/* Agent state CSS variables - define fallback values */
|
||||
--state-idle-bg: rgba(139, 148, 158, 0.15);
|
||||
--state-idle-text: #8b949e;
|
||||
--state-idle-border: #8b949e;
|
||||
--state-active-bg: rgba(46, 160, 67, 0.15);
|
||||
--state-active-text: #3fb950;
|
||||
--state-active-border: #3fb950;
|
||||
--state-paused-bg: rgba(227, 179, 65, 0.15);
|
||||
--state-paused-text: #e3b541;
|
||||
--state-paused-border: #e3b541;
|
||||
--state-error-bg: rgba(248, 81, 73, 0.15);
|
||||
--state-error-text: #f85149;
|
||||
--state-error-border: #f85149;
|
||||
--text-secondary: var(--text-muted, #8b949e);
|
||||
}
|
||||
|
||||
.agents-view-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.agents-view-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agents-view-title h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.agents-view-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agents-view-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.agent-controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.agent-controls .select {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.agent-create-form {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.agent-create-form .input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.agent-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.agent-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.agent-board-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-top-width: 3px;
|
||||
border-radius: 8px;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.agent-board-card:hover {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.agent-board-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent-board-icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.agent-board-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.agent-board-health {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-board-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agent-board-id {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.agent-board-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.agent-board-actions .btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.agent-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 48px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
border: 1px solid var(--border);
|
||||
border-left-width: 4px;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.agent-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.agent-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.agent-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.agent-icon--clickable {
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.agent-icon--clickable:hover {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.agent-icon--clickable:focus {
|
||||
outline: 2px solid var(--accent, #58a6ff);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.agent-role-select {
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
min-width: 120px;
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agent-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.agent-id {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.agent-badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.agent-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.agent-task,
|
||||
.agent-heartbeat {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,8 +34,8 @@ interface HeaderProps {
|
||||
enginePaused?: boolean;
|
||||
onToggleGlobalPause?: () => void;
|
||||
onToggleEnginePause?: () => void;
|
||||
view?: "board" | "list";
|
||||
onChangeView?: (view: "board" | "list") => void;
|
||||
view?: "board" | "list" | "agents";
|
||||
onChangeView?: (view: "board" | "list" | "agents") => void;
|
||||
searchQuery?: string;
|
||||
onSearchChange?: (query: string) => void;
|
||||
}
|
||||
@@ -238,6 +238,15 @@ export function Header({
|
||||
>
|
||||
<List size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`view-toggle-btn${view === "agents" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("agents")}
|
||||
title="Agents view"
|
||||
aria-label="Agents view"
|
||||
aria-pressed={view === "agents"}
|
||||
>
|
||||
<Bot size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
477
packages/dashboard/app/components/__tests__/AgentsView.test.tsx
Normal file
477
packages/dashboard/app/components/__tests__/AgentsView.test.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { AgentsView } from "../AgentsView";
|
||||
import * as apiModule from "../../api";
|
||||
import type { Agent, AgentState, AgentCapability } from "../../api";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAgents: vi.fn(),
|
||||
createAgent: vi.fn(),
|
||||
updateAgent: vi.fn(),
|
||||
updateAgentState: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchAgents = vi.mocked(apiModule.fetchAgents);
|
||||
const mockCreateAgent = vi.mocked(apiModule.createAgent);
|
||||
const mockUpdateAgentState = vi.mocked(apiModule.updateAgentState);
|
||||
const mockDeleteAgent = vi.mocked(apiModule.deleteAgent);
|
||||
|
||||
describe("AgentsView", () => {
|
||||
const mockAddToast = vi.fn();
|
||||
|
||||
const mockAgents: Agent[] = [
|
||||
{
|
||||
id: "agent-001",
|
||||
name: "Test Agent 1",
|
||||
role: "executor" as AgentCapability,
|
||||
state: "idle" as AgentState,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: "agent-002",
|
||||
name: "Test Agent 2",
|
||||
role: "triage" as AgentCapability,
|
||||
state: "active" as AgentState,
|
||||
taskId: "FN-001",
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
createdAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: "agent-003",
|
||||
name: "Test Agent 3",
|
||||
role: "custom" as AgentCapability,
|
||||
state: "paused" as AgentState,
|
||||
createdAt: new Date(Date.now() - 172800000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: "agent-004",
|
||||
name: "Test Agent 4",
|
||||
role: "reviewer" as AgentCapability,
|
||||
state: "terminated" as AgentState,
|
||||
createdAt: new Date(Date.now() - 259200000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockFetchAgents.mockResolvedValue(mockAgents);
|
||||
mockCreateAgent.mockResolvedValue(mockAgents[0]);
|
||||
mockUpdateAgentState.mockResolvedValue({ ...mockAgents[0], state: "active" });
|
||||
mockDeleteAgent.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders the agents view header", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Agents")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders agent list on mount", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Agent 1")).toBeTruthy();
|
||||
expect(screen.getByText("Test Agent 2")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches agents on mount", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders empty state when no agents", async () => {
|
||||
mockFetchAgents.mockResolvedValue([]);
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No agents found")).toBeTruthy();
|
||||
expect(screen.getByText("Create an agent to get started")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays agent states", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("idle")).toBeTruthy();
|
||||
expect(screen.getByText("active")).toBeTruthy();
|
||||
expect(screen.getByText("paused")).toBeTruthy();
|
||||
expect(screen.getByText("terminated")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays agent task when working on one", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("FN-001")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows refresh button", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
const refreshBtn = screen.getByTitle("Refresh");
|
||||
expect(refreshBtn).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("view toggle (list/board)", () => {
|
||||
it("can toggle between list and board view", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Agent 1")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Initially should show list view (default)
|
||||
expect(document.querySelector(".agent-list")).toBeTruthy();
|
||||
|
||||
// Switch to board view
|
||||
fireEvent.click(screen.getByTitle("Board view"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".agent-board")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Switch back to list view
|
||||
fireEvent.click(screen.getByTitle("List view"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".agent-list")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("board view shows compact cards", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Agents")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("Board view"));
|
||||
|
||||
await waitFor(() => {
|
||||
const boardCards = document.querySelectorAll(".agent-board-card");
|
||||
expect(boardCards.length).toBe(mockAgents.length);
|
||||
});
|
||||
});
|
||||
|
||||
it("persists view toggle preference to localStorage", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Agents")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("Board view"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("kb-agent-view")).toBe("board");
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to list view when no localStorage preference exists", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const listContainer = document.querySelector(".agent-list");
|
||||
expect(listContainer).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("marks board view button as active when in board mode", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Agents")).toBeTruthy();
|
||||
});
|
||||
|
||||
const boardBtn = screen.getByTitle("Board view");
|
||||
fireEvent.click(boardBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(boardBtn.className).toContain("active");
|
||||
expect(boardBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("filter agents by state", () => {
|
||||
it("can filter agents by state", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("All States")).toBeTruthy();
|
||||
});
|
||||
|
||||
const filterSelect = screen.getByDisplayValue("All States");
|
||||
fireEvent.change(filterSelect, { target: { value: "active" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalledWith({ state: "active" });
|
||||
});
|
||||
});
|
||||
|
||||
it("clears filter when selecting 'all'", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("All States")).toBeTruthy();
|
||||
});
|
||||
|
||||
const filterSelect = screen.getByDisplayValue("All States");
|
||||
fireEvent.change(filterSelect, { target: { value: "idle" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "idle" });
|
||||
});
|
||||
|
||||
fireEvent.change(filterSelect, { target: { value: "all" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("create new agent", () => {
|
||||
it("can create new agent", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Open create form
|
||||
fireEvent.click(screen.getByText("New Agent"));
|
||||
|
||||
// Fill in agent name
|
||||
const nameInput = screen.getByPlaceholderText("Agent name...");
|
||||
fireEvent.change(nameInput, { target: { value: "My Agent" } });
|
||||
|
||||
// Click create button
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateAgent).toHaveBeenCalledWith({
|
||||
name: "My Agent",
|
||||
role: "custom",
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("My Agent"),
|
||||
"success"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows create form when clicking New Agent button", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("New Agent"));
|
||||
|
||||
expect(screen.getByPlaceholderText("Agent name...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not create agent with empty name", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("New Agent"));
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
|
||||
expect(mockCreateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles creation error gracefully", async () => {
|
||||
mockCreateAgent.mockRejectedValue(new Error("Creation failed"));
|
||||
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("New Agent"));
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("Agent name...");
|
||||
fireEvent.change(nameInput, { target: { value: "Fail Agent" } });
|
||||
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Creation failed"),
|
||||
"error"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("change agent state", () => {
|
||||
it("can change agent state - activate idle agent", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Activate")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("Activate"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active");
|
||||
});
|
||||
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("active"),
|
||||
"success"
|
||||
);
|
||||
});
|
||||
|
||||
it("can pause active agent", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const agentCards = document.querySelectorAll(".agent-card");
|
||||
expect(agentCards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Find the active agent card
|
||||
const agentCards = document.querySelectorAll(".agent-card");
|
||||
let activeCard: Element | null = null;
|
||||
agentCards.forEach(card => {
|
||||
if (card.textContent?.includes("agent-002")) {
|
||||
activeCard = card;
|
||||
}
|
||||
});
|
||||
expect(activeCard).toBeTruthy();
|
||||
|
||||
const pauseButton = activeCard?.querySelector('[title="Pause"]') as HTMLElement;
|
||||
expect(pauseButton).toBeTruthy();
|
||||
fireEvent.click(pauseButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "paused");
|
||||
});
|
||||
});
|
||||
|
||||
it("can resume paused agent", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Resume")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("Resume"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-003", "active");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles state change error gracefully", async () => {
|
||||
mockUpdateAgentState.mockRejectedValue(new Error("State change failed"));
|
||||
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Activate")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("Activate"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("State change failed"),
|
||||
"error"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete agent", () => {
|
||||
it("shows Delete button only for terminated agents", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Delete")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("confirms before deleting agent", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Delete")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("Delete"));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Test Agent 4")
|
||||
);
|
||||
expect(mockDeleteAgent).not.toHaveBeenCalled();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("deletes agent after confirmation", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Delete")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("Delete"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-004");
|
||||
});
|
||||
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("deleted"),
|
||||
"success"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh functionality", () => {
|
||||
it("refreshes agent list when clicking refresh button", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Refresh")).toBeTruthy();
|
||||
});
|
||||
|
||||
mockFetchAgents.mockClear();
|
||||
fireEvent.click(screen.getByTitle("Refresh"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -210,6 +210,47 @@ describe("Header", () => {
|
||||
expect(boardBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
// ── Agents View Toggle ──────────────────────────────────────────
|
||||
|
||||
it("renders agents view button in view toggle when onChangeView is provided", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="board" onChangeView={onChangeView} />);
|
||||
const agentsBtn = screen.getByTitle("Agents view");
|
||||
expect(agentsBtn).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onChangeView with 'agents' when agents view button is clicked", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="board" onChangeView={onChangeView} />);
|
||||
const agentsBtn = screen.getByTitle("Agents view");
|
||||
fireEvent.click(agentsBtn);
|
||||
expect(onChangeView).toHaveBeenCalledWith("agents");
|
||||
});
|
||||
|
||||
it("marks agents view button as active when view is 'agents'", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="agents" onChangeView={onChangeView} />);
|
||||
const agentsBtn = screen.getByTitle("Agents view");
|
||||
expect(agentsBtn.className).toContain("active");
|
||||
expect(agentsBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("does not mark agents view button as active when view is 'board'", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="board" onChangeView={onChangeView} />);
|
||||
const agentsBtn = screen.getByTitle("Agents view");
|
||||
expect(agentsBtn.className).not.toContain("active");
|
||||
expect(agentsBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
it("does not mark board view button as active when view is 'agents'", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="agents" onChangeView={onChangeView} />);
|
||||
const boardBtn = screen.getByTitle("Board view");
|
||||
expect(boardBtn.className).not.toContain("active");
|
||||
expect(boardBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
// ── Terminal Button ─────────────────────────────────────────────
|
||||
|
||||
it("renders terminal button with correct title", () => {
|
||||
|
||||
Reference in New Issue
Block a user