fix(canvas): envelope flies dot→dot with a grow-then-shrink arc #2472
@@ -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]);
|
||||
|
||||
@@ -4,17 +4,25 @@
|
||||
* 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";
|
||||
* 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<WorkspaceNodeData>): 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<WorkspaceNodeData>,
|
||||
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<HTMLElement>(
|
||||
`.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<WorkspaceNodeData>[];
|
||||
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 <FlightEnvelope from={pos.current.from} to={pos.current.to} kind={flight.kind} />;
|
||||
}
|
||||
|
||||
export function MessageFlightLayer() {
|
||||
const flights = useA2AFlights();
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
const nodes = useCanvasStore((s) => s.nodes) as Node<WorkspaceNodeData>[];
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
|
||||
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} />
|
||||
);
|
||||
})}
|
||||
{flights.map((f) => (
|
||||
<CanvasFlight
|
||||
key={f.key}
|
||||
flight={f}
|
||||
nodes={nodes}
|
||||
screenToFlowPosition={screenToFlowPosition}
|
||||
/>
|
||||
))}
|
||||
</ViewportPortal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between gap-2 mb-2.5">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className={`w-2.5 h-2.5 rounded-full shrink-0 ${statusCfg.dot} ${statusCfg.glow} shadow-sm`} />
|
||||
<div data-flight-anchor className={`w-2.5 h-2.5 rounded-full shrink-0 ${statusCfg.dot} ${statusCfg.glow} shadow-sm`} />
|
||||
<span className="text-[15px] font-semibold text-ink truncate leading-tight">
|
||||
{data.name}
|
||||
</span>
|
||||
|
||||
@@ -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(
|
||||
<FlightEnvelope from={{ x: 120, y: 240 }} to={{ x: 400, y: 60 }} kind="send" />,
|
||||
);
|
||||
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(
|
||||
<FlightEnvelope from={{ x: 0, y: 0 }} to={{ x: 10, y: 10 }} kind={kind} />,
|
||||
);
|
||||
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(
|
||||
<FlightEnvelope from={{ x: 0, y: 0 }} to={{ x: 1, y: 1 }} kind="task" />,
|
||||
);
|
||||
expect(getByTestId("flight-envelope")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user