fix(canvas): envelope flies dot→dot with a grow-then-shrink arc #2472

Merged
molecule-code-reviewer merged 1 commits from fix/envelope-anchor-dot-and-scale into main 2026-06-09 15:51:41 +00:00
4 changed files with 147 additions and 22 deletions
+15 -5
View File
@@ -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]);
+77 -16
View File
@@ -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>
);
}
+1 -1
View File
@@ -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();
});
});