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:
Fusion
2026-05-04 23:35:13 -07:00
committed by gsxdsm
parent aab28e1adb
commit 21611b506d
7 changed files with 144 additions and 32 deletions

View File

@@ -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>

View File

@@ -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" && (

View File

@@ -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>
);

View File

@@ -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,

View File

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

View File

@@ -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;

View File

@@ -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", () => {