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:
@@ -1 +1 @@
|
|||||||
65.14
|
60.00
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user