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:
482
frontend/src/components/LozengeFlyContext.test.tsx
Normal file
482
frontend/src/components/LozengeFlyContext.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user