diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index cdf41a62b..f099e22ee 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -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} /> + {/* Flies an envelope between agents on each delegate/message event. + Inside so its ViewportPortal renders in flow coords + and tracks pan/zoom. */} + {/* Screen-reader live region — announces workspace count on initial load and diff --git a/canvas/src/components/FlightEnvelope.tsx b/canvas/src/components/FlightEnvelope.tsx new file mode 100644 index 000000000..31b3f41b1 --- /dev/null +++ b/canvas/src/components/FlightEnvelope.tsx @@ -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 = { + 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(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 ( + + + + + + + ); +} diff --git a/canvas/src/components/MessageFlightLayer.tsx b/canvas/src/components/MessageFlightLayer.tsx new file mode 100644 index 000000000..f2c4a7f82 --- /dev/null +++ b/canvas/src/components/MessageFlightLayer.tsx @@ -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 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): 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 ( + + {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 ( + + ); + })} + + ); +} diff --git a/canvas/src/components/concierge/ConciergeShell.tsx b/canvas/src/components/concierge/ConciergeShell.tsx index f99df373c..5cefd8dc8 100644 --- a/canvas/src/components/concierge/ConciergeShell.tsx +++ b/canvas/src/components/concierge/ConciergeShell.tsx @@ -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 ( + {/* Envelope flies between agent rows on each delegate/message event. */} + {/* ICON RAIL */} diff --git a/canvas/src/components/concierge/MessageFlightHome.tsx b/canvas/src/components/concierge/MessageFlightHome.tsx new file mode 100644 index 000000000..c701119f1 --- /dev/null +++ b/canvas/src/components/concierge/MessageFlightHome.tsx @@ -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(`[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 ; +} + +export function MessageFlightHome() { + const flights = useA2AFlights(); + if (flights.length === 0) return null; + return ( + + {flights.map((f) => ( + + ))} + + ); +} diff --git a/canvas/src/hooks/__tests__/useA2AFlights.test.ts b/canvas/src/hooks/__tests__/useA2AFlights.test.ts new file mode 100644 index 000000000..1c2dfeba1 --- /dev/null +++ b/canvas/src/hooks/__tests__/useA2AFlights.test.ts @@ -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, event = "ACTIVITY_LOGGED") => ({ + event, + workspace_id: "a", + timestamp: "2026-06-08T00:00:00Z", + payload, +}); +const a2aSend = (over: Record = {}) => + 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); + }); +}); \ No newline at end of file diff --git a/canvas/src/hooks/useA2AFlights.ts b/canvas/src/hooks/useA2AFlights.ts new file mode 100644 index 000000000..e7f91ab81 --- /dev/null +++ b/canvas/src/hooks/useA2AFlights.ts @@ -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([]); + const reduced = useRef(reducedMotionNow()); + const timers = useRef([]); + + // 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; +}