Merge pull request #949 from Molecule-AI/feat/canvas-batch-operations

feat(canvas): batch operations — multi-select + restart/pause/delete
This commit is contained in:
molecule-ai[bot] 2026-04-20 08:48:26 -07:00 committed by GitHub
commit a3e70b739c
9 changed files with 326 additions and 3 deletions

View File

@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import { useCanvasStore } from "@/store/canvas";
import { ConfirmDialog } from "./ConfirmDialog";
import { showToast } from "./Toaster";
type BatchAction = "restart" | "pause" | "delete" | null;
export function BatchActionBar() {
const selectedNodeIds = useCanvasStore((s) => s.selectedNodeIds);
const clearSelection = useCanvasStore((s) => s.clearSelection);
const batchRestart = useCanvasStore((s) => s.batchRestart);
const batchPause = useCanvasStore((s) => s.batchPause);
const batchDelete = useCanvasStore((s) => s.batchDelete);
const [pending, setPending] = useState<BatchAction>(null);
const [busy, setBusy] = useState(false);
const count = selectedNodeIds.size;
if (count < 2) return null;
const confirmMessages: Record<NonNullable<BatchAction>, string> = {
restart: `Restart ${count} workspace${count !== 1 ? "s" : ""}? Each will briefly go offline while it restarts.`,
pause: `Pause ${count} workspace${count !== 1 ? "s" : ""}? Their containers will be stopped.`,
delete: `Permanently delete ${count} workspace${count !== 1 ? "s" : ""}? This cannot be undone.`,
};
const confirmLabels: Record<NonNullable<BatchAction>, string> = {
restart: "Restart All",
pause: "Pause All",
delete: "Delete All",
};
async function execute() {
if (!pending) return;
setBusy(true);
try {
if (pending === "restart") await batchRestart();
if (pending === "pause") await batchPause();
if (pending === "delete") await batchDelete();
showToast(`${pending.charAt(0).toUpperCase() + pending.slice(1)} applied to ${count} workspace${count !== 1 ? "s" : ""}`, "success");
clearSelection();
} catch {
showToast(`Batch ${pending} failed`, "error");
} finally {
setBusy(false);
setPending(null);
}
}
const bar = (
<div
role="toolbar"
aria-label="Batch workspace actions"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[200] flex items-center gap-3 px-4 py-2.5 rounded-2xl bg-zinc-900/95 border border-zinc-700/70 shadow-2xl shadow-black/50 backdrop-blur-md"
>
{/* Selection count badge */}
<span className="text-[12px] font-semibold text-zinc-100 bg-blue-600/80 px-2.5 py-0.5 rounded-full tabular-nums">
{count} selected
</span>
<div className="w-px h-5 bg-zinc-700/60" aria-hidden="true" />
{/* Action buttons */}
<button
disabled={busy}
onClick={() => setPending("restart")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-sky-300 bg-sky-900/30 hover:bg-sky-800/50 border border-sky-700/30 hover:border-sky-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70"
>
<span aria-hidden="true"></span>
Restart All
</button>
<button
disabled={busy}
onClick={() => setPending("pause")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-amber-300 bg-amber-900/30 hover:bg-amber-800/50 border border-amber-700/30 hover:border-amber-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
>
<span aria-hidden="true"></span>
Pause All
</button>
<button
disabled={busy}
onClick={() => setPending("delete")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-red-300 bg-red-900/30 hover:bg-red-800/50 border border-red-700/30 hover:border-red-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70"
>
<span aria-hidden="true"></span>
Delete All
</button>
<div className="w-px h-5 bg-zinc-700/60" aria-hidden="true" />
{/* Deselect */}
<button
disabled={busy}
onClick={clearSelection}
aria-label="Clear selection"
title="Clear selection (Escape)"
className="p-1.5 rounded-lg text-[12px] text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500/70"
>
</button>
</div>
);
return (
<>
{typeof window !== "undefined" ? createPortal(bar, document.body) : null}
<ConfirmDialog
open={!!pending}
title={pending ? confirmLabels[pending] : ""}
message={pending ? confirmMessages[pending] : ""}
confirmLabel={pending ? confirmLabels[pending] : "Confirm"}
confirmVariant={pending === "delete" ? "danger" : pending === "pause" ? "warning" : "primary"}
onConfirm={execute}
onCancel={() => setPending(null)}
/>
</>
);
}

View File

@ -32,6 +32,8 @@ import { Toolbar } from "./Toolbar";
import { ConfirmDialog } from "./ConfirmDialog";
// Phase 20 components
import { SettingsPanel, DeleteConfirmDialog } from "./settings";
// Phase 20.3 batch operations
import { BatchActionBar } from "./BatchActionBar";
import { ProvisioningTimeout } from "./ProvisioningTimeout";
const nodeTypes = {
@ -133,7 +135,9 @@ function CanvasInner() {
const onPaneClick = useCallback(() => {
selectNode(null);
useCanvasStore.getState().closeContextMenu();
const state = useCanvasStore.getState();
state.closeContextMenu();
state.clearSelection();
}, [selectNode]);
// Team zoom-in: double-click a team node to zoom to its children
@ -192,6 +196,8 @@ function CanvasInner() {
const state = useCanvasStore.getState();
if (state.contextMenu) {
state.closeContextMenu();
} else if (state.selectedNodeIds.size > 0) {
state.clearSelection();
} else if (state.selectedNodeId) {
state.selectNode(null);
}
@ -336,6 +342,7 @@ function CanvasInner() {
<Toaster />
<ProvisioningTimeout />
{!selectedNodeId && <CreateWorkspaceButton />}
<BatchActionBar />
{/* Confirmation dialog for structure changes */}
<ConfirmDialog

View File

@ -47,6 +47,9 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
const nestNode = useCanvasStore((s) => s.nestNode);
const isDragTarget = useCanvasStore((s) => s.dragOverNodeId === id);
const isSelected = selectedNodeId === id;
// Batch selection (Phase 20.3)
const isBatchSelected = useCanvasStore((s) => s.selectedNodeIds.has(id));
const toggleNodeSelection = useCanvasStore((s) => s.toggleNodeSelection);
const isOnline = data.status === "online";
// Get children + hierarchy info (single stable selector avoids redundant re-renders)
@ -68,7 +71,11 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
aria-pressed={isSelected}
onClick={(e) => {
e.stopPropagation();
selectNode(isSelected ? null : id);
if (e.shiftKey) {
toggleNodeSelection(id);
} else {
selectNode(isSelected ? null : id);
}
}}
onDoubleClick={(e) => {
e.stopPropagation();
@ -84,7 +91,11 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectNode(isSelected ? null : id);
if (e.shiftKey) {
toggleNodeSelection(id);
} else {
selectNode(isSelected ? null : id);
}
} else if (e.key === "ContextMenu") {
e.preventDefault();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@ -103,6 +114,8 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
transition-all duration-200 ease-out
${isDragTarget
? "bg-emerald-950/40 border-2 border-emerald-400/60 ring-2 ring-emerald-400/20 scale-[1.03]"
: isBatchSelected
? "bg-zinc-900/95 border-2 border-blue-500/80 ring-2 ring-blue-500/30 shadow-lg shadow-blue-500/15"
: isSelected
? "bg-zinc-900/95 border border-blue-500/70 ring-1 ring-blue-500/30 shadow-lg shadow-blue-500/10"
: "bg-zinc-900/90 border border-zinc-700/80 hover:border-zinc-500/60 shadow-lg shadow-black/30 hover:shadow-xl hover:shadow-black/40"

View File

@ -0,0 +1,127 @@
// @vitest-environment jsdom
/**
* BatchActionBar tests Phase 20.3
*
* Covers:
* - Not rendered when fewer than 2 nodes selected
* - Renders with correct count badge when 2+ selected
* - Restart/Pause/Delete buttons exist with correct labels
* - Clear selection button exists
* - ConfirmDialog appears on destructive action click
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});
// ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock("@/components/Toaster", () => ({
showToast: vi.fn(),
}));
const mockClearSelection = vi.fn();
const mockBatchRestart = vi.fn(() => Promise.resolve());
const mockBatchPause = vi.fn(() => Promise.resolve());
const mockBatchDelete = vi.fn(() => Promise.resolve());
let mockSelectedNodeIds = new Set<string>();
vi.mock("@/store/canvas", () => ({
useCanvasStore: vi.fn((selector: (s: Record<string, unknown>) => unknown) =>
selector({
selectedNodeIds: mockSelectedNodeIds,
clearSelection: mockClearSelection,
batchRestart: mockBatchRestart,
batchPause: mockBatchPause,
batchDelete: mockBatchDelete,
})
),
}));
// Mock ConfirmDialog to just render buttons for testing
vi.mock("@/components/ConfirmDialog", () => ({
ConfirmDialog: ({
open,
title,
onConfirm,
onCancel,
}: {
open: boolean;
title: string;
confirmLabel: string;
message: string;
confirmVariant: string;
onConfirm: () => void;
onCancel: () => void;
}) =>
open ? (
<div data-testid="confirm-dialog">
<span>{title}</span>
<button onClick={onConfirm}>confirm</button>
<button onClick={onCancel}>cancel</button>
</div>
) : null,
}));
// Import after mocks
import { BatchActionBar } from "../BatchActionBar";
// ── Tests ────────────────────────────────────────────────────────────────────
describe("BatchActionBar", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSelectedNodeIds = new Set<string>();
});
it("does not render when fewer than 2 nodes selected", () => {
mockSelectedNodeIds = new Set(["ws-1"]);
const { container } = render(<BatchActionBar />);
expect(container.innerHTML).toBe("");
});
it("renders count badge when 2+ nodes selected", () => {
mockSelectedNodeIds = new Set(["ws-1", "ws-2", "ws-3"]);
render(<BatchActionBar />);
expect(screen.getByText("3 selected")).toBeTruthy();
});
it("renders Restart All, Pause All, Delete All buttons", () => {
mockSelectedNodeIds = new Set(["ws-1", "ws-2"]);
render(<BatchActionBar />);
expect(screen.getByText("Restart All")).toBeTruthy();
expect(screen.getByText("Pause All")).toBeTruthy();
expect(screen.getByText("Delete All")).toBeTruthy();
});
it("renders clear selection button with aria-label", () => {
mockSelectedNodeIds = new Set(["ws-1", "ws-2"]);
render(<BatchActionBar />);
const clearBtn = screen.getByRole("button", { name: "Clear selection" });
expect(clearBtn).toBeTruthy();
});
it("clicking clear selection calls clearSelection", () => {
mockSelectedNodeIds = new Set(["ws-1", "ws-2"]);
render(<BatchActionBar />);
fireEvent.click(screen.getByRole("button", { name: "Clear selection" }));
expect(mockClearSelection).toHaveBeenCalled();
});
it("clicking Delete All opens ConfirmDialog", () => {
mockSelectedNodeIds = new Set(["ws-1", "ws-2"]);
render(<BatchActionBar />);
fireEvent.click(screen.getByText("Delete All"));
expect(screen.getByTestId("confirm-dialog")).toBeTruthy();
});
it("has role=toolbar with aria-label", () => {
mockSelectedNodeIds = new Set(["ws-1", "ws-2"]);
render(<BatchActionBar />);
const toolbar = screen.getByRole("toolbar");
expect(toolbar.getAttribute("aria-label")).toBe("Batch workspace actions");
});
});

View File

@ -69,6 +69,9 @@ const mockStoreState = {
showA2AEdges: false,
setShowA2AEdges: vi.fn(),
setPanelTab: vi.fn(),
selectedNodeIds: new Set<string>(),
clearSelection: vi.fn(),
toggleNodeSelection: vi.fn(),
};
vi.mock("@/store/canvas", () => ({
@ -109,6 +112,7 @@ vi.mock("../ProvisioningTimeout", () => ({
<div data-testid="provisioning-timeout-sentinel" />
),
}));
vi.mock("../BatchActionBar", () => ({ BatchActionBar: () => null }));
// ── Import the component under test AFTER mocks ───────────────────────────────
import { Canvas } from "../Canvas";

View File

@ -79,6 +79,9 @@ const mockStoreState = {
showA2AEdges: false,
setShowA2AEdges: vi.fn(),
setPanelTab: vi.fn(),
selectedNodeIds: new Set<string>(),
clearSelection: vi.fn(),
toggleNodeSelection: vi.fn(),
};
vi.mock("@/store/canvas", () => ({
@ -113,6 +116,8 @@ vi.mock("../settings", () => ({
}));
vi.mock("../Toaster", () => ({ Toaster: () => null }));
vi.mock("../WorkspaceNode", () => ({ WorkspaceNode: () => null }));
vi.mock("../BatchActionBar", () => ({ BatchActionBar: () => null }));
vi.mock("../ProvisioningTimeout", () => ({ ProvisioningTimeout: () => null }));
import { Canvas } from "../Canvas";

View File

@ -125,6 +125,8 @@ const mockStoreState = {
nestNode: mockNestNode,
restartWorkspace: vi.fn(() => Promise.resolve()),
setPanelTab: vi.fn(),
selectedNodeIds: new Set<string>(),
toggleNodeSelection: vi.fn(),
};
vi.mock("@/store/canvas", () => ({

View File

@ -97,6 +97,8 @@ const mockStoreState = {
isDescendant: vi.fn(() => false),
restartWorkspace: vi.fn(),
setPanelTab: vi.fn(),
selectedNodeIds: new Set<string>(),
toggleNodeSelection: vi.fn(),
};
vi.mock("@/store/canvas", () => ({

View File

@ -71,6 +71,13 @@ interface CanvasState {
viewport: { x: number; y: number; zoom: number };
setViewport: (v: { x: number; y: number; zoom: number }) => void;
saveViewport: (x: number, y: number, zoom: number) => void;
// ── Batch selection (Phase 20.3) ─────────────────────────────────────────
selectedNodeIds: Set<string>;
toggleNodeSelection: (id: string) => void;
clearSelection: () => void;
batchRestart: () => Promise<void>;
batchPause: () => Promise<void>;
batchDelete: () => Promise<void>;
/** Agent-pushed messages keyed by workspace ID. ChatTab consumes and clears these. */
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string }>>;
consumeAgentMessages: (workspaceId: string) => Array<{ id: string; content: string; timestamp: string }>;
@ -96,6 +103,38 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
panelTab: "chat",
dragOverNodeId: null,
contextMenu: null,
// Batch selection
selectedNodeIds: new Set<string>(),
toggleNodeSelection: (id) => {
const prev = get().selectedNodeIds;
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
set({ selectedNodeIds: next });
},
clearSelection: () => set({ selectedNodeIds: new Set<string>() }),
batchRestart: async () => {
const ids = Array.from(get().selectedNodeIds);
await Promise.allSettled(ids.map((id) => api.post(`/workspaces/${id}/restart`)));
for (const id of ids) {
get().updateNodeData(id, { needsRestart: false });
}
},
batchPause: async () => {
const ids = Array.from(get().selectedNodeIds);
await Promise.allSettled(ids.map((id) => api.post(`/workspaces/${id}/pause`)));
},
batchDelete: async () => {
const ids = Array.from(get().selectedNodeIds);
await Promise.allSettled(ids.map((id) => api.del(`/workspaces/${id}`)));
for (const id of ids) {
get().removeNode(id);
}
set({ selectedNodeIds: new Set<string>() });
},
wsStatus: "connecting",
setWsStatus: (status) => set({ wsStatus: status }),
hydrationError: null,