feat(FN-3322): allow deletion of paused agents across dashboard views
This merge enables deletion of paused agents across the dashboard UI — adding delete controls to the AgentDetailView, AgentListModal, and AgentsView, with tests covering all three flows and a final typecheck compatibility fix. Fusion-Task-Id: FN-3322
This commit is contained in:
@@ -551,10 +551,16 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
</button>
|
||||
)}
|
||||
{agent.state === "paused" && (
|
||||
<button className="btn btn-task-create btn--compact agent-detail-mobile-icon-control" onClick={() => void handleStateChange("active")} disabled={isTransitioning} aria-label="Resume">
|
||||
<Play size={14} />
|
||||
<span className="agent-detail-control-label">Resume</span>
|
||||
</button>
|
||||
<>
|
||||
<button className="btn btn-task-create btn--compact agent-detail-mobile-icon-control" onClick={() => void handleStateChange("active")} disabled={isTransitioning} aria-label="Resume">
|
||||
<Play size={14} />
|
||||
<span className="agent-detail-control-label">Resume</span>
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={handleDelete}>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "running" && (
|
||||
<>
|
||||
@@ -3122,7 +3128,7 @@ function ConfigTab({
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
|
||||
const isDeletableState = agent.state === "idle" || agent.state === "terminated";
|
||||
const isDeletableState = agent.state === "idle" || agent.state === "terminated" || agent.state === "paused";
|
||||
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const previousAgentRuntimeSyncRef = useRef<{ id: string; updatedAt: string } | null>(null);
|
||||
const lastSavedSignatureRef = useRef<string | null>(null);
|
||||
@@ -4148,7 +4154,7 @@ function ConfigTab({
|
||||
<span className="config-danger-note">
|
||||
{isDeletableState
|
||||
? "Deletion is permanent and cannot be undone."
|
||||
: `Agent deletion is only available when state is idle or terminated (current state: ${agent.state}).`}
|
||||
: `Agent deletion is only available when state is idle, terminated, or paused (current state: ${agent.state}).`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -416,6 +416,13 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
>
|
||||
<Square 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 === "running" && (
|
||||
@@ -609,6 +616,13 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
>
|
||||
<Square size={14} /> Stop
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "running" && (
|
||||
|
||||
@@ -1363,13 +1363,15 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
{(agent.state === "idle" || agent.state === "terminated" || agent.state === "paused") && (
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./MobileNavBar.css";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
|
||||
@@ -792,6 +792,22 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Delete button for paused agent", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "paused" }));
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Delete button for terminated agent", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "terminated" }));
|
||||
|
||||
@@ -1476,7 +1492,7 @@ describe("AgentDetailView", () => {
|
||||
await user.click(screen.getByText("Settings"));
|
||||
};
|
||||
|
||||
it("shows settings delete control for idle and terminated agents", async () => {
|
||||
it("shows settings delete control for idle, terminated, and paused agents", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "idle" }));
|
||||
@@ -1493,6 +1509,19 @@ describe("AgentDetailView", () => {
|
||||
idleRender.unmount();
|
||||
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "terminated" }));
|
||||
const terminatedRender = render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
expect(await screen.findByRole("button", { name: "Delete Agent" })).toBeEnabled();
|
||||
terminatedRender.unmount();
|
||||
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "paused" }));
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
@@ -1578,7 +1607,7 @@ describe("AgentDetailView", () => {
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Delete Agent" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText("Agent deletion is only available when state is idle or terminated (current state: active)."),
|
||||
screen.getByText("Agent deletion is only available when state is idle, terminated, or paused (current state: active)."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -544,7 +544,7 @@ describe("AgentListModal", () => {
|
||||
});
|
||||
|
||||
// Find the agent card for the active agent (agent-002)
|
||||
const activeCard = Array.from(document.querySelectorAll(".agent-card")).find(
|
||||
const activeCard = Array.from(document.querySelectorAll(".agent-card, .agent-board-card")).find(
|
||||
(card) => card.textContent?.includes("agent-002")
|
||||
) ?? null;
|
||||
expect(activeCard).toBeTruthy();
|
||||
@@ -571,7 +571,7 @@ describe("AgentListModal", () => {
|
||||
});
|
||||
|
||||
// Find the agent card for the active agent (agent-002)
|
||||
const activeCard = Array.from(document.querySelectorAll(".agent-card")).find(
|
||||
const activeCard = Array.from(document.querySelectorAll(".agent-card, .agent-board-card")).find(
|
||||
(card) => card.textContent?.includes("agent-002")
|
||||
) ?? null;
|
||||
expect(activeCard).toBeTruthy();
|
||||
@@ -600,7 +600,7 @@ describe("AgentListModal", () => {
|
||||
});
|
||||
|
||||
// Find the agent card for the active agent (agent-002)
|
||||
const activeCard = Array.from(document.querySelectorAll(".agent-card")).find(
|
||||
const activeCard = Array.from(document.querySelectorAll(".agent-card, .agent-board-card")).find(
|
||||
(card) => card.textContent?.includes("agent-002")
|
||||
) ?? null;
|
||||
expect(activeCard).toBeTruthy();
|
||||
@@ -857,6 +857,42 @@ describe("AgentListModal", () => {
|
||||
expect(screen.queryByText("Test Agent 4")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows Delete button for paused agents in list view", async () => {
|
||||
render(
|
||||
<AgentListModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
addToast={mockAddToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const pausedCard = Array.from(document.querySelectorAll(".agent-card, .agent-board-card")).find(
|
||||
(card) => card.textContent?.includes("agent-003"),
|
||||
) ?? null;
|
||||
expect((pausedCard as Element | null)?.querySelector('[title="Delete"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Delete button for paused agents in board view", async () => {
|
||||
render(
|
||||
<AgentListModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
addToast={mockAddToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle("Board view"));
|
||||
|
||||
await waitFor(() => {
|
||||
const pausedBoardCard = Array.from(document.querySelectorAll(".agent-board-card")).find(
|
||||
(card) => card.textContent?.includes("agent-003"),
|
||||
) ?? null;
|
||||
expect((pausedBoardCard as Element | null)?.querySelector('[title="Delete"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Delete button for terminated agents when explicitly filtered", async () => {
|
||||
render(
|
||||
<AgentListModal
|
||||
@@ -880,12 +916,12 @@ describe("AgentListModal", () => {
|
||||
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Verify Start button appears for terminated agent
|
||||
const terminatedCard = Array.from(document.querySelectorAll(".agent-card")).find(
|
||||
// Verify terminated agent exposes delete action
|
||||
const terminatedCard = Array.from(document.querySelectorAll(".agent-card, .agent-board-card")).find(
|
||||
(card) => card.textContent?.includes("agent-004")
|
||||
) ?? null;
|
||||
const terminatedStartBtn = (terminatedCard as Element | null)?.querySelector('[title="Start"]');
|
||||
expect(terminatedStartBtn).toBeTruthy();
|
||||
const terminatedDeleteBtn = (terminatedCard as Element | null)?.querySelector('[title="Delete"]');
|
||||
expect(terminatedDeleteBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("confirms before deleting terminated agent (from terminated filter)", async () => {
|
||||
@@ -913,7 +949,7 @@ describe("AgentListModal", () => {
|
||||
});
|
||||
|
||||
// Find delete button for terminated agent (agent-004)
|
||||
const terminatedCard = Array.from(document.querySelectorAll(".agent-card")).find(
|
||||
const terminatedCard = Array.from(document.querySelectorAll(".agent-card, .agent-board-card")).find(
|
||||
(card) => card.textContent?.includes("agent-004")
|
||||
) ?? null;
|
||||
const terminatedDeleteBtn = (terminatedCard as Element | null)?.querySelector('[title="Delete"]') as HTMLElement;
|
||||
@@ -950,7 +986,7 @@ describe("AgentListModal", () => {
|
||||
});
|
||||
|
||||
// Find delete button for terminated agent (agent-004)
|
||||
const terminatedCard = Array.from(document.querySelectorAll(".agent-card")).find(
|
||||
const terminatedCard = Array.from(document.querySelectorAll(".agent-card, .agent-board-card")).find(
|
||||
(card) => card.textContent?.includes("agent-004")
|
||||
) ?? null;
|
||||
const terminatedDeleteBtn = (terminatedCard as Element | null)?.querySelector('[title="Delete"]') as HTMLElement;
|
||||
@@ -984,7 +1020,7 @@ describe("AgentListModal", () => {
|
||||
});
|
||||
|
||||
// Find delete button for idle agent (agent-001)
|
||||
const idleCard = Array.from(document.querySelectorAll(".agent-card")).find(
|
||||
const idleCard = Array.from(document.querySelectorAll(".agent-card, .agent-board-card")).find(
|
||||
(card) => card.textContent?.includes("agent-001")
|
||||
) ?? null;
|
||||
const idleDeleteBtn = (idleCard as Element | null)?.querySelector('[title="Delete"]') as HTMLElement;
|
||||
@@ -1018,7 +1054,7 @@ describe("AgentListModal", () => {
|
||||
});
|
||||
|
||||
// Click the idle agent's delete button
|
||||
const idleCard = Array.from(document.querySelectorAll(".agent-card")).find(
|
||||
const idleCard = Array.from(document.querySelectorAll(".agent-card, .agent-board-card")).find(
|
||||
(card) => card.textContent?.includes("agent-001")
|
||||
) ?? null;
|
||||
const idleDeleteBtn = (idleCard as Element | null)?.querySelector('[title="Delete"]') as HTMLElement;
|
||||
|
||||
@@ -1886,18 +1886,25 @@ describe("AgentsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show Delete button for active or paused agents", async () => {
|
||||
it("does not show Delete button for active agents", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Find the active agent card (agent-002)
|
||||
const allCards = Array.from(document.querySelectorAll(".agent-card"));
|
||||
const activeCard = allCards.find((card) => card.textContent?.includes("agent-002")) ?? null;
|
||||
|
||||
expect((activeCard as Element | null)?.querySelector('[title="Delete"]')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Delete button for paused agents", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const allCards = Array.from(document.querySelectorAll(".agent-card"));
|
||||
const pausedCard = allCards.find((card) => card.textContent?.includes("agent-003")) ?? null;
|
||||
|
||||
// Active and paused agents should not have delete buttons
|
||||
expect((activeCard as Element | null)?.querySelector('[title="Delete"]')).toBeFalsy();
|
||||
expect((pausedCard as Element | null)?.querySelector('[title="Delete"]')).toBeFalsy();
|
||||
expect((pausedCard as Element | null)?.querySelector('[title="Delete"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1985,6 +1992,23 @@ describe("AgentsView", () => {
|
||||
"success"
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes paused agent after confirmation (from default view)", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const pausedCard = Array.from(document.querySelectorAll(".agent-card")).find(
|
||||
(card) => card.textContent?.includes("agent-003"),
|
||||
) ?? null;
|
||||
const pausedDeleteBtn = (pausedCard as Element | null)?.querySelector('[title="Delete"]') as HTMLElement;
|
||||
expect(pausedDeleteBtn).toBeTruthy();
|
||||
fireEvent.click(pausedDeleteBtn);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-003", undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh functionality", () => {
|
||||
|
||||
Reference in New Issue
Block a user