diff --git a/frontend/src/components/GatewayPanel.test.tsx b/frontend/src/components/GatewayPanel.test.tsx index 561ed49a..01c8eff1 100644 --- a/frontend/src/components/GatewayPanel.test.tsx +++ b/frontend/src/components/GatewayPanel.test.tsx @@ -25,7 +25,7 @@ describe("StoryRow", () => { expect(container).toMatchSnapshot(); }); - it("renders name without id prefix when story_id has no leading number", () => { + it("renders awaiting-slot badge for merge item with no agent", () => { const item: PipelineItem = { story_id: "no-number-id", name: "Mystery Story", @@ -33,6 +33,65 @@ describe("StoryRow", () => { }; const { container } = render(); expect(container).toMatchSnapshot(); + expect(screen.getByText("awaiting-slot")).toBeInTheDocument(); + }); + + // AC1: active mergemaster is visually distinct + it("shows MERGING badge for merge item with running mergemaster (active)", () => { + const item: PipelineItem = { + story_id: "70_story_merging_active", + name: "Merging Active", + stage: "merge", + agent: { agent_name: "mergemaster", model: "claude", status: "running" }, + }; + render(); + expect(screen.getByText("▶ MERGING")).toBeInTheDocument(); + }); + + // AC2: awaiting-slot with queue position labels + it("shows NEXT IN QUEUE for first awaiting-slot merge item", () => { + const item: PipelineItem = { + story_id: "71_story_next_in_queue", + name: "Next in Queue", + stage: "merge", + }; + render(); + expect(screen.getByText("NEXT IN QUEUE")).toBeInTheDocument(); + }); + + it("shows awaiting-slot with position for subsequent queued merge items", () => { + const item: PipelineItem = { + story_id: "72_story_second_in_queue", + name: "Second in Queue", + stage: "merge", + }; + render(); + expect(screen.getByText("awaiting-slot (#2)")).toBeInTheDocument(); + }); + + // AC2: failure kind labels derived from merge_failure string + it("shows ConflictDetected for merge_failure with conflict text", () => { + const item: PipelineItem = { + story_id: "73_story_conflict", + name: "Conflict Story", + stage: "merge", + blocked: true, + merge_failure: "Merge conflict: conflicts detected", + }; + render(); + expect(screen.getByText("ConflictDetected")).toBeInTheDocument(); + }); + + it("shows GatesFailed for merge_failure with quality gates text", () => { + const item: PipelineItem = { + story_id: "74_story_gates", + name: "Gates Failed Story", + stage: "merge", + blocked: true, + merge_failure: "Quality gates failed: cargo test failed", + }; + render(); + expect(screen.getByText("GatesFailed")).toBeInTheDocument(); }); it("shows RECOVERING badge for merge_failure item with running mergemaster", () => { diff --git a/frontend/src/components/GatewayPanel.tsx b/frontend/src/components/GatewayPanel.tsx index ebacdeaf..38fbda58 100644 --- a/frontend/src/components/GatewayPanel.tsx +++ b/frontend/src/components/GatewayPanel.tsx @@ -66,15 +66,36 @@ const STAGE_LABELS: Record = { archived: "Archived", }; +/// Derive a short label from a merge failure string based on the failure kind. +function mergeFailureKindLabel(failure: string): string { + if (failure.includes("Merge conflict") || failure.includes("CONFLICT")) { + return "ConflictDetected"; + } + if (failure.includes("Quality gates failed") || failure.includes("gates failed")) { + return "GatesFailed"; + } + if (failure.includes("no code changes") || failure.includes("empty diff")) { + return "EmptyDiff"; + } + if (failure.includes("No commits")) { + return "NoCommits"; + } + return "✕ FAILED"; +} + /// A single story row inside a project pipeline card. /** Render one story row in a gateway-aggregate panel: `# ` with stage badge. */ -export function StoryRow({ item }: { item: PipelineItem }) { +export function StoryRow({ item, mergeQueuePos }: { item: PipelineItem; mergeQueuePos?: number }) { const isStuck = item.merge_failure != null || item.blocked; + const isMergeActive = item.stage === "merge" && !isStuck && item.agent?.status === "running"; let color: string; let label: string; - if (isStuck) { + if (isMergeActive) { + color = "#58a6ff"; + label = "▶ MERGING"; + } else if (isStuck) { const agentStatus = item.agent?.status; if (agentStatus === "running") { color = "#e3b341"; @@ -82,9 +103,24 @@ export function StoryRow({ item }: { item: PipelineItem }) { } else if (agentStatus === "pending") { color = "#e3b341"; label = "⏳ QUEUED"; + } else if (item.merge_failure != null) { + color = "#f85149"; + label = mergeFailureKindLabel(item.merge_failure); } else { color = "#f85149"; - label = item.merge_failure != null ? "✕ FAILED" : "⊘ BLOCKED"; + label = "⊘ BLOCKED"; + } + } else if (item.stage === "merge" && item.agent?.status === "pending") { + color = "#e3b341"; + label = "⏳ QUEUED"; + } else if (item.stage === "merge") { + color = "#6e7681"; + if (mergeQueuePos === 1) { + label = "NEXT IN QUEUE"; + } else if (mergeQueuePos != null) { + label = `awaiting-slot (#${mergeQueuePos})`; + } else { + label = "awaiting-slot"; } } else { color = STAGE_COLORS[item.stage] ?? "#8b949e"; @@ -101,6 +137,10 @@ export function StoryRow({ item }: { item: PipelineItem }) { gap: "8px", padding: "4px 0", fontSize: "0.82em", + background: isMergeActive ? "#58a6ff0a" : undefined, + borderRadius: isMergeActive ? "4px" : undefined, + paddingLeft: isMergeActive ? "4px" : undefined, + paddingRight: isMergeActive ? "4px" : undefined, }} > @@ -470,7 +512,7 @@ function ProjectStoryRow({ )}
- +
); @@ -503,6 +545,20 @@ function InProgressTabContent({ (s) => byStage[s].length > 0, ); + // Compute queue position among clean awaiting merge items (Stage::Merge, no failure, no running agent). + const mergeQueuePosMap = new Map(); + let queuePos = 0; + for (const { project, item } of byStage.merge) { + if ( + !item.blocked && + !item.merge_failure && + item.agent?.status !== "running" + ) { + queuePos += 1; + mergeQueuePosMap.set(`${project}:${item.story_id}`, queuePos); + } + } + if (allItems.length === 0) { return (

@@ -538,6 +594,11 @@ function InProgressTabContent({ project={project} item={item} showProject={multiProject} + mergeQueuePos={ + stage === "merge" + ? mergeQueuePosMap.get(`${project}:${item.story_id}`) + : undefined + } /> ))} diff --git a/frontend/src/components/__snapshots__/GatewayPanel.test.tsx.snap b/frontend/src/components/__snapshots__/GatewayPanel.test.tsx.snap index 66361478..1637c34b 100644 --- a/frontend/src/components/__snapshots__/GatewayPanel.test.tsx.snap +++ b/frontend/src/components/__snapshots__/GatewayPanel.test.tsx.snap @@ -3,7 +3,7 @@ exports[`StoryRow > renders #id prefix before the story name 1`] = `

renders #id prefix before the story name 1`] = ` exports[`StoryRow > renders #id prefix for a backlogged story 1`] = `
renders #id prefix for a backlogged story 1`] = `
`; -exports[`StoryRow > renders name without id prefix when story_id has no leading number 1`] = ` +exports[`StoryRow > renders awaiting-slot badge for merge item with no agent 1`] = `
- Merging + awaiting-slot