diff --git a/canvas/src/components/KeyboardShortcutsDialog.tsx b/canvas/src/components/KeyboardShortcutsDialog.tsx new file mode 100644 index 00000000..31b73da1 --- /dev/null +++ b/canvas/src/components/KeyboardShortcutsDialog.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +interface ShortcutGroup { + title: string; + shortcuts: Array<{ keys: string[]; description: string }>; +} + +const SHORTCUT_GROUPS: ShortcutGroup[] = [ + { + title: "Canvas", + shortcuts: [ + { + keys: ["Esc"], + description: "Close context menu, clear selection, or deselect", + }, + { + keys: ["Enter"], + description: "Descend into selected node's first child", + }, + { + keys: ["Shift", "Enter"], + description: "Ascend to selected node's parent", + }, + { + keys: ["Cmd", "]"], + description: "Bring selected node forward in z-order", + }, + { + keys: ["Cmd", "["], + description: "Send selected node backward in z-order", + }, + { + keys: ["Z"], + description: "Zoom to fit the selected team and its sub-workspaces", + }, + ], + }, + { + title: "Navigation", + shortcuts: [ + { + keys: ["⌘K"], + description: "Open workspace search", + }, + { + keys: ["Palette"], + description: "Open the template palette to deploy a new workspace", + }, + { + keys: ["Dbl-click"], + description: "Zoom canvas to fit a team node and all its sub-workspaces", + }, + { + keys: ["Right-click"], + description: "Open the workspace context menu", + }, + ], + }, + { + title: "Agent", + shortcuts: [ + { + keys: ["Chat"], + description: "Send a message or resume a running task", + }, + { + keys: ["Config"], + description: "Edit skills, model, secrets, and runtime settings", + }, + { + keys: ["Audit"], + description: "View the activity ledger for the selected workspace", + }, + ], + }, +]; + +interface Props { + open: boolean; + onClose: () => void; +} + +export function KeyboardShortcutsDialog({ open, onClose }: Props) { + const dialogRef = useRef(null); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + // Move focus into the dialog when it opens (WCAG 2.1 SC 2.4.3) + useEffect(() => { + if (!open || !mounted) return; + const raf = requestAnimationFrame(() => { + dialogRef.current?.querySelector("button")?.focus(); + }); + return () => cancelAnimationFrame(raf); + }, [open, mounted]); + + // Keyboard: Escape closes, Tab is trapped + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key === "Tab" && dialogRef.current) { + const focusable = Array.from( + dialogRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + ).filter((el) => !el.hasAttribute("disabled")); + if (focusable.length === 0) { + e.preventDefault(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + if (!open || !mounted) return null; + + return createPortal( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+ {/* Header */} +
+

+ Keyboard Shortcuts +

+ +
+ + {/* Content */} +
+ {SHORTCUT_GROUPS.map((group) => ( +
+

+ {group.title} +

+
+ {group.shortcuts.map((shortcut, i) => ( +
+ + {shortcut.description} + + + {shortcut.keys.map((k, j) => ( + + {j > 0 && ( + + + + + )} + + {k} + + + ))} + +
+ ))} +
+
+ ))} +
+ + {/* Footer */} +
+

+ Press{" "} + + Esc + {" "} + to close +

+
+
+
, + document.body + ); +} diff --git a/canvas/src/components/Toolbar.tsx b/canvas/src/components/Toolbar.tsx index 777d4f92..1d8cc12f 100644 --- a/canvas/src/components/Toolbar.tsx +++ b/canvas/src/components/Toolbar.tsx @@ -9,6 +9,7 @@ import { ConfirmDialog } from "@/components/ConfirmDialog"; import { showToast } from "@/components/Toaster"; import { ThemeToggle } from "@/components/ThemeToggle"; import { statusDotClass } from "@/lib/design-tokens"; +import { KeyboardShortcutsDialog } from "@/components/KeyboardShortcutsDialog"; export function Toolbar() { const nodes = useCanvasStore((s) => s.nodes); @@ -33,6 +34,7 @@ export function Toolbar() { const [restartingAll, setRestartingAll] = useState(false); const [restartConfirmOpen, setRestartConfirmOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false); + const [shortcutsOpen, setShortcutsOpen] = useState(false); const helpRef = useRef(null); // Suppress toast on the very first connect at page load; only fire on reconnects. @@ -127,6 +129,29 @@ export function Toolbar() { }; }, []); + // Global ? shortcut opens the shortcuts dialog (mirrors the help button). + // Skip when the user is typing in an input so ? in a text field doesn't + // steal focus. Also skip when a modal/dialog is already open. + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key !== "?") return; + const tag = (e.target as HTMLElement).tagName; + const inInput = + tag === "INPUT" || + tag === "TEXTAREA" || + tag === "SELECT" || + (e.target as HTMLElement).isContentEditable; + if (inInput) return; + // Don't fire when a modal/dialog is already mounted (canvas modals, + // side panel, etc. use z-50 or above). + if (document.querySelector('[role="dialog"][aria-modal="true"]')) return; + e.preventDefault(); + setShortcutsOpen(true); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + return (
+ {/* Link to the full keyboard shortcuts dialog */} +
)} @@ -340,6 +373,11 @@ export function Toolbar() { onConfirm={restartAll} onCancel={() => setRestartConfirmOpen(false)} /> + + setShortcutsOpen(false)} + /> ); }