mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
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:
parent
b877a38e5f
commit
b28ce93d2b
3 changed files with 285 additions and 48 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue