diff --git a/canvas/src/components/FlightEnvelope.tsx b/canvas/src/components/FlightEnvelope.tsx index 31b3f41b1..ac273b61c 100644 --- a/canvas/src/components/FlightEnvelope.tsx +++ b/canvas/src/components/FlightEnvelope.tsx @@ -40,14 +40,24 @@ export function FlightEnvelope({ if (!el || typeof el.animate !== "function") return; const dx = to.x - from.x; const dy = to.y - from.y; + // Launch small from the source dot, GROW BIG as it crosses the gap (peak + // mid-flight), then SHRINK small as it lands on the target dot — reads as an + // envelope flung from one agent and received by the other. translate tracks + // the straight path (fraction == keyframe offset); scale arcs independently. + const at = (frac: number, scale: number, opacity: number, offset?: number) => ({ + transform: `translate(-50%,-50%) translate(${dx * frac}px,${dy * frac}px) scale(${scale})`, + opacity, + ...(offset === undefined ? {} : { offset }), + }); 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 }, + at(0, 0.5, 0), + at(0.2, 1.25, 1, 0.2), // faded in + grown + at(0.5, 1.7, 1, 0.5), // BIG at mid-flight + at(0.82, 1.05, 1, 0.82), // shrinking on approach + at(1, 0.5, 0), // small + faded out, arrived on the target dot ], - { duration: FLIGHT_DURATION_MS, easing: "cubic-bezier(0.45, 0, 0.25, 1)", fill: "forwards" }, + { duration: FLIGHT_DURATION_MS, easing: "ease-in-out", fill: "forwards" }, ); return () => anim.cancel(); }, [from.x, from.y, to.x, to.y]); diff --git a/canvas/src/components/MessageFlightLayer.tsx b/canvas/src/components/MessageFlightLayer.tsx index f2c4a7f82..0055772a5 100644 --- a/canvas/src/components/MessageFlightLayer.tsx +++ b/canvas/src/components/MessageFlightLayer.tsx @@ -4,17 +4,25 @@ * 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"; + * expiry) lives in useA2AFlights — this component only resolves endpoints and + * renders. + * + * Endpoints anchor on each workspace's STATUS DOT (the green/glowing presence + * indicator), not the card's geometric centre — so an envelope visibly leaves + * the source agent's dot and lands on the target agent's dot. The dot carries + * `data-flight-anchor`; we read its rendered rect and convert screen→flow via + * React Flow, falling back to the card centre only when the dot isn't in the + * DOM yet (node just mounted / scrolled out). */ +import { useRef } from "react"; +import { ViewportPortal, useReactFlow, type Node } from "@xyflow/react"; import { useCanvasStore } from "@/store/canvas"; -import { useA2AFlights } from "@/hooks/useA2AFlights"; +import { useA2AFlights, type A2AFlight } 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. +// 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; @@ -24,23 +32,76 @@ function nodeCenter(n: Node): Point { return { x: n.position.x + w / 2, y: n.position.y + h / 2 }; } +/** Resolve a node's status-dot centre in FLOW coordinates. Reads the dot's + * rendered screen rect (it carries data-flight-anchor) and converts it back to + * flow space, so the anchor is exact regardless of pan/zoom and survives any + * header-layout change. Falls back to the card centre when the dot isn't + * rendered. */ +function dotAnchor( + n: Node, + screenToFlowPosition: (p: Point) => Point, +): Point { + if (typeof document !== "undefined") { + const id = + typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(n.id) : n.id; + const el = document.querySelector( + `.react-flow__node[data-id="${id}"] [data-flight-anchor]`, + ); + if (el) { + const r = el.getBoundingClientRect(); + if (r.width > 0 && r.height > 0) { + return screenToFlowPosition({ x: r.left + r.width / 2, y: r.top + r.height / 2 }); + } + } + } + return nodeCenter(n); +} + +/** One flight. Captures the source/target dot anchors ONCE on mount (a ref, not + * per-render) so a pan/zoom or re-render mid-flight doesn't restart the + * animation — mirrors HomeFlight's capture-once contract. */ +function CanvasFlight({ + flight, + nodes, + screenToFlowPosition, +}: { + flight: A2AFlight; + nodes: Node[]; + screenToFlowPosition: (p: Point) => Point; +}) { + const pos = useRef<{ from: Point; to: Point } | null>(null); + if (pos.current === null) { + const src = nodes.find((n) => n.id === flight.sourceId); + const dst = nodes.find((n) => n.id === flight.targetId); + // Both endpoints must be on-canvas to draw a path between them. + if (src && dst) { + pos.current = { + from: dotAnchor(src, screenToFlowPosition), + to: dotAnchor(dst, screenToFlowPosition), + }; + } + } + if (!pos.current) return null; + return ; +} + export function MessageFlightLayer() { const flights = useA2AFlights(); - const nodes = useCanvasStore((s) => s.nodes); + const nodes = useCanvasStore((s) => s.nodes) as Node[]; + const { screenToFlowPosition } = useReactFlow(); 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 ( - - ); - })} + {flights.map((f) => ( + + ))} ); } diff --git a/canvas/src/components/WorkspaceNode.tsx b/canvas/src/components/WorkspaceNode.tsx index 46ded3b63..5b2e5a885 100644 --- a/canvas/src/components/WorkspaceNode.tsx +++ b/canvas/src/components/WorkspaceNode.tsx @@ -215,7 +215,7 @@ export function WorkspaceNode({ id, data }: NodeProps>) {/* Header row */}
-
+
{data.name} diff --git a/canvas/src/components/__tests__/FlightEnvelope.test.tsx b/canvas/src/components/__tests__/FlightEnvelope.test.tsx new file mode 100644 index 000000000..f0a1bde55 --- /dev/null +++ b/canvas/src/components/__tests__/FlightEnvelope.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +/** + * Tests for FlightEnvelope — the envelope that animates from `from` to `to`. + * + * Locks the render contract the canvas + concierge-home both depend on: + * - the envelope is positioned at the `from` point (its launch anchor), + * - it is coloured by activity kind, + * - it degrades gracefully when Element.animate is unavailable (jsdom / SSR). + * + * The grow→shrink scale arc itself uses the Web Animations API, which jsdom + * does not implement, so we assert the static render + graceful degradation + * rather than keyframe values. + */ +import React from "react"; +import { render, cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { FlightEnvelope } from "../FlightEnvelope"; + +afterEach(cleanup); + +describe("FlightEnvelope", () => { + it("positions the envelope at the `from` launch point", () => { + const { getByTestId } = render( + , + ); + const el = getByTestId("flight-envelope"); + expect(el.style.left).toBe("120px"); + expect(el.style.top).toBe("240px"); + expect(el.querySelector("svg")).toBeTruthy(); + }); + + it("colours the envelope by activity kind", () => { + const stroke = (kind: "send" | "receive" | "task") => { + const { container } = render( + , + ); + const s = container.querySelector("rect")?.getAttribute("stroke"); + cleanup(); + return s; + }; + expect(stroke("send")).toBe("#22d3ee"); + expect(stroke("receive")).toBe("#8b5cf6"); + expect(stroke("task")).toBe("#f5a623"); + }); + + it("degrades to a static render (no throw) when Element.animate is unavailable", () => { + // jsdom does not implement Element.animate — the component must still render. + expect(typeof document.createElement("div").animate).not.toBe("function"); + const { getByTestId } = render( + , + ); + expect(getByTestId("flight-envelope")).toBeTruthy(); + }); +});