From 1136a934beb8aa31c7c9aa3a82fe2b03ef4d6617 Mon Sep 17 00:00:00 2001
From: Jeff <158072326+jeffrey701@users.noreply.github.com>
Date: Fri, 10 Jul 2026 10:35:12 -0400
Subject: [PATCH] feat(ui): reversal-rate + auto-action health card
Add ReversalHealthCard to the self-host analytics dashboard (#2193):
how often a human reopened or reverted a bot auto-action
(computeAgentHealth, src/review/ops.ts) -- the reversal rate as a
percentage plus reversals / manual-rate counts and the specific
overridden targets.
- reversal-health-card-model.ts: ReversalHealthReport / ReversedTarget
types + pure summarizeReversalHealth (null rate on an empty
denominator) and bandForReversalHealth (info when no auto-actions,
ready at zero reversals, warn at/under 10%, blocked above).
- reversal-health-card.tsx: Stat tiles + StatusPill, the reversed-target
list, and an EmptyState when none / when the health field is absent.
- Wired into app.analytics.tsx behind an optional agentHealth field so it
renders once the shape is present on the operator-dashboard payload
(backend computation is #1967) and shows a graceful EmptyState until.
Vitest: the rate/band folds (zero-reversals, above-threshold, empty
denominator) and the card's populated / empty-list / absent arms.
Closes #2193
---
.../app-panels/reversal-health-card-model.ts | 63 ++++++++++++++
.../app-panels/reversal-health-card.test.tsx | 87 +++++++++++++++++++
.../site/app-panels/reversal-health-card.tsx | 76 ++++++++++++++++
.../src/routes/app.analytics.tsx | 5 ++
4 files changed, 231 insertions(+)
create mode 100644 apps/gittensory-ui/src/components/site/app-panels/reversal-health-card-model.ts
create mode 100644 apps/gittensory-ui/src/components/site/app-panels/reversal-health-card.test.tsx
create mode 100644 apps/gittensory-ui/src/components/site/app-panels/reversal-health-card.tsx
diff --git a/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card-model.ts b/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card-model.ts
new file mode 100644
index 0000000000..ca7c9ecabe
--- /dev/null
+++ b/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card-model.ts
@@ -0,0 +1,63 @@
+// Reversal-rate + auto-action health analytics card model (#2193). UI-only display slice: the card consumes an
+// agent-health shape assumed present on the operator-dashboard payload (backend computation is computeAgentHealth
+// in src/review/ops.ts). Types + the pure rate/band helpers live here (not in the .tsx) so the component file
+// exports only components (react-refresh/only-export-components).
+
+import type { Status } from "@/components/site/control-primitives";
+
+/** A bot auto-action a human overrode (a revert of a bot-merge / a reopen of a bot-close). Mirrors
+ * src/review/ops.ts ReversedTarget. Public-safe: PR number + repo + status, no scores/rewards. */
+export interface ReversedTarget {
+ number: number;
+ repo: string;
+ status: string;
+ eventType: string;
+}
+
+/** The agent-health slice delivered on the operator-dashboard payload: how often humans reopened/reverted a bot
+ * auto-action over a rolling window. Public-safe counts only (mirrors the reversal fields of
+ * src/review/ops.ts AgentHealth). */
+export interface ReversalHealthReport {
+ /** Bot auto-actions a human overrode in the window. */
+ reversals: number;
+ /** reversals / recentAutoActions; 0 when no auto-actions were taken. */
+ reversalRate: number;
+ /** Share of terminal targets that took the manual (human) path rather than an auto-action. */
+ manualRate: number;
+ /** Auto-actions taken in the window — the reversal-rate denominator. */
+ recentAutoActions: number;
+ /** The specific overridden targets, for the detail list. */
+ reversedTargets: ReversedTarget[];
+ /** Rolling measurement window, in days. */
+ windowDays: number;
+}
+
+/** The card's derived view: the raw counts plus display percentages (a null rate when nothing was auto-actioned). */
+export interface ReversalHealthSummary {
+ reversals: number;
+ /** reversalRate as a percentage; null when the denominator (recentAutoActions) is 0 — nothing to be a rate of. */
+ reversalRatePct: number | null;
+ manualRatePct: number;
+ recentAutoActions: number;
+ reversedCount: number;
+}
+
+/** Pure fold: derive the display summary from the raw health counts. An empty denominator yields a null rate. */
+export function summarizeReversalHealth(report: ReversalHealthReport): ReversalHealthSummary {
+ return {
+ reversals: report.reversals,
+ reversalRatePct: report.recentAutoActions > 0 ? report.reversalRate * 100 : null,
+ manualRatePct: report.manualRate * 100,
+ recentAutoActions: report.recentAutoActions,
+ reversedCount: report.reversedTargets.length,
+ };
+}
+
+/** StatusPill quality band for reversal health: no auto-actions yet is informational (no signal); zero reversals
+ * over real auto-actions reads healthy; a reversal rate at or under 10% warns; above that blocks (humans are
+ * frequently overriding the bot). Mirrors the Status vocabulary in control-primitives.ts. */
+export function bandForReversalHealth(report: ReversalHealthReport): Status {
+ if (report.recentAutoActions === 0) return "info";
+ if (report.reversals === 0) return "ready";
+ return report.reversalRate <= 0.1 ? "warn" : "blocked";
+}
diff --git a/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card.test.tsx
new file mode 100644
index 0000000000..ad26038778
--- /dev/null
+++ b/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card.test.tsx
@@ -0,0 +1,87 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { ReversalHealthCard } from "@/components/site/app-panels/reversal-health-card";
+import {
+ bandForReversalHealth,
+ summarizeReversalHealth,
+} from "@/components/site/app-panels/reversal-health-card-model";
+
+const base = {
+ reversals: 0,
+ reversalRate: 0,
+ manualRate: 0.2,
+ recentAutoActions: 10,
+ reversedTargets: [],
+ windowDays: 30,
+};
+
+describe("summarizeReversalHealth", () => {
+ it("derives the percentages and the reversed-target count", () => {
+ expect(
+ summarizeReversalHealth({
+ ...base,
+ reversals: 2,
+ reversalRate: 0.2,
+ reversedTargets: [{ number: 1, repo: "o/r", status: "reverted", eventType: "auto-merge" }],
+ }),
+ ).toEqual({
+ reversals: 2,
+ reversalRatePct: 20,
+ manualRatePct: 20,
+ recentAutoActions: 10,
+ reversedCount: 1,
+ });
+ });
+
+ it("returns a null rate for an empty denominator (no auto-actions)", () => {
+ expect(summarizeReversalHealth({ ...base, recentAutoActions: 0 }).reversalRatePct).toBeNull();
+ });
+});
+
+describe("bandForReversalHealth", () => {
+ it("bands: no auto-actions info, zero reversals ready, <=10% warn, above blocked", () => {
+ expect(bandForReversalHealth({ ...base, recentAutoActions: 0 })).toBe("info");
+ expect(bandForReversalHealth({ ...base, reversals: 0, recentAutoActions: 10 })).toBe("ready");
+ expect(
+ bandForReversalHealth({ ...base, reversals: 1, reversalRate: 0.1, recentAutoActions: 10 }),
+ ).toBe("warn");
+ expect(
+ bandForReversalHealth({ ...base, reversals: 5, reversalRate: 0.5, recentAutoActions: 10 }),
+ ).toBe("blocked");
+ });
+});
+
+describe("ReversalHealthCard", () => {
+ it("renders the reversal rate, counts, and the reversed-target list when present", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Auto-action reversal health")).toBeTruthy();
+ expect(screen.getByText("20%")).toBeTruthy();
+ expect(screen.getByText("35%")).toBeTruthy();
+ expect(screen.getByText("30-day window")).toBeTruthy();
+ expect(screen.getByText("o/r#7 — auto-merge (reverted)")).toBeTruthy();
+ });
+
+ it("renders '—' for the rate and an empty list state when there are no auto-actions", () => {
+ render();
+ expect(screen.getByText("—")).toBeTruthy();
+ expect(screen.getByText("No reversed auto-actions")).toBeTruthy();
+ });
+
+ it("renders a graceful EmptyState when the health field is absent", () => {
+ render();
+ expect(screen.getByText("Auto-action health not yet available")).toBeTruthy();
+ });
+});
diff --git a/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card.tsx b/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card.tsx
new file mode 100644
index 0000000000..09206b2c82
--- /dev/null
+++ b/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card.tsx
@@ -0,0 +1,76 @@
+import { Stat, StatusPill } from "@/components/site/control-primitives";
+import { EmptyState } from "@/components/site/state-views";
+
+import {
+ bandForReversalHealth,
+ summarizeReversalHealth,
+ type ReversalHealthReport,
+} from "./reversal-health-card-model";
+
+/** Self-host analytics card (#2193): auto-action reversal health — how often a human reopened or reverted a bot
+ * auto-action (computeAgentHealth, src/review/ops.ts). Reversal rate as a percentage plus reversals / manual-rate
+ * counts and the specific overridden targets. UI-only display slice; the health shape is assumed present on the
+ * operator-dashboard payload (backend computation is #1967), so absence renders a graceful "not yet available"
+ * EmptyState. */
+export function ReversalHealthCard({ health }: { health?: ReversalHealthReport }) {
+ if (!health) {
+ return (
+
+ );
+ }
+ const summary = summarizeReversalHealth(health);
+ return (
+
+
+
+
Auto-action reversal health
+
+ How often a human reopened or reverted a bot auto-action. Public-safe counts only.
+
+
+ {`${health.windowDays}-day window`}
+
+
+ reversals / auto-actions}
+ />
+ auto-actions a human overrode}
+ />
+ terminal targets taken manually}
+ />
+