feat(ui): pick the Lens run report's one next action from a situation table

An ordered rule table reads the latest run into a situation, and lookup tables map each situation to its copy and its single primary action: Stop run, Retry, Raise budget, Connect worker, Review issues, Monitor this, or Run now. Run now moves into the overflow menu. Progress becomes a segmented bar with one weighted segment per analysis stage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yujong Lee 2026-10-04 21:12:45 -07:00
parent b630654146
commit 29a1f3175d
12 changed files with 549 additions and 218 deletions

View file

@ -1,79 +1,66 @@
"use client";
import { Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { analysisFraction, analysisPace, analysisProgress, remainingLabel } from "../model/progress";
import {
analysisElapsed,
analysisFraction,
analysisPace,
analysisProgress,
analysisStages,
remainingLabel,
} from "../model/progress";
import { useProgressSamples } from "./useProgressSamples";
import { type Job } from "../model/types";
const STAGES = ["Review runs", "Find patterns", "Check evidence"];
function stageState(index: number, current: number) {
if (index < current) return "done";
return index === current ? "active" : "todo";
}
export function InvestigationProgress({ job, now, onCancel }: { job: Job; now: number; onCancel?: () => void }) {
export function InvestigationProgress({ job, now }: { job: Job; now: number }) {
const progress = analysisProgress(job);
const percent = Math.round(analysisFraction(progress) * 100);
const samples = useProgressSamples(progress);
const { secondsLeft } = analysisPace(samples, now);
const { secondsLeft } = analysisPace(useProgressSamples(progress), now);
const queued = progress.step < 0;
return (
<section aria-label="Analysis progress" className="space-y-3">
<div className="flex items-baseline justify-between gap-3">
<p role="status" className="min-w-0 text-sm">
<span className="font-medium">{progress.title}</span>
<span className="text-muted-foreground"> · {progress.detail}</span>
</p>
<span className="shrink-0 text-sm tabular-nums text-muted-foreground">
{queued ? "" : `${percent}% · ${remainingLabel(secondsLeft)} left`}
<section aria-label="Analysis progress" className="space-y-2">
<div className="flex items-baseline justify-between gap-3 tabular-nums">
<span
role="progressbar"
aria-label="Investigation progress"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
aria-valuetext={`${progress.title}: ${progress.detail}`}
className="text-2xl font-semibold"
>
{queued ? "–" : `${percent}%`}
</span>
<span className="text-xs text-muted-foreground">
{analysisElapsed(job.created_at, now)} elapsed
{!queued && ` · ${remainingLabel(secondsLeft)} left`}
</span>
</div>
<div
role="progressbar"
aria-label="Investigation progress"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
aria-valuetext={queued ? progress.title : `${progress.title}: ${progress.detail}`}
className="h-1.5 overflow-hidden rounded-full bg-muted"
>
<div
data-state={queued ? "queued" : "running"}
className="h-full rounded-full bg-primary transition-[width] duration-500 data-[state=queued]:w-1/4 data-[state=queued]:animate-pulse data-[state=queued]:bg-muted-foreground/40"
style={queued ? undefined : { width: `${percent}%` }}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<ol aria-label="Analysis stages" className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
{STAGES.map((label, index) => {
const state = stageState(index, progress.step);
return (
<li
key={label}
data-state={state}
aria-current={state === "active" ? "step" : undefined}
className="flex items-center gap-1.5 text-muted-foreground data-[state=active]:font-medium data-[state=active]:text-foreground"
>
<span
data-state={state}
className="flex size-5 items-center justify-center rounded-full border tabular-nums data-[state=active]:border-primary data-[state=active]:text-primary data-[state=done]:border-transparent data-[state=done]:bg-primary data-[state=done]:text-primary-foreground"
>
{state === "done" ? <Check className="size-3" /> : index + 1}
<ol aria-label="Analysis stages" className="flex gap-1">
{analysisStages(job).map((stage) => (
<li
key={stage.label}
data-state={stage.state}
aria-current={stage.state === "active" ? "step" : undefined}
className="group min-w-0 space-y-1.5"
style={{ flexGrow: stage.weight, flexBasis: 0 }}
>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-[width] duration-500"
style={{ width: `${stage.fill * 100}%` }}
/>
</div>
<div className="flex items-baseline justify-between gap-2 text-xs text-muted-foreground group-data-[state=active]:text-foreground">
<span className="truncate group-data-[state=active]:font-medium">{stage.label}</span>
{stage.state !== "todo" && stage.total > 0 && (
<span className="shrink-0 tabular-nums">
{stage.done.toLocaleString()} / {stage.total.toLocaleString()}
</span>
{label}
</li>
);
})}
</ol>
{onCancel && (
<Button variant="ghost" size="xs" onClick={onCancel}>
Cancel
</Button>
)}
</div>
)}
</div>
</li>
))}
</ol>
</section>
);
}

View file

@ -297,8 +297,12 @@ it("runs with saved settings from Run now without opening setup, then accepts an
});
proxy.post.mockResolvedValue(lens);
const user = userEvent.setup();
const runNow = async () => {
await user.click(await screen.findByRole("button", { name: "Investigation actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Run now" }));
};
renderWithProviders(<InvestigationsView />);
await user.click(await screen.findByRole("button", { name: "Run now" }));
await runNow();
const choices = await screen.findByRole("dialog", { name: "Run now" });
expect(within(choices).getByRole("button", { name: "Since last run" })).toHaveAttribute("aria-pressed", "true");
await user.click(within(choices).getByRole("button", { name: "Run now" }));
@ -306,7 +310,7 @@ it("runs with saved settings from Run now without opening setup, then accepts an
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
proxy.post.mockClear();
await user.click(screen.getByRole("button", { name: "Run now" }));
await runNow();
const custom = await screen.findByRole("dialog", { name: "Run now" });
fireEvent.change(within(custom).getByRole("combobox", { name: "Agent" }), { target: { value: "billing" } });
await user.click(within(custom).getByRole("button", { name: "Last 24h" }));
@ -755,6 +759,22 @@ it("reports what the selected run found, cost, and covered", async () => {
expect(stat("Issues")).toHaveTextContent("11 high priority");
});
it("sends the report's Review issues action to the open issues of that run", async () => {
window.history.replaceState({}, "", "/lens/?lens=lens&kind=pattern&finding_status=resolved");
testQueryClient.clear();
proxy.get.mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens], workers: [], tracing_enabled: true };
if (path === "/lens/lens/runs") return lens.jobs;
return { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView />);
const report = within(await screen.findByRole("region", { name: "Run report" }));
expect(screen.queryByRole("button", { name: issue.title })).not.toBeInTheDocument();
await user.click(report.getByRole("button", { name: "Review 1 issue" }));
expect(await screen.findByRole("button", { name: new RegExp(issue.title) })).toBeVisible();
});
it("keeps a finding open to retry when its update fails", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
@ -795,7 +815,7 @@ it("pauses monitoring from the detail menu by saving the investigation with moni
expect(sentBody(proxy.put, "/lens/lens")).toEqual([{ ...watching.settings, enabled: false }]);
});
it("cancels the running job from the progress banner", async () => {
it("stops the running job from the run report's primary action", async () => {
testQueryClient.clear();
const running = { ...lens.jobs[0], id: "live", status: "running" as const, stage: "Reading executions" };
proxy.get.mockImplementation(async (path) => {
@ -806,7 +826,7 @@ it("cancels the running job from the progress banner", async () => {
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView />);
await user.click(await screen.findByRole("button", { name: "Cancel" }));
await user.click(await screen.findByRole("button", { name: "Stop run" }));
await waitFor(() => expect(proxy.post).toHaveBeenCalledWith("/lens/lens/cancel", expect.anything()));
});

View file

@ -64,7 +64,7 @@ export function InvestigationsView({ readOnly = false }: InvestigationsViewProps
const actions = useInvestigationActions();
const { dialog, target, openDialog, closeDialog } = useDialogRoute();
const { issueKey, setIssueKey } = useIssueRoute();
const { lensId, setLensId } = useLensRoute();
const { lensId, setLensId, setTab } = useLensRoute();
const list = useQuery(lensQueries.list(api));
const status = useLensReadiness(true);
const { connected } = status;
@ -162,6 +162,7 @@ export function InvestigationsView({ readOnly = false }: InvestigationsViewProps
onEnableMonitoring={() => openDialog("monitoring")}
onCancelRun={() => void actions.cancelRun(row.lens)}
onRunNow={() => openDialog("run_now")}
onConnectWorker={() => setTab("settings")}
onReviewFinding={(owned, reviewStatus, reason) =>
void actions.review(owned.lens, owned.finding, reviewStatus, reason)
}

View file

@ -53,7 +53,8 @@ describe("InvestigationActions", () => {
it("reports each menu choice as an intent without touching the API", async () => {
const user = userEvent.setup();
const intents = renderActions();
await user.click(screen.getByRole("button", { name: "Run now" }));
await user.click(screen.getByRole("button", { name: "Investigation actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Run now" }));
expect(intents.onRunNow).toHaveBeenCalledOnce();
await user.click(screen.getByRole("button", { name: "Investigation actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Pause monitoring" }));
@ -77,7 +78,7 @@ describe("InvestigationActions", () => {
expect(intents.onPause).not.toHaveBeenCalled();
});
it("blocks a second run while one is queued or running", () => {
it("blocks a second run while one is queued or running", async () => {
const job = {
id: "job",
status: "queued" as const,
@ -95,7 +96,9 @@ describe("InvestigationActions", () => {
steps: [],
findings: [],
};
const user = userEvent.setup();
renderActions({ lens: { ...lens, jobs: [job] } });
expect(screen.getByRole("button", { name: "Run now" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Investigation actions" }));
expect(await screen.findByRole("menuitem", { name: "Run now" })).toHaveAttribute("aria-disabled", "true");
});
});

View file

@ -18,9 +18,10 @@ export interface InvestigationIntents {
readonly onEnableMonitoring: () => void;
readonly onCancelRun: () => void;
readonly onRunNow: () => void;
readonly onConnectWorker: () => void;
}
export type InvestigationActionsProps = Omit<InvestigationIntents, "onCancelRun"> & {
export type InvestigationActionsProps = Omit<InvestigationIntents, "onCancelRun" | "onConnectWorker"> & {
readonly lens: Lens;
readonly ready: boolean;
readonly busy: boolean;
@ -37,37 +38,35 @@ export function InvestigationActions({
onRunNow,
}: InvestigationActionsProps) {
return (
<div className="flex flex-wrap gap-2">
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" aria-label="Investigation actions" />}>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={onEdit}>
<Settings2 />
Edit investigation
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" aria-label="Investigation actions" />}>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem disabled={busy || hasActiveJob(lens.jobs) || !ready} onClick={onRunNow}>
<Play />
Run now
</DropdownMenuItem>
<DropdownMenuItem onClick={onEdit}>
<Settings2 />
Edit investigation
</DropdownMenuItem>
<DropdownMenuItem disabled={!ready} onClick={onDuplicate}>
<Copy />
Duplicate
</DropdownMenuItem>
{lens.settings.enabled ? (
<DropdownMenuItem disabled={busy} onClick={onPause}>
<Pause />
Pause monitoring
</DropdownMenuItem>
<DropdownMenuItem disabled={!ready} onClick={onDuplicate}>
<Copy />
Duplicate
) : (
<DropdownMenuItem disabled={!ready || busy} onClick={onEnableMonitoring}>
<Play />
Enable monitoring
</DropdownMenuItem>
{lens.settings.enabled ? (
<DropdownMenuItem disabled={busy} onClick={onPause}>
<Pause />
Pause monitoring
</DropdownMenuItem>
) : (
<DropdownMenuItem disabled={!ready || busy} onClick={onEnableMonitoring}>
<Play />
Enable monitoring
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<Button disabled={busy || hasActiveJob(lens.jobs) || !ready} onClick={onRunNow}>
<Play className="size-3" />
Run now
</Button>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -5,11 +5,12 @@ import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { RunsTab } from "./RunsTab";
import { InvestigationSummary } from "./InvestigationSummary";
import { RunReport } from "./RunReport";
import { RunReport, type RunActionHandlers } from "./RunReport";
import type { OwnedFinding } from "../../model/inbox";
import { type Finding, type Lens } from "../../model/types";
import { FindingPanel } from "../FindingDetails";
import { useSectionRoute } from "../../route";
import { mergeFeedback } from "../../model/findings";
import { useFindingFilters, useSectionRoute } from "../../route";
import { useRunSnapshot } from "../useRunSnapshot";
import { InvestigationActions, type InvestigationIntents } from "./InvestigationActions";
@ -38,8 +39,22 @@ export function InvestigationDetail({
...intents
}: InvestigationDetailProps) {
const { section, setSection } = useSectionRoute();
const { setKind, setStatus } = useFindingFilters();
const snapshot = useRunSnapshot(lens);
const { job, batchId, batchSettings, batchFindings, missingSnapshot } = snapshot;
const runActions: RunActionHandlers = {
run: intents.onRunNow,
retry: intents.onRunNow,
stop: onCancelRun,
raiseBudget: intents.onEdit,
connectWorker: intents.onConnectWorker,
monitor: intents.onEnableMonitoring,
reviewIssues: () => {
setSection("findings");
setKind("issue");
setStatus("open");
},
};
return (
<div>
<section className="min-w-0 space-y-5">
@ -51,11 +66,14 @@ export function InvestigationDetail({
{!readOnly && <InvestigationActions lens={lens} ready={ready} busy={busy} {...intents} />}
</div>
<RunReport
lens={lens}
job={job}
findings={job?.findings}
findings={job?.findings && mergeFeedback(job.findings, lens.findings ?? [])}
connected={connected}
ready={ready}
busy={busy}
picker={<RunPicker lens={lens} job={job} />}
onCancel={readOnly ? undefined : onCancelRun}
actions={readOnly ? undefined : runActions}
/>
<Tabs value={section} onValueChange={setSection} key={lens.id}>
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b">

View file

@ -1,56 +1,139 @@
"use client";
import { ChevronRight } from "lucide-react";
import type { ReactNode } from "react";
import {
ChevronRight,
CircleStop,
ListChecks,
Play,
Plug,
RefreshCw,
Repeat,
Wallet,
type LucideIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { useNow } from "@/hooks/useNow";
import { analysisElapsed } from "../../model/progress";
import { money } from "../../model/format";
import { modelsUsed, shortTime, windowLabel } from "../../model/inbox";
import type { Finding, Job } from "../../model/types";
import { NEXT_ACTION, openIssues, runSituation, type RunAction, type RunSituation } from "../../model/runSituation";
import type { Finding, Job, Lens } from "../../model/types";
import { InvestigationProgress } from "../InvestigationProgress";
import { StepFeed } from "../StepFeed";
export type RunActionHandlers = Record<RunAction, () => void>;
export interface RunReportProps {
readonly lens: Lens;
readonly job: Job | undefined;
/** Null when the run predates saved result snapshots. */
readonly findings: readonly Finding[] | null | undefined;
readonly connected: boolean;
readonly ready: boolean;
readonly busy: boolean;
readonly picker: ReactNode;
readonly onCancel?: () => void;
/** Absent for viewers who cannot act on the investigation. */
readonly actions?: RunActionHandlers;
}
const STATUS_LABEL: Record<Job["status"], string> = {
queued: "Queued",
running: "Running",
completed: "Completed",
failed: "Failed",
cancelled: "Cancelled",
interface Facts {
readonly runs: string;
readonly found: string;
readonly openIssues: number;
}
type Tone = "info" | "success" | "destructive" | "muted";
interface SituationView {
readonly status: string;
readonly tone: Tone;
readonly headline: (facts: Facts) => string;
readonly body: "progress" | "error" | null;
}
const completed = ({ found, runs }: Facts) =>
found ? `Found ${found} across ${runs}` : `Nothing found across ${runs}`;
const SITUATIONS: Record<RunSituation, SituationView> = {
never: { status: "Not run yet", tone: "muted", headline: () => "Run it to get the first report", body: null },
queued: {
status: "Queued",
tone: "info",
headline: () => "Waiting for an analyzer to pick this up",
body: "progress",
},
running: { status: "Running", tone: "info", headline: () => "Investigating now", body: "progress" },
budget: {
status: "Failed",
tone: "destructive",
headline: () => "Stopped: the monthly budget is used up",
body: "error",
},
offline: {
status: "Failed",
tone: "destructive",
headline: () => "Stopped: no analyzer is connected",
body: "error",
},
failed: { status: "Failed", tone: "destructive", headline: () => "Stopped before it finished", body: "error" },
cancelled: {
status: "Cancelled",
tone: "muted",
headline: ({ runs }) => `Cancelled after reviewing ${runs}`,
body: "error",
},
unknown: { status: "Completed", tone: "success", headline: ({ runs }) => `Reviewed ${runs}`, body: null },
issues: { status: "Completed", tone: "success", headline: completed, body: null },
watching: { status: "Completed", tone: "success", headline: completed, body: null },
clean: { status: "Completed", tone: "success", headline: completed, body: null },
};
interface ActionView {
readonly label: (facts: Facts) => string;
readonly icon: LucideIcon;
readonly variant: ComponentProps<typeof Button>["variant"];
readonly needsReady: boolean;
}
const ACTIONS: Record<RunAction, ActionView> = {
run: { label: () => "Run now", icon: Play, variant: "default", needsReady: true },
stop: { label: () => "Stop run", icon: CircleStop, variant: "outline", needsReady: false },
retry: { label: () => "Retry", icon: RefreshCw, variant: "default", needsReady: true },
raiseBudget: { label: () => "Raise budget", icon: Wallet, variant: "default", needsReady: false },
connectWorker: { label: () => "Connect worker", icon: Plug, variant: "default", needsReady: false },
reviewIssues: {
label: ({ openIssues: count }) => `Review ${plural(count, "issue")}`,
icon: ListChecks,
variant: "default",
needsReady: false,
},
monitor: { label: () => "Monitor this", icon: Repeat, variant: "default", needsReady: true },
};
const TONE_CLASS: Record<Tone, string> = {
info: "text-info",
success: "text-success",
destructive: "text-destructive",
muted: "text-muted-foreground",
};
const plural = (count: number, noun: string) => `${count.toLocaleString()} ${noun}${count === 1 ? "" : "s"}`;
const isActive = (job: Job) => job.status === "queued" || job.status === "running";
function headline(job: Job, findings: readonly Finding[] | null | undefined, connected: boolean): string {
const runs = plural(job.coverage?.screened ?? 0, "run");
const issues = findings?.filter((f) => f.kind === "issue").length ?? 0;
const patterns = findings?.filter((f) => f.kind === "pattern").length ?? 0;
switch (job.status) {
case "queued":
return connected ? "Waiting for the analyzer to pick this up" : "Waiting for an analyzer to connect";
case "running":
return "Investigating now";
case "failed":
return "Stopped before it finished";
case "cancelled":
return `Cancelled after reviewing ${runs}`;
case "completed":
if (!findings) return `Reviewed ${runs}`;
if (issues + patterns === 0) return `Nothing found across ${runs}`;
return `Found ${[issues && plural(issues, "issue"), patterns && plural(patterns, "pattern")].filter(Boolean).join(" and ")} across ${runs}`;
}
function facts(job: Job | undefined, findings: readonly Finding[] | null | undefined): Facts {
const count = (kind: Finding["kind"], noun: string) => {
const total = findings?.filter((f) => f.kind === kind).length ?? 0;
return total ? plural(total, noun) : "";
};
return {
runs: plural(job?.coverage?.screened ?? 0, "run"),
found: [count("issue", "issue"), count("pattern", "pattern")].filter(Boolean).join(" and "),
openIssues: openIssues(findings),
};
}
function Stat({ label, value, note }: { label: string; value: string; note?: string }) {
@ -73,25 +156,26 @@ function coverageNote(job: Job): string | undefined {
return notes.length ? notes.join(" · ") : undefined;
}
function issueNote(job: Job, high: number): string | undefined {
if (isActive(job)) return "When the run finishes";
return high ? `${high} high priority` : undefined;
const isActive = (job: Job) => job.status === "queued" || job.status === "running";
function issueStat(job: Job, findings: readonly Finding[] | null | undefined) {
if (isActive(job)) return { value: "–", note: "When the run finishes" };
if (job.status !== "completed" || findings == null) return { value: "–" };
const issues = findings.filter((f) => f.kind === "issue");
const high = issues.filter((f) => f.priority === "high").length;
return { value: issues.length.toLocaleString(), note: high ? `${high} high priority` : undefined };
}
function RunStats({ job, findings, now }: { job: Job; findings: readonly Finding[] | null | undefined; now: number }) {
const active = isActive(job);
const known = job.status === "completed" && findings != null;
const calls = (job.steps ?? []).filter((step) => step.kind === "model").length;
const { screened = 0, selected = 0 } = job.coverage ?? {};
const issues = findings?.filter((f) => f.kind === "issue") ?? [];
const high = issues.filter((f) => f.priority === "high").length;
const end = job.finished_at ? Date.parse(job.finished_at) : now;
return (
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4">
<Stat label="Cost" value={money(job.cost ?? 0)} note={plural(calls, "model call")} />
<Stat
label="Duration"
value={active || job.finished_at ? analysisElapsed(job.created_at, end) : "–"}
value={isActive(job) || job.finished_at ? analysisElapsed(job.created_at, end) : "–"}
note={`Started ${shortTime(job.created_at)}`}
/>
<Stat
@ -99,7 +183,7 @@ function RunStats({ job, findings, now }: { job: Job; findings: readonly Finding
value={`${screened.toLocaleString()} / ${selected.toLocaleString()}`}
note={coverageNote(job)}
/>
<Stat label="Issues" value={known ? issues.length.toLocaleString() : "–"} note={issueNote(job, high)} />
<Stat label="Issues" {...issueStat(job, findings)} />
</dl>
);
}
@ -147,35 +231,53 @@ function RunLog({ job }: { job: Job }) {
);
}
export function RunReport({ job, findings, connected, picker, onCancel }: RunReportProps) {
const active = job !== undefined && isActive(job);
const now = useNow(active ? 1000 : 60000);
if (!job) {
return (
<section aria-label="Run report" className="flex items-center justify-between gap-3 rounded-lg border p-4">
<p className="text-sm text-muted-foreground">No runs yet. Run it now to get the first report.</p>
{picker}
</section>
);
}
function NextAction({
action,
facts: known,
ready,
busy,
onClick,
}: {
action: RunAction;
facts: Facts;
ready: boolean;
busy: boolean;
onClick: () => void;
}) {
const { label, icon: Icon, variant, needsReady } = ACTIONS[action];
return (
<Button variant={variant} disabled={busy || (needsReady && !ready)} onClick={onClick}>
<Icon className="size-3.5" />
{label(known)}
</Button>
);
}
export function RunReport({ lens, job, findings, connected, ready, busy, picker, actions }: RunReportProps) {
const now = useNow(job && isActive(job) ? 1000 : 60000);
const situation = runSituation({ lens, job, findings, connected });
const view = SITUATIONS[situation];
const known = facts(job, findings);
const latest = job === undefined || job.id === lens.jobs[0]?.id;
const action = latest ? NEXT_ACTION[situation] : null;
return (
<section aria-label="Run report" className="space-y-4 rounded-lg border p-4">
<header className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p
data-state={job.status}
className="text-xs font-medium text-muted-foreground data-[state=completed]:text-success data-[state=failed]:text-destructive data-[state=running]:text-info"
>
{STATUS_LABEL[job.status]}
</p>
<h3 className="mt-0.5 text-base font-semibold">{headline(job, findings, connected)}</h3>
<p className={cn("text-xs font-medium", TONE_CLASS[view.tone])}>{view.status}</p>
<h3 className="mt-0.5 text-base font-semibold">{view.headline(known)}</h3>
</div>
<div className="flex items-center gap-1">
{job && picker}
{action && actions && (
<NextAction action={action} facts={known} ready={ready} busy={busy} onClick={actions[action]} />
)}
</div>
{picker}
</header>
{active && <InvestigationProgress key={job.id} job={job} now={now} onCancel={onCancel} />}
{job.error && <RunFailure job={job} connected={connected} />}
<RunStats job={job} findings={findings} now={now} />
<RunLog job={job} />
{job && view.body === "progress" && <InvestigationProgress key={job.id} job={job} now={now} />}
{job?.error && view.body === "error" && <RunFailure job={job} connected={connected} />}
{job && <RunStats job={job} findings={findings} now={now} />}
{job && <RunLog job={job} />}
</section>
);
}

View file

@ -3,9 +3,9 @@ import {
analysisElapsed,
analysisProgress,
analysisFraction,
analysisStages,
analysisPace,
remainingLabel,
stageDurations,
} from "./progress";
import { type Job } from "./types";
@ -207,20 +207,27 @@ describe("Analysis pace", () => {
});
});
describe("Stage durations", () => {
const start = Date.parse("2026-09-30T12:00:00Z");
const createdAt = "2026-09-30T12:00:00Z";
it("times finished stages from the transitions it saw and the active stage up to now", () => {
const samples = [
{ at: start + 5000, step: 0, done: 10, fraction: 0.1 },
{ at: start + 124000, step: 1, done: 0, fraction: 0.6 },
];
expect(stageDurations(samples, createdAt, start + 145000)).toEqual([124, 21, null]);
describe("Analysis stages", () => {
it("fills finished stages, fills the active one by its own count, and leaves later ones empty", () => {
const grouping = {
...job,
stage: "Grouping observations",
coverage: { ...coverage, selected: 40, screened: 40, grouping_batches: 4, grouped_batches: 1 },
};
expect(analysisStages(grouping).map(({ state, fill, done, total }) => ({ state, fill, done, total }))).toEqual([
{ state: "done", fill: 1, done: 40, total: 40 },
{ state: "active", fill: 0.25, done: 1, total: 4 },
{ state: "todo", fill: 0, done: 0, total: 0 },
]);
});
it("does not guess when a stage started before the page was opened", () => {
const samples = [{ at: start + 90000, step: 1, done: 2, fraction: 0.7 }];
expect(stageDurations(samples, createdAt, start + 100000)).toEqual([null, null, null]);
it("weights the stages so the bar agrees with the overall percentage", () => {
const grouping = {
...job,
stage: "Grouping observations",
coverage: { ...coverage, selected: 40, screened: 40, grouping_batches: 4, grouped_batches: 1 },
};
const covered = analysisStages(grouping).reduce((sum, stage) => sum + stage.weight * stage.fill, 0);
expect(covered).toBeCloseTo(analysisFraction(analysisProgress(grouping)));
});
});

View file

@ -59,20 +59,48 @@ export function analysisProgress(job: Job) {
};
}
export function analysisStages(job: Job): { done: number; total: number }[] {
const {
screened = 0,
selected = 0,
grouped_batches = 0,
grouping_batches = 0,
investigated = 0,
candidates = 0,
} = job.coverage ?? {};
return [
{ done: screened, total: selected },
{ done: grouped_batches, total: grouping_batches },
{ done: investigated, total: candidates },
];
type Coverage = Job["coverage"];
/** The three analysis stages in order, with their share of the overall progress and how each one counts its work. */
export const ANALYSIS_STAGES = [
{ label: "Review runs", weight: 0.6, done: (c: Coverage) => c.screened, total: (c: Coverage) => c.selected },
{
label: "Find patterns",
weight: 0.2,
done: (c: Coverage) => c.grouped_batches,
total: (c: Coverage) => c.grouping_batches,
},
{ label: "Check evidence", weight: 0.2, done: (c: Coverage) => c.investigated, total: (c: Coverage) => c.candidates },
] as const;
export interface StageProgress {
readonly label: string;
readonly weight: number;
readonly state: "done" | "active" | "todo";
readonly done: number;
readonly total: number;
readonly fill: number;
}
function stageState(index: number, current: number): StageProgress["state"] {
if (index < current) return "done";
return index === current ? "active" : "todo";
}
function stageFill(state: StageProgress["state"], done: number, total: number): number {
if (state === "done") return 1;
if (state === "todo" || !total) return 0;
return Math.min(1, done / total);
}
export function analysisStages(job: Job): readonly StageProgress[] {
const step = analysisProgress(job).step;
return ANALYSIS_STAGES.map((stage, index) => {
const state = stageState(index, step);
const done = job.coverage ? stage.done(job.coverage) : 0;
const total = job.coverage ? stage.total(job.coverage) : 0;
return { label: stage.label, weight: stage.weight, state, done, total, fill: stageFill(state, done, total) };
});
}
export interface ProgressSample {
@ -82,16 +110,14 @@ export interface ProgressSample {
fraction: number;
}
const stageWeights = [0.6, 0.2, 0.2];
export function analysisFraction({
step,
done,
total,
}: Pick<ReturnType<typeof analysisProgress>, "step" | "done" | "total">): number {
if (step < 0) return 0;
const before = stageWeights.slice(0, step).reduce((sum, weight) => sum + weight, 0);
return before + stageWeights[step] * (total ? Math.min(1, done / total) : 0);
const before = ANALYSIS_STAGES.slice(0, step).reduce((sum, stage) => sum + stage.weight, 0);
return before + ANALYSIS_STAGES[step].weight * (total ? Math.min(1, done / total) : 0);
}
function windowStart(samples: readonly ProgressSample[], now: number): ProgressSample | undefined {
@ -115,22 +141,6 @@ export function analysisPace(samples: readonly ProgressSample[], now: number) {
return { perMinute, secondsLeft };
}
export function stageDurations(samples: readonly ProgressSample[], createdAt: string, now: number): (number | null)[] {
const current = samples.at(-1)?.step ?? -1;
const starts = [0, 1, 2].map((stage) => {
if (stage === 0) return Date.parse(createdAt);
const entered = samples.findIndex(
(sample, index) => index > 0 && sample.step >= stage && samples[index - 1].step < stage,
);
return entered < 0 ? null : samples[entered].at;
});
return starts.map((start, stage) => {
if (start === null || stage > current) return null;
const end = stage === current ? now : starts[stage + 1];
return end === null ? null : Math.max(0, Math.floor((end - start) / 1000));
});
}
export function remainingLabel(seconds: number | null): string {
if (seconds === null) return "estimating";
if (seconds < 60) return "<1m";

View file

@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { NEXT_ACTION, runSituation, type SituationInput } from "./runSituation";
import type { Finding, Job, Lens } from "./types";
const settings: Lens["settings"] = {
context: "",
lookback_hours: 24,
q: "",
interval_minutes: 60,
sample_size: 100,
sample_percent: 100,
concurrency: 8,
execution_ids: [],
monthly_budget: 20,
name: "Release reviews",
model: "analysis",
enabled: false,
checks: [],
};
const month = new Date().toISOString().slice(0, 7);
const lens: Lens = {
version: 0,
spent: 1,
id: "lens",
scope: { all_teams: true, api_key_hash: "", team_id: "" },
settings,
revision: 1,
created_at: "2026-09-30T10:00:00Z",
next_run_at: "2026-09-30T10:00:00Z",
budget_month: month,
findings: [],
jobs: [],
};
const job: Job = {
id: "run",
assessments: [],
steps: [],
trigger: "manual",
attempts: 1,
error: "",
cost: 0,
coverage: {
eligible: 10,
selected: 10,
screened: 10,
investigated: 0,
inconclusive: 0,
grouping_batches: 0,
grouped_batches: 0,
candidates: 0,
partial: 0,
unassessable: 0,
},
status: "completed",
stage: "Complete",
created_at: "2026-09-30T10:00:00Z",
start: "2026-09-29T10:00:00Z",
end: "2026-09-30T10:00:00Z",
settings,
revision: 1,
};
const finding = (kind: Finding["kind"], status: Finding["status"]): Finding => ({
id: `${kind}-${status}`,
check_id: "check",
title: "",
description: "",
reason: "",
suggestion: "",
limitation: "",
kind,
priority: "high",
status,
revision: 1,
first_seen: job.created_at,
last_seen: job.created_at,
occurrences: [],
evidence: [],
});
const input = (overrides: Partial<SituationInput> & { jobPatch?: Partial<Job> }): SituationInput => {
const { jobPatch, ...rest } = overrides;
return { lens, job: { ...job, ...jobPatch }, findings: [], connected: true, ...rest };
};
const failed = { status: "failed", error: "Model request failed" } as const;
const watching = { ...lens, settings: { ...settings, enabled: true } };
describe("runSituation", () => {
it.each([
["never", input({ job: undefined }), "run"],
["queued", input({ jobPatch: { status: "queued" } }), "stop"],
["running", input({ jobPatch: { status: "running" } }), "stop"],
["budget", input({ jobPatch: failed, lens: { ...lens, spent: 20 } }), "raiseBudget"],
["budget", input({ jobPatch: { ...failed, error: "Monthly lens budget reached" } }), "raiseBudget"],
["offline", input({ jobPatch: failed, connected: false }), "connectWorker"],
["failed", input({ jobPatch: failed }), "retry"],
["failed", input({ jobPatch: { ...failed, error: "Could not reserve analysis budget" } }), "retry"],
["cancelled", input({ jobPatch: { status: "cancelled" } }), "retry"],
["unknown", input({ findings: null }), null],
["issues", input({ lens: watching, findings: [finding("issue", "open")] }), "reviewIssues"],
["watching", input({ lens: watching, findings: [finding("issue", "resolved")] }), null],
["clean", input({ findings: [finding("pattern", "open")] }), "monitor"],
] as const)("reads %s and offers %s", (expected, situationInput, action) => {
const situation = runSituation(situationInput);
expect(situation).toBe(expected);
expect(NEXT_ACTION[situation]).toBe(action);
});
it("puts a running job ahead of a spent budget, so Stop stays reachable", () => {
expect(runSituation(input({ jobPatch: { status: "running" }, lens: { ...lens, spent: 99 } }))).toBe("running");
});
it("ignores spend from an earlier month", () => {
expect(runSituation(input({ jobPatch: failed, lens: { ...lens, spent: 99, budget_month: "1999-01" } }))).toBe(
"failed",
);
});
});

View file

@ -0,0 +1,67 @@
import type { Finding, Job, Lens } from "./types";
export interface SituationInput {
readonly lens: Lens;
readonly job: Job | undefined;
readonly findings: readonly Finding[] | null | undefined;
readonly connected: boolean;
}
export type RunSituation =
| "never"
| "queued"
| "running"
| "budget"
| "offline"
| "failed"
| "cancelled"
| "unknown"
| "issues"
| "watching"
| "clean";
export type RunAction = "run" | "stop" | "retry" | "raiseBudget" | "connectWorker" | "reviewIssues" | "monitor";
const failed = (job: Job | undefined) => job?.status === "failed";
export function budgetReached(lens: Lens, now = new Date()): boolean {
const spent = lens.budget_month === now.toISOString().slice(0, 7) ? lens.spent ?? 0 : 0;
return spent >= (lens.settings.monthly_budget ?? 100);
}
export const openIssues = (findings: readonly Finding[] | null | undefined) =>
findings?.filter((finding) => finding.kind === "issue" && finding.status === "open").length ?? 0;
/** First match wins, so the order is the precedence between overlapping situations. */
const RULES: readonly (readonly [RunSituation, (input: SituationInput) => boolean])[] = [
["never", ({ job }) => !job],
["queued", ({ job }) => job?.status === "queued"],
["running", ({ job }) => job?.status === "running"],
["budget", ({ job, lens }) => failed(job) && (budgetReached(lens) || /budget reached/i.test(job?.error ?? ""))],
["offline", ({ job, connected }) => failed(job) && !connected],
["failed", ({ job }) => failed(job)],
["cancelled", ({ job }) => job?.status === "cancelled"],
["unknown", ({ findings }) => findings == null],
["issues", ({ findings }) => openIssues(findings) > 0],
["watching", ({ lens }) => lens.settings.enabled],
["clean", () => true],
];
export function runSituation(input: SituationInput): RunSituation {
return RULES.find(([, matches]) => matches(input))?.[0] ?? "clean";
}
/** The one thing worth doing next in each situation; null when nothing needs the user. */
export const NEXT_ACTION: Record<RunSituation, RunAction | null> = {
never: "run",
queued: "stop",
running: "stop",
budget: "raiseBudget",
offline: "connectWorker",
failed: "retry",
cancelled: "retry",
unknown: null,
issues: "reviewIssues",
watching: null,
clean: "monitor",
};

View file

@ -1,4 +1,5 @@
import { formatActivityTimestamp } from "@/utils/activityTimestamp";
import { budgetReached } from "./runSituation";
import type { Job, Lens, LensList } from "./types";
export function workerConnected(worker: LensList["workers"][number], now = Date.now()): boolean {
@ -23,8 +24,7 @@ export function listPollInterval(list: LensList | undefined, settingsOpen: boole
export function lensStatus(lens: Lens, connected: boolean): string {
const active = lens.jobs?.find((job) => ["queued", "running"].includes(job.status ?? ""));
if (active) return connected ? active.stage ?? "Queued" : "Waiting for analyzer";
const spent = lens.budget_month === new Date().toISOString().slice(0, 7) ? lens.spent ?? 0 : 0;
if (spent >= (lens.settings.monthly_budget ?? 100)) return "Budget reached";
if (budgetReached(lens)) return "Budget reached";
const latest = lens.jobs?.[0];
if (latest?.status === "failed") return "Failed";
if (latest?.status === "cancelled") return "Cancelled";