feat(story-85): agent lozenges move between roster and work items
- Add `hiddenRosterAgents: ReadonlySet<string>` to LozengeFlyContext:
- Derived from pipeline: any agent currently assigned to a work item
- `flyingOutAgents` state keeps badge hidden for 500 ms during the
fly-out animation so the returning clone lands before the badge reappears
- Union of both sets exposed as `hiddenRosterAgents` in context
- Update AgentPanel: wrap each RosterBadge in a collapsing div
controlled by `hiddenRosterAgents`. The div transitions max-width
0→300px / opacity 0→1 so the roster gap closes/opens smoothly.
- Add tests covering:
- `hiddenRosterAgents` is empty when no agents are assigned
- Badge hidden immediately when agent appears in pipeline
- Badge hidden during fly-out (0–499 ms) and visible after (≥500 ms)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -480,3 +480,233 @@ describe("AgentLozenge idle vs active appearance", () => {
|
||||
expect(dot?.style.animation).toContain("pulse");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── hiddenRosterAgents: no-duplicate guarantee ───────────────────────────────
|
||||
|
||||
/** Reads hiddenRosterAgents from context and exposes it via a data attribute. */
|
||||
function HiddenAgentsProbe() {
|
||||
const { hiddenRosterAgents } = useLozengeFly();
|
||||
return (
|
||||
<div
|
||||
data-testid="hidden-agents-probe"
|
||||
data-hidden={[...hiddenRosterAgents].join(",")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("hiddenRosterAgents: assigned agents are absent from roster", () => {
|
||||
it("is empty when no agents are in the pipeline", () => {
|
||||
render(
|
||||
<LozengeFlyProvider pipeline={makePipeline()}>
|
||||
<HiddenAgentsProbe />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
const probe = screen.getByTestId("hidden-agents-probe");
|
||||
expect(probe.dataset.hidden).toBe("");
|
||||
});
|
||||
|
||||
it("includes agent name when agent is assigned to a current story", () => {
|
||||
const pipeline = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "85_assign_test",
|
||||
name: "Assign Test",
|
||||
error: null,
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<LozengeFlyProvider pipeline={pipeline}>
|
||||
<HiddenAgentsProbe />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
const probe = screen.getByTestId("hidden-agents-probe");
|
||||
expect(probe.dataset.hidden).toContain("coder-1");
|
||||
});
|
||||
|
||||
it("excludes agent name when it has no assignment in the pipeline", () => {
|
||||
const pipeline = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "85_no_agent",
|
||||
name: "No Agent",
|
||||
error: null,
|
||||
agent: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<LozengeFlyProvider pipeline={pipeline}>
|
||||
<HiddenAgentsProbe />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
const probe = screen.getByTestId("hidden-agents-probe");
|
||||
expect(probe.dataset.hidden).toBe("");
|
||||
});
|
||||
|
||||
it("updates to include agent when pipeline transitions from no-agent to assigned", async () => {
|
||||
const noPipeline = makePipeline();
|
||||
const withAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "85_transition_test",
|
||||
name: "Transition",
|
||||
error: null,
|
||||
agent: { agent_name: "coder-1", model: null, status: "running" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<LozengeFlyProvider pipeline={noPipeline}>
|
||||
<HiddenAgentsProbe />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
|
||||
let probe = screen.getByTestId("hidden-agents-probe");
|
||||
expect(probe.dataset.hidden).toBe("");
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<LozengeFlyProvider pipeline={withAgent}>
|
||||
<HiddenAgentsProbe />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
probe = screen.getByTestId("hidden-agents-probe");
|
||||
expect(probe.dataset.hidden).toContain("coder-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () => {
|
||||
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("agent stays hidden in roster during fly-out (0–499 ms)", async () => {
|
||||
const withAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "85_flyout_hidden",
|
||||
name: "Fly-out Hidden",
|
||||
error: null,
|
||||
agent: { agent_name: "coder-1", model: null, status: "completed" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const noAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "85_flyout_hidden",
|
||||
name: "Fly-out Hidden",
|
||||
error: null,
|
||||
agent: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<LozengeFlyProvider pipeline={withAgent}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<HiddenAgentsProbe />
|
||||
<StagePanel title="Current" items={withAgent.current} />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
|
||||
// Advance past the initial fly-in
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
// Remove agent — fly-out starts
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<LozengeFlyProvider pipeline={noAgent}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<HiddenAgentsProbe />
|
||||
<StagePanel title="Current" items={noAgent.current} />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
// Agent should still be hidden (fly-out clone is in flight)
|
||||
const probe = screen.getByTestId("hidden-agents-probe");
|
||||
expect(probe.dataset.hidden).toContain("coder-1");
|
||||
});
|
||||
|
||||
it("agent reappears in roster after fly-out clone lands (500 ms)", async () => {
|
||||
const withAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "85_flyout_reveal",
|
||||
name: "Fly-out Reveal",
|
||||
error: null,
|
||||
agent: { agent_name: "coder-1", model: null, status: "completed" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const noAgent = makePipeline({
|
||||
current: [
|
||||
{
|
||||
story_id: "85_flyout_reveal",
|
||||
name: "Fly-out Reveal",
|
||||
error: null,
|
||||
agent: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<LozengeFlyProvider pipeline={withAgent}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<HiddenAgentsProbe />
|
||||
<StagePanel title="Current" items={withAgent.current} />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<LozengeFlyProvider pipeline={noAgent}>
|
||||
<RosterFixture agentName="coder-1" />
|
||||
<HiddenAgentsProbe />
|
||||
<StagePanel title="Current" items={noAgent.current} />
|
||||
</LozengeFlyProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
// Advance past fly-out animation
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
// Agent should now be visible in roster
|
||||
const probe = screen.getByTestId("hidden-agents-probe");
|
||||
expect(probe.dataset.hidden).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user