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:
gsxdsm
2026-03-31 23:25:32 -07:00
parent e40e3f087e
commit 5f6b930c24
6 changed files with 1294 additions and 6 deletions

View File

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": minor
---
Add Agents view as a third option in the main view selector. Users can now toggle between board, list, and agents views directly from the header toggle, without needing to open the separate Agents modal.

View File

@@ -21,6 +21,7 @@ import { ScheduledTasksModal } from "./components/ScheduledTasksModal";
import { ActivityLogModal } from "./components/ActivityLogModal"; import { ActivityLogModal } from "./components/ActivityLogModal";
import { WorkflowStepManager } from "./components/WorkflowStepManager"; import { WorkflowStepManager } from "./components/WorkflowStepManager";
import { AgentListModal } from "./components/AgentListModal"; import { AgentListModal } from "./components/AgentListModal";
import { AgentsView } from "./components/AgentsView";
import { useTasks } from "./hooks/useTasks"; import { useTasks } from "./hooks/useTasks";
import { ToastProvider, useToast } from "./hooks/useToast"; import { ToastProvider, useToast } from "./hooks/useToast";
import { useTheme } from "./hooks/useTheme"; import { useTheme } from "./hooks/useTheme";
@@ -49,11 +50,11 @@ function AppInner() {
const [autoMerge, setAutoMerge] = useState(true); const [autoMerge, setAutoMerge] = useState(true);
const [globalPaused, setGlobalPaused] = useState(false); const [globalPaused, setGlobalPaused] = useState(false);
const [enginePaused, setEnginePaused] = 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 // Initialize from localStorage if available
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const saved = localStorage.getItem("kb-dashboard-view"); const saved = localStorage.getItem("kb-dashboard-view");
if (saved === "list" || saved === "board") { if (saved === "list" || saved === "board" || saved === "agents") {
return saved; return saved;
} }
} }
@@ -113,7 +114,7 @@ function AppInner() {
localStorage.setItem("kb-dashboard-view", view); localStorage.setItem("kb-dashboard-view", view);
}, [view]); }, [view]);
const handleChangeView = useCallback((newView: "board" | "list") => { const handleChangeView = useCallback((newView: "board" | "list" | "agents") => {
setView(newView); setView(newView);
}, []); }, []);
@@ -276,7 +277,9 @@ function AppInner() {
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={setSearchQuery}
/> />
{view === "board" ? ( {view === "agents" ? (
<AgentsView addToast={addToast} />
) : view === "board" ? (
<Board <Board
tasks={tasks} tasks={tasks}
maxConcurrent={maxConcurrent} maxConcurrent={maxConcurrent}

View 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>
);
}

View File

@@ -34,8 +34,8 @@ interface HeaderProps {
enginePaused?: boolean; enginePaused?: boolean;
onToggleGlobalPause?: () => void; onToggleGlobalPause?: () => void;
onToggleEnginePause?: () => void; onToggleEnginePause?: () => void;
view?: "board" | "list"; view?: "board" | "list" | "agents";
onChangeView?: (view: "board" | "list") => void; onChangeView?: (view: "board" | "list" | "agents") => void;
searchQuery?: string; searchQuery?: string;
onSearchChange?: (query: string) => void; onSearchChange?: (query: string) => void;
} }
@@ -238,6 +238,15 @@ export function Header({
> >
<List size={16} /> <List size={16} />
</button> </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> </div>
)} )}

View 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();
});
});
});
});

View File

@@ -210,6 +210,47 @@ describe("Header", () => {
expect(boardBtn.getAttribute("aria-pressed")).toBe("false"); 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 ───────────────────────────────────────────── // ── Terminal Button ─────────────────────────────────────────────
it("renders terminal button with correct title", () => { it("renders terminal button with correct title", () => {