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