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 <frontend-engineer@agents.moleculesai.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
molecule-ai[bot] 2026-04-17 00:35:54 +00:00 committed by GitHub
parent 15f55f2fb0
commit b37f71b6da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 219 additions and 8 deletions

View File

@ -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() {
<Canvas />
<Legend />
<CommunicationOverlay />
{hydrationError && (
<div
role="alert"
className="fixed inset-0 flex flex-col items-center justify-center bg-zinc-950 text-zinc-300 gap-4 z-[9999]"
>
<p className="text-zinc-400 text-sm">{hydrationError}</p>
<button
onClick={() => {
setHydrationError(null);
window.location.reload();
}}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-md text-sm"
>
Retry
</button>
</div>
)}
</>
);
}

View File

@ -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

View File

@ -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<Array<HTMLButtonElement | null>>([]);
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() {
<div className="col-span-3 text-[11px] text-zinc-400 mb-1">
Tier
</div>
{[
{ 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) => (
<button
key={t.value}
ref={(el) => { radioRefs.current[idx] = el; }}
role="radio"
aria-checked={tier === t.value}
tabIndex={tier === t.value ? 0 : -1}
onClick={() => setTier(t.value)}
onKeyDown={(e) => handleRadioKeyDown(e, idx)}
className={`py-2 rounded-lg text-center transition-colors ${
tier === t.value
? "bg-blue-600/20 border border-blue-500/50 text-blue-300"

View File

@ -163,4 +163,50 @@ describe("ContextMenu — keyboard accessibility", () => {
const { container } = render(<ContextMenu />);
expect(container.firstChild).toBeNull();
});
// ── Zoom to Team (#557) ───────────────────────────────────────────────────
it("does NOT show 'Zoom to Team' when node has no children", () => {
mockStore.nodes = []; // no children
render(<ContextMenu />);
const items = screen.getAllByRole("menuitem");
const labels = items.map((el) => el.textContent ?? "");
expect(labels.some((l) => l.includes("Zoom to Team"))).toBe(false);
});
it("shows 'Zoom to Team' when the node has children", () => {
mockStore.nodes = [{ id: "child-1", data: { parentId: "ws-1" } }];
render(<ContextMenu />);
const items = screen.getAllByRole("menuitem");
const labels = items.map((el) => el.textContent ?? "");
expect(labels.some((l) => l.includes("Zoom to Team"))).toBe(true);
});
it("clicking 'Zoom to Team' dispatches molecule:zoom-to-team event", () => {
mockStore.nodes = [{ id: "child-1", data: { parentId: "ws-1" } }];
const dispatched: CustomEvent[] = [];
window.addEventListener("molecule:zoom-to-team", (e) => {
dispatched.push(e as CustomEvent);
});
render(<ContextMenu />);
const items = screen.getAllByRole("menuitem");
const zoomItem = items.find((el) => el.textContent?.includes("Zoom to Team"))!;
expect(zoomItem).toBeTruthy();
fireEvent.click(zoomItem);
expect(dispatched).toHaveLength(1);
expect(dispatched[0].detail.nodeId).toBe("ws-1");
window.removeEventListener("molecule:zoom-to-team", () => {});
});
it("clicking 'Zoom to Team' closes the context menu", () => {
mockStore.nodes = [{ id: "child-1", data: { parentId: "ws-1" } }];
render(<ContextMenu />);
const items = screen.getAllByRole("menuitem");
const zoomItem = items.find((el) => el.textContent?.includes("Zoom to Team"))!;
fireEvent.click(zoomItem);
expect(closeContextMenu).toHaveBeenCalled();
});
});

View File

@ -89,4 +89,75 @@ describe("CreateWorkspaceDialog — accessibility", () => {
expect(t2?.getAttribute("aria-checked")).toBe("true")
);
});
// ── Arrow-key navigation (WCAG 2.1 radio group) — Issue #556 ──────────────
it("selected radio has tabIndex=0, others have tabIndex=-1 (roving tabIndex)", async () => {
await openDialog();
const radios = screen.getAllByRole("radio");
const t1 = radios.find((r) => r.textContent?.includes("T1"))!;
const t2 = radios.find((r) => r.textContent?.includes("T2"))!;
const t3 = radios.find((r) => r.textContent?.includes("T3"))!;
// T1 is default selected
expect(t1.getAttribute("tabindex")).toBe("0");
expect(t2.getAttribute("tabindex")).toBe("-1");
expect(t3.getAttribute("tabindex")).toBe("-1");
});
it("ArrowDown moves selection from T1 to T2", async () => {
await openDialog();
const radios = screen.getAllByRole("radio");
const t1 = radios.find((r) => r.textContent?.includes("T1"))!;
const t2 = radios.find((r) => r.textContent?.includes("T2"))!;
t1.focus();
fireEvent.keyDown(t1, { key: "ArrowDown" });
await waitFor(() => expect(t2.getAttribute("aria-checked")).toBe("true"));
expect(t1.getAttribute("aria-checked")).toBe("false");
});
it("ArrowRight moves selection from T2 to T3", async () => {
await openDialog();
const radios = screen.getAllByRole("radio");
const t2 = radios.find((r) => r.textContent?.includes("T2"))!;
const t3 = radios.find((r) => r.textContent?.includes("T3"))!;
fireEvent.click(t2); // select T2 first
await waitFor(() => expect(t2.getAttribute("aria-checked")).toBe("true"));
t2.focus();
fireEvent.keyDown(t2, { key: "ArrowRight" });
await waitFor(() => expect(t3.getAttribute("aria-checked")).toBe("true"));
});
it("ArrowDown wraps from T3 back to T1", async () => {
await openDialog();
const radios = screen.getAllByRole("radio");
const t1 = radios.find((r) => r.textContent?.includes("T1"))!;
const t3 = radios.find((r) => r.textContent?.includes("T3"))!;
fireEvent.click(t3); // select T3 first
await waitFor(() => expect(t3.getAttribute("aria-checked")).toBe("true"));
t3.focus();
fireEvent.keyDown(t3, { key: "ArrowDown" });
await waitFor(() => expect(t1.getAttribute("aria-checked")).toBe("true"));
});
it("ArrowUp moves selection from T2 to T1", async () => {
await openDialog();
const radios = screen.getAllByRole("radio");
const t1 = radios.find((r) => r.textContent?.includes("T1"))!;
const t2 = radios.find((r) => r.textContent?.includes("T2"))!;
fireEvent.click(t2);
await waitFor(() => expect(t2.getAttribute("aria-checked")).toBe("true"));
t2.focus();
fireEvent.keyDown(t2, { key: "ArrowUp" });
await waitFor(() => expect(t1.getAttribute("aria-checked")).toBe("true"));
});
it("ArrowLeft wraps from T1 back to T3", async () => {
await openDialog();
const radios = screen.getAllByRole("radio");
const t1 = radios.find((r) => r.textContent?.includes("T1"))!;
const t3 = radios.find((r) => r.textContent?.includes("T3"))!;
t1.focus();
fireEvent.keyDown(t1, { key: "ArrowLeft" });
await waitFor(() => expect(t3.getAttribute("aria-checked")).toBe("true"));
});
});

View File

@ -1,3 +1,5 @@
"use client";
interface RevealToggleProps {
revealed: boolean;
onToggle: () => void;

View File

@ -719,6 +719,33 @@ describe("misc state setters", () => {
});
});
// ---------- hydrationError (#554) ----------
describe("hydrationError", () => {
it("initial value is null", () => {
expect(useCanvasStore.getState().hydrationError).toBeNull();
});
it("setHydrationError stores an error message", () => {
useCanvasStore.getState().setHydrationError("Network timeout");
expect(useCanvasStore.getState().hydrationError).toBe("Network timeout");
});
it("setHydrationError(null) clears the error", () => {
useCanvasStore.getState().setHydrationError("Some error");
useCanvasStore.getState().setHydrationError(null);
expect(useCanvasStore.getState().hydrationError).toBeNull();
});
it("setHydrationError does not affect other state", () => {
useCanvasStore.getState().hydrate([makeWS({ id: "ws-x", name: "X" })]);
useCanvasStore.getState().setHydrationError("oops");
// Nodes should still be intact
expect(useCanvasStore.getState().nodes).toHaveLength(1);
expect(useCanvasStore.getState().nodes[0].id).toBe("ws-x");
});
});
// ---------- ACTIVITY_LOGGED event ----------
describe("ACTIVITY_LOGGED event", () => {

View File

@ -73,6 +73,9 @@ interface CanvasState {
/** WebSocket connection status — drives the live indicator in the Toolbar. */
wsStatus: "connected" | "connecting" | "disconnected";
setWsStatus: (status: "connected" | "connecting" | "disconnected") => void;
/** Hydration error message — set when initial canvas load fails. Null when no error. */
hydrationError: string | null;
setHydrationError: (error: string | null) => void;
}
export const useCanvasStore = create<CanvasState>((set, get) => ({
@ -84,6 +87,8 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
contextMenu: null,
wsStatus: "connecting",
setWsStatus: (status) => set({ wsStatus: status }),
hydrationError: null,
setHydrationError: (error) => set({ hydrationError: error }),
viewport: { x: 0, y: 0, zoom: 1 },