feat(canvas): batch operations — multi-select + restart/pause/delete (Phase 20.3)

- Shift+click to toggle node selection (multi-select mode)
- BatchActionBar floating at bottom when >1 node selected
- Batch Restart All, Pause All, Delete All with ConfirmDialog
- Selected nodes get blue ring highlight
- Escape clears selection
- Pane click clears selection
- Dark theme, accessible (ARIA labels, focus rings)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-18 01:16:55 -07:00
parent f9ded2a4d7
commit 6de705b2a1
4 changed files with 186 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

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