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:
parent
15f55f2fb0
commit
b37f71b6da
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
interface RevealToggleProps {
|
||||
revealed: boolean;
|
||||
onToggle: () => void;
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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 },
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user