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:
Fusion
2026-04-24 04:42:05 -07:00
committed by gsxdsm
parent c105cfaff1
commit 8ffe005ba1
4 changed files with 156 additions and 1 deletions

View File

@@ -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.
![Agents view](./screenshots/agents-view.png)
## Built-In Agent Prompt Templates

View File

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

View File

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

View File

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