diff --git a/canvas/src/components/BatchActionBar.tsx b/canvas/src/components/BatchActionBar.tsx index 004b3205..2a293631 100644 --- a/canvas/src/components/BatchActionBar.tsx +++ b/canvas/src/components/BatchActionBar.tsx @@ -30,6 +30,24 @@ export function BatchActionBar() { if (count === 0 && hasFailedBatch) setHasFailedBatch(false); }, [count, hasFailedBatch]); + // Esc clears selection — the deselect button title has been promising + // "(Escape)" since the bar shipped, but no handler was wired. Skip when + // the confirm dialog is open (`pending !== null`) so the dialog's own + // Esc-cancels takes precedence and we don't double-handle the keystroke. + // Also skip during a busy in-flight action so the user can't accidentally + // strand a partial-failure mid-flight. + useEffect(() => { + if (count === 0 || pending !== null || busy) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + clearSelection(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [count, pending, busy, clearSelection]); + // Hide when nothing is selected. Hide for single-node selection UNLESS a // partial-failure left a survivor awaiting retry. if (count === 0) return null; @@ -129,7 +147,7 @@ export function BatchActionBar() { onClick={clearSelection} aria-label="Clear selection" title="Clear selection (Escape)" - className="p-1.5 rounded-lg text-[12px] text-ink-mid hover:text-ink hover:bg-surface-card/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500/70" + className="p-1.5 rounded-lg text-[12px] text-ink-mid hover:text-ink hover:bg-surface-card/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50" > ✕ diff --git a/canvas/src/components/__tests__/BatchActionBar.test.tsx b/canvas/src/components/__tests__/BatchActionBar.test.tsx index 4dd9fef6..f72b7570 100644 --- a/canvas/src/components/__tests__/BatchActionBar.test.tsx +++ b/canvas/src/components/__tests__/BatchActionBar.test.tsx @@ -130,6 +130,26 @@ describe("BatchActionBar", () => { const toolbar = screen.getByRole("toolbar"); expect(toolbar.getAttribute("aria-label")).toBe("Batch workspace actions"); }); + + it("Esc clears the selection — matches the deselect button title", () => { + // The deselect button has been promising "Clear selection (Escape)" + // since the bar shipped, but no handler was wired. This pins the + // contract. + mockSelectedNodeIds = new Set(["ws-1", "ws-2"]); + render(); + fireEvent.keyDown(window, { key: "Escape" }); + expect(mockClearSelection).toHaveBeenCalled(); + }); + + it("Esc is a no-op when nothing is selected", () => { + mockSelectedNodeIds = new Set(); + render(); + fireEvent.keyDown(window, { key: "Escape" }); + // The early-return at count===0 prevents the bar from mounting at all, + // so the keydown listener never registers. clearSelection must NOT be + // called. + expect(mockClearSelection).not.toHaveBeenCalled(); + }); }); /**