From 2aea674747620c74384aafd8a1de3828ddec6b56 Mon Sep 17 00:00:00 2001 From: "molecule-ai[bot]" <276602405+molecule-ai[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:45:34 +0000 Subject: [PATCH] feat(canvas): A2A topology overlay with animated delegation edges (issue #744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New A2ATopologyOverlay component polls /activity fan-out every 60s and writes directed edges to a2aEdges store slice (separate from topology edges) - buildA2AEdges aggregates delegate rows per source→target pair; violet-500 animated edge when last call <5 min ago, blue-500 static otherwise - Toolbar toggle persists to localStorage (molecule:show-a2a-edges) - Canvas.tsx merges a2aEdges into allEdges via useMemo; pointerEvents:none on all edge elements keeps nodes draggable - 24 new unit tests across pure function, helper, and component suites - Fix Canvas.a11y and Canvas.pan-to-node store mocks (missing A2A fields) Closes #744 Co-Authored-By: Claude Sonnet 4.6 --- canvas/src/components/A2ATopologyOverlay.tsx | 188 ++++++++++++ canvas/src/components/Canvas.tsx | 11 +- canvas/src/components/Toolbar.tsx | 36 +++ .../__tests__/A2ATopologyOverlay.test.tsx | 280 ++++++++++++++++++ .../components/__tests__/Canvas.a11y.test.tsx | 6 + .../__tests__/Canvas.pan-to-node.test.tsx | 6 + canvas/src/store/canvas.ts | 20 ++ 7 files changed, 546 insertions(+), 1 deletion(-) create mode 100644 canvas/src/components/A2ATopologyOverlay.tsx create mode 100644 canvas/src/components/__tests__/A2ATopologyOverlay.test.tsx diff --git a/canvas/src/components/A2ATopologyOverlay.tsx b/canvas/src/components/A2ATopologyOverlay.tsx new file mode 100644 index 00000000..4a35e638 --- /dev/null +++ b/canvas/src/components/A2ATopologyOverlay.tsx @@ -0,0 +1,188 @@ +'use client'; + +import { useEffect, useMemo, useCallback } from "react"; +import { type Edge, MarkerType } from "@xyflow/react"; +import { api } from "@/lib/api"; +import { useCanvasStore } from "@/store/canvas"; +import type { ActivityEntry } from "@/types/activity"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** 60-minute look-back window for delegation activity */ +export const A2A_WINDOW_MS = 60 * 60 * 1000; + +/** Polling interval — refresh edges every 60 seconds */ +export const A2A_POLL_MS = 60 * 1_000; + +/** Threshold for "hot" edges: < 5 minutes → animated + violet stroke */ +export const A2A_HOT_MS = 5 * 60 * 1_000; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Format millisecond timestamp as human-readable relative time ("2m ago"). */ +export function formatA2ARelativeTime(ts: number, now = Date.now()): string { + const diff = now - ts; + if (diff < 60_000) return "just now"; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + return `${Math.floor(diff / 3_600_000)}h ago`; +} + +// ── Pure aggregation function (exported for unit tests) ─────────────────────── + +/** + * Converts raw delegation activity rows into React Flow overlay edges. + * + * Rules applied: + * - Only `method === "delegate"` rows (initiation, not result) to avoid double-counting. + * - Rows older than A2A_WINDOW_MS are discarded. + * - Rows with null source_id or target_id are skipped. + * - Multiple rows on the same source→target pair are aggregated (count + latest timestamp). + * - Edge is animated + violet-500 when lastAt < A2A_HOT_MS ago; otherwise blue-500. + * - All styles have `pointerEvents: "none"` so canvas nodes remain draggable. + */ +export function buildA2AEdges( + rows: ActivityEntry[], + now = Date.now() +): Edge[] { + const cutoff = now - A2A_WINDOW_MS; + + // 1. Filter: only delegate initiations within the window with valid endpoints + const initiations = rows.filter( + (r) => + r.method === "delegate" && + r.source_id != null && + r.target_id != null && + new Date(r.created_at).getTime() > cutoff + ); + + if (initiations.length === 0) return []; + + // 2. Aggregate by "source→target" pair + type Agg = { source: string; target: string; count: number; lastAt: number }; + const map = new Map(); + + for (const row of initiations) { + const source = row.source_id as string; + const target = row.target_id as string; + const key = `${source}→${target}`; + const ts = new Date(row.created_at).getTime(); + const prev = map.get(key) ?? { source, target, count: 0, lastAt: 0 }; + map.set(key, { + ...prev, + count: prev.count + 1, + lastAt: Math.max(prev.lastAt, ts), + }); + } + + // 3. Build React Flow Edge objects + return Array.from(map.values()).map(({ source, target, count, lastAt }) => { + const isHot = now - lastAt < A2A_HOT_MS; + const stroke = isHot ? "#8b5cf6" : "#3b82f6"; // violet-500 : blue-500 + + const callWord = count === 1 ? "call" : "calls"; + const label = `${count} ${callWord} · ${formatA2ARelativeTime(lastAt, now)}`; + + return { + id: `a2a-${source}-${target}`, + source, + target, + animated: isHot, + markerEnd: { + type: MarkerType.ArrowClosed, + color: stroke, + width: 12, + height: 12, + }, + style: { + stroke, + strokeWidth: 2, + // Non-blocking: label overlay never intercepts pointer events + pointerEvents: "none" as React.CSSProperties["pointerEvents"], + }, + label, + labelStyle: { + fill: "#a1a1aa", // zinc-400 + fontSize: 10, + pointerEvents: "none" as React.CSSProperties["pointerEvents"], + }, + labelBgStyle: { + fill: "#18181b", // zinc-900 + fillOpacity: 0.9, + pointerEvents: "none" as React.CSSProperties["pointerEvents"], + }, + labelBgPadding: [4, 6] as [number, number], + labelBgBorderRadius: 4, + }; + }); +} + +// ── Component ───────────────────────────────────────────────────────────────── + +/** + * A2ATopologyOverlay — null-rendering side-effect component. + * + * Fetches delegation activity from all visible workspace nodes (fan-out), + * aggregates into directed edges, and writes them to the canvas store as + * `a2aEdges`. Canvas.tsx merges these with topology edges and passes the + * combined list to ReactFlow. + * + * Mount this inside CanvasInner (no ReactFlow hook dependency). + */ +export function A2ATopologyOverlay() { + const showA2AEdges = useCanvasStore((s) => s.showA2AEdges); + // Stable Zustand action reference — safe to call inside effects + const setA2AEdges = useCanvasStore((s) => s.setA2AEdges); + + // Read the nodes array as a primitive ref; derive visible IDs outside the selector + const nodes = useCanvasStore((s) => s.nodes); + + // IDs of visible (non-nested, non-hidden) workspace nodes. + // Recomputed only when the nodes array reference changes. + const visibleIds = useMemo( + () => nodes.filter((n) => !n.hidden).map((n) => n.id), + [nodes] + ); + + // Fetch delegation activity for all visible workspaces and rebuild overlay edges. + const fetchAndUpdate = useCallback(async () => { + if (visibleIds.length === 0) { + setA2AEdges([]); + return; + } + try { + // Fan-out — one request per visible workspace. + // Per-request failures are swallowed so one broken workspace doesn't blank the overlay. + const allRows = ( + await Promise.all( + visibleIds.map((id) => + api + .get( + `/workspaces/${id}/activity?type=delegation&limit=500&source=agent` + ) + .catch(() => [] as ActivityEntry[]) + ) + ) + ).flat(); + + setA2AEdges(buildA2AEdges(allRows)); + } catch { + // Overlay failure is non-critical — canvas remains functional + } + }, [visibleIds, setA2AEdges]); + + useEffect(() => { + if (!showA2AEdges) { + // Clear edges immediately when toggled off + setA2AEdges([]); + return; + } + + // Initial fetch, then poll every 60 s + void fetchAndUpdate(); + const timer = setInterval(() => void fetchAndUpdate(), A2A_POLL_MS); + return () => clearInterval(timer); + }, [showA2AEdges, fetchAndUpdate, setA2AEdges]); + + // Pure side-effect — renders nothing + return null; +} diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index d0c9553a..add2ffa4 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -16,6 +16,7 @@ import { import "@xyflow/react/dist/style.css"; import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; +import { A2ATopologyOverlay } from "./A2ATopologyOverlay"; import { WorkspaceNode } from "./WorkspaceNode"; import { SidePanel } from "./SidePanel"; import { CreateWorkspaceButton } from "./CreateWorkspaceDialog"; @@ -56,6 +57,13 @@ export function Canvas() { function CanvasInner() { const nodes = useCanvasStore((s) => s.nodes); const edges = useCanvasStore((s) => s.edges); + const a2aEdges = useCanvasStore((s) => s.a2aEdges); + const showA2AEdges = useCanvasStore((s) => s.showA2AEdges); + // Merge topology edges with A2A overlay edges via useMemo (no new object in selector) + const allEdges = useMemo( + () => (showA2AEdges ? [...edges, ...a2aEdges] : edges), + [edges, a2aEdges, showA2AEdges] + ); const onNodesChange = useCanvasStore((s) => s.onNodesChange); const savePosition = useCanvasStore((s) => s.savePosition); const selectNode = useCanvasStore((s) => s.selectNode); @@ -257,7 +265,7 @@ function CanvasInner() { {nodes.length === 0 && } + diff --git a/canvas/src/components/Toolbar.tsx b/canvas/src/components/Toolbar.tsx index 7af056a4..0c2a78d5 100644 --- a/canvas/src/components/Toolbar.tsx +++ b/canvas/src/components/Toolbar.tsx @@ -12,6 +12,8 @@ import { statusDotClass } from "@/lib/design-tokens"; export function Toolbar() { const nodes = useCanvasStore((s) => s.nodes); const wsStatus = useCanvasStore((s) => s.wsStatus); + const showA2AEdges = useCanvasStore((s) => s.showA2AEdges); + const setShowA2AEdges = useCanvasStore((s) => s.setShowA2AEdges); const [stopping, setStopping] = useState(false); const [restartingAll, setRestartingAll] = useState(false); @@ -180,6 +182,40 @@ export function Toolbar() { )} + {/* A2A topology overlay toggle */} + + {/* Search shortcut */}