diff --git a/apps/web/src/bench/approvals-insights.css b/apps/web/src/bench/approvals-insights.css
new file mode 100644
index 000000000..f5280da1c
--- /dev/null
+++ b/apps/web/src/bench/approvals-insights.css
@@ -0,0 +1,32 @@
+.appr {
+ display: grid;
+ gap: 12px;
+}
+.appr-row {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 4px 12px;
+ font-size: 0.85rem;
+}
+.appr-row b {
+ font-variant-numeric: tabular-nums;
+}
+.appr-row .bi-meter {
+ grid-column: 1 / -1;
+ display: flex;
+ width: auto;
+ margin-right: 0;
+}
+.appr-line {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 16px;
+ font-size: 0.85rem;
+}
+.appr-line i {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ margin-right: 6px;
+ border-radius: 50%;
+}
diff --git a/apps/web/src/bench/approvals-insights.tsx b/apps/web/src/bench/approvals-insights.tsx
new file mode 100644
index 000000000..f18905941
--- /dev/null
+++ b/apps/web/src/bench/approvals-insights.tsx
@@ -0,0 +1,98 @@
+import { Skeleton } from "@corbits/react-ui";
+import { useQueries } from "@tanstack/react-query";
+
+import { apiQueryOptions } from "../api";
+import { insightsRunApprovalsPath, RunApprovalsResponse, type InsightsRun } from "../insights-api";
+import { countApprovals, type ApprovalCounts } from "../insights-stats";
+
+import "./approvals-insights.css";
+
+const ROWS = [
+ ["once", "Allowed once", "var(--chart-ok)"],
+ ["always", "Always allowed", "var(--info)"],
+ ["denied", "Denied", "var(--chart-fail)"],
+ ["waiting", "Waiting on you", "var(--action)"],
+] as const;
+
+type ApprovalTally =
+ | { readonly kind: "loading" }
+ | { readonly kind: "error" }
+ | { readonly kind: "ready"; readonly counts: ApprovalCounts };
+
+function useApprovalTally(tenantId: string, runs: readonly InsightsRun[]): ApprovalTally {
+ const results = useQueries({
+ queries: runs.map((run) =>
+ apiQueryOptions(insightsRunApprovalsPath(tenantId, run.id), RunApprovalsResponse),
+ ),
+ });
+ if (results.some((r) => r.isLoading)) return { kind: "loading" };
+ if (results.some((r) => r.isError)) return { kind: "error" };
+ return {
+ kind: "ready",
+ counts: countApprovals(results.flatMap((r) => r.data?.approvals ?? [])),
+ };
+}
+
+export function ApprovalsCard({
+ tenantId,
+ runs,
+ capped,
+}: {
+ readonly tenantId: string;
+ readonly runs: readonly InsightsRun[];
+ readonly capped: boolean;
+}) {
+ const tally = useApprovalTally(tenantId, runs);
+ return (
+
+ Approvals
+
+ How you answered when a worker asked to act.
+ {capped ? ` Based on the ${runs.length} most recent runs.` : ""}
+
+ {tally.kind === "loading" ? (
+
+ ) : tally.kind === "error" ? (
+ Couldn't load approvals.
+ ) : (
+
+ {ROWS.map(([key, label, color]) => {
+ const max = Math.max(...Object.values(tally.counts), 1);
+ return (
+
+ {label}
+ {tally.counts[key]}
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+/** One-line drawer version of the Approvals card. */
+export function ApprovalsLine({
+ tenantId,
+ runs,
+}: {
+ readonly tenantId: string;
+ readonly runs: readonly InsightsRun[];
+}) {
+ const tally = useApprovalTally(tenantId, runs);
+ if (tally.kind === "loading") return ;
+ if (tally.kind === "error") return
Couldn't load approvals.
;
+ return (
+
+ {ROWS.map(([key, label, color]) => (
+
+
+ {label} {tally.counts[key]}
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/bench/insights-tab.tsx b/apps/web/src/bench/insights-tab.tsx
index 91c18fd72..a7463b4d7 100644
--- a/apps/web/src/bench/insights-tab.tsx
+++ b/apps/web/src/bench/insights-tab.tsx
@@ -2,10 +2,16 @@ import { RichEmptyState, Skeleton } from "@corbits/react-ui";
import { useAPIQuery } from "../api";
import { insightsTopLevelRunsPath, TopLevelRunsSchema } from "../insights-api";
-import { computeBenchInsights, durationLabel, formatCount } from "../insights-stats";
+import {
+ approvalRunsInRange,
+ computeBenchInsights,
+ durationLabel,
+ formatCount,
+} from "../insights-stats";
import { Link } from "../navigation";
import { workbenchInsightsPath } from "../insights-deeplinks";
import { benchLink } from "../shell/page-crumbs";
+import { ApprovalsLine } from "./approvals-insights";
import { OutcomeChart } from "../pages/bench-insights";
/** The bench Insights page in miniature: the same runs query and
@@ -53,6 +59,15 @@ export function InsightsTab({ workbenchTenantId }: { readonly workbenchTenantId:
+
{
+ const t = Date.parse(run.createdAt);
+ return !Number.isNaN(t) && t >= start && t <= now;
+ })
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+ return { runs: inRange.slice(0, APPROVAL_RUN_CAP), capped: inRange.length > APPROVAL_RUN_CAP };
+}
+
+export type ApprovalCounts = {
+ readonly once: number;
+ readonly always: number;
+ readonly denied: number;
+ readonly waiting: number;
+};
+
+/** Timed-out and expired approvals were never answered, so they join no bucket. */
+export function countApprovals(approvals: readonly Approval[]): ApprovalCounts {
+ let once = 0;
+ let always = 0;
+ let denied = 0;
+ let waiting = 0;
+ for (const a of approvals) {
+ if (a.status === "pending") waiting += 1;
+ else if (a.status === "rejected") denied += 1;
+ else if (a.status === "approved") {
+ if (a.scope === "always") always += 1;
+ else once += 1;
+ }
+ }
+ return { once, always, denied, waiting };
+}
diff --git a/apps/web/src/pages/bench-insights.tsx b/apps/web/src/pages/bench-insights.tsx
index 58445913c..d0159a6ab 100644
--- a/apps/web/src/pages/bench-insights.tsx
+++ b/apps/web/src/pages/bench-insights.tsx
@@ -4,9 +4,11 @@
import { Badge, RichEmptyState, Skeleton, RUN_STATUS_TONE } from "@corbits/react-ui";
import { useState, type ReactNode } from "react";
+import { ApprovalsCard } from "../bench/approvals-insights";
import { useAPIQuery } from "../api";
import { insightsTopLevelRunsPath, TopLevelRunsSchema, type InsightsRun } from "../insights-api";
import {
+ approvalRunsInRange,
BENCH_RANGES,
computeBenchInsights,
durationLabel,
@@ -201,7 +203,9 @@ export function BenchInsights({
/>
);
} else {
- body = ;
+ body = (
+
+ );
}
return (
@@ -217,15 +221,18 @@ export function BenchInsights({
}
function InsightsBody({
+ tenantId,
runs,
range,
onOpenRun,
}: {
+ readonly tenantId: string;
readonly runs: { readonly data: readonly InsightsRun[]; readonly nextCursor: string | null };
readonly range: BenchRange;
readonly onOpenRun: (id: string) => void;
}) {
const stats = computeBenchInsights(runs.data, range);
+ const approvalRuns = approvalRunsInRange(runs.data, range);
const finished = stats.ok + stats.fail;
const tiles: readonly (readonly [string, string])[] = [
["Runs", formatCount(stats.total)],
@@ -254,6 +261,8 @@ function InsightsBody({
+
+
By workflow
{stats.workflows.length === 0 ? (