feat(lens): show investigation progress as one staged bar with time left (#44301)

* feat(lens): compute overall investigation progress, speed and time left

* test(lens): cover overall progress, speed and time left estimates

* feat(lens): show investigation progress as one staged bar with time left

* feat(lens): track per-stage counts and durations for the progress readout

* test(lens): cover stage durations and short time left labels

* feat(lens): restyle investigation progress as a terminal-style readout
This commit is contained in:
ishaan-berri 2026-10-02 18:28:22 -07:00 • committed by GitHub
parent b877a38e5f
commit b28ce93d2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 285 additions and 48 deletions

View file

@ -1,11 +1,31 @@
"use client";
import { useEffect, useState } from "react";
import { Check, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { analysisElapsed, analysisProgress, nextCheckStatus, type Lens, type Job } from "./lensData";
import {
analysisElapsed,
analysisFraction,
analysisPace,
analysisProgress,
analysisStages,
durationText,
nextCheckStatus,
remainingLabel,
stageDurations,
type Lens,
type Job,
type ProgressSample,
} from "./lensData";
const steps = ["Review runs", "Find patterns", "Check evidence"];
const steps = ["review runs", "find patterns", "check evidence"];
const markers = { done: "✓", active: "▸", todo: "·" };
const rowText = { done: "text-foreground", active: "font-medium text-foreground", todo: "text-muted-foreground" };
const blocks = 32;
function stageState(index: number, current: number): keyof typeof markers {
if (index < current) return "done";
return index === current ? "active" : "todo";
}
export function LensProgress({ job, onCancel }: { job: Job; onCancel?: () => void }) {
const [now, setNow] = useState(Date.now);
@ -14,57 +34,98 @@ export function LensProgress({ job, onCancel }: { job: Job; onCancel?: () => voi
return () => window.clearInterval(timer);
}, []);
const progress = analysisProgress(job);
const percent = progress.total ? Math.min(100, (progress.done / progress.total) * 100) : undefined;
const fraction = analysisFraction(progress);
const [samples, setSamples] = useState<ProgressSample[]>([]);
const latest = samples.at(-1);
if (!latest || latest.step !== progress.step || latest.done !== progress.done) {
setSamples([...samples, { at: now, step: progress.step, done: progress.done, fraction }].slice(-120));
}
const pace = analysisPace(samples, now);
const percent = Math.round(fraction * 100);
const queued = progress.step < 0;
const counts = analysisStages(job);
const durations = stageDurations(samples, job.created_at, now);
const filled = Math.round(fraction * blocks);
const stats = [
["eta", remainingLabel(pace.secondsLeft)],
["rate", pace.perMinute === null ? "–" : `${Math.round(pace.perMinute)}/min`],
["elapsed", analysisElapsed(job.created_at, now)],
];
return (
<section aria-label="Analysis progress" className="space-y-4 rounded-xl border bg-muted/30 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium" role="status">
<Loader2 aria-hidden="true" className="size-4 motion-safe:animate-spin text-muted-foreground" />
{progress.title}
</div>
<span className="text-xs tabular-nums text-muted-foreground">
{analysisElapsed(job.created_at, now)} elapsed
<section aria-label="Analysis progress" className="space-y-3 rounded-md border bg-muted/40 px-4 py-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="truncate" role="status">
<span className="font-medium">{progress.title}</span>
{!queued && <span className="text-muted-foreground"> · {progress.detail}</span>}
</span>
</div>
<ol aria-label="Analysis stages" className="grid grid-cols-3 gap-2">
{steps.map((label, index) => (
<li key={label} aria-current={index === progress.step ? "step" : undefined} className="space-y-2">
<div className={`h-1 rounded-full ${index <= progress.step ? "bg-foreground" : "bg-border"}`} />
<span
className={`flex items-center gap-1 text-xs ${index === progress.step ? "font-medium" : "text-muted-foreground"}`}
>
{index < progress.step && <Check aria-label="Complete" className="size-3 shrink-0" />}
{label}
</span>
</li>
))}
</ol>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{progress.detail}</p>
<div
role="progressbar"
aria-label={progress.title}
aria-valuemin={0}
aria-valuemax={progress.total || undefined}
aria-valuenow={progress.total ? Math.min(progress.done, progress.total) : undefined}
aria-valuetext={progress.detail}
className="h-1.5 overflow-hidden rounded-full bg-muted"
>
<div
className={`h-full rounded-full bg-foreground/70 transition-[width] duration-500 ${percent === undefined ? "motion-safe:animate-pulse" : ""}`}
style={{ width: percent === undefined ? "33%" : `${percent}%` }}
/>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
{job.status === "running" && <span>You can leave this page while the investigation runs.</span>}
{onCancel && (
<Button variant="ghost" size="sm" onClick={onCancel}>
Cancel analysis
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs" onClick={onCancel}>
Cancel
</Button>
)}
</div>
<div className="max-w-2xl space-y-1.5 pl-3">
<div className="flex items-center gap-2">
<span aria-hidden="true" className="text-muted-foreground">
[
</span>
<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="flex h-3.5 min-w-0 flex-1 gap-px"
>
{Array.from({ length: blocks }, (_, index) => (
<span
key={index}
className={`flex-1 ${index < filled ? "bg-foreground" : "bg-foreground/10"} ${
queued && index < blocks / 3 ? "motion-safe:animate-pulse bg-foreground/30" : ""
}`}
/>
))}
</div>
<span aria-hidden="true" className="text-muted-foreground">
]
</span>
<span className="w-10 text-right tabular-nums">{percent}%</span>
</div>
<ol aria-label="Analysis stages" className="space-y-0.5">
{steps.map((label, index) => {
const state = stageState(index, progress.step);
const { done, total } = counts[index];
const seconds = durations[index];
return (
<li
key={label}
aria-current={state === "active" ? "step" : undefined}
className={`grid grid-cols-[1rem_minmax(0,9rem)_6rem_auto] items-baseline gap-2 tabular-nums ${rowText[state]}`}
>
<span aria-hidden="true">{markers[state]}</span>
<span className="truncate">{label}</span>
<span className="text-right text-muted-foreground">
{state === "todo" || !total ? "–" : `${Math.min(done, total)}/${total}`}
</span>
<span className="text-muted-foreground">{seconds === null ? "" : durationText(seconds)}</span>
</li>
);
})}
</ol>
<div className="flex flex-wrap gap-x-5 gap-y-1 pt-1 tabular-nums">
{queued ? (
<span className="text-muted-foreground">{progress.detail}</span>
) : (
stats.map(([key, value]) => (
<span key={key}>
<span className="text-muted-foreground">{key}</span> {value}
</span>
))
)}
</div>
</div>
</section>
);
}

View file

@ -4,6 +4,10 @@ import {
workerConnected,
type LensList,
analysisProgress,
analysisFraction,
analysisPace,
remainingLabel,
stageDurations,
normalizeFilters,
sortedFindings,
type Finding,
@ -136,6 +140,96 @@ describe("Analysis progress", () => {
});
});
describe("Analysis pace", () => {
it("fills the bar left to right across stages without jumping backwards at a stage boundary", () => {
const endOfReview = analysisFraction({ step: 0, done: 20, total: 20 });
const startOfGrouping = analysisFraction({ step: 1, done: 0, total: 4 });
expect(analysisFraction({ step: -1, done: 0, total: 0 })).toBe(0);
expect(analysisFraction({ step: 0, done: 10, total: 20 })).toBeLessThan(endOfReview);
expect(startOfGrouping).toBeCloseTo(endOfReview);
expect(analysisFraction({ step: 2, done: 5, total: 5 })).toBeCloseTo(1);
});
it("measures speed within the current stage and projects time left from overall progress", () => {
const start = Date.parse("2026-09-30T12:00:00Z");
const pace = analysisPace(
[
{ at: start, step: 0, done: 0, fraction: 0 },
{ at: start + 30000, step: 0, done: 30, fraction: 0.25 },
],
start + 30000,
);
expect(pace.perMinute).toBe(60);
expect(pace.secondsLeft).toBe(90);
});
it("waits for enough samples instead of showing a wild first estimate", () => {
const start = Date.parse("2026-09-30T12:00:00Z");
expect(analysisPace([{ at: start, step: 0, done: 1, fraction: 0.01 }], start + 2000)).toEqual({
perMinute: null,
secondsLeft: null,
});
expect(remainingLabel(null)).toBe("estimating");
});
it("lengthens the estimate while progress stalls", () => {
const start = Date.parse("2026-09-30T12:00:00Z");
const samples = [
{ at: start, step: 0, done: 0, fraction: 0 },
{ at: start + 10000, step: 0, done: 10, fraction: 0.1 },
];
const moving = analysisPace(samples, start + 10000).secondsLeft ?? 0;
const stalled = analysisPace(samples, start + 40000).secondsLeft ?? 0;
expect(stalled).toBeGreaterThan(moving);
});
it("still estimates when the worker reports progress less than once a minute", () => {
const start = Date.parse("2026-09-30T12:00:00Z");
const pace = analysisPace(
[
{ at: start, step: 2, done: 0, fraction: 0.8 },
{ at: start + 90000, step: 2, done: 1, fraction: 0.85 },
],
start + 90000,
);
expect(pace.secondsLeft).toBeCloseTo(270);
expect(pace.perMinute).toBeCloseTo(2 / 3);
});
it("measures from the last minute rather than the whole run once updates are frequent", () => {
const start = Date.parse("2026-09-30T12:00:00Z");
const samples = [
{ at: start, step: 0, done: 0, fraction: 0 },
{ at: start + 120000, step: 0, done: 12, fraction: 0.06 },
{ at: start + 180000, step: 0, done: 72, fraction: 0.36 },
];
expect(analysisPace(samples, start + 180000).perMinute).toBe(60);
});
it("rounds remaining time up so the label never promises less than the estimate", () => {
expect(remainingLabel(61)).toBe("~2m");
expect(remainingLabel(30)).toBe("<1m");
});
});
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]);
});
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]);
});
});
describe("Lens selection and findings", () => {
it("preserves literal equals signs in a metadata value", () => {
expect(normalizeFilters([{ key: " swarm ", value: " research=v2 " }])).toEqual([

View file

@ -126,8 +126,90 @@ 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 },
];
}
export interface ProgressSample {
at: number;
step: number;
done: number;
fraction: number;
}
export 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);
}
function windowStart(samples: readonly ProgressSample[], now: number): ProgressSample | undefined {
return samples.findLast((sample) => now - sample.at >= 60000) ?? samples[0];
}
export function analysisPace(samples: readonly ProgressSample[], now: number) {
const latest = samples.at(-1);
if (!latest) return { perMinute: null, secondsLeft: null };
const first = windowStart(
samples.filter((sample) => sample.step === latest.step),
now,
);
const anchor = windowStart(samples, now);
if (!first || !anchor) return { perMinute: null, secondsLeft: null };
const stepMinutes = (now - first.at) / 60000;
const perMinute = stepMinutes >= 1 / 6 ? (latest.done - first.done) / stepMinutes : null;
const spanSeconds = (now - anchor.at) / 1000;
const gained = latest.fraction - anchor.fraction;
const secondsLeft = spanSeconds >= 10 && gained > 0 ? ((1 - latest.fraction) * spanSeconds) / gained : null;
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";
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
return `~${Math.floor(seconds / 3600)}h ${Math.ceil((seconds % 3600) / 60)}m`;
}
export function analysisElapsed(createdAt: string, now: number): string {
const seconds = Math.max(0, Math.floor((now - Date.parse(createdAt)) / 1000));
return durationText(Math.max(0, Math.floor((now - Date.parse(createdAt)) / 1000)));
}
export function durationText(seconds: number): string {
if (!Number.isFinite(seconds)) return "0s";
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;