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)} + /> ); } diff --git a/workspace/tests/test_mcp_cli_multi_workspace.py b/workspace/tests/test_mcp_cli_multi_workspace.py index 9ca4f434..b562951a 100644 --- a/workspace/tests/test_mcp_cli_multi_workspace.py +++ b/workspace/tests/test_mcp_cli_multi_workspace.py @@ -184,9 +184,14 @@ class TestPlatformAuthRegistry: assert b["Authorization"] == "Bearer tok-b" assert a["Origin"] == "https://example.test" - def test_auth_headers_with_no_arg_uses_legacy_path(self, monkeypatch): + def test_auth_headers_with_no_arg_uses_legacy_path(self, monkeypatch, tmp_path): import platform_auth + # Wipe the module-level token cache and redirect _token_file() to a + # non-existent path so the env var isolation is clean. Without this, + # the real /configs/.auth_token pollutes the result. + platform_auth.clear_cache() + monkeypatch.setattr(platform_auth, "_token_file", lambda: tmp_path / ".auth_token") monkeypatch.setenv("PLATFORM_URL", "https://example.test") monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "legacy-tok") # Multi-workspace registry populated, but auth_headers() with @@ -199,10 +204,15 @@ class TestPlatformAuthRegistry: assert h["Authorization"] == "Bearer legacy-tok" def test_auth_headers_with_unknown_workspace_falls_back_to_legacy( - self, monkeypatch + self, monkeypatch, tmp_path ): import platform_auth + # Wipe the module-level token cache and redirect _token_file() to a + # non-existent path so the env var isolation is clean. Without this, + # the real /configs/.auth_token pollutes the result. + platform_auth.clear_cache() + monkeypatch.setattr(platform_auth, "_token_file", lambda: tmp_path / ".auth_token") monkeypatch.setenv("PLATFORM_URL", "https://example.test") monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "legacy-tok") platform_auth.register_workspace_token("ws-a", "tok-a") diff --git a/workspace/tests/test_mcp_doctor.py b/workspace/tests/test_mcp_doctor.py index 5b587d24..ed109bf9 100644 --- a/workspace/tests/test_mcp_doctor.py +++ b/workspace/tests/test_mcp_doctor.py @@ -166,9 +166,15 @@ def test_resolve_token_returns_value_and_label_for_env(monkeypatch): assert mcp_doctor._resolve_token_summary() == label -def test_resolve_token_returns_none_when_missing(monkeypatch): +def test_resolve_token_returns_none_when_missing(monkeypatch, tmp_path): monkeypatch.delenv("MOLECULE_WORKSPACE_TOKEN", raising=False) monkeypatch.delenv("MOLECULE_WORKSPACE_TOKEN_FILE", raising=False) + # The .auth_token file at /configs/.auth_token (present in container env) + # must not pollute the test. Patch configs_dir.resolve() to return a + # bare temp dir so the disk-file fallback in _resolve_token() has + # nothing to find. + import configs_dir + monkeypatch.setattr(configs_dir, "resolve", lambda: tmp_path) val, label = mcp_doctor._resolve_token() assert val is None assert label is None