diff --git a/apps/loopover-ui/src/components/site/app-panels/analytics-card-shell.test.tsx b/apps/loopover-ui/src/components/site/app-panels/analytics-card-shell.test.tsx index 98053ebc20..e50d41cae5 100644 --- a/apps/loopover-ui/src/components/site/app-panels/analytics-card-shell.test.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/analytics-card-shell.test.tsx @@ -50,4 +50,25 @@ describe("AnalyticsCardShell", () => { expect(screen.getByRole("heading", { name: "Queue health" })).toBeTruthy(); expect(container.querySelectorAll("p").length).toBe(0); }); + + it("renders the action header slot across every state (loading, empty, ready)", () => { + for (const state of ["loading", "empty", "ready"] as const) { + const { unmount } = render( + action slot}> +
ready content
+
, + ); + expect(screen.getByText("action slot")).toBeTruthy(); + unmount(); + } + }); + + it("omits the action slot entirely when none is provided", () => { + render( + +
ready content
+
, + ); + expect(screen.queryByText("action slot")).toBeNull(); + }); }); diff --git a/apps/loopover-ui/src/components/site/app-panels/analytics-card-shell.tsx b/apps/loopover-ui/src/components/site/app-panels/analytics-card-shell.tsx index 46e9da1141..613534e473 100644 --- a/apps/loopover-ui/src/components/site/app-panels/analytics-card-shell.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/analytics-card-shell.tsx @@ -15,6 +15,7 @@ export function AnalyticsCardShell({ state, emptyTitle = "No data yet", emptyHint, + action, children, }: { title: string; @@ -22,6 +23,9 @@ export function AnalyticsCardShell({ state: AnalyticsCardState; emptyTitle?: string; emptyHint?: ReactNode; + /** Header-right slot (e.g. a status pill, boundary badge, or freshness stamp) shown across every + * state, matching the header's existing `justify-between` layout. */ + action?: ReactNode; children?: ReactNode; }) { return ( @@ -33,6 +37,7 @@ export function AnalyticsCardShell({

{description}

) : null} + {action} {state === "loading" ? ( diff --git a/apps/loopover-ui/src/components/site/app-panels/cycle-time-card.test.tsx b/apps/loopover-ui/src/components/site/app-panels/cycle-time-card.test.tsx index 550847c8f5..2bb631634f 100644 --- a/apps/loopover-ui/src/components/site/app-panels/cycle-time-card.test.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/cycle-time-card.test.tsx @@ -51,7 +51,7 @@ describe("CycleTimeCard", () => { expect(screen.getByText("2 paired PR(s)")).toBeTruthy(); }); - it("shows inline empty copy when there are no samples", () => { + it("shows the shared EmptyState (not a bare paragraph) when there are no samples (#6175)", () => { const cycleTime: CycleTimeAggregate = { p50Ms: null, p90Ms: null, @@ -61,7 +61,14 @@ describe("CycleTimeCard", () => { }; render(); expect(screen.getByText("no samples yet")).toBeTruthy(); + expect(screen.getByText("No paired samples yet")).toBeTruthy(); + expect( + screen.getByText( + "Paired gate decisions and PR outcomes will appear here once the gate has resolved pull requests in the analytics window.", + ), + ).toBeTruthy(); expect(screen.queryByText("Cycle-time distribution")).toBeNull(); + expect(screen.queryByText("p50")).toBeNull(); }); it("omits the sparkbar when the distribution is empty", () => { diff --git a/apps/loopover-ui/src/components/site/app-panels/cycle-time-card.tsx b/apps/loopover-ui/src/components/site/app-panels/cycle-time-card.tsx index 08a7bc2443..1c62105022 100644 --- a/apps/loopover-ui/src/components/site/app-panels/cycle-time-card.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/cycle-time-card.tsx @@ -1,3 +1,4 @@ +import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell"; import { MiniSparkbar, Stat, StatusPill } from "@/components/site/control-primitives"; import { formatCycleTimeMs, @@ -5,59 +6,48 @@ import { } from "@/components/site/app-panels/cycle-time-card-model"; /** Self-host maintainer analytics card (#2194): PR review cycle-time percentiles (p50/p90/p99) from the stats - * feed, read-only over the operator-dashboard payload. Shows an inline empty state when there are no paired - * gate_decision → pr_outcome samples in the window. */ + * feed, read-only over the operator-dashboard payload. Shows the shared EmptyState (#6175) when there are no + * paired gate_decision → pr_outcome samples in the window. */ export function CycleTimeCard({ cycleTime }: { cycleTime: CycleTimeAggregate }) { const hasSamples = cycleTime.sampleSize > 0; const hasDistribution = cycleTime.distribution.length > 0; return ( -
-
-
-

Review cycle time

-

- Gate decision → PR outcome duration percentiles from review_audit. Public-safe - aggregates only. -

-
+ {hasSamples ? `${cycleTime.sampleSize} paired PR(s)` : "no samples yet"} + } + > +
+ median cycle time} + /> + 90th percentile} + /> + 99th percentile} + />
- - {hasSamples ? ( - <> -
- median cycle time} - /> - 90th percentile} - /> - 99th percentile} - /> -
- {hasDistribution ? ( -
-
Cycle-time distribution
- -
- ) : null} - - ) : ( -

- Paired gate decisions and PR outcomes will appear here once the gate has resolved pull - requests in the analytics window. -

- )} -
+ {hasDistribution ? ( +
+
Cycle-time distribution
+ +
+ ) : null} + ); } diff --git a/apps/loopover-ui/src/components/site/app-panels/gate-outcome-card.tsx b/apps/loopover-ui/src/components/site/app-panels/gate-outcome-card.tsx index 358f68d190..5b6c11bebe 100644 --- a/apps/loopover-ui/src/components/site/app-panels/gate-outcome-card.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/gate-outcome-card.tsx @@ -1,3 +1,4 @@ +import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell"; import { BoundaryBadge, Stat } from "@/components/site/control-primitives"; import { EmptyState } from "@/components/site/state-views"; import { @@ -9,30 +10,28 @@ import { import { formatGeneratedAt } from "@/components/site/app-panels/slop-duplicate-trend-card-model"; /** Gate-outcome breakdown card (#2203, part of #539): auto-merged / auto-closed / held counts and rates - * from repo-scoped gate-outcome audit events. Read-only; public-safe aggregate counts only. */ + * from repo-scoped gate-outcome audit events. Read-only; public-safe aggregate counts only. The count Stats + * always render regardless of the outcome mix, so this card stays in AnalyticsCardShell's "ready" state and + * keeps its own inner outcome-mix-vs-EmptyState toggle (#6175), matching ReversalHealthCard's shape. */ export function GateOutcomeCard({ breakdown }: { breakdown: GateOutcomeCardData }) { const segments = gateOutcomeSegments(breakdown); const hasSamples = gateOutcomeHasSamples(breakdown); return ( -
-
-
-

Gate outcomes

-

- Terminal gate dispositions from audit events over the last {breakdown.windowDays}{" "} - day(s). -

-
+ generated {formatGeneratedAt(breakdown.generatedAt)}
- - -
+ } + > +
)} -
+ ); } diff --git a/apps/loopover-ui/src/components/site/app-panels/gate-precision-card.tsx b/apps/loopover-ui/src/components/site/app-panels/gate-precision-card.tsx index f05e3d4886..776d669cc0 100644 --- a/apps/loopover-ui/src/components/site/app-panels/gate-precision-card.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/gate-precision-card.tsx @@ -1,4 +1,5 @@ import { cn } from "@/lib/utils"; +import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell"; import { Stat, StatusPill } from "@/components/site/control-primitives"; import { aggregateGateEval, type GateEvalReport } from "./gate-precision-card-model"; @@ -8,27 +9,25 @@ const MIN_DECIDED_FLOOR = 10; /** Self-host maintainer analytics card (#2191): gate merge-precision + the TP/FP/FN/TN confusion matrix from * computeGateEval, read-only over the operator-dashboard payload. Renders nothing when there are no evaluated - * projects at all (keeps the analytics page clean until the gate has produced eval rows). */ + * projects at all (keeps the analytics page clean until the gate has produced eval rows) -- this early return, + * not AnalyticsCardShell's own "empty" state, is this card's documented no-data behavior (#6175). */ export function GatePrecisionCard({ report }: { report: GateEvalReport }) { if (report.rows.length === 0) return null; const matrix = aggregateGateEval(report); return ( -
-
-
-

