diff --git a/packages/dashboard/app/components/AgentDetailView.css b/packages/dashboard/app/components/AgentDetailView.css index 4bfe5d4e6..e0e0dc5de 100644 --- a/packages/dashboard/app/components/AgentDetailView.css +++ b/packages/dashboard/app/components/AgentDetailView.css @@ -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)); diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index 85d4c8b23..cd9743357 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -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(null); const [agentMailbox, setAgentMailbox] = useState(null); const [isLoadingMailbox, setIsLoadingMailbox] = useState(false); const [mailboxError, setMailboxError] = useState(null); const agentDetailModalRef = useRef(null); + const bulkMenuRef = useRef(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 (
+
+ + {isBulkMenuOpen && ( +
+ + +
+ )} +
diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.test.tsx b/packages/dashboard/app/components/__tests__/AgentDetailView.test.tsx index 792216f2c..b6e762bab 100644 --- a/packages/dashboard/app/components/__tests__/AgentDetailView.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentDetailView.test.tsx @@ -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(); + + 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(); + + 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(); + + 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();