feat: FLIP-style lozenge fly animation when agents are assigned to stories

Implements Story 74: agent lozenges now animate as fixed-position overlays
that fly from the roster badge in AgentPanel to the story slot in StagePanel
(and back when the agent is removed), satisfying all acceptance criteria.

Key changes:
- LozengeFlyContext.tsx (new): coordinates FLIP animations via React context.
  LozengeFlyProvider tracks pipeline changes, hides slot lozenges during
  fly-in (useLayoutEffect before paint), then creates a portal-rendered
  fixed-position clone that transitions from roster → slot (or reverse).
  z-index 9999 ensures the clone travels above all other UI elements.
- AgentPanel.tsx: RosterBadge registers its DOM element with the context
  so fly animations know the correct start/end coordinates.
- StagePanel.tsx: AgentLozenge registers its DOMRect on every render via
  useLayoutEffect (for fly-out) and reads pendingFlyIns to stay hidden
  while a fly-in clone is in flight. Added align-self: flex-start so the
  lozenge maintains its intrinsic width and never stretches in the panel.
- Chat.tsx: right-column panels wrapped in LozengeFlyProvider.
- LozengeFlyContext.test.tsx (new): 10 tests covering fixed width,
  fly-in/fly-out clone creation, portal placement, opacity lifecycle,
  and idle vs active visual distinction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-23 15:04:10 +00:00
parent d5efbc0a53
commit ef728331cf
5 changed files with 915 additions and 11 deletions

View File

@@ -6,6 +6,7 @@ import type {
} from "../api/agents";
import { agentsApi, subscribeAgentStream } from "../api/agents";
import { settingsApi } from "../api/settings";
import { useLozengeFly } from "./LozengeFlyContext";
const { useCallback, useEffect, useRef, useState } = React;
@@ -81,11 +82,22 @@ function RosterBadge({
agent: AgentConfigInfo;
activeStoryId: string | null;
}) {
const { registerRosterEl } = useLozengeFly();
const badgeRef = useRef<HTMLSpanElement>(null);
const isActive = activeStoryId !== null;
const storyNumber = activeStoryId?.match(/^(\d+)/)?.[1];
// Register this element so fly animations know where to start/end
useEffect(() => {
const el = badgeRef.current;
if (el) registerRosterEl(agent.name, el);
return () => registerRosterEl(agent.name, null);
}, [agent.name, registerRosterEl]);
return (
<span
ref={badgeRef}
data-testid={`roster-badge-${agent.name}`}
style={{
display: "inline-flex",
alignItems: "center",
@@ -298,7 +310,9 @@ export function AgentPanel() {
const cleanupRefs = useRef<Record<string, () => void>>({});
const logEndRefs = useRef<Record<string, HTMLDivElement | null>>({});
// Refs for fade-out timers (pause/resume on expand/collapse)
const fadeTimerRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const fadeTimerRef = useRef<Record<string, ReturnType<typeof setTimeout>>>(
{},
);
const fadeElapsedRef = useRef<Record<string, number>>({});
const fadeTimerStartRef = useRef<Record<string, number | null>>({});
@@ -316,8 +330,7 @@ export function AgentPanel() {
const now = Date.now();
for (const a of agentList) {
const key = agentKey(a.story_id, a.agent_name);
const isTerminal =
a.status === "completed" || a.status === "failed";
const isTerminal = a.status === "completed" || a.status === "failed";
agentMap[key] = {
agentName: a.agent_name,
status: a.status,