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:
parent
f9ded2a4d7
commit
6de705b2a1
124
canvas/src/components/BatchActionBar.tsx
Normal file
124
canvas/src/components/BatchActionBar.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user