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 */}