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}
+
);
}