feat(FN-4058): add bulk lifecycle transitions to agent detail view
Adds a bulk actions menu to the AgentDetailView header with support for bulk lifecycle transitions, backed by a new test suite for the feature. Fusion-Task-Id: FN-4058
This commit is contained in:
@@ -165,6 +165,56 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-detail-bulk-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.agent-detail-bulk-menu-popover {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + var(--space-xs));
|
||||
min-width: calc(var(--space-xl) * 9);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 2;
|
||||
padding: var(--space-xs);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.agent-detail-bulk-menu-item {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
padding: var(--space-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--space-xs) * 0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agent-detail-bulk-menu-item:hover:not(:disabled),
|
||||
.agent-detail-bulk-menu-item:focus-visible {
|
||||
outline: none;
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.agent-detail-bulk-menu-item:disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.agent-detail-bulk-menu-item-hint {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.agent-detail-import-btn {
|
||||
min-height: calc(var(--space-lg) + var(--space-md) + var(--space-xs));
|
||||
}
|
||||
@@ -1620,6 +1670,10 @@
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.agent-detail-bulk-menu-popover {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.agent-detail-utility-actions .btn-icon {
|
||||
min-height: calc(var(--space-lg) + var(--space-md) + var(--space-xs));
|
||||
min-width: calc(var(--space-lg) + var(--space-md) + var(--space-xs));
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch, ListChecks,
|
||||
AlertCircle,
|
||||
ChevronDown, ChevronRight, ChevronLeft, BarChart3, BookOpen, Eye, FileEdit,
|
||||
Mail, Send, Inbox as InboxIcon, User
|
||||
Mail, Send, Inbox as InboxIcon, User, MoreVertical
|
||||
} from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -15,7 +15,7 @@ import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, Mod
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead } from "../api";
|
||||
import type { Agent } from "../api";
|
||||
import type { AgentLogEntry, Task, Message, ParticipantType } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import { getErrorMessage, isEphemeralAgent } from "@fusion/core";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
import { AgentReflectionsTab } from "./AgentReflectionsTab";
|
||||
import { getAgentHealthStatus } from "../utils/agentHealth";
|
||||
@@ -141,12 +141,18 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [isStartingRun, setIsStartingRun] = useState(false);
|
||||
const [isBulkMenuOpen, setIsBulkMenuOpen] = useState(false);
|
||||
const [isBulkActionRunning, setIsBulkActionRunning] = useState(false);
|
||||
const [isBulkEligibilityLoading, setIsBulkEligibilityLoading] = useState(false);
|
||||
const [bulkPauseEligibleCount, setBulkPauseEligibleCount] = useState(0);
|
||||
const [bulkResumeEligibleCount, setBulkResumeEligibleCount] = useState(0);
|
||||
const [runNowRefreshToken, setRunNowRefreshToken] = useState(0);
|
||||
const [latestRun, setLatestRun] = useState<AgentHeartbeatRun | null>(null);
|
||||
const [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null);
|
||||
const [isLoadingMailbox, setIsLoadingMailbox] = useState(false);
|
||||
const [mailboxError, setMailboxError] = useState<string | null>(null);
|
||||
const agentDetailModalRef = useRef<HTMLDivElement>(null);
|
||||
const bulkMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const overlayMouseDownRef = useRef(false);
|
||||
useModalResizePersist(agentDetailModalRef, !inline, "fusion:agent-detail-modal-size");
|
||||
const onCloseRef = useRef(onClose);
|
||||
@@ -288,6 +294,59 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
}
|
||||
}, [agent, activeTab, loadMailbox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBulkMenuOpen) return;
|
||||
|
||||
const onDocumentMouseDown = (event: MouseEvent) => {
|
||||
if (!bulkMenuRef.current?.contains(event.target as Node)) {
|
||||
setIsBulkMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsBulkMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", onDocumentMouseDown);
|
||||
document.addEventListener("keydown", onDocumentKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDocumentMouseDown);
|
||||
document.removeEventListener("keydown", onDocumentKeyDown);
|
||||
};
|
||||
}, [isBulkMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBulkMenuOpen) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsBulkEligibilityLoading(true);
|
||||
|
||||
fetchAgents(undefined, projectId)
|
||||
.then((projectAgents) => {
|
||||
if (cancelled) return;
|
||||
const nonEphemeralAgents = projectAgents.filter((projectAgent) => !isEphemeralAgent(projectAgent));
|
||||
setBulkPauseEligibleCount(nonEphemeralAgents.filter((projectAgent) => projectAgent.state === "active" || projectAgent.state === "running").length);
|
||||
setBulkResumeEligibleCount(nonEphemeralAgents.filter((projectAgent) => projectAgent.state === "paused").length);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setBulkPauseEligibleCount(0);
|
||||
setBulkResumeEligibleCount(0);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsBulkEligibilityLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isBulkMenuOpen, projectId]);
|
||||
|
||||
// When falling back to latest-run logs (no taskId) and that run is active,
|
||||
// subscribe to the run-scoped SSE stream so the Logs tab tails updates.
|
||||
useEffect(() => {
|
||||
@@ -460,6 +519,63 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkStateChange = async (targetState: "paused" | "active") => {
|
||||
if (isBulkActionRunning) return;
|
||||
setIsBulkMenuOpen(false);
|
||||
setIsBulkActionRunning(true);
|
||||
|
||||
try {
|
||||
const projectAgents = await fetchAgents(undefined, projectId);
|
||||
const nonEphemeralAgents = projectAgents.filter((projectAgent) => !isEphemeralAgent(projectAgent));
|
||||
const eligibleAgents = nonEphemeralAgents.filter((projectAgent) => (
|
||||
targetState === "paused"
|
||||
? projectAgent.state === "active" || projectAgent.state === "running"
|
||||
: projectAgent.state === "paused"
|
||||
));
|
||||
|
||||
const skippedCount = nonEphemeralAgents.length - eligibleAgents.length;
|
||||
if (eligibleAgents.length === 0) {
|
||||
addToast(`No agents eligible to ${targetState === "paused" ? "pause" : "resume"}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: targetState === "paused" ? "Pause All Agents" : "Resume All Agents",
|
||||
message: `${targetState === "paused" ? "Pause" : "Resume"} ${eligibleAgents.length} agent${eligibleAgents.length === 1 ? "" : "s"} in this project?`,
|
||||
danger: targetState === "paused",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
eligibleAgents.map((projectAgent) => updateAgentState(projectAgent.id, targetState, projectId)),
|
||||
);
|
||||
|
||||
const failedResults = results
|
||||
.map((result, index) => ({ result, agent: eligibleAgents[index] }))
|
||||
.filter((entry): entry is { result: PromiseRejectedResult; agent: Agent } => entry.result.status === "rejected");
|
||||
|
||||
const successCount = results.length - failedResults.length;
|
||||
const failureCount = failedResults.length;
|
||||
const baseSummary = `${targetState === "paused" ? "Paused" : "Resumed"} ${successCount} agent${successCount === 1 ? "" : "s"}; skipped ${skippedCount}`;
|
||||
|
||||
if (failureCount > 0) {
|
||||
const failureSummary = failedResults
|
||||
.slice(0, 3)
|
||||
.map(({ agent, result }) => `${agent.name || agent.id}: ${getErrorMessage(result.reason)}`)
|
||||
.join("; ");
|
||||
addToast(`${baseSummary}; failed ${failureCount}${failureSummary ? ` (${failureSummary})` : ""}`, "error");
|
||||
} else {
|
||||
addToast(baseSummary, "success");
|
||||
}
|
||||
|
||||
await handleSavedMutation();
|
||||
} catch (err) {
|
||||
addToast(`Failed to ${targetState === "paused" ? "pause" : "resume"} agents: ${getErrorMessage(err)}`, "error");
|
||||
} finally {
|
||||
setIsBulkActionRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunHeartbeat = async () => {
|
||||
if (isStartingRun) return;
|
||||
setIsStartingRun(true);
|
||||
@@ -553,6 +669,8 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
const stateStyle = STATE_COLORS[agent.state];
|
||||
const health = getHealthStatus();
|
||||
const detailShellClassName = inline ? "agent-detail-inline" : "agent-detail-modal";
|
||||
const isPauseAllDisabled = isBulkEligibilityLoading || bulkPauseEligibleCount === 0;
|
||||
const isResumeAllDisabled = isBulkEligibilityLoading || bulkResumeEligibleCount === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -684,6 +802,56 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
|
||||
{/* Utility actions: refresh + close */}
|
||||
<div className="agent-detail-utility-actions">
|
||||
<div className="agent-detail-bulk-menu" ref={bulkMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => setIsBulkMenuOpen((open) => !open)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isBulkMenuOpen}
|
||||
aria-label="Bulk agent actions"
|
||||
title="Bulk agent actions"
|
||||
disabled={isTransitioning || isBulkActionRunning}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
{isBulkMenuOpen && (
|
||||
<div className="agent-detail-bulk-menu-popover" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="agent-detail-bulk-menu-item"
|
||||
onClick={() => void handleBulkStateChange("paused")}
|
||||
disabled={isPauseAllDisabled || isBulkActionRunning}
|
||||
>
|
||||
Pause All Agents
|
||||
<span className="agent-detail-bulk-menu-item-hint">
|
||||
{isBulkEligibilityLoading
|
||||
? "Loading eligible agents..."
|
||||
: isPauseAllDisabled
|
||||
? "No active agents eligible"
|
||||
: `Pause ${bulkPauseEligibleCount} active/running agent${bulkPauseEligibleCount === 1 ? "" : "s"}`}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="agent-detail-bulk-menu-item"
|
||||
onClick={() => void handleBulkStateChange("active")}
|
||||
disabled={isResumeAllDisabled || isBulkActionRunning}
|
||||
>
|
||||
Resume All Agents
|
||||
<span className="agent-detail-bulk-menu-item-hint">
|
||||
{isBulkEligibilityLoading
|
||||
? "Loading eligible agents..."
|
||||
: isResumeAllDisabled
|
||||
? "No paused agents eligible"
|
||||
: `Resume ${bulkResumeEligibleCount} paused agent${bulkResumeEligibleCount === 1 ? "" : "s"}`}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn-icon" onClick={() => void loadAgent()} title="Refresh" aria-label="Refresh">
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
|
||||
@@ -1309,11 +1309,83 @@ describe("AgentDetailView", () => {
|
||||
|
||||
const utilityContainer = headerActions?.querySelector(".agent-detail-utility-actions");
|
||||
expect(utilityContainer).toBeTruthy();
|
||||
expect(utilityContainer?.querySelector('[title="Bulk agent actions"]')).toBeTruthy();
|
||||
expect(utilityContainer?.querySelector('[title="Refresh"]')).toBeTruthy();
|
||||
expect(utilityContainer?.querySelector('[title="Close"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders bulk lifecycle menu with state-aware eligibility hints", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchAgents.mockResolvedValueOnce([
|
||||
{ id: "agent-001", name: "Alpha", state: "active", role: "executor", metadata: {} },
|
||||
{ id: "agent-002", name: "Bravo", state: "running", role: "executor", metadata: {} },
|
||||
{ id: "agent-003", name: "Charlie", state: "paused", role: "executor", metadata: {} },
|
||||
] as any);
|
||||
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Bulk agent actions" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Pause All Agents")).toBeInTheDocument();
|
||||
expect(screen.getByText("Resume All Agents")).toBeInTheDocument();
|
||||
expect(screen.getByText("Pause 2 active/running agents")).toBeInTheDocument();
|
||||
expect(screen.getByText("Resume 1 paused agent")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("bulk pause confirms, skips ineligible/system agents, and refreshes detail", async () => {
|
||||
const user = userEvent.setup();
|
||||
const addToast = vi.fn();
|
||||
mockFetchAgents.mockResolvedValue([
|
||||
{ id: "agent-001", name: "Alpha", state: "active", role: "executor", metadata: {} },
|
||||
{ id: "agent-002", name: "Bravo", state: "running", role: "executor", metadata: {} },
|
||||
{ id: "agent-003", name: "Charlie", state: "paused", role: "executor", metadata: {} },
|
||||
{ id: "agent-004", name: "System", state: "active", role: "executor", metadata: { type: "spawned" } },
|
||||
] as any);
|
||||
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={addToast} />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Bulk agent actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: /Pause All Agents/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfirm).toHaveBeenCalled();
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "paused", undefined);
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "paused", undefined);
|
||||
expect(mockUpdateAgentState).not.toHaveBeenCalledWith("agent-003", "paused", undefined);
|
||||
expect(mockUpdateAgentState).not.toHaveBeenCalledWith("agent-004", "paused", undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("Paused 2 agents; skipped 1", "success");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("reports partial failures during bulk resume", async () => {
|
||||
const user = userEvent.setup();
|
||||
const addToast = vi.fn();
|
||||
mockFetchAgents.mockResolvedValue([
|
||||
{ id: "agent-001", name: "Alpha", state: "paused", role: "executor", metadata: {} },
|
||||
{ id: "agent-002", name: "Bravo", state: "paused", role: "executor", metadata: {} },
|
||||
{ id: "agent-003", name: "Charlie", state: "idle", role: "executor", metadata: {} },
|
||||
] as any);
|
||||
mockUpdateAgentState
|
||||
.mockResolvedValueOnce(createMockAgent({ state: "active" }))
|
||||
.mockRejectedValueOnce(new Error("network"));
|
||||
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={addToast} />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Bulk agent actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: /Resume All Agents/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Resumed 1 agent; skipped 1; failed 1"), "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps mobile inline header controls on the same row as identity", () => {
|
||||
const stylesContent = loadAllAppCss();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user