Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions canvas/src/components/A2ATopologyOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<string, Agg>();

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<ActivityEntry[]>(
`/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;
}
11 changes: 10 additions & 1 deletion canvas/src/components/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -257,7 +265,7 @@ function CanvasInner() {
<ReactFlow
colorMode="dark"
nodes={nodes}
edges={edges}
edges={allEdges}
onNodesChange={onNodesChange}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
Expand Down Expand Up @@ -316,6 +324,7 @@ function CanvasInner() {
</div>

{nodes.length === 0 && <EmptyState />}
<A2ATopologyOverlay />
<OnboardingWizard />
<Toolbar />
<ApprovalBanner />
Expand Down
36 changes: 36 additions & 0 deletions canvas/src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -180,6 +182,40 @@ export function Toolbar() {
</button>
)}

{/* A2A topology overlay toggle */}
<button
onClick={() => setShowA2AEdges(!showA2AEdges)}
aria-pressed={showA2AEdges}
aria-label={showA2AEdges ? "Hide A2A edges" : "Show A2A edges"}
title={showA2AEdges ? "Hide A2A delegation edges" : "Show A2A delegation edges (last 60 min)"}
className={`flex items-center gap-1.5 px-2.5 py-1 border rounded-lg transition-colors ${
showA2AEdges
? "bg-blue-950/50 hover:bg-blue-900/50 border-blue-800/40 text-blue-300"
: "bg-zinc-800/50 hover:bg-zinc-700/50 border-zinc-700/40 text-zinc-500 hover:text-zinc-300"
}`}
>
{/* Mesh / network icon */}
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
className="shrink-0"
aria-hidden="true"
>
<circle cx="3" cy="3" r="2" stroke="currentColor" strokeWidth="1.4" />
<circle cx="13" cy="3" r="2" stroke="currentColor" strokeWidth="1.4" />
<circle cx="8" cy="13" r="2" stroke="currentColor" strokeWidth="1.4" />
<path
d="M5 3h6M3.7 5l3.3 6M12.3 5l-3.3 6"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
<span className="text-[10px] font-medium">A2A</span>
</button>

{/* Search shortcut */}
<button
onClick={() => useCanvasStore.getState().setSearchOpen(true)}
Expand Down
Loading
Loading