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) => (