feat(FN-864): add delete action for idle and terminated agents
- Expose delete action on idle and terminated agents in AgentsView and AgentListModal - Update AgentDetailView to support delete with confirmation - Update agent-related documentation in README.md and dashboard README - Add/update tests for AgentDetailView, AgentListModal, and AgentsView delete behavior - Remove outdated TaskDetailModal test
This commit is contained in:
@@ -102,7 +102,7 @@ Manage AI agents with a dedicated control surface accessible from the main dashb
|
||||
**Features**:
|
||||
- **State Filter**: Styled dropdown to filter agents by state (All States, Idle, Active, Paused, Terminated) with Filter icon, aria-label, and consistent dashboard styling using design tokens (`--radius-sm`, `--border`, `--bg`, `--focus-ring`)
|
||||
- **View Modes**: Board (compact grid) and list (detailed card) layouts, persisted to localStorage
|
||||
- **Agent CRUD**: Create agents with name and role (create form's text input and role/type select both use tokenized styling — `var(--surface)`, `var(--text)`, `var(--border)`, `var(--radius-sm)`, `var(--focus-ring)` — for consistent theme-aware rendering across all color themes and light/dark modes), change state, update roles inline, delete terminated agents
|
||||
- **Agent CRUD**: Create agents with name and role (create form's text input and role/type select both use tokenized styling — `var(--surface)`, `var(--text)`, `var(--border)`, `var(--radius-sm)`, `var(--focus-ring)` — for consistent theme-aware rendering across all color themes and light/dark modes), change state, update roles inline, delete idle and terminated agents (active and paused agents must be stopped/terminated first)
|
||||
- **Health Monitoring**: Heartbeat-based health status (Healthy, Unresponsive, Starting, Paused, Terminated) using CSS variable references for theme consistency
|
||||
- **Agent Detail**: Click any agent card to open a detail modal with full agent information. The modal uses component-local token aliases (`--bg-primary`, `--accent`, `--text-primary`, `--bg-hover`) mapped to global tokens (`--surface`, `--todo`, `--text`, `--card-hover`) for theme consistency. The **Settings** tab now includes **editable advanced settings** (heartbeat interval, max retries, task timeout, log level) persisted through `agent.metadata`. Empty fields revert to system defaults, invalid values block save with inline error messages
|
||||
|
||||
|
||||
@@ -257,10 +257,16 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast }: Agent
|
||||
<div className="agent-detail-actions">
|
||||
{/* State-dependent action buttons */}
|
||||
{agent.state === "idle" && (
|
||||
<button className="btn btn--primary" onClick={() => void handleStateChange("active")}>
|
||||
<Play size={16} />
|
||||
Start
|
||||
</button>
|
||||
<>
|
||||
<button className="btn btn--primary" onClick={() => void handleStateChange("active")}>
|
||||
<Play size={16} />
|
||||
Start
|
||||
</button>
|
||||
<button className="btn btn--danger" onClick={handleDelete}>
|
||||
<Trash2 size={16} />
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "active" && (
|
||||
<>
|
||||
|
||||
@@ -288,13 +288,22 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
<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>
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
title="Activate"
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "active" && (
|
||||
<>
|
||||
@@ -428,13 +437,22 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
|
||||
<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>
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
title="Activate"
|
||||
>
|
||||
<Play size={14} /> Start
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "active" && (
|
||||
<>
|
||||
|
||||
@@ -288,13 +288,22 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
</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>
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
title="Activate"
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "active" && (
|
||||
<>
|
||||
@@ -439,13 +448,22 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
|
||||
<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>
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
title="Activate"
|
||||
>
|
||||
<Play size={14} /> Start
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "active" && (
|
||||
<>
|
||||
|
||||
@@ -327,6 +327,22 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Delete button for idle agent", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "idle" }));
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows statistics section on dashboard", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
|
||||
@@ -564,7 +564,7 @@ describe("AgentListModal", () => {
|
||||
});
|
||||
|
||||
describe("agent deletion", () => {
|
||||
it("shows Delete button only for terminated agents", async () => {
|
||||
it("shows Delete button for idle and terminated agents", async () => {
|
||||
render(
|
||||
<AgentListModal
|
||||
isOpen={true}
|
||||
@@ -574,7 +574,9 @@ describe("AgentListModal", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Delete")).toBeTruthy();
|
||||
// Multiple delete buttons: one for idle (agent-001) and one for terminated (agent-004)
|
||||
const deleteButtons = screen.getAllByTitle("Delete");
|
||||
expect(deleteButtons.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -590,10 +592,17 @@ describe("AgentListModal", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Delete")).toBeTruthy();
|
||||
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("Delete"));
|
||||
// Find delete button for terminated agent (agent-004)
|
||||
const agentCards = document.querySelectorAll(".agent-card");
|
||||
let terminatedCard: Element | null = null;
|
||||
agentCards.forEach(card => {
|
||||
if (card.textContent?.includes("agent-004")) terminatedCard = card;
|
||||
});
|
||||
const terminatedDeleteBtn = terminatedCard?.querySelector('[title="Delete"]') as HTMLElement;
|
||||
fireEvent.click(terminatedDeleteBtn);
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Test Agent 4")
|
||||
@@ -615,10 +624,17 @@ describe("AgentListModal", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Delete")).toBeTruthy();
|
||||
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("Delete"));
|
||||
// Find delete button for terminated agent (agent-004)
|
||||
const agentCards = document.querySelectorAll(".agent-card");
|
||||
let terminatedCard: Element | null = null;
|
||||
agentCards.forEach(card => {
|
||||
if (card.textContent?.includes("agent-004")) terminatedCard = card;
|
||||
});
|
||||
const terminatedDeleteBtn = terminatedCard?.querySelector('[title="Delete"]') as HTMLElement;
|
||||
fireEvent.click(terminatedDeleteBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-004", undefined);
|
||||
@@ -630,6 +646,40 @@ describe("AgentListModal", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes idle agent after confirmation", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<AgentListModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
// Find delete button for idle agent (agent-001)
|
||||
const agentCards = document.querySelectorAll(".agent-card");
|
||||
let idleCard: Element | null = null;
|
||||
agentCards.forEach(card => {
|
||||
if (card.textContent?.includes("agent-001")) idleCard = card;
|
||||
});
|
||||
const idleDeleteBtn = idleCard?.querySelector('[title="Delete"]') as HTMLElement;
|
||||
fireEvent.click(idleDeleteBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-001", undefined);
|
||||
});
|
||||
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("deleted"),
|
||||
"success"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles deletion error gracefully", async () => {
|
||||
mockDeleteAgent.mockRejectedValue(new Error("Delete failed"));
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
@@ -643,10 +693,17 @@ describe("AgentListModal", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Delete")).toBeTruthy();
|
||||
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("Delete"));
|
||||
// Click the first available delete button
|
||||
const agentCards = document.querySelectorAll(".agent-card");
|
||||
let terminatedCard: Element | null = null;
|
||||
agentCards.forEach(card => {
|
||||
if (card.textContent?.includes("agent-004")) terminatedCard = card;
|
||||
});
|
||||
const terminatedDeleteBtn = terminatedCard?.querySelector('[title="Delete"]') as HTMLElement;
|
||||
fireEvent.click(terminatedDeleteBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
|
||||
@@ -500,11 +500,32 @@ describe("AgentsView", () => {
|
||||
});
|
||||
|
||||
describe("delete agent", () => {
|
||||
it("shows Delete button only for terminated agents", async () => {
|
||||
it("shows Delete button for idle and terminated agents", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Delete")).toBeTruthy();
|
||||
// There should be multiple Delete buttons: one for idle (agent-001) and one for terminated (agent-004)
|
||||
const deleteButtons = screen.getAllByTitle("Delete");
|
||||
expect(deleteButtons.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show Delete button for active or paused agents", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Find the active agent card (agent-002)
|
||||
const agentCards = document.querySelectorAll(".agent-card");
|
||||
let activeCard: Element | null = null;
|
||||
let pausedCard: Element | null = null;
|
||||
agentCards.forEach(card => {
|
||||
if (card.textContent?.includes("agent-002")) activeCard = card;
|
||||
if (card.textContent?.includes("agent-003")) pausedCard = card;
|
||||
});
|
||||
|
||||
// Active and paused agents should not have delete buttons
|
||||
expect(activeCard?.querySelector('[title="Delete"]')).toBeFalsy();
|
||||
expect(pausedCard?.querySelector('[title="Delete"]')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -514,11 +535,19 @@ describe("AgentsView", () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Delete")).toBeTruthy();
|
||||
// Click the delete button for the terminated agent (agent-004)
|
||||
const deleteButtons = screen.getAllByTitle("Delete");
|
||||
// Find the delete button in the terminated agent card
|
||||
const agentCards = document.querySelectorAll(".agent-card");
|
||||
let terminatedCard: Element | null = null;
|
||||
agentCards.forEach(card => {
|
||||
if (card.textContent?.includes("agent-004")) terminatedCard = card;
|
||||
});
|
||||
const terminatedDeleteBtn = terminatedCard?.querySelector('[title="Delete"]') as HTMLElement;
|
||||
expect(terminatedDeleteBtn).toBeTruthy();
|
||||
fireEvent.click(terminatedDeleteBtn);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("Delete"));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Test Agent 4")
|
||||
);
|
||||
@@ -533,10 +562,17 @@ describe("AgentsView", () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Delete")).toBeTruthy();
|
||||
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("Delete"));
|
||||
// Find the delete button for terminated agent (agent-004)
|
||||
const agentCards = document.querySelectorAll(".agent-card");
|
||||
let terminatedCard: Element | null = null;
|
||||
agentCards.forEach(card => {
|
||||
if (card.textContent?.includes("agent-004")) terminatedCard = card;
|
||||
});
|
||||
const terminatedDeleteBtn = terminatedCard?.querySelector('[title="Delete"]') as HTMLElement;
|
||||
fireEvent.click(terminatedDeleteBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-004", undefined);
|
||||
@@ -547,6 +583,34 @@ describe("AgentsView", () => {
|
||||
"success"
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes idle agent after confirmation", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
// Find the delete button for idle agent (agent-001)
|
||||
const agentCards = document.querySelectorAll(".agent-card");
|
||||
let idleCard: Element | null = null;
|
||||
agentCards.forEach(card => {
|
||||
if (card.textContent?.includes("agent-001")) idleCard = card;
|
||||
});
|
||||
const idleDeleteBtn = idleCard?.querySelector('[title="Delete"]') as HTMLElement;
|
||||
fireEvent.click(idleDeleteBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-001", undefined);
|
||||
});
|
||||
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("deleted"),
|
||||
"success"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh functionality", () => {
|
||||
|
||||
Reference in New Issue
Block a user