feat(KB-644): add list/board view toggle to agent list modal

- Add view state management and toggle UI for switching between list and board views
- Implement board view layout with card-based agent display
- Update TaskCard component for board view compatibility with new props
- Add comprehensive tests for view toggle and both AgentListModal view modes
- Remove unused CSS styles now handled by view-specific layouts
This commit is contained in:
gsxdsm
2026-03-31 19:21:03 -07:00
parent 2873c96f0a
commit 5de661976d
2 changed files with 364 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import type { JSX } from "react";
import { X, Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot } from "lucide-react";
import { X, Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List } from "lucide-react";
import type { Agent, AgentCapability, AgentState } from "../api";
import { fetchAgents, createAgent, updateAgentState, deleteAgent } from "../api";
@@ -33,6 +33,16 @@ export function AgentListModal({ isOpen, onClose, addToast }: AgentListModalProp
const [newAgentName, setNewAgentName] = useState("");
const [newAgentRole, setNewAgentRole] = useState<AgentCapability>("custom");
const [filterState, setFilterState] = useState<AgentState | "all">("all");
const [view, setView] = 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", view);
}, [view]);
const loadAgents = useCallback(async () => {
setIsLoading(true);
@@ -120,6 +130,26 @@ export function AgentListModal({ isOpen, onClose, addToast }: AgentListModalProp
Agents
</h2>
<div className="modal-actions">
<div className="view-toggle">
<button
className={`view-toggle-btn${view === "board" ? " active" : ""}`}
onClick={() => setView("board")}
title="Board view"
aria-label="Board view"
aria-pressed={view === "board"}
>
<LayoutGrid size={16} />
</button>
<button
className={`view-toggle-btn${view === "list" ? " active" : ""}`}
onClick={() => setView("list")}
title="List view"
aria-label="List view"
aria-pressed={view === "list"}
>
<List size={16} />
</button>
</div>
<button
className="btn-icon"
onClick={() => void loadAgents()}
@@ -188,14 +218,101 @@ export function AgentListModal({ isOpen, onClose, addToast }: AgentListModalProp
)}
{/* Agent List */}
<div className="agent-list">
<div className={view === "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>
) : view === "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];
@@ -366,6 +483,81 @@ export function AgentListModal({ isOpen, onClose, addToast }: AgentListModalProp
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;

View File

@@ -751,4 +751,174 @@ describe("AgentListModal", () => {
expect(styleTag?.textContent).toContain("--state-error-bg");
});
});
describe("view toggle", () => {
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
});
it("toggles between board and list views", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("Agents")).toBeTruthy();
});
// Initially should show list view (default)
const boardButton = screen.getByTitle("Board view");
const listButton = screen.getByTitle("List view");
expect(boardButton).toBeTruthy();
expect(listButton).toBeTruthy();
// Click board view button
fireEvent.click(boardButton);
// Should now show board layout (agent-board class)
await waitFor(() => {
const boardContainer = document.querySelector(".agent-board");
expect(boardContainer).toBeTruthy();
});
// Click list view button
fireEvent.click(listButton);
// Should now show list layout (agent-list class)
await waitFor(() => {
const listContainer = document.querySelector(".agent-list");
expect(listContainer).toBeTruthy();
});
});
it("persists view preference to localStorage", async () => {
const { unmount } = render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("Agents")).toBeTruthy();
});
// Click board view button
fireEvent.click(screen.getByTitle("Board view"));
await waitFor(() => {
expect(localStorage.getItem("kb-agent-view")).toBe("board");
});
// Unmount and remount to test persistence
unmount();
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
// Should restore board view from localStorage
const boardContainer = document.querySelector(".agent-board");
expect(boardContainer).toBeTruthy();
});
});
it("board view shows compact agent cards", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("Agents")).toBeTruthy();
});
// Switch to board view
fireEvent.click(screen.getByTitle("Board view"));
await waitFor(() => {
// Board view should render compact cards
const boardCards = document.querySelectorAll(".agent-board-card");
expect(boardCards.length).toBe(mockAgents.length);
});
// Check that board view elements are present
expect(document.querySelector(".agent-board-icon")).toBeTruthy();
expect(document.querySelector(".agent-board-name")).toBeTruthy();
expect(document.querySelector(".agent-board-badge")).toBeTruthy();
// Board view should NOT have the detailed card body elements
const cardBodies = document.querySelectorAll(".agent-card-body");
expect(cardBodies.length).toBe(0);
});
it("board view cards show action buttons", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("Agents")).toBeTruthy();
});
// Switch to board view
fireEvent.click(screen.getByTitle("Board view"));
await waitFor(() => {
const boardCards = document.querySelectorAll(".agent-board-card");
expect(boardCards.length).toBeGreaterThan(0);
});
// Find an idle agent card and verify Start button exists
const startButtons = document.querySelectorAll(".agent-board-actions .btn");
expect(startButtons.length).toBeGreaterThan(0);
// Click a start button to verify it works
const firstStartButton = startButtons[0] as HTMLElement;
fireEvent.click(firstStartButton);
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalled();
});
});
it("defaults to list view when no localStorage preference exists", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
// Should default to list view (detailed card layout)
const listContainer = document.querySelector(".agent-list");
expect(listContainer).toBeTruthy();
// Detailed cards should be present
const agentCards = document.querySelectorAll(".agent-card");
expect(agentCards.length).toBe(mockAgents.length);
});
});
});
});