diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index ebd8a1d3..5983b72f 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { ReactFlow, ReactFlowProvider, @@ -187,6 +187,23 @@ function CanvasInner() { // Pan-to-node / zoom-to-team CustomEvent listeners + viewport save. const { onMoveEnd } = useCanvasViewport(); + // Screen-reader announcements — read liveAnnouncement from the store and + // immediately clear it so the same announcement doesn't re-fire on + // re-render. Using a ref avoids a setState loop while keeping the + // effect reactive to new announcement strings. + const liveAnnouncement = useCanvasStore((s) => s.liveAnnouncement); + const clearAnnouncement = useCanvasStore((s) => s.setLiveAnnouncement); + const prevAnnouncement = useRef(""); + useEffect(() => { + if (liveAnnouncement && liveAnnouncement !== prevAnnouncement.current) { + prevAnnouncement.current = liveAnnouncement; + // Small delay so the DOM update lands before clearing, giving + // screen readers time to pick up the new text. + const timer = setTimeout(() => clearAnnouncement(""), 500); + return () => clearTimeout(timer); + } + }, [liveAnnouncement, clearAnnouncement]); + // Delete-confirmation lives in the store so the dialog survives ContextMenu // unmounting — the prior local-in-ContextMenu state raced with the menu's // outside-click handler. @@ -326,11 +343,21 @@ function CanvasInner() { - {/* Screen-reader live region: announces workspace count on canvas load or change */} -
- {nodes.filter((n) => !n.parentId).length === 0 - ? "No workspaces on canvas" - : `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas`} + {/* Screen-reader live region — announces workspace count on initial load and + live status updates from WebSocket events (online, offline, provisioning, etc.). + The liveAnnouncement text is cleared after the screen reader has had time + to read it so the same message doesn't re-announce on re-render. */} +
+ {liveAnnouncement || ( + nodes.filter((n) => !n.parentId).length === 0 + ? "No workspaces on canvas" + : `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas` + )}
{nodes.length === 0 && } diff --git a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx index 074d96fc..cb49309b 100644 --- a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx +++ b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx @@ -513,7 +513,20 @@ function GroupedCommsView({ />
{visible.map((msg) => - msg.status === "error" ? ( + // Only render the error UI when there is NO usable response + // content. A "error" status from the platform means the HTTP + // transport layer had a problem — but the agent response text + // may have arrived and been stored in response_body.text. + // Delegation results set responseText via extractResponseText + // once that function learned to parse body.text, so checking + // !msg.responseText here correctly identifies "no actual reply + // was received" vs. "reply arrived but status=error". + // + // Without this guard, successful delegation results were + // rendered as error banners, PMs saw "restart" prompts and + // restarted working agents, and retry storms formed as the + // platform re-delivered the same completed work (issue #159). + msg.status === "error" && !msg.responseText ? ( ) : ( diff --git a/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.render.test.tsx b/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.render.test.tsx index 80b37982..b8032c33 100644 --- a/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.render.test.tsx +++ b/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.render.test.tsx @@ -4,9 +4,11 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; // API mock — tests can override per case via apiGetMock.mockImplementationOnce. const apiGetMock = vi.fn<(url: string) => Promise>(); +const apiPostMock = vi.fn<(url: string, body?: unknown) => Promise>(); vi.mock("@/lib/api", () => ({ api: { get: (url: string) => apiGetMock(url), + post: (url: string, body?: unknown) => apiPostMock(url, body), }, })); @@ -16,17 +18,23 @@ vi.mock("@/hooks/useSocketEvent", () => ({ useSocketEvent: () => {}, })); -// Canvas store — peer name resolution. -vi.mock("@/store/canvas", () => ({ - useCanvasStore: { - getState: () => ({ - nodes: [ - { id: "ws-self", data: { name: "Self" } }, - { id: "ws-peer", data: { name: "Peer Agent" } }, - ], - }), - }, -})); +// Canvas store — peer name resolution + ErrorMessage requires selectNode +// (Zustand hook usage). The mock must support BOTH: +// useCanvasStore.getState().nodes (plain object with getState) +// useCanvasStore((s) => s.selectNode) (Zustand hook with selector) +vi.mock("@/store/canvas", () => { + const state = { + nodes: [ + { id: "ws-self", data: { name: "Self" } }, + { id: "ws-peer", data: { name: "Peer Agent" } }, + ], + selectNode: vi.fn(), + }; + const hook = (selector?: (s: typeof state) => unknown) => + selector ? selector(state) : state; + hook.getState = () => state; + return { useCanvasStore: hook }; +}); // Toaster shim — AgentCommsPanel imports showToast. vi.mock("../../Toaster", () => ({ @@ -41,6 +49,8 @@ import { AgentCommsPanel } from "../AgentCommsPanel"; const scrollSpy = vi.fn<(opts?: ScrollIntoViewOptions | boolean) => void>(); beforeEach(() => { apiGetMock.mockReset(); + apiPostMock.mockReset(); + apiPostMock.mockResolvedValue({}); scrollSpy.mockReset(); Element.prototype.scrollIntoView = scrollSpy as unknown as Element["scrollIntoView"]; }); @@ -49,6 +59,81 @@ afterEach(() => { vi.clearAllMocks(); }); +// Regression test: when a delegation succeeds but the platform persisted +// status="error" (transport-layer HTTP failure, not agent failure), the +// canvas had the response text in msg.text but rendered ErrorMessage +// anyway, burying the real content in an "Underlying error" banner and +// prompting PMs to restart working agents (issue #159). +describe("AgentCommsPanel — error rendering guard (issue #159)", () => { + it("renders NormalMessage when status=error but msg.text is present (successful delegation)", async () => { + // Simulate a delegation result where status="error" (HTTP transport + // failed) but response_body.text carries the actual agent response. + // The correct behaviour: show the content as a normal inbound bubble, + // NOT an error banner. + apiGetMock.mockResolvedValueOnce([ + { + id: "act-1", + activity_type: "delegation", + method: "delegate_result", + source_id: "ws-self", + target_id: "ws-peer", + summary: "Delegation completed", + request_body: null, + // delegation.go stores response_body as {text: "...", delegation_id: "..."} + response_body: { + text: "PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)", + delegation_id: "delg_01jx8q4n3k", + }, + status: "error", // transport-layer error, not agent failure + created_at: "2026-04-25T18:00:00Z", + }, + ]); + render(); + + // The response text should appear in a normal inbound bubble, NOT in + // an error banner. Specifically: no "Failed to deliver" or "returned + // an error" text should appear. + await waitFor(() => { + expect(screen.queryByText(/failed to deliver/i)).toBeNull(); + expect(screen.queryByText(/returned an error/i)).toBeNull(); + }); + // The actual content must be visible. + await waitFor(() => + expect( + screen.getByText(/tier-check fails NO REVIEWS/i), + ).toBeDefined(), + ); + }); + + it("renders ErrorMessage when status=error and msg.text is absent (true failure)", async () => { + // True delivery failure: no response body, no text. The error banner + // IS appropriate here. + apiGetMock.mockResolvedValueOnce([ + { + id: "act-1", + activity_type: "a2a_send", + source_id: "ws-self", + target_id: "ws-peer", + method: "message/send", + summary: "A2A send failed", + request_body: null, + response_body: null, + status: "error", + created_at: "2026-04-25T18:00:00Z", + }, + ]); + render(); + + // Error banner IS shown for true failures (no content). + // jsdom doesn't reliably match role="alert" in getByRole, so use + // getByText instead. + const errorBanner = await waitFor(() => + screen.getByText(/failed to deliver/i), + ); + expect(errorBanner).toBeDefined(); + }); +}); + describe("AgentCommsPanel — initial-state parity with ChatTab my-chat", () => { it("shows loading text while history fetch is in flight", () => { apiGetMock.mockReturnValueOnce(new Promise(() => { /* never resolves */ })); diff --git a/canvas/src/components/tabs/chat/__tests__/message-parser.test.ts b/canvas/src/components/tabs/chat/__tests__/message-parser.test.ts index 71befb4e..3a4748a7 100644 --- a/canvas/src/components/tabs/chat/__tests__/message-parser.test.ts +++ b/canvas/src/components/tabs/chat/__tests__/message-parser.test.ts @@ -209,6 +209,43 @@ describe("extractResponseText", () => { }; expect(extractResponseText(body)).toBe("Summary\nDetail block one\nDetail block two"); }); + + // Regression: delegation.go stores response_body as + // {"text": "...", "delegation_id": "..."} — no "result" wrapper. + // Without body.text handling, extractResponseText returns "" for + // delegate_result rows, causing the error UI to fire even when the + // delegation succeeded (issue #159). + it("extracts from body.text (delegation response_body shape)", () => { + const body = { + text: "PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)", + delegation_id: "delg_01jx8q4n3k", + }; + expect(extractResponseText(body)).toBe( + "PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)" + ); + }); + + it("prefers body.result over body.text when both present", () => { + const body = { + result: { parts: [{ kind: "text", text: "A2A result wins" }] }, + text: "Delegation text", + }; + // result path is checked first; A2A wins when both present. + expect(extractResponseText(body)).toBe("A2A result wins"); + }); + + it("returns empty string when body.text is empty string", () => { + expect(extractResponseText({ text: "" })).toBe(""); + }); + + it("extracts from body.response_preview (DELEGATION_COMPLETE WS event shape)", () => { + const body = { + response_preview: "PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)", + }; + expect(extractResponseText(body)).toBe( + "PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)" + ); + }); }); describe("extractTextsFromParts", () => { diff --git a/canvas/src/components/tabs/chat/message-parser.ts b/canvas/src/components/tabs/chat/message-parser.ts index 9ce8e502..cc1cf5e1 100644 --- a/canvas/src/components/tabs/chat/message-parser.ts +++ b/canvas/src/components/tabs/chat/message-parser.ts @@ -168,10 +168,10 @@ export function extractResponseText(body: Record): string { if (rootTexts.length > 0) collected.push(rootTexts.join("\n")); // Task shape: {result: {artifacts: [{parts: [...]}]}} - const artifacts = result.artifacts as Array> | undefined; + const artifacts = result.artifacts as Array | undefined>; if (artifacts) { for (const a of artifacts) { - const t = extractTextsFromParts(a.parts); + const t = extractTextsFromParts(a?.parts); if (t) collected.push(t); } } @@ -179,6 +179,20 @@ export function extractResponseText(body: Record): string { if (collected.length > 0) return collected.join("\n"); } + // Delegation results from delegation.go store response_body as + // {"text": "...", "delegation_id": "..."} — no "result" wrapper. + // Check this after the body.result path so A2A responses take + // precedence when both shapes are somehow present. + // Without this, responseText is always "" for delegate_result rows, + // causing the error UI to fire even when the delegation succeeded + // (issue #159). + if (typeof body.text === "string" && body.text) return body.text; + // DELEGATION_COMPLETE event (via canvas-events WS handler) stores + // response_body as {response_preview: "..."}. Handle this too. + if (typeof body.response_preview === "string" && body.response_preview) { + return body.response_preview; + } + // {task: "text"} — request body format, shouldn't be in response but handle it if (typeof body.task === "string") return body.task; } catch { /* ignore */ } diff --git a/canvas/src/store/__tests__/canvas-events.test.ts b/canvas/src/store/__tests__/canvas-events.test.ts index 84f81d57..ddd7d0cc 100644 --- a/canvas/src/store/__tests__/canvas-events.test.ts +++ b/canvas/src/store/__tests__/canvas-events.test.ts @@ -835,3 +835,181 @@ describe("handleCanvasEvent – unknown event", () => { ).not.toThrow(); }); }); + +// --------------------------------------------------------------------------- +// Screen-reader live announcements +// --------------------------------------------------------------------------- + +describe("handleCanvasEvent – liveAnnouncement", () => { + it("announces WORKSPACE_ONLINE with node name", () => { + const node = makeNode("ws-1", { name: "Alpha" }); + const { get, set, state } = makeStore([node]); + + handleCanvasEvent( + makeMsg({ event: "WORKSPACE_ONLINE", workspace_id: "ws-1" }), + get, + set + ); + + expect(state.liveAnnouncement).toBe("Alpha is now online"); + }); + + it("announces WORKSPACE_OFFLINE with node name", () => { + const node = makeNode("ws-1", { name: "Beta" }); + const { get, set, state } = makeStore([node]); + + handleCanvasEvent( + makeMsg({ event: "WORKSPACE_OFFLINE", workspace_id: "ws-1" }), + get, + set + ); + + expect(state.liveAnnouncement).toBe("Beta is now offline"); + }); + + it("announces WORKSPACE_PAUSED with node name", () => { + const node = makeNode("ws-1", { name: "Gamma" }); + const { get, set, state } = makeStore([node]); + + handleCanvasEvent( + makeMsg({ event: "WORKSPACE_PAUSED", workspace_id: "ws-1" }), + get, + set + ); + + expect(state.liveAnnouncement).toBe("Gamma has been paused"); + }); + + it("announces WORKSPACE_DEGRADED with node name", () => { + const node = makeNode("ws-1", { name: "Delta" }); + const { get, set, state } = makeStore([node]); + + handleCanvasEvent( + makeMsg({ + event: "WORKSPACE_DEGRADED", + workspace_id: "ws-1", + payload: { sample_error: "connection timeout" }, + }), + get, + set + ); + + expect(state.liveAnnouncement).toBe("Delta is degraded"); + }); + + it("announces WORKSPACE_PROVISIONING for new workspace with payload name", () => { + const { get, set, state } = makeStore([]); + + handleCanvasEvent( + makeMsg({ + event: "WORKSPACE_PROVISIONING", + workspace_id: "ws-new", + payload: { name: "NewBot" }, + }), + get, + set + ); + + expect(state.liveAnnouncement).toBe("NewBot is provisioning"); + }); + + it("announces WORKSPACE_PROVISIONING for new workspace with default name", () => { + const { get, set, state } = makeStore([]); + + handleCanvasEvent( + makeMsg({ + event: "WORKSPACE_PROVISIONING", + workspace_id: "ws-new", + payload: {}, + }), + get, + set + ); + + expect(state.liveAnnouncement).toBe("New Workspace is provisioning"); + }); + + it("announces WORKSPACE_REMOVED with node name", () => { + const node = makeNode("ws-1", { name: "Gamma" }); + const { get, set, state } = makeStore([node]); + + handleCanvasEvent( + makeMsg({ event: "WORKSPACE_REMOVED", workspace_id: "ws-1" }), + get, + set + ); + + expect(state.liveAnnouncement).toBe("Gamma was removed"); + }); + + it("announces WORKSPACE_PROVISION_FAILED with node name", () => { + const node = makeNode("ws-1", { name: "Delta" }); + const { get, set, state } = makeStore([node]); + + handleCanvasEvent( + makeMsg({ + event: "WORKSPACE_PROVISION_FAILED", + workspace_id: "ws-1", + payload: { error: "docker pull failed" }, + }), + get, + set + ); + + expect(state.liveAnnouncement).toBe("Delta provisioning failed"); + }); + + it("does not announce for TASK_UPDATED", () => { + const node = makeNode("ws-1", { name: "Alpha" }); + const { get, set, state } = makeStore([node]); + + handleCanvasEvent( + makeMsg({ + event: "TASK_UPDATED", + workspace_id: "ws-1", + payload: { current_task: "building release", active_tasks: 1 }, + }), + get, + set + ); + + // TASK_UPDATED is noisy (every heartbeat); it should not announce + expect(state.liveAnnouncement ?? "").toBe(""); + }); + + it("does not announce for AGENT_MESSAGE", () => { + const node = makeNode("ws-1", { name: "Alpha" }); + const { get, set, state } = makeStore([node]); + + handleCanvasEvent( + makeMsg({ + event: "AGENT_MESSAGE", + workspace_id: "ws-1", + payload: { message: "hello from the agent" }, + }), + get, + set + ); + + expect(state.liveAnnouncement ?? "").toBe(""); + }); + + it("uses payload name for ONLINE when node not found in store", () => { + const { get, set, state } = makeStore([]); + + handleCanvasEvent( + makeMsg({ + event: "WORKSPACE_ONLINE", + workspace_id: "ws-1", + payload: { name: "FromPayload" }, + }), + get, + set + ); + + // ONLINE when node doesn't exist just buffers _pendingOnline; + // no announcement should be set + expect(state.liveAnnouncement ?? "").toBe(""); + }); +}); +}); diff --git a/canvas/src/store/canvas-events.ts b/canvas/src/store/canvas-events.ts index 30807530..97b204e2 100644 --- a/canvas/src/store/canvas-events.ts +++ b/canvas/src/store/canvas-events.ts @@ -80,6 +80,7 @@ export function handleCanvasEvent( switch (msg.event) { case "WORKSPACE_ONLINE": { const existing = nodes.find((n) => n.id === msg.workspace_id); + const nodeName = existing?.data.name ?? (msg.payload.name as string) ?? "Workspace"; if (!existing) { // PROVISIONING event hasn't been applied yet (WS reorder or // this tab joined mid-deploy). Buffer so the later PROVISIONING @@ -105,6 +106,7 @@ export function handleCanvasEvent( ? { ...n, data: { ...n.data, status: "online" } } : n, ), + liveAnnouncement: `${nodeName} is now online`, }); // Remove the laser class after its keyframe ends so the edge // settles into the app's default solid styling. Fire-and-forget. @@ -123,28 +125,36 @@ export function handleCanvasEvent( } case "WORKSPACE_OFFLINE": { + const offlineNode = nodes.find((n) => n.id === msg.workspace_id); + const offlineName = offlineNode?.data.name ?? "Workspace"; set({ nodes: nodes.map((n) => n.id === msg.workspace_id ? { ...n, data: { ...n.data, status: "offline" } } : n ), + liveAnnouncement: `${offlineName} is now offline`, }); break; } case "WORKSPACE_PAUSED": { + const pausedNode = nodes.find((n) => n.id === msg.workspace_id); + const pausedName = pausedNode?.data.name ?? "Workspace"; set({ nodes: nodes.map((n) => n.id === msg.workspace_id ? { ...n, data: { ...n.data, status: "paused", currentTask: "" } } : n ), + liveAnnouncement: `${pausedName} has been paused`, }); break; } case "WORKSPACE_DEGRADED": { + const degradedNode = nodes.find((n) => n.id === msg.workspace_id); + const degradedName = degradedNode?.data.name ?? "Workspace"; set({ nodes: nodes.map((n) => n.id === msg.workspace_id @@ -160,6 +170,7 @@ export function handleCanvasEvent( } : n ), + liveAnnouncement: `${degradedName} is degraded`, }); break; } @@ -230,6 +241,7 @@ export function handleCanvasEvent( // removed per demo feedback. A2A edges (showA2AEdges) still // render when enabled — those represent runtime traffic, // which nesting doesn't express. + const newNodeName = (msg.payload.name as string) ?? "New Workspace"; set({ nodes: [ ...nodes, @@ -244,7 +256,7 @@ export function handleCanvasEvent( ...(parentId ? { parentId } : {}), className: "mol-deploy-spawn", data: { - name: (msg.payload.name as string) ?? "New Workspace", + name: newNodeName, status: "provisioning", tier: (msg.payload.tier as number) ?? 1, agentCard: null, @@ -261,6 +273,7 @@ export function handleCanvasEvent( }, }, ], + liveAnnouncement: `${newNodeName} is provisioning`, }); // Grow the parent to fit the just-landed child. DEBOUNCED @@ -345,6 +358,7 @@ export function handleCanvasEvent( case "WORKSPACE_REMOVED": { const removedNode = nodes.find((n) => n.id === msg.workspace_id); + const removedName = removedNode?.data.name ?? "Workspace"; const parentOfRemoved = removedNode?.data.parentId ?? null; set({ nodes: nodes @@ -363,6 +377,7 @@ export function handleCanvasEvent( e.source !== msg.workspace_id && e.target !== msg.workspace_id ), selectedNodeId: selectedNodeId === msg.workspace_id ? null : selectedNodeId, + liveAnnouncement: `${removedName} was removed`, }); break; } @@ -445,6 +460,8 @@ export function handleCanvasEvent( case "WORKSPACE_PROVISION_FAILED": { const errorMsg = (msg.payload.error as string) ?? "Unknown provisioning error"; + const failedNode = nodes.find((n) => n.id === msg.workspace_id); + const failedName = failedNode?.data.name ?? "Workspace"; set({ nodes: nodes.map((n) => n.id === msg.workspace_id @@ -458,6 +475,7 @@ export function handleCanvasEvent( } : n ), + liveAnnouncement: `${failedName} provisioning failed`, }); break; } diff --git a/canvas/src/store/canvas.ts b/canvas/src/store/canvas.ts index 6f09907d..e2d6e150 100644 --- a/canvas/src/store/canvas.ts +++ b/canvas/src/store/canvas.ts @@ -225,6 +225,11 @@ interface CanvasState { /** Whether the A2A topology overlay is visible. Persisted to localStorage. Default: true. */ showA2AEdges: boolean; setShowA2AEdges: (show: boolean) => void; + /** Screen-reader announcement text. Set by handleCanvasEvent on significant + * status changes; consumed and cleared by the aria-live region in Canvas.tsx + * so the same announcement doesn't re-fire on re-render. */ + liveAnnouncement: string; + setLiveAnnouncement: (msg: string) => void; } export const useCanvasStore = create((set, get) => ({ @@ -321,6 +326,8 @@ export const useCanvasStore = create((set, get) => ({ localStorage.setItem("molecule:show-a2a-edges", String(show)); } }, + liveAnnouncement: "", + setLiveAnnouncement: (msg) => set({ liveAnnouncement: msg }), viewport: { x: 0, y: 0, zoom: 1 }, diff --git a/docs/design-system/canvas-audit-items.md b/docs/design-system/canvas-audit-items.md index 86888f69..2affbbdb 100644 --- a/docs/design-system/canvas-audit-items.md +++ b/docs/design-system/canvas-audit-items.md @@ -41,11 +41,10 @@ canvas/src/ ## Known Issues -### 🔴 HIGH: secrets-store.ts Performance +### ✅ MEDIUM: secrets-store.ts Performance (mitigated) **File:** `canvas/src/stores/secrets-store.ts` -**Issue:** `getGrouped()` selector creates new objects every call (Object.fromEntries + arrays). Not memoized. -**Impact:** Causes unnecessary re-renders on frequent selector access. -**Fix needed:** Memoize the selector or use a proper Zustand selector pattern. +**Issue:** `getGrouped()` selector creates new objects every call. Not memoized. +**Impact:** Mitigated — `SecretsTab.tsx` wraps the call in `useMemo`, so no active re-render issues in the single consumer. The store-level fix (memoizing `getGrouped` itself) is optional but low priority now. ### 🟡 MEDIUM: Pre-commit Hook Verification **Issue:** Pre-commit hook checks 'use client' on hook-using components but unclear if it actually fails on violations. @@ -108,7 +107,7 @@ canvas/src/ | Priority | Item | Files | Status | |----------|------|-------|--------| -| HIGH | Screen reader announcements for canvas state changes | Canvas.tsx | Not started | +| ~~HIGH~~ | ~~Screen reader announcements for canvas state changes~~ | ~~Canvas.tsx, canvas-events.ts, canvas.ts~~ | ✅ Done — PR #172 | | MEDIUM | Keyboard shortcut help dialog | useKeyboardShortcuts.ts | Not started | | MEDIUM | Keyboard-accessible node drag | WorkspaceNode.tsx, useDragHandlers.ts | Not started | | LOW | Edge anchor keyboard accessibility | A2AEdge.tsx | Not started |