feat(FN-2320): add settings-tab agent deletion controls
- Add a Danger Zone section in AgentDetailView settings with a Delete Agent action - Reuse the existing delete confirmation/handler flow and gate deletion to idle or terminated agent states - Add UI styling for danger sections and deletion guidance text in settings - Expand AgentDetailView tests to cover enabled/disabled states plus confirm/cancel deletion paths - Document settings-tab deletion behavior and constraints in docs/agents.md
This commit is contained in:
@@ -92,6 +92,15 @@ The agents surface provides:
|
||||
- Run history
|
||||
- Task assignment context
|
||||
|
||||
### Agent Deletion Controls
|
||||
|
||||
Agent deletion is available from both the detail header lifecycle controls and the **Settings** tab's danger zone.
|
||||
|
||||
- The Settings-tab delete button reuses the same delete flow as the header action.
|
||||
- Deletion still requires confirmation before calling `DELETE /api/agents/:id`.
|
||||
- On successful deletion, the dashboard shows a success toast and closes the detail view.
|
||||
- Deletion availability is intentionally restricted to agents in `idle` or `terminated` state.
|
||||
|
||||

|
||||
|
||||
## Built-In Agent Prompt Templates
|
||||
|
||||
@@ -581,6 +581,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
addToast={addToast}
|
||||
onSaved={loadAgent}
|
||||
onHasChangesChange={handleConfigChangesState}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -2694,12 +2695,14 @@ function ConfigTab({
|
||||
addToast,
|
||||
onSaved,
|
||||
onHasChangesChange,
|
||||
onDelete,
|
||||
}: {
|
||||
agent: AgentDetail;
|
||||
projectId?: string;
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
onSaved: () => Promise<void>;
|
||||
onHasChangesChange?: (hasChanges: boolean) => void;
|
||||
onDelete?: () => Promise<void> | void;
|
||||
}) {
|
||||
// Identity field state
|
||||
const [nameValue, setNameValue] = useState(agent.name);
|
||||
@@ -2836,6 +2839,7 @@ function ConfigTab({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const isDeletableState = agent.state === "idle" || agent.state === "terminated";
|
||||
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const previousAgentRuntimeSyncRef = useRef<{ id: string; updatedAt: string } | null>(null);
|
||||
|
||||
@@ -3691,6 +3695,30 @@ function ConfigTab({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section config-section--danger">
|
||||
<h3>Danger Zone</h3>
|
||||
<p className="config-description">
|
||||
Permanently delete this agent from the project.
|
||||
</p>
|
||||
<div className="config-fields">
|
||||
<div className="config-field">
|
||||
<button
|
||||
className="btn btn--danger"
|
||||
disabled={!isDeletableState || !onDelete}
|
||||
onClick={() => void onDelete?.()}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Delete Agent
|
||||
</button>
|
||||
<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}).`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,12 +88,13 @@ vi.mock("../SkillMultiselect", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchModels, fetchAgentLogsWithMeta } from "../../api";
|
||||
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchModels, fetchAgentLogsWithMeta } from "../../api";
|
||||
|
||||
const mockFetchAgent = vi.mocked(fetchAgent);
|
||||
const mockFetchAgents = vi.mocked(fetchAgents);
|
||||
const mockUpdateAgent = vi.mocked(updateAgent);
|
||||
const mockUpdateAgentState = vi.mocked(updateAgentState);
|
||||
const mockDeleteAgent = vi.mocked(deleteAgent);
|
||||
const mockFetchAgentChildren = vi.mocked(fetchAgentChildren);
|
||||
const mockFetchAgentRunLogs = vi.mocked(fetchAgentRunLogs);
|
||||
const mockFetchAgentRuns = vi.mocked(fetchAgentRuns);
|
||||
@@ -165,6 +166,7 @@ describe("AgentDetailView", () => {
|
||||
{ id: "agent-003", name: "Director Agent", role: "triage", state: "active", metadata: {} },
|
||||
] as any);
|
||||
mockUpdateAgentState.mockResolvedValue(createMockAgent({ state: "paused" }));
|
||||
mockDeleteAgent.mockResolvedValue(undefined);
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
|
||||
// Default: return runs from mock agent
|
||||
mockFetchAgentRuns.mockResolvedValue([
|
||||
@@ -984,6 +986,113 @@ describe("AgentDetailView", () => {
|
||||
await user.click(screen.getByText("Settings"));
|
||||
};
|
||||
|
||||
it("shows settings delete control for idle and terminated agents", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "idle" }));
|
||||
const idleRender = render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
expect(await screen.findByRole("button", { name: "Delete Agent" })).toBeEnabled();
|
||||
idleRender.unmount();
|
||||
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "terminated" }));
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
expect(await screen.findByRole("button", { name: "Delete Agent" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("deletes an agent from Settings after confirmation", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "idle" }));
|
||||
const addToast = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
projectId="proj_123"
|
||||
onClose={onClose}
|
||||
addToast={addToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
await user.click(await screen.findByRole("button", { name: "Delete Agent" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Delete agent "Test Agent"? This cannot be undone.');
|
||||
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-001", "proj_123");
|
||||
expect(addToast).toHaveBeenCalledWith('Agent "Test Agent" deleted', "success");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not delete from Settings when confirmation is canceled", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "idle" }));
|
||||
const addToast = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
projectId="proj_123"
|
||||
onClose={onClose}
|
||||
addToast={addToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
await user.click(await screen.findByRole("button", { name: "Delete Agent" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockDeleteAgent).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("deleted"), "success");
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shows settings delete control as unavailable for non-deletable states", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "active" }));
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
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)."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders advanced settings form fields on Settings tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
||||
@@ -27695,6 +27695,15 @@ html .column.drag-over * {
|
||||
margin: 0 0 var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.config-section--danger {
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 40%, transparent);
|
||||
}
|
||||
|
||||
.config-danger-note {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* === Agent Content Preview === */
|
||||
.agent-content-toolbar {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user