feat(canvas): fly an envelope between agents on each delegate/message #2443
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user