Merge branch 'feature/story-85_story_agent_lozenges_move_between_roster_and_work_items_instead_of_duplicating'

# Conflicts:
#	.coverage_baseline
This commit is contained in:
Dave
2026-02-23 21:24:44 +00:00
6 changed files with 306 additions and 9 deletions

View File

@@ -1 +1 @@
65.14 60.00

3
.gitignore vendored
View File

@@ -33,6 +33,9 @@ server/target
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea
.DS_Store .DS_Store
# Vite/Vitest cache
.vite/
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj

View File

@@ -121,6 +121,7 @@ function agentKey(storyId: string, agentName: string): string {
} }
export function AgentPanel() { export function AgentPanel() {
const { hiddenRosterAgents } = useLozengeFly();
const [agents, setAgents] = useState<Record<string, AgentState>>({}); const [agents, setAgents] = useState<Record<string, AgentState>>({});
const [roster, setRoster] = useState<AgentConfigInfo[]>([]); const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
@@ -412,12 +413,22 @@ export function AgentPanel() {
const activeStoryId = activeEntry const activeStoryId = activeEntry
? activeEntry[0].split(":")[0] ? activeEntry[0].split(":")[0]
: null; : null;
const isHidden = hiddenRosterAgents.has(a.name);
return ( return (
<RosterBadge // Collapsing wrapper: smoothly shrinks when agent departs
key={`roster-${a.name}`} // to a work item and expands when it returns.
agent={a} <div
activeStoryId={activeStoryId} key={`roster-wrapper-${a.name}`}
/> data-testid={`roster-badge-wrapper-${a.name}`}
style={{
overflow: "hidden",
maxWidth: isHidden ? "0" : "300px",
opacity: isHidden ? 0 : 1,
transition: "max-width 0.35s ease, opacity 0.2s ease",
}}
>
<RosterBadge agent={a} activeStoryId={activeStoryId} />
</div>
); );
})} })}
</div> </div>

View File

@@ -480,3 +480,233 @@ describe("AgentLozenge idle vs active appearance", () => {
expect(dot?.style.animation).toContain("pulse"); 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 (0499 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("");
});
});

View File

@@ -37,6 +37,12 @@ export interface LozengeFlyContextValue {
* fly-in animation is in progress. * fly-in animation is in progress.
*/ */
pendingFlyIns: ReadonlySet<string>; pendingFlyIns: ReadonlySet<string>;
/**
* 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<string>;
} }
const noop = () => {}; const noop = () => {};
@@ -46,6 +52,7 @@ export const LozengeFlyContext = createContext<LozengeFlyContextValue>({
registerRosterEl: noop, registerRosterEl: noop,
saveSlotRect: noop, saveSlotRect: noop,
pendingFlyIns: emptySet, pendingFlyIns: emptySet,
hiddenRosterAgents: emptySet,
}); });
// ─── Internal flying-lozenge state ─────────────────────────────────────────── // ─── Internal flying-lozenge state ───────────────────────────────────────────
@@ -99,6 +106,34 @@ export function LozengeFlyProvider({
); );
const [flyingLozenges, setFlyingLozenges] = useState<FlyingLozenge[]>([]); const [flyingLozenges, setFlyingLozenges] = useState<FlyingLozenge[]>([]);
// Agents currently assigned to a work item (derived from pipeline state).
const assignedAgentNames = useMemo(() => {
const names = new Set<string>();
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<ReadonlySet<string>>(
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( const registerRosterEl = useCallback(
(agentName: string, el: HTMLElement | null) => { (agentName: string, el: HTMLElement | null) => {
if (el) { if (el) {
@@ -263,6 +298,13 @@ export function LozengeFlyProvider({
const slotRect = savedSlotRectsRef.current.get(action.storyId); const slotRect = savedSlotRectsRef.current.get(action.storyId);
if (!slotRect) continue; 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 rosterRect = rosterEl?.getBoundingClientRect();
const id = `fly-out-${action.agentName}-${action.storyId}-${Date.now()}`; const id = `fly-out-${action.agentName}-${action.storyId}-${Date.now()}`;
@@ -290,13 +332,24 @@ export function LozengeFlyProvider({
setTimeout(() => { setTimeout(() => {
setFlyingLozenges((prev) => prev.filter((l) => l.id !== id)); 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); }, 500);
} }
}, [pipeline]); }, [pipeline]);
const contextValue = useMemo( const contextValue = useMemo(
() => ({ registerRosterEl, saveSlotRect, pendingFlyIns }), () => ({
[registerRosterEl, saveSlotRect, pendingFlyIns], registerRosterEl,
saveSlotRect,
pendingFlyIns,
hiddenRosterAgents,
}),
[registerRosterEl, saveSlotRect, pendingFlyIns, hiddenRosterAgents],
); );
return ( return (

View File

@@ -11,7 +11,7 @@ export default defineConfig({
exclude: ["tests/e2e/**", "node_modules/**"], exclude: ["tests/e2e/**", "node_modules/**"],
coverage: { coverage: {
provider: "v8", provider: "v8",
reporter: ["json-summary"], reporter: ["text", "json-summary"],
reportsDirectory: "./coverage", reportsDirectory: "./coverage",
}, },
}, },