Gate precision

-

- The gate's merge/close predictions scored against realized PR outcomes. Public-safe - counts only. -

-
+ {report.hasSignal ? `${matrix.decided} decided` : `below ${MIN_DECIDED_FLOOR}-sample floor`} -
-
+ } + > +
-
+ ); } diff --git a/apps/loopover-ui/src/components/site/app-panels/reversal-health-card.tsx b/apps/loopover-ui/src/components/site/app-panels/reversal-health-card.tsx index fd711f190e..9b5efa7ad0 100644 --- a/apps/loopover-ui/src/components/site/app-panels/reversal-health-card.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/reversal-health-card.tsx @@ -1,3 +1,4 @@ +import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell"; import { Stat, StatusPill } from "@/components/site/control-primitives"; import { EmptyState } from "@/components/site/state-views"; import { @@ -8,25 +9,22 @@ import { } from "@/components/site/app-panels/reversal-health-card-model"; /** Analytics card (#2193): reversal rate and recent auto-action health from computeAgentHealth — read-only - * over the operator-dashboard payload. Lists reversed targets when present; EmptyState when none. */ + * over the operator-dashboard payload. Lists reversed targets when present; EmptyState when none. The rate + * Stats always render regardless of the list, so this card stays in AnalyticsCardShell's "ready" state and + * keeps its own inner list-vs-EmptyState toggle (#6175) rather than using the shell's own "empty" state, + * which would also hide the Stats. */ export function ReversalHealthCard({ health }: { health: ReversalHealth }) { const status = reversalHealthStatus(health); const reversedTargets = health.reversedTargets ?? []; return ( -
-
-
-

