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:
Dave
2026-02-23 19:52:23 +00:00
parent a91d663894
commit 6c1f8555e8
3 changed files with 301 additions and 7 deletions

View File

@@ -37,6 +37,12 @@ export interface LozengeFlyContextValue {
* fly-in animation is in progress.
*/
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 = () => {};
@@ -46,6 +52,7 @@ export const LozengeFlyContext = createContext<LozengeFlyContextValue>({
registerRosterEl: noop,
saveSlotRect: noop,
pendingFlyIns: emptySet,
hiddenRosterAgents: emptySet,
});
// ─── Internal flying-lozenge state ───────────────────────────────────────────
@@ -99,6 +106,34 @@ export function LozengeFlyProvider({
);
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(
(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 (