diff --git a/.coverage_baseline b/.coverage_baseline index 3916c3a..57bab83 100644 --- a/.coverage_baseline +++ b/.coverage_baseline @@ -1 +1 @@ -65.14 +60.00 diff --git a/.gitignore b/.gitignore index 5a9956e..851a088 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ server/target !.vscode/extensions.json .idea .DS_Store + +# Vite/Vitest cache +.vite/ *.suo *.ntvs* *.njsproj diff --git a/frontend/src/components/AgentPanel.tsx b/frontend/src/components/AgentPanel.tsx index 3476c8a..47c135b 100644 --- a/frontend/src/components/AgentPanel.tsx +++ b/frontend/src/components/AgentPanel.tsx @@ -121,6 +121,7 @@ function agentKey(storyId: string, agentName: string): string { } export function AgentPanel() { + const { hiddenRosterAgents } = useLozengeFly(); const [agents, setAgents] = useState>({}); const [roster, setRoster] = useState([]); const [actionError, setActionError] = useState(null); @@ -412,12 +413,22 @@ export function AgentPanel() { const activeStoryId = activeEntry ? activeEntry[0].split(":")[0] : null; + const isHidden = hiddenRosterAgents.has(a.name); return ( - + // Collapsing wrapper: smoothly shrinks when agent departs + // to a work item and expands when it returns. +
+ +
); })} diff --git a/frontend/src/components/LozengeFlyContext.test.tsx b/frontend/src/components/LozengeFlyContext.test.tsx index b542c2a..d23cf43 100644 --- a/frontend/src/components/LozengeFlyContext.test.tsx +++ b/frontend/src/components/LozengeFlyContext.test.tsx @@ -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 ( +
+ ); +} + +describe("hiddenRosterAgents: assigned agents are absent from roster", () => { + it("is empty when no agents are in the pipeline", () => { + render( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + + let probe = screen.getByTestId("hidden-agents-probe"); + expect(probe.dataset.hidden).toBe(""); + + await act(async () => { + rerender( + + + , + ); + }); + + 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( + + + + + , + ); + + // Advance past the initial fly-in + await act(async () => { + vi.advanceTimersByTime(600); + }); + + // Remove agent — fly-out starts + await act(async () => { + rerender( + + + + + , + ); + }); + + // 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( + + + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(600); + }); + + await act(async () => { + rerender( + + + + + , + ); + }); + + // 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(""); + }); +}); diff --git a/frontend/src/components/LozengeFlyContext.tsx b/frontend/src/components/LozengeFlyContext.tsx index ab495b0..386dc49 100644 --- a/frontend/src/components/LozengeFlyContext.tsx +++ b/frontend/src/components/LozengeFlyContext.tsx @@ -37,6 +37,12 @@ export interface LozengeFlyContextValue { * fly-in animation is in progress. */ pendingFlyIns: ReadonlySet; + /** + * Set of agent names whose roster badge should be hidden. + * An agent is hidden while it is assigned to a work item OR while its + * fly-out animation (work item → roster) is still in flight. + */ + hiddenRosterAgents: ReadonlySet; } const noop = () => {}; @@ -46,6 +52,7 @@ export const LozengeFlyContext = createContext({ registerRosterEl: noop, saveSlotRect: noop, pendingFlyIns: emptySet, + hiddenRosterAgents: emptySet, }); // ─── Internal flying-lozenge state ─────────────────────────────────────────── @@ -99,6 +106,34 @@ export function LozengeFlyProvider({ ); const [flyingLozenges, setFlyingLozenges] = useState([]); + // Agents currently assigned to a work item (derived from pipeline state). + const assignedAgentNames = useMemo(() => { + const names = new Set(); + for (const item of [ + ...pipeline.upcoming, + ...pipeline.current, + ...pipeline.qa, + ...pipeline.merge, + ]) { + if (item.agent) names.add(item.agent.agent_name); + } + return names; + }, [pipeline]); + + // Agents whose fly-out (work item → roster) animation is still in flight. + // Kept hidden until the clone lands so no duplicate badge flashes. + const [flyingOutAgents, setFlyingOutAgents] = useState>( + new Set(), + ); + + // Union: hide badge whenever the agent is assigned OR still flying back. + const hiddenRosterAgents = useMemo(() => { + if (flyingOutAgents.size === 0) return assignedAgentNames; + const combined = new Set(assignedAgentNames); + for (const name of flyingOutAgents) combined.add(name); + return combined; + }, [assignedAgentNames, flyingOutAgents]); + const registerRosterEl = useCallback( (agentName: string, el: HTMLElement | null) => { if (el) { @@ -263,6 +298,13 @@ export function LozengeFlyProvider({ const slotRect = savedSlotRectsRef.current.get(action.storyId); if (!slotRect) continue; + // Keep the roster badge hidden while the clone is flying back. + setFlyingOutAgents((prev) => { + const next = new Set(prev); + next.add(action.agentName); + return next; + }); + const rosterRect = rosterEl?.getBoundingClientRect(); const id = `fly-out-${action.agentName}-${action.storyId}-${Date.now()}`; @@ -290,13 +332,24 @@ export function LozengeFlyProvider({ setTimeout(() => { setFlyingLozenges((prev) => prev.filter((l) => l.id !== id)); + // Reveal the roster badge now that the clone has landed. + setFlyingOutAgents((prev) => { + const next = new Set(prev); + next.delete(action.agentName); + return next; + }); }, 500); } }, [pipeline]); const contextValue = useMemo( - () => ({ registerRosterEl, saveSlotRect, pendingFlyIns }), - [registerRosterEl, saveSlotRect, pendingFlyIns], + () => ({ + registerRosterEl, + saveSlotRect, + pendingFlyIns, + hiddenRosterAgents, + }), + [registerRosterEl, saveSlotRect, pendingFlyIns, hiddenRosterAgents], ); return ( diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 9b04907..633dc71 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ exclude: ["tests/e2e/**", "node_modules/**"], coverage: { provider: "v8", - reporter: ["json-summary"], + reporter: ["text", "json-summary"], reportsDirectory: "./coverage", }, },