diff --git a/frontend/src/components/AgentPanel.test.tsx b/frontend/src/components/AgentPanel.test.tsx index 8e72384..f9c79dd 100644 --- a/frontend/src/components/AgentPanel.test.tsx +++ b/frontend/src/components/AgentPanel.test.tsx @@ -1,6 +1,14 @@ import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import type { AgentConfigInfo, AgentInfo } from "../api/agents"; import { agentsApi } from "../api/agents"; @@ -337,15 +345,21 @@ describe("AgentPanel fade-out", () => { const { container } = render(); - // Wait for the agent entry to appear - await waitFor(() => { - expect( - container.querySelector( - '[data-testid="agent-entry-73_remove_test:coder-1"]', - ), - ).toBeInTheDocument(); + // With fake timers active, waitFor's polling setInterval never fires. + // Use act to flush pending promises and React state updates instead. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); }); + expect( + container.querySelector( + '[data-testid="agent-entry-73_remove_test:coder-1"]', + ), + ).toBeInTheDocument(); + // Advance timers by 60 seconds and flush React state updates await act(async () => { vi.advanceTimersByTime(60_000); diff --git a/frontend/src/components/AgentPanel.tsx b/frontend/src/components/AgentPanel.tsx index 48a549e..154edaa 100644 --- a/frontend/src/components/AgentPanel.tsx +++ b/frontend/src/components/AgentPanel.tsx @@ -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(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 ( void>>({}); const logEndRefs = useRef>({}); // Refs for fade-out timers (pause/resume on expand/collapse) - const fadeTimerRef = useRef>>({}); + const fadeTimerRef = useRef>>( + {}, + ); const fadeElapsedRef = useRef>({}); const fadeTimerStartRef = useRef>({}); @@ -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, diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 47c18c1..04c64f4 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -7,6 +7,7 @@ import { api, ChatWebSocket } from "../api/client"; import type { Message, ProviderConfig, ToolCall } from "../types"; import { AgentPanel } from "./AgentPanel"; import { ChatHeader } from "./ChatHeader"; +import { LozengeFlyProvider } from "./LozengeFlyContext"; import { StagePanel } from "./StagePanel"; const { useCallback, useEffect, useRef, useState } = React; @@ -713,12 +714,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { gap: "12px", }} > - + + - - - - + + + + + diff --git a/frontend/src/components/LozengeFlyContext.test.tsx b/frontend/src/components/LozengeFlyContext.test.tsx new file mode 100644 index 0000000..c623bd9 --- /dev/null +++ b/frontend/src/components/LozengeFlyContext.test.tsx @@ -0,0 +1,482 @@ +import { act, render, screen } from "@testing-library/react"; +import * as React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PipelineState } from "../api/client"; +import { LozengeFlyProvider, useLozengeFly } from "./LozengeFlyContext"; +import { StagePanel } from "./StagePanel"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makePipeline(overrides: Partial = {}): PipelineState { + return { + upcoming: [], + current: [], + qa: [], + merge: [], + ...overrides, + }; +} + +/** A minimal roster element fixture that registers itself with the context. */ +function RosterFixture({ agentName }: { agentName: string }) { + const { registerRosterEl } = useLozengeFly(); + const ref = React.useRef(null); + React.useEffect(() => { + const el = ref.current; + if (el) registerRosterEl(agentName, el); + return () => registerRosterEl(agentName, null); + }, [agentName, registerRosterEl]); + return ( + + ); +} + +function Wrapper({ + pipeline, + children, +}: { + pipeline: PipelineState; + children: React.ReactNode; +}) { + return ( + {children} + ); +} + +// ─── Agent lozenge fixed intrinsic width ────────────────────────────────────── + +describe("AgentLozenge fixed intrinsic width", () => { + it("has align-self: flex-start so it never stretches inside a flex column", () => { + const items = [ + { + story_id: "74_width_test", + name: "Width Test", + error: null, + agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, + }, + ]; + const pipeline = makePipeline({ current: items }); + const { container } = render( + + + , + ); + + const lozenge = container.querySelector( + '[data-testid="slot-lozenge-74_width_test"]', + ) as HTMLElement; + expect(lozenge).toBeInTheDocument(); + expect(lozenge.style.alignSelf).toBe("flex-start"); + }); +}); + +// ─── Fly-in: slot lozenge visibility ───────────────────────────────────────── + +describe("LozengeFlyProvider fly-in visibility", () => { + beforeEach(() => { + Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({ + left: 100, + top: 50, + right: 180, + bottom: 70, + width: 80, + height: 20, + x: 100, + y: 50, + toJSON: () => ({}), + }); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + cb(0); + return 0; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("slot lozenge starts hidden when a matching roster element exists", async () => { + const noPipeline = makePipeline(); + const withAgent = makePipeline({ + current: [ + { + story_id: "74_hidden_test", + name: "Hidden Test", + error: null, + agent: { agent_name: "coder-1", model: null, status: "running" }, + }, + ], + }); + + const { rerender } = render( + + + + , + ); + + // Rerender with the agent assigned + await act(async () => { + rerender( + + + + , + ); + }); + + const lozenge = screen.getByTestId("slot-lozenge-74_hidden_test"); + // Hidden while fly-in is in progress + expect(lozenge.style.opacity).toBe("0"); + }); + + it("slot lozenge is visible when no roster element is registered", async () => { + const noPipeline = makePipeline(); + const withAgent = makePipeline({ + current: [ + { + story_id: "74_no_roster", + name: "No Roster", + error: null, + agent: { + agent_name: "unknown-agent", + model: null, + status: "running", + }, + }, + ], + }); + + const { rerender } = render( + + {/* No RosterFixture for "unknown-agent" */} + + , + ); + + await act(async () => { + rerender( + + + , + ); + }); + + const lozenge = screen.getByTestId("slot-lozenge-74_no_roster"); + // Immediately visible because no fly-in animation is possible + expect(lozenge.style.opacity).toBe("1"); + }); +}); + +// ─── Fly-in: flying clone in document.body portal ──────────────────────────── + +describe("LozengeFlyProvider fly-in clone", () => { + beforeEach(() => { + vi.useFakeTimers(); + Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({ + left: 100, + top: 50, + right: 180, + bottom: 70, + width: 80, + height: 20, + x: 100, + y: 50, + toJSON: () => ({}), + }); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + cb(0); + return 0; + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("renders a fixed-position clone in document.body when fly-in triggers", async () => { + const noPipeline = makePipeline(); + const withAgent = makePipeline({ + current: [ + { + story_id: "74_portal_test", + name: "Portal Test", + error: null, + agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, + }, + ], + }); + + const { rerender } = render( + + + + , + ); + + await act(async () => { + rerender( + + + + , + ); + vi.runAllTimers(); + }); + + // Clone is in document.body (portal), not inside the component container + const clone = document.body.querySelector( + '[data-testid^="flying-lozenge-fly-in"]', + ) as HTMLElement | null; + expect(clone).not.toBeNull(); + expect(clone?.style.position).toBe("fixed"); + expect(Number(clone?.style.zIndex)).toBeGreaterThanOrEqual(9999); + expect(clone?.style.pointerEvents).toBe("none"); + }); + + it("clone is removed from document.body after 500 ms", async () => { + const noPipeline = makePipeline(); + const withAgent = makePipeline({ + current: [ + { + story_id: "74_clone_remove", + name: "Clone Remove", + error: null, + agent: { agent_name: "coder-1", model: null, status: "running" }, + }, + ], + }); + + const { rerender } = render( + + + + , + ); + + await act(async () => { + rerender( + + + + , + ); + }); + + // Clone should exist before timeout + const cloneBefore = document.body.querySelector( + '[data-testid^="flying-lozenge-fly-in"]', + ); + expect(cloneBefore).not.toBeNull(); + + // Advance past the 500ms cleanup timeout + await act(async () => { + vi.advanceTimersByTime(600); + }); + + const cloneAfter = document.body.querySelector( + '[data-testid^="flying-lozenge-fly-in"]', + ); + expect(cloneAfter).toBeNull(); + }); + + it("slot lozenge becomes visible (opacity 1) after 500 ms timeout", async () => { + const noPipeline = makePipeline(); + const withAgent = makePipeline({ + current: [ + { + story_id: "74_reveal_test", + name: "Reveal Test", + error: null, + agent: { agent_name: "coder-1", model: null, status: "running" }, + }, + ], + }); + + const { rerender } = render( + + + + , + ); + + await act(async () => { + rerender( + + + + , + ); + }); + + // Initially hidden + const lozenge = screen.getByTestId("slot-lozenge-74_reveal_test"); + expect(lozenge.style.opacity).toBe("0"); + + // After 500ms the slot becomes visible + await act(async () => { + vi.advanceTimersByTime(600); + }); + + expect(lozenge.style.opacity).toBe("1"); + }); +}); + +// ─── Fly-out animation ──────────────────────────────────────────────────────── + +describe("LozengeFlyProvider fly-out", () => { + beforeEach(() => { + vi.useFakeTimers(); + Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({ + left: 100, + top: 50, + right: 180, + bottom: 70, + width: 80, + height: 20, + x: 100, + y: 50, + toJSON: () => ({}), + }); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + cb(0); + return 0; + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("creates a fly-out clone in document.body when agent is removed", async () => { + const withAgent = makePipeline({ + current: [ + { + story_id: "74_fly_out_test", + name: "Fly Out Test", + error: null, + agent: { agent_name: "coder-1", model: "haiku", status: "completed" }, + }, + ], + }); + + const { rerender } = render( + + + + , + ); + + // Advance past initial fly-in animation to get a clean state + await act(async () => { + vi.advanceTimersByTime(600); + }); + + // Remove the agent from the pipeline + const noAgent = makePipeline({ + current: [ + { + story_id: "74_fly_out_test", + name: "Fly Out Test", + error: null, + agent: null, + }, + ], + }); + + await act(async () => { + rerender( + + + + , + ); + }); + + // A fly-out clone should now be in document.body + const clone = document.body.querySelector( + '[data-testid^="flying-lozenge-fly-out"]', + ); + expect(clone).not.toBeNull(); + }); +}); + +// ─── Idle vs active visual distinction ──────────────────────────────────────── + +describe("AgentLozenge idle vs active appearance", () => { + it("running agent lozenge uses the blue active color", () => { + const items = [ + { + story_id: "74_running_color", + name: "Running", + error: null, + agent: { agent_name: "coder-1", model: null, status: "running" }, + }, + ]; + const { container } = render( + + + , + ); + + const lozenge = container.querySelector( + '[data-testid="slot-lozenge-74_running_color"]', + ) as HTMLElement; + expect(lozenge).toBeInTheDocument(); + // Blue: rgb(88, 166, 255) = #58a6ff + expect(lozenge.style.color).toBe("rgb(88, 166, 255)"); + }); + + it("pending agent lozenge uses the yellow pending color", () => { + const items = [ + { + story_id: "74_pending_color", + name: "Pending", + error: null, + agent: { agent_name: "coder-1", model: null, status: "pending" }, + }, + ]; + const { container } = render( + + + , + ); + + const lozenge = container.querySelector( + '[data-testid="slot-lozenge-74_pending_color"]', + ) as HTMLElement; + expect(lozenge).toBeInTheDocument(); + // Yellow: rgb(227, 179, 65) = #e3b341 + expect(lozenge.style.color).toBe("rgb(227, 179, 65)"); + }); + + it("running lozenge has a pulsing dot child element", () => { + const items = [ + { + story_id: "74_pulse_dot", + name: "Pulse", + error: null, + agent: { agent_name: "coder-1", model: null, status: "running" }, + }, + ]; + const { container } = render( + + + , + ); + + const lozenge = container.querySelector( + '[data-testid="slot-lozenge-74_pulse_dot"]', + ) as HTMLElement; + // The pulse dot is a child span with animation: pulse + const dot = lozenge.querySelector("span"); + expect(dot).not.toBeNull(); + expect(dot?.style.animation).toContain("pulse"); + }); +}); diff --git a/frontend/src/components/LozengeFlyContext.tsx b/frontend/src/components/LozengeFlyContext.tsx new file mode 100644 index 0000000..f40d426 --- /dev/null +++ b/frontend/src/components/LozengeFlyContext.tsx @@ -0,0 +1,376 @@ +/** + * LozengeFlyContext – FLIP-style animation system for agent lozenges. + * + * When an agent is assigned to a story, a fixed-positioned clone of the + * agent lozenge "flies" from the roster badge in AgentPanel to the slot + * in StagePanel (or vice-versa when the agent is removed). The overlay + * travels above all other UI elements (z-index 9999) so it is never + * clipped by the layout. + */ +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import type { PipelineState } from "../api/client"; + +const { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} = React; + +// ─── Public context shape ───────────────────────────────────────────────────── + +export interface LozengeFlyContextValue { + /** Register/unregister a roster badge DOM element by agent name. */ + registerRosterEl: (agentName: string, el: HTMLElement | null) => void; + /** + * Save the latest DOMRect for a story's lozenge slot. + * Called on every render of AgentLozenge via useLayoutEffect. + */ + saveSlotRect: (storyId: string, rect: DOMRect) => void; + /** + * Set of storyIds whose slot lozenges should be hidden because a + * fly-in animation is in progress. + */ + pendingFlyIns: ReadonlySet; +} + +const noop = () => {}; +const emptySet: ReadonlySet = new Set(); + +export const LozengeFlyContext = createContext({ + registerRosterEl: noop, + saveSlotRect: noop, + pendingFlyIns: emptySet, +}); + +// ─── Internal flying-lozenge state ─────────────────────────────────────────── + +interface FlyingLozenge { + id: string; + label: string; + isActive: boolean; + startX: number; + startY: number; + endX: number; + endY: number; + /** false = positioned at start, true = CSS transition to end */ + flying: boolean; +} + +interface PendingFlyIn { + storyId: string; + agentName: string; + label: string; + isActive: boolean; +} + +interface PendingFlyOut { + storyId: string; + agentName: string; + label: string; +} + +// ─── Provider ───────────────────────────────────────────────────────────────── + +interface LozengeFlyProviderProps { + children: React.ReactNode; + pipeline: PipelineState; +} + +export function LozengeFlyProvider({ + children, + pipeline, +}: LozengeFlyProviderProps) { + const rosterElsRef = useRef>(new Map()); + const savedSlotRectsRef = useRef>(new Map()); + const prevPipelineRef = useRef(null); + + // Actions detected in useLayoutEffect, consumed in useEffect + const pendingFlyInActionsRef = useRef([]); + const pendingFlyOutActionsRef = useRef([]); + + const [pendingFlyIns, setPendingFlyIns] = useState>( + new Set(), + ); + const [flyingLozenges, setFlyingLozenges] = useState([]); + + const registerRosterEl = useCallback( + (agentName: string, el: HTMLElement | null) => { + if (el) { + rosterElsRef.current.set(agentName, el); + } else { + rosterElsRef.current.delete(agentName); + } + }, + [], + ); + + const saveSlotRect = useCallback((storyId: string, rect: DOMRect) => { + savedSlotRectsRef.current.set(storyId, rect); + }, []); + + // ── Detect pipeline changes (runs before paint) ─────────────────────────── + // Sets pendingFlyIns so slot lozenges hide before the browser paints, + // preventing a one-frame "flash" of the visible lozenge before fly-in. + useLayoutEffect(() => { + if (prevPipelineRef.current === null) { + prevPipelineRef.current = pipeline; + return; + } + + const prev = prevPipelineRef.current; + const allPrev = [ + ...prev.upcoming, + ...prev.current, + ...prev.qa, + ...prev.merge, + ]; + const allCurr = [ + ...pipeline.upcoming, + ...pipeline.current, + ...pipeline.qa, + ...pipeline.merge, + ]; + + const newFlyInStoryIds = new Set(); + + for (const curr of allCurr) { + const prevItem = allPrev.find((p) => p.story_id === curr.story_id); + const agentChanged = + curr.agent && + (!prevItem?.agent || + prevItem.agent.agent_name !== curr.agent.agent_name); + if (agentChanged && curr.agent) { + const label = curr.agent.model + ? `${curr.agent.agent_name} ${curr.agent.model}` + : curr.agent.agent_name; + pendingFlyInActionsRef.current.push({ + storyId: curr.story_id, + agentName: curr.agent.agent_name, + label, + isActive: curr.agent.status === "running", + }); + newFlyInStoryIds.add(curr.story_id); + } + } + + for (const prevItem of allPrev) { + if (!prevItem.agent) continue; + const currItem = allCurr.find((c) => c.story_id === prevItem.story_id); + const agentRemoved = + !currItem?.agent || + currItem.agent.agent_name !== prevItem.agent.agent_name; + if (agentRemoved) { + const label = prevItem.agent.model + ? `${prevItem.agent.agent_name} ${prevItem.agent.model}` + : prevItem.agent.agent_name; + pendingFlyOutActionsRef.current.push({ + storyId: prevItem.story_id, + agentName: prevItem.agent.agent_name, + label, + }); + } + } + + prevPipelineRef.current = pipeline; + + // Only hide slots for stories that have a matching roster element + if (newFlyInStoryIds.size > 0) { + const hideable = new Set(); + 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 ( )} - {item.agent && } + {item.agent && ( + + )} ); })}