From b37f71b6da92bf3d4dd5e0a2b2a0b35b039f3dd7 Mon Sep 17 00:00:00 2001 From: "molecule-ai[bot]" <276602405+molecule-ai[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:35:54 +0000 Subject: [PATCH] fix(canvas): hydration error UI (#554), radio arrow-key nav (#556), zoom-to-team context menu (#557) (#565) - #554 CRITICAL: Add hydrationError state to Zustand store; catch handler now calls setHydrationError instead of silent console.error; page renders a full-screen zinc-950 error banner with a Retry button that reloads the page - #556 MEDIUM: Add roving tabIndex + ArrowDown/Up/Left/Right keyboard handler to the tier radio group in CreateWorkspaceDialog (WCAG 2.1 compliant) - #557 MEDIUM: Add "Zoom to Team" menu item to ContextMenu (visible only when node has children); dispatches molecule:zoom-to-team for keyboard accessibility - Bonus: add missing 'use client' directive to RevealToggle.tsx Co-authored-by: Molecule AI Frontend Engineer Co-authored-by: Claude Sonnet 4.6 --- canvas/src/app/page.tsx | 25 ++++++- canvas/src/components/ContextMenu.tsx | 13 +++- .../src/components/CreateWorkspaceDialog.tsx | 38 ++++++++-- .../__tests__/ContextMenu.keyboard.test.tsx | 46 ++++++++++++ .../CreateWorkspaceDialog.a11y.test.tsx | 71 +++++++++++++++++++ canvas/src/components/ui/RevealToggle.tsx | 2 + canvas/src/store/__tests__/canvas.test.ts | 27 +++++++ canvas/src/store/canvas.ts | 5 ++ 8 files changed, 219 insertions(+), 8 deletions(-) diff --git a/canvas/src/app/page.tsx b/canvas/src/app/page.tsx index e785cb9a..b8976a35 100644 --- a/canvas/src/app/page.tsx +++ b/canvas/src/app/page.tsx @@ -10,6 +10,9 @@ import { api } from "@/lib/api"; import type { WorkspaceData } from "@/store/socket"; export default function Home() { + const hydrationError = useCanvasStore((s) => s.hydrationError); + const setHydrationError = useCanvasStore((s) => s.setHydrationError); + useEffect(() => { connectSocket(); @@ -23,8 +26,11 @@ export default function Home() { useCanvasStore.getState().setViewport(viewport); } }).catch((err) => { - // Initial hydration failed — socket reconnect will retry + // Initial hydration failed — show error banner to user console.error("Canvas: initial hydration failed", err); + useCanvasStore.getState().setHydrationError( + err instanceof Error && err.message ? err.message : "Failed to load canvas" + ); }); return () => { @@ -37,6 +43,23 @@ export default function Home() { + {hydrationError && ( +
+

{hydrationError}

+ +
+ )} ); } diff --git a/canvas/src/components/ContextMenu.tsx b/canvas/src/components/ContextMenu.tsx index 5e1d2f4f..c03fb8fa 100644 --- a/canvas/src/components/ContextMenu.tsx +++ b/canvas/src/components/ContextMenu.tsx @@ -235,6 +235,14 @@ export function ContextMenu() { closeContextMenu(); }, [contextMenu, nestNode, closeContextMenu]); + const handleZoomToTeam = useCallback(() => { + if (!contextMenu) return; + window.dispatchEvent( + new CustomEvent("molecule:zoom-to-team", { detail: { nodeId: contextMenu.nodeId } }) + ); + closeContextMenu(); + }, [contextMenu, closeContextMenu]); + if (!contextMenu) return null; const isOfflineOrFailed = contextMenu.nodeData.status === "offline" || contextMenu.nodeData.status === "failed"; @@ -253,7 +261,10 @@ export function ContextMenu() { ? [{ label: "Extract from Team", icon: "⤴", action: handleRemoveFromTeam }] : []), ...(hasChildren - ? [{ label: "Collapse Team", icon: "◁", action: handleCollapse }] + ? [ + { label: "Collapse Team", icon: "◁", action: handleCollapse }, + { label: "Zoom to Team", icon: "⊕", action: handleZoomToTeam }, + ] : [{ label: "Expand to Team", icon: "▷", action: handleExpand }]), { label: "", icon: "", action: () => {}, divider: true }, ...(isPaused diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 4b0a8065..9c5f4dd0 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import * as Dialog from "@radix-ui/react-dialog"; import { api } from "@/lib/api"; @@ -50,6 +50,33 @@ export function CreateWorkspaceButton() { const [hermesProvider, setHermesProvider] = useState("anthropic"); const [hermesApiKey, setHermesApiKey] = useState(""); + // Refs for roving tabIndex on the tier radio group (WCAG 2.1 arrow-key nav) + const radioRefs = useRef>([]); + const TIERS = [ + { value: 1, label: "T1", desc: "Sandboxed" }, + { value: 2, label: "T2", desc: "Standard" }, + { value: 3, label: "T3", desc: "Full Access" }, + ]; + + const handleRadioKeyDown = useCallback( + (e: React.KeyboardEvent, currentIndex: number) => { + if (e.key === "ArrowDown" || e.key === "ArrowRight") { + e.preventDefault(); + const next = (currentIndex + 1) % TIERS.length; + setTier(TIERS[next].value); + radioRefs.current[next]?.focus(); + } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { + e.preventDefault(); + const prev = (currentIndex - 1 + TIERS.length) % TIERS.length; + setTier(TIERS[prev].value); + radioRefs.current[prev]?.focus(); + } + }, + // TIERS is stable (module-level constant pattern), setTier is stable from useState + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + const isHermes = template.trim().toLowerCase() === "hermes"; // Reset form and load workspaces whenever dialog opens @@ -172,16 +199,15 @@ export function CreateWorkspaceButton() {
Tier
- {[ - { value: 1, label: "T1", desc: "Sandboxed" }, - { value: 2, label: "T2", desc: "Standard" }, - { value: 3, label: "T3", desc: "Full Access" }, - ].map((t) => ( + {TIERS.map((t, idx) => (