();
+ for (const storyId of newFlyInStoryIds) {
+ const action = pendingFlyInActionsRef.current.find(
+ (a) => a.storyId === storyId,
+ );
+ if (action && rosterElsRef.current.has(action.agentName)) {
+ hideable.add(storyId);
+ }
+ }
+ if (hideable.size > 0) {
+ setPendingFlyIns((prev) => {
+ const next = new Set(prev);
+ for (const id of hideable) next.add(id);
+ return next;
+ });
+ }
+ }
+ }, [pipeline]);
+
+ // ── Execute animations (runs after paint, DOM positions are stable) ───────
+ useEffect(() => {
+ const flyIns = [...pendingFlyInActionsRef.current];
+ pendingFlyInActionsRef.current = [];
+ const flyOuts = [...pendingFlyOutActionsRef.current];
+ pendingFlyOutActionsRef.current = [];
+
+ for (const action of flyIns) {
+ const rosterEl = rosterElsRef.current.get(action.agentName);
+ const slotRect = savedSlotRectsRef.current.get(action.storyId);
+
+ if (!rosterEl || !slotRect) {
+ // No roster element: immediately reveal the slot lozenge
+ setPendingFlyIns((prev) => {
+ const next = new Set(prev);
+ next.delete(action.storyId);
+ return next;
+ });
+ continue;
+ }
+
+ const rosterRect = rosterEl.getBoundingClientRect();
+ const id = `fly-in-${action.agentName}-${action.storyId}-${Date.now()}`;
+
+ setFlyingLozenges((prev) => [
+ ...prev,
+ {
+ id,
+ label: action.label,
+ isActive: action.isActive,
+ startX: rosterRect.left,
+ startY: rosterRect.top,
+ endX: slotRect.left,
+ endY: slotRect.top,
+ flying: false,
+ },
+ ]);
+
+ // FLIP "Play" step: after two frames the transition begins
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ setFlyingLozenges((prev) =>
+ prev.map((l) => (l.id === id ? { ...l, flying: true } : l)),
+ );
+ });
+ });
+
+ // After the transition completes, remove clone and reveal slot lozenge
+ setTimeout(() => {
+ setFlyingLozenges((prev) => prev.filter((l) => l.id !== id));
+ setPendingFlyIns((prev) => {
+ const next = new Set(prev);
+ next.delete(action.storyId);
+ return next;
+ });
+ }, 500);
+ }
+
+ for (const action of flyOuts) {
+ const rosterEl = rosterElsRef.current.get(action.agentName);
+ const slotRect = savedSlotRectsRef.current.get(action.storyId);
+ if (!slotRect) continue;
+
+ const rosterRect = rosterEl?.getBoundingClientRect();
+ const id = `fly-out-${action.agentName}-${action.storyId}-${Date.now()}`;
+
+ setFlyingLozenges((prev) => [
+ ...prev,
+ {
+ id,
+ label: action.label,
+ isActive: false,
+ startX: slotRect.left,
+ startY: slotRect.top,
+ endX: rosterRect?.left ?? slotRect.left,
+ endY: rosterRect?.top ?? Math.max(0, slotRect.top - 80),
+ flying: false,
+ },
+ ]);
+
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ setFlyingLozenges((prev) =>
+ prev.map((l) => (l.id === id ? { ...l, flying: true } : l)),
+ );
+ });
+ });
+
+ setTimeout(() => {
+ setFlyingLozenges((prev) => prev.filter((l) => l.id !== id));
+ }, 500);
+ }
+ }, [pipeline]);
+
+ const contextValue = useMemo(
+ () => ({ registerRosterEl, saveSlotRect, pendingFlyIns }),
+ [registerRosterEl, saveSlotRect, pendingFlyIns],
+ );
+
+ return (
+
+ {children}
+ {ReactDOM.createPortal(
+ ,
+ document.body,
+ )}
+
+ );
+}
+
+// ─── Portal surface ───────────────────────────────────────────────────────────
+
+function FloatingLozengeSurface({ lozenges }: { lozenges: FlyingLozenge[] }) {
+ return (
+ <>
+ {lozenges.map((l) => (
+
+ ))}
+ >
+ );
+}
+
+function FlyingLozengeClone({ lozenge }: { lozenge: FlyingLozenge }) {
+ const color = lozenge.isActive ? "#58a6ff" : "#e3b341";
+ const x = lozenge.flying ? lozenge.endX : lozenge.startX;
+ const y = lozenge.flying ? lozenge.endY : lozenge.startY;
+
+ return (
+
+ {lozenge.isActive && (
+
+ )}
+ {lozenge.label}
+
+ );
+}
+
+// ─── Hook ─────────────────────────────────────────────────────────────────────
+
+export function useLozengeFly(): LozengeFlyContextValue {
+ return useContext(LozengeFlyContext);
+}
diff --git a/frontend/src/components/StagePanel.tsx b/frontend/src/components/StagePanel.tsx
index 62780bb..1d6e1c4 100644
--- a/frontend/src/components/StagePanel.tsx
+++ b/frontend/src/components/StagePanel.tsx
@@ -1,4 +1,8 @@
+import * as React from "react";
import type { AgentAssignment, PipelineStageItem } from "../api/client";
+import { useLozengeFly } from "./LozengeFlyContext";
+
+const { useLayoutEffect, useRef } = React;
interface StagePanelProps {
title: string;
@@ -6,7 +10,15 @@ interface StagePanelProps {
emptyMessage?: string;
}
-function AgentLozenge({ agent }: { agent: AgentAssignment }) {
+function AgentLozenge({
+ agent,
+ storyId,
+}: {
+ agent: AgentAssignment;
+ storyId: string;
+}) {
+ const { saveSlotRect, pendingFlyIns } = useLozengeFly();
+ const lozengeRef = useRef(null);
const isRunning = agent.status === "running";
const isPending = agent.status === "pending";
const color = isRunning ? "#58a6ff" : isPending ? "#e3b341" : "#aaa";
@@ -14,9 +26,20 @@ function AgentLozenge({ agent }: { agent: AgentAssignment }) {
? `${agent.agent_name} ${agent.model}`
: agent.agent_name;
+ const isFlyingIn = pendingFlyIns.has(storyId);
+
+ // Save our rect on every render so flyOut can reference it after unmount
+ useLayoutEffect(() => {
+ if (lozengeRef.current) {
+ saveSlotRect(storyId, lozengeRef.current.getBoundingClientRect());
+ }
+ });
+
return (
{isRunning && (
@@ -151,7 +179,9 @@ export function StagePanel({
)}
- {item.agent && }
+ {item.agent && (
+
+ )}
);
})}