Reversal health

-

- How often humans reopened or reverted a bot auto-action in the last 7 days. Public-safe - counts only. -

-
- {status.label} -
- -
+ {status.label}} + > +
)} -
+ ); } diff --git a/apps/loopover-ui/src/components/site/app-panels/slop-duplicate-trend-card.test.tsx b/apps/loopover-ui/src/components/site/app-panels/slop-duplicate-trend-card.test.tsx index 91619e9de6..0b1417a0f2 100644 --- a/apps/loopover-ui/src/components/site/app-panels/slop-duplicate-trend-card.test.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/slop-duplicate-trend-card.test.tsx @@ -71,12 +71,15 @@ describe("SlopDuplicateTrendCard", () => { })} />, ); + expect(screen.getByText("No snapshot history yet")).toBeTruthy(); expect( screen.getByText( /Queue-health snapshot history will appear here after signal snapshot jobs run/i, ), ).toBeTruthy(); expect(screen.queryByLabelText("Trend chart")).toBeNull(); + // The header action slot (freshness pill + generated-at stamp) renders in every state (#6175). + expect(screen.getByText("fresh snapshot")).toBeTruthy(); }); it("surfaces the stale snapshot pill when data is old", () => { diff --git a/apps/loopover-ui/src/components/site/app-panels/slop-duplicate-trend-card.tsx b/apps/loopover-ui/src/components/site/app-panels/slop-duplicate-trend-card.tsx index a381f23997..7e01136f94 100644 --- a/apps/loopover-ui/src/components/site/app-panels/slop-duplicate-trend-card.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/slop-duplicate-trend-card.tsx @@ -1,3 +1,4 @@ +import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell"; import { StatusPill } from "@/components/site/control-primitives"; import { TrendChart } from "@/components/site/trend-chart"; import { @@ -28,14 +29,13 @@ export function SlopDuplicateTrendCard({ trend }: { trend: MaintainerSlopDuplica const latest = latestWeekWithSignal(trend.weeks); return ( -
-
-
-

Slop + duplicate trend

-

- Weekly slop-flag and duplicate-flag rates from queue-health snapshots. Band labels only. -

-
+ {trend.stale ? "stale snapshot" : "fresh snapshot"} @@ -44,62 +44,53 @@ export function SlopDuplicateTrendCard({ trend }: { trend: MaintainerSlopDuplica generated {formatGeneratedAt(trend.generatedAt)}
+ } + > +
+ +
- {hasSignal ? ( - <> -
- - -
- -
- - -
+
+ + +
-

{trend.summary}

- - ) : ( -

- Queue-health snapshot history will appear here after signal snapshot jobs run for your - scoped repositories. -

- )} -
+

{trend.summary}

+ ); }