Merge story 74: agent lozenges float across UI during assignment

Resolves conflict in AgentPanel.test.tsx between story 73 (fade-out)
and story 74 (lozenge fly animation) - kept both the assertion and
the comment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-23 15:15:50 +00:00
6 changed files with 937 additions and 19 deletions

View File

@@ -1,6 +1,14 @@
import { act, render, screen, waitFor } from "@testing-library/react"; import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; 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 type { AgentConfigInfo, AgentInfo } from "../api/agents";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
@@ -337,14 +345,20 @@ describe("AgentPanel fade-out", () => {
const { container } = render(<AgentPanel />); const { container } = render(<AgentPanel />);
// Wait for the agent entry to appear // With fake timers active, waitFor's polling setInterval never fires.
await waitFor(() => { // 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( expect(
container.querySelector( container.querySelector(
'[data-testid="agent-entry-73_remove_test:coder-1"]', '[data-testid="agent-entry-73_remove_test:coder-1"]',
), ),
).toBeInTheDocument(); ).toBeInTheDocument();
});
// Advance timers by 60 seconds and flush React state updates // Advance timers by 60 seconds and flush React state updates
await act(async () => { await act(async () => {

View File

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

View File

@@ -7,6 +7,7 @@ import { api, ChatWebSocket } from "../api/client";
import type { Message, ProviderConfig, ToolCall } from "../types"; import type { Message, ProviderConfig, ToolCall } from "../types";
import { AgentPanel } from "./AgentPanel"; import { AgentPanel } from "./AgentPanel";
import { ChatHeader } from "./ChatHeader"; import { ChatHeader } from "./ChatHeader";
import { LozengeFlyProvider } from "./LozengeFlyContext";
import { StagePanel } from "./StagePanel"; import { StagePanel } from "./StagePanel";
const { useCallback, useEffect, useRef, useState } = React; const { useCallback, useEffect, useRef, useState } = React;
@@ -713,12 +714,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
gap: "12px", gap: "12px",
}} }}
> >
<LozengeFlyProvider pipeline={pipeline}>
<AgentPanel /> <AgentPanel />
<StagePanel title="To Merge" items={pipeline.merge} /> <StagePanel title="To Merge" items={pipeline.merge} />
<StagePanel title="QA" items={pipeline.qa} /> <StagePanel title="QA" items={pipeline.qa} />
<StagePanel title="Current" items={pipeline.current} /> <StagePanel title="Current" items={pipeline.current} />
<StagePanel title="Upcoming" items={pipeline.upcoming} /> <StagePanel title="Upcoming" items={pipeline.upcoming} />
</LozengeFlyProvider>
</div> </div>
</div> </div>

View File

@@ -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> = {}): 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<HTMLSpanElement>(null);
React.useEffect(() => {
const el = ref.current;
if (el) registerRosterEl(agentName, el);
return () => registerRosterEl(agentName, null);
}, [agentName, registerRosterEl]);
return (
<span
ref={ref}
data-testid={`roster-${agentName}`}
style={{ position: "fixed", top: 10, left: 20, width: 80, height: 20 }}
/>
);
}
function Wrapper({
pipeline,
children,
}: {
pipeline: PipelineState;
children: React.ReactNode;
}) {
return (
<LozengeFlyProvider pipeline={pipeline}>{children}</LozengeFlyProvider>
);
}
// ─── 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(
<Wrapper pipeline={pipeline}>
<StagePanel title="Current" items={items} />
</Wrapper>,
);
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(
<Wrapper pipeline={noPipeline}>
<RosterFixture agentName="coder-1" />
<StagePanel title="Current" items={[]} />
</Wrapper>,
);
// Rerender with the agent assigned
await act(async () => {
rerender(
<Wrapper pipeline={withAgent}>
<RosterFixture agentName="coder-1" />
<StagePanel title="Current" items={withAgent.current} />
</Wrapper>,
);
});
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(
<Wrapper pipeline={noPipeline}>
{/* No RosterFixture for "unknown-agent" */}
<StagePanel title="Current" items={[]} />
</Wrapper>,
);
await act(async () => {
rerender(
<Wrapper pipeline={withAgent}>
<StagePanel title="Current" items={withAgent.current} />
</Wrapper>,
);
});
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(
<Wrapper pipeline={noPipeline}>
<RosterFixture agentName="coder-1" />
<StagePanel title="Current" items={[]} />
</Wrapper>,
);
await act(async () => {
rerender(
<Wrapper pipeline={withAgent}>
<RosterFixture agentName="coder-1" />
<StagePanel title="Current" items={withAgent.current} />
</Wrapper>,
);
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(
<Wrapper pipeline={noPipeline}>
<RosterFixture agentName="coder-1" />
<StagePanel title="Current" items={[]} />
</Wrapper>,
);
await act(async () => {
rerender(
<Wrapper pipeline={withAgent}>
<RosterFixture agentName="coder-1" />
<StagePanel title="Current" items={withAgent.current} />
</Wrapper>,
);
});
// 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(
<Wrapper pipeline={noPipeline}>
<RosterFixture agentName="coder-1" />
<StagePanel title="Current" items={[]} />
</Wrapper>,
);
await act(async () => {
rerender(
<Wrapper pipeline={withAgent}>
<RosterFixture agentName="coder-1" />
<StagePanel title="Current" items={withAgent.current} />
</Wrapper>,
);
});
// 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(
<Wrapper pipeline={withAgent}>
<RosterFixture agentName="coder-1" />
<StagePanel title="Current" items={withAgent.current} />
</Wrapper>,
);
// 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(
<Wrapper pipeline={noAgent}>
<RosterFixture agentName="coder-1" />
<StagePanel title="Current" items={noAgent.current} />
</Wrapper>,
);
});
// 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(
<Wrapper pipeline={makePipeline({ current: items })}>
<StagePanel title="Current" items={items} />
</Wrapper>,
);
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(
<Wrapper pipeline={makePipeline({ current: items })}>
<StagePanel title="Current" items={items} />
</Wrapper>,
);
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(
<Wrapper pipeline={makePipeline({ current: items })}>
<StagePanel title="Current" items={items} />
</Wrapper>,
);
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");
});
});

