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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user