feat(canvas): fly an envelope between agents on each delegate/message #2443

Merged
agent-reviewer merged 1 commits from feat/a2a-message-flight-envelope into main 2026-06-08 21:14:22 +00:00
7 changed files with 397 additions and 0 deletions
+5
View File
@@ -17,6 +17,7 @@ import { WORKSPACE_KIND } from "@/lib/workspace-kind";
import { stripPlatformRootForMap } from "@/store/canvas-topology";
import { useTheme } from "@/lib/theme-provider";
import { A2ATopologyOverlay } from "./A2ATopologyOverlay";
import { MessageFlightLayer } from "./MessageFlightLayer";
import { WorkspaceNode } from "./WorkspaceNode";
import { SidePanel } from "./SidePanel";
import { CreateWorkspaceButton } from "./CreateWorkspaceDialog";
@@ -371,6 +372,10 @@ function CanvasInner() {
nodeBorderRadius={4}
/>
<DropTargetBadge />
{/* Flies an envelope between agents on each delegate/message event.
Inside <ReactFlow> so its ViewportPortal renders in flow coords
and tracks pan/zoom. */}
<MessageFlightLayer />
</ReactFlow>
{/* Screen-reader live region — announces workspace count on initial load and
+84
View File
@@ -0,0 +1,84 @@
/** FlightEnvelope — a single envelope that animates from `from` to `to` and
* fades out, used by both the canvas (flow coords inside a ViewportPortal) and
* the concierge home (screen coords inside a fixed overlay). The parent owns
* the coordinate space; this component only animates the translate delta.
*
* Uses the Web Animations API so the from/to delta can be dynamic per flight
* (a static CSS @keyframes can't translate to a runtime-computed point). */
import { useEffect, useRef } from "react";
import { FLIGHT_DURATION_MS, type A2AFlightKind } from "@/hooks/useA2AFlights";
/** Stroke colour by activity kind — mirrors CommunicationOverlay's palette
* (send = cyan, receive = violet/accent, task = warm) so the two surfaces
* read as the same event. */
const KIND_COLOR: Record<A2AFlightKind, string> = {
send: "#22d3ee",
receive: "#8b5cf6",
task: "#f5a623",
};
export interface Point {
x: number;
y: number;
}
export function FlightEnvelope({
from,
to,
kind,
}: {
from: Point;
to: Point;
kind: A2AFlightKind;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
// Element.animate is unavailable in some test/SSR environments — degrade to
// a static (instantly-finished) envelope rather than throw.
if (!el || typeof el.animate !== "function") return;
const dx = to.x - from.x;
const dy = to.y - from.y;
const anim = el.animate(
[
{ transform: "translate(-50%,-50%) translate(0px,0px) scale(0.45)", opacity: 0 },
{ opacity: 1, offset: 0.16 },
{ opacity: 1, offset: 0.8 },
{ transform: `translate(-50%,-50%) translate(${dx}px,${dy}px) scale(1)`, opacity: 0 },
],
{ duration: FLIGHT_DURATION_MS, easing: "cubic-bezier(0.45, 0, 0.25, 1)", fill: "forwards" },
);
return () => anim.cancel();
}, [from.x, from.y, to.x, to.y]);
const color = KIND_COLOR[kind];
return (
<div
ref={ref}
data-testid="flight-envelope"
aria-hidden="true"
style={{
position: "absolute",
left: from.x,
top: from.y,
pointerEvents: "none",
willChange: "transform, opacity",
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.45))",
zIndex: 6,
}}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="2.5" y="5.5" width="19" height="13" rx="2.5" fill="#0b0b0f" stroke={color} strokeWidth="1.6" />
<path
d="M3.5 7.5l8.5 6 8.5-6"
stroke={color}
strokeWidth="1.6"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
);
}
@@ -0,0 +1,46 @@
/** MessageFlightLayer — flies an envelope from the source agent to the target
* agent on the spatial canvas whenever a delegate / message event fires.
*
* Mounted INSIDE <ReactFlow> so its ViewportPortal places the envelope in flow
* coordinates; it therefore pans and zooms with the canvas for free. The
* flight lifecycle (which events become envelopes, reduced-motion opt-out,
* expiry) lives in useA2AFlights — this component only resolves node centres
* and renders. */
import { ViewportPortal, type Node } from "@xyflow/react";
import { useCanvasStore } from "@/store/canvas";
import { useA2AFlights } from "@/hooks/useA2AFlights";
import { FlightEnvelope, type Point } from "./FlightEnvelope";
import type { WorkspaceNodeData } from "@/store/canvas";
// Fallback node footprint when React Flow has not measured a node yet. Matches
// WorkspaceNode's leaf size (w-[300px] min-h-[176px]); a slightly-off centre
// for the first frame after mount is invisible at flight scale.
const DEFAULT_W = 300;
const DEFAULT_H = 176;
function nodeCenter(n: Node<WorkspaceNodeData>): Point {
const w = n.measured?.width ?? DEFAULT_W;
const h = n.measured?.height ?? DEFAULT_H;
return { x: n.position.x + w / 2, y: n.position.y + h / 2 };
}
export function MessageFlightLayer() {
const flights = useA2AFlights();
const nodes = useCanvasStore((s) => s.nodes);
if (flights.length === 0) return null;
return (
<ViewportPortal>
{flights.map((f) => {
const src = nodes.find((n) => n.id === f.sourceId);
const dst = nodes.find((n) => n.id === f.targetId);
// Both endpoints must be on-canvas to draw a path between them.
if (!src || !dst) return null;
return (
<FlightEnvelope key={f.key} from={nodeCenter(src)} to={nodeCenter(dst)} kind={f.kind} />
);
})}
</ViewportPortal>
);
}
@@ -9,6 +9,7 @@ import { showToast } from "@/components/Toaster";
import type { ActivityEntry } from "@/types/activity";
import { Canvas } from "@/components/Canvas";
import { CommunicationOverlay } from "@/components/CommunicationOverlay";
import { MessageFlightHome } from "./MessageFlightHome";
import { ChatTab } from "@/components/tabs/ChatTab";
import { WorkspacePanelTabs } from "@/components/WorkspacePanelTabs";
import { SettingsTabs } from "@/components/settings";
@@ -237,6 +238,7 @@ export function ConciergeShell() {
tabIndex={0}
data-testid="agent-tree-node"
data-node-name={n.data.name}
data-ws-id={n.id}
data-platform={isPlatform ? "true" : "false"}
data-depth={depth}
className={`${s.ws} ${selectedNodeId === n.id ? s.active : ""}`}
@@ -299,6 +301,8 @@ export function ConciergeShell() {
return (
<div className={s.root}>
{/* Envelope flies between agent rows on each delegate/message event. */}
<MessageFlightHome />
<div className={`${s.app} ${railOpen ? s.railOpen : ""}`}>
{/* ICON RAIL */}
<nav className={s.rail}>
@@ -0,0 +1,50 @@
/** MessageFlightHome — the concierge-home counterpart of MessageFlightLayer.
* The home view is a vertical agent tree (not a spatial canvas), so an envelope
* flies between the source and target agent ROWS. It shares the exact same
* flight stream (useA2AFlights) as the canvas, and resolves endpoints from each
* row's DOM rect (rows carry data-ws-id). Reduced-motion is honoured by the
* shared hook (it emits no flights). */
import { useRef } from "react";
import { useA2AFlights, type A2AFlight } from "@/hooks/useA2AFlights";
import { FlightEnvelope, type Point } from "../FlightEnvelope";
function rowCenter(wsId: string): Point | null {
if (typeof document === "undefined") return null;
const sel =
typeof CSS !== "undefined" && typeof CSS.escape === "function"
? CSS.escape(wsId)
: wsId;
const el = document.querySelector<HTMLElement>(`[data-ws-id="${sel}"]`);
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
/** One flight. Captures the source/target row rects ONCE on mount (a ref, not
* per-render) so a later re-render or scroll mid-flight does not restart the
* animation. */
function HomeFlight({ flight }: { flight: A2AFlight }) {
const pos = useRef<{ from: Point; to: Point } | null>(null);
if (pos.current === null) {
const from = rowCenter(flight.sourceId);
const to = rowCenter(flight.targetId);
if (from && to) pos.current = { from, to };
}
if (!pos.current) return null; // one or both agents not visible in the tree
return <FlightEnvelope from={pos.current.from} to={pos.current.to} kind={flight.kind} />;
}
export function MessageFlightHome() {
const flights = useA2AFlights();
if (flights.length === 0) return null;
return (
<div
aria-hidden="true"
style={{ position: "fixed", inset: 0, pointerEvents: "none", zIndex: 50 }}
>
{flights.map((f) => (
<HomeFlight key={f.key} flight={f} />
))}
</div>
);
}
@@ -0,0 +1,105 @@
// @vitest-environment jsdom
/** Unit tests for useA2AFlights — the event→flight lifecycle that drives the
* envelope animations on the canvas (MessageFlightLayer) and the concierge
* home (MessageFlightHome). useSocketEvent is mocked so we can drive the
* ACTIVITY_LOGGED handler directly. */
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
// Capture the handler the hook registers with the socket bus. vi.hoisted is
// required because vi.mock factories are hoisted above normal declarations and
// may only close over hoisted state.
const h = vi.hoisted(() => ({ captured: null as ((msg: unknown) => void) | null }));
vi.mock("@/hooks/useSocketEvent", () => ({
useSocketEvent: (cb: (msg: unknown) => void) => {
h.captured = cb;
},
}));
import { useA2AFlights, FLIGHT_DURATION_MS } from "@/hooks/useA2AFlights";
function setReducedMotion(reduce: boolean) {
window.matchMedia = vi.fn().mockImplementation((q: string) => ({
matches: reduce && q.includes("reduce"),
media: q,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
const msg = (payload: Record<string, unknown>, event = "ACTIVITY_LOGGED") => ({
event,
workspace_id: "a",
timestamp: "2026-06-08T00:00:00Z",
payload,
});
const a2aSend = (over: Record<string, unknown> = {}) =>
msg({ activity_type: "a2a_send", source_id: "a", target_id: "b", ...over });
describe("useA2AFlights", () => {
beforeEach(() => {
h.captured = null;
vi.useRealTimers();
setReducedMotion(false);
});
it("emits a flight for an a2a_send between two distinct agents", () => {
const { result } = renderHook(() => useA2AFlights());
act(() => h.captured?.(a2aSend()));
expect(result.current).toHaveLength(1);
expect(result.current[0]).toMatchObject({ sourceId: "a", targetId: "b", kind: "send" });
});
it("maps a2a_receive / task_update to their kinds", () => {
const { result } = renderHook(() => useA2AFlights());
act(() => h.captured?.(a2aSend({ activity_type: "a2a_receive" })));
act(() => h.captured?.(a2aSend({ activity_type: "task_update" })));
const kinds = result.current.map((f) => f.kind);
expect(kinds).toContain("receive");
expect(kinds).toContain("task");
});
it("ignores non-A2A activity and non-ACTIVITY_LOGGED events", () => {
const { result } = renderHook(() => useA2AFlights());
act(() => h.captured?.(msg({ activity_type: "status_change", source_id: "a", target_id: "b" })));
act(() => h.captured?.(a2aSend({}, )));
act(() => h.captured?.({ event: "WORKSPACE_UPDATED", workspace_id: "a", payload: {} }));
expect(result.current.every((f) => f.kind === "send")).toBe(true);
expect(result.current).toHaveLength(1); // only the one valid a2aSend
});
it("skips self-loops and flights with no target", () => {
const { result } = renderHook(() => useA2AFlights());
act(() => h.captured?.(a2aSend({ target_id: "a" }))); // self-loop
act(() => h.captured?.(a2aSend({ target_id: "" }))); // missing target
expect(result.current).toHaveLength(0);
});
it("emits nothing when prefers-reduced-motion is set", () => {
setReducedMotion(true);
const { result } = renderHook(() => useA2AFlights());
act(() => h.captured?.(a2aSend()));
expect(result.current).toHaveLength(0);
});
it("emits nothing when disabled", () => {
const { result } = renderHook(() => useA2AFlights(false));
act(() => h.captured?.(a2aSend()));
expect(result.current).toHaveLength(0);
});
it("expires a flight after the TTL", () => {
vi.useFakeTimers();
const { result } = renderHook(() => useA2AFlights());
act(() => h.captured?.(a2aSend()));
expect(result.current).toHaveLength(1);
act(() => {
vi.advanceTimersByTime(FLIGHT_DURATION_MS + 300);
});
expect(result.current).toHaveLength(0);
});
});
+103
View File
@@ -0,0 +1,103 @@
/** useA2AFlights — turns the org's live A2A activity stream into transient
* "flights" (one per delegate / message event, source → target) that an
* overlay can animate as an envelope travelling between two agents.
*
* This hook owns ONLY the event→flight lifecycle: it subscribes to the same
* ACTIVITY_LOGGED WS bus the CommunicationOverlay uses, keeps a small bounded
* list of in-flight envelopes, and expires each after the animation window.
* The caller resolves positions and renders the envelope, so the exact same
* flight data drives both the spatial canvas (flow coords) and the concierge
* home (DOM row rects).
*
* Honours `prefers-reduced-motion`: when the user opts out of motion the hook
* emits no flights at all, so no envelope ever animates. */
import { useEffect, useRef, useState } from "react";
import { useSocketEvent } from "@/hooks/useSocketEvent";
export type A2AFlightKind = "send" | "receive" | "task";
export interface A2AFlight {
/** unique per flight instance (not per pair) so a burst renders distinct envelopes */
key: string;
sourceId: string;
targetId: string;
kind: A2AFlightKind;
}
/** Total time an envelope is alive (ms). Kept in sync with the overlay's
* Web-Animations duration; the extra tail gives the fade-out room to finish
* before the element unmounts. */
export const FLIGHT_DURATION_MS = 1200;
const FLIGHT_TTL_MS = FLIGHT_DURATION_MS + 120;
/** Cap concurrent envelopes so a delegation storm can't spawn unbounded DOM. */
const MAX_CONCURRENT = 12;
function reducedMotionNow(): boolean {
return (
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
export function useA2AFlights(enabled = true): A2AFlight[] {
const [flights, setFlights] = useState<A2AFlight[]>([]);
const reduced = useRef<boolean>(reducedMotionNow());
const timers = useRef<number[]>([]);
// Track reduced-motion preference changes live (a user can toggle it mid-session).
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = () => {
reduced.current = mq.matches;
if (mq.matches) setFlights([]); // drop any in-flight envelopes immediately
};
mq.addEventListener?.("change", onChange);
return () => mq.removeEventListener?.("change", onChange);
}, []);
// Clear pending expiry timers on unmount.
useEffect(() => {
const t = timers.current;
return () => {
t.forEach((id) => window.clearTimeout(id));
};
}, []);
useSocketEvent((msg) => {
if (!enabled || reduced.current) return;
if (msg.event !== "ACTIVITY_LOGGED") return;
const p = (msg.payload || {}) as {
activity_type?: string;
source_id?: string | null;
target_id?: string | null;
};
const t = p.activity_type;
if (t !== "a2a_send" && t !== "a2a_receive" && t !== "task_update") return;
const sourceId = p.source_id || msg.workspace_id;
const targetId = p.target_id || "";
// A flight needs two distinct endpoints; a self-loop or missing peer has
// nowhere to fly, so skip it.
if (!sourceId || !targetId || sourceId === targetId) return;
const kind: A2AFlightKind =
t === "a2a_receive" ? "receive" : t === "task_update" ? "task" : "send";
const key = `${msg.timestamp || Date.now()}:${sourceId}:${targetId}:${Math.random()
.toString(36)
.slice(2, 8)}`;
setFlights((prev) => [...prev.slice(-(MAX_CONCURRENT - 1)), { key, sourceId, targetId, kind }]);
const id = window.setTimeout(() => {
setFlights((prev) => prev.filter((f) => f.key !== key));
timers.current = timers.current.filter((x) => x !== id);
}, FLIGHT_TTL_MS);
timers.current.push(id);
});
return flights;
}