View File

@@ -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<string>;
}
const noop = () => {};
const emptySet: ReadonlySet<string> = new Set();
export const LozengeFlyContext = createContext<LozengeFlyContextValue>({
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<Map<string, HTMLElement>>(new Map());
const savedSlotRectsRef = useRef<Map<string, DOMRect>>(new Map());
const prevPipelineRef = useRef<PipelineState | null>(null);
// Actions detected in useLayoutEffect, consumed in useEffect
const pendingFlyInActionsRef = useRef<PendingFlyIn[]>([]);
const pendingFlyOutActionsRef = useRef<PendingFlyOut[]>([]);
const [pendingFlyIns, setPendingFlyIns] = useState<ReadonlySet<string>>(
new Set(),
);
const [flyingLozenges, setFlyingLozenges] = useState<FlyingLozenge[]>([]);
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<string>();
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<string>();
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 (
<LozengeFlyContext.Provider value={contextValue}>
{children}
{ReactDOM.createPortal(
<FloatingLozengeSurface lozenges={flyingLozenges} />,
document.body,
)}
</LozengeFlyContext.Provider>
);
}
// ─── Portal surface ───────────────────────────────────────────────────────────
function FloatingLozengeSurface({ lozenges }: { lozenges: FlyingLozenge[] }) {
return (
<>
{lozenges.map((l) => (
<FlyingLozengeClone key={l.id} lozenge={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 (
<div
data-testid={`flying-lozenge-${lozenge.id}`}
style={{
position: "fixed",
left: `${x}px`,
top: `${y}px`,
zIndex: 9999,
pointerEvents: "none",
transition: lozenge.flying
? "left 0.4s cubic-bezier(0.4, 0, 0.2, 1), top 0.4s cubic-bezier(0.4, 0, 0.2, 1)"
: "none",
display: "inline-flex",
alignItems: "center",
gap: "5px",
padding: "2px 8px",
borderRadius: "999px",
fontSize: "0.72em",
fontWeight: 600,
background: `${color}18`,
color,
border: `1px solid ${color}44`,
whiteSpace: "nowrap",
}}
>
{lozenge.isActive && (
<span
style={{
width: "5px",
height: "5px",
borderRadius: "50%",
background: color,
animation: "pulse 1.5s infinite",
flexShrink: 0,
}}
/>
)}
{lozenge.label}
</div>
);
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useLozengeFly(): LozengeFlyContextValue {
return useContext(LozengeFlyContext);
}

View File

@@ -1,4 +1,8 @@
import * as React from "react";
import type { AgentAssignment, PipelineStageItem } from "../api/client"; import type { AgentAssignment, PipelineStageItem } from "../api/client";
import { useLozengeFly } from "./LozengeFlyContext";
const { useLayoutEffect, useRef } = React;
interface StagePanelProps { interface StagePanelProps {
title: string; title: string;
@@ -6,7 +10,15 @@ interface StagePanelProps {
emptyMessage?: string; emptyMessage?: string;
} }
function AgentLozenge({ agent }: { agent: AgentAssignment }) { function AgentLozenge({
agent,
storyId,
}: {
agent: AgentAssignment;
storyId: string;
}) {
const { saveSlotRect, pendingFlyIns } = useLozengeFly();
const lozengeRef = useRef<HTMLDivElement>(null);
const isRunning = agent.status === "running"; const isRunning = agent.status === "running";
const isPending = agent.status === "pending"; const isPending = agent.status === "pending";
const color = isRunning ? "#58a6ff" : isPending ? "#e3b341" : "#aaa"; const color = isRunning ? "#58a6ff" : isPending ? "#e3b341" : "#aaa";
@@ -14,9 +26,20 @@ function AgentLozenge({ agent }: { agent: AgentAssignment }) {
? `${agent.agent_name} ${agent.model}` ? `${agent.agent_name} ${agent.model}`
: agent.agent_name; : 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 ( return (
<div <div
ref={lozengeRef}
className="agent-lozenge" className="agent-lozenge"
data-testid={`slot-lozenge-${storyId}`}
style={{ style={{
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
@@ -29,7 +52,12 @@ function AgentLozenge({ agent }: { agent: AgentAssignment }) {
color, color,
border: `1px solid ${color}44`, border: `1px solid ${color}44`,
marginTop: "4px", marginTop: "4px",
animation: "agentAppear 0.3s ease-out", // Fixed intrinsic width never stretches to fill parent panel
alignSelf: "flex-start",
// Hidden during fly-in; revealed with a fade once the clone arrives
opacity: isFlyingIn ? 0 : 1,
transition: isFlyingIn ? "none" : "opacity 0.15s",
animation: isFlyingIn ? "none" : "agentAppear 0.3s ease-out",
}} }}
> >
{isRunning && ( {isRunning && (
@@ -151,7 +179,9 @@ export function StagePanel({
</div> </div>
)} )}
</div> </div>
{item.agent && <AgentLozenge agent={item.agent} />} {item.agent && (
<AgentLozenge agent={item.agent} storyId={item.story_id} />
)}
</div> </div>
); );
})} })}