diff --git a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationProgress.tsx b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationProgress.tsx
index 08e5abdb01d..5fdda9e630e 100644
--- a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationProgress.tsx
+++ b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationProgress.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useNow } from "@/hooks/useNow";
import {
analysisElapsed,
analysisFraction,
@@ -13,9 +14,11 @@ import { type Job } from "../model/types";
import { QueueReasonText } from "./QueueReasonText";
import { useQueueReason, type QueueContext } from "./useQueueReason";
-export function InvestigationProgress({ job, now, queue }: { job: Job; now: number; queue?: QueueContext }) {
+export function InvestigationProgress({ job, queue }: { job: Job; queue?: QueueContext }) {
+ const now = useNow(1000);
const reason = useQueueReason(job, queue);
const progress = analysisProgress(job);
+ const stages = analysisStages(job);
const percent = Math.round(analysisFraction(progress) * 100);
const { secondsLeft } = analysisPace(useProgressSamples(progress), now);
const queued = progress.step < 0;
@@ -28,10 +31,12 @@ export function InvestigationProgress({ job, now, queue }: { job: Job; now: numb
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
- aria-valuetext={`${progress.title}: ${progress.detail}`}
+ aria-valuetext={`${percent}% overall. ${progress.title}: ${progress.detail}`}
+ title={`Overall progress: ${stages.map((stage) => `${stage.label} ${stage.weight * 100}%`).join(", ")}`}
className="text-2xl font-semibold"
>
{queued ? "–" : `${percent}%`}
+ {!queued && overall}
{analysisElapsed(job.created_at, now)} elapsed
@@ -39,7 +44,7 @@ export function InvestigationProgress({ job, now, queue }: { job: Job; now: numb
- {analysisStages(job).map((stage) => (
+ {stages.map((stage) => (
- {
expect(within(screen.getByRole("tabpanel", { name: "History" })).getByText(/Took 2m 13s/)).toBeVisible();
});
+it("keeps history status and cost updating when an older run fails to load", async () => {
+ testQueryClient.clear();
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
+ const running = { ...lens.jobs[0], status: "running" as const, cost: 0.25 };
+ const older = { ...lens.jobs[0], id: "older", created_at: "2026-09-29T10:00:00Z" };
+ const runs = vi.fn().mockResolvedValue([running, older]);
+ proxy.get.mockImplementation(async (path) => {
+ if (path.endsWith("/reviews")) return { reviews: [], reviewed: 0 };
+ if (path === "/lens") return { lenses: [{ ...lens, jobs: [running] }], workers: [], tracing_enabled: true };
+ if (path === "/lens/lens/runs") return runs();
+ if (path === "/lens/lens/runs/older") throw new Error("Run unavailable");
+ return { data: [] };
+ });
+ const user = userEvent.setup();
+ const { unmount } = renderWithProviders();
+ try {
+ await user.click(await screen.findByRole("tab", { name: "History" }));
+ const history = within(screen.getByRole("tabpanel", { name: "History" }));
+ expect(await history.findByText("Running")).toBeVisible();
+ expect(history.getByText("$0.25")).toBeVisible();
+ await user.selectOptions(screen.getByRole("combobox", { name: "Investigation run" }), "older");
+ expect(await screen.findByText(/Could not load this run/)).toBeVisible();
+
+ runs.mockResolvedValue([{ ...running, status: "completed", cost: 1.75 }, older]);
+ await act(async () => vi.advanceTimersByTimeAsync(10_000));
+ expect(await history.findByText("$1.75")).toBeVisible();
+ expect(history.queryByText("Running")).not.toBeInTheDocument();
+ expect(history.getAllByText("Completed")).toHaveLength(2);
+ } finally {
+ unmount();
+ vi.useRealTimers();
+ }
+});
+
it("reads request content from the beginning after its abbreviated preview", async () => {
testQueryClient.clear();
const requestId = btoa(JSON.stringify(["requests", "", "request-1"]));
@@ -932,6 +967,59 @@ 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("updates elapsed time without reformatting the activity log and still shows new model calls", async () => {
+ vi.useFakeTimers();
+ const formatTime = vi.spyOn(Date.prototype, "toLocaleTimeString");
+ const created_at = new Date(Date.now() - 60_000).toISOString();
+ const running = {
+ ...lens.jobs[0],
+ status: "running" as const,
+ stage: "Reading executions",
+ created_at,
+ coverage: { ...lens.jobs[0].coverage, selected: 62, screened: 13 },
+ steps: Array.from({ length: 200 }, (_, index) => ({
+ at: created_at,
+ kind: "model" as const,
+ label: `Reviewed run ${index}`,
+ model: "analysis",
+ purpose: "extract" as const,
+ prompt_tokens: 100,
+ completion_tokens: 20,
+ cost: 0.01,
+ })),
+ };
+ const view = (job: typeof running) => (
+
+ );
+ const { rerender, unmount } = renderWithProviders(view(running));
+ try {
+ fireEvent.click(screen.getByRole("button", { name: "Activity log" }));
+ expect(screen.getByRole("progressbar", { name: "Investigation progress" })).toHaveAttribute("aria-valuenow", "13");
+ expect(screen.getByRole("progressbar", { name: "Investigation progress" })).toHaveAttribute(
+ "aria-valuetext",
+ "13% overall. Reviewing activity: 13 of 62 selected runs reviewed",
+ );
+ expect(within(screen.getByRole("list", { name: "Investigation steps" })).getAllByRole("listitem")).toHaveLength(
+ 200,
+ );
+ formatTime.mockClear();
+ await act(async () => vi.advanceTimersByTime(3000));
+ expect(screen.getByText("1m 3s")).toBeVisible();
+ expect(formatTime.mock.calls.length).toBe(0);
+
+ rerender(
+ view({ ...running, cost: 2.01, steps: [...running.steps, { ...running.steps[0], label: "New model call" }] }),
+ );
+ expect(screen.getByText("201 model calls")).toBeVisible();
+ expect(screen.getByText("$2.01")).toBeVisible();
+ expect(screen.getByText(/New model call/)).toBeVisible();
+ } finally {
+ unmount();
+ formatTime.mockRestore();
+ vi.useRealTimers();
+ }
+});
+
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" };
diff --git a/ui/litellm-dashboard/src/components/lens/investigations/detail/HistoryTab.tsx b/ui/litellm-dashboard/src/components/lens/investigations/detail/HistoryTab.tsx
index 7bd87f8ab26..80d6aed0a48 100644
--- a/ui/litellm-dashboard/src/components/lens/investigations/detail/HistoryTab.tsx
+++ b/ui/litellm-dashboard/src/components/lens/investigations/detail/HistoryTab.tsx
@@ -23,11 +23,23 @@ export interface HistoryTabProps {
export function HistoryTab({ lens }: HistoryTabProps) {
const [offset, setOffset] = useState(0);
- const history = useRunHistory(lens, offset);
+ return (
+
+
+
+ );
+}
+
+function HistoryContent({
+ lens,
+ offset,
+ setOffset,
+}: HistoryTabProps & { offset: number; setOffset: (offset: number) => void }) {
+ const history = useRunHistory(lens, offset, offset > 0);
const { openRun } = useRunRoute();
const rows = history.data ?? lens.jobs;
return (
-
+ <>
{history.error && (
Could not load run history.{" "}
@@ -85,6 +97,6 @@ export function HistoryTab({ lens }: HistoryTabProps) {
)}
-
+ >
);
}
diff --git a/ui/litellm-dashboard/src/components/lens/investigations/detail/RunPicker.tsx b/ui/litellm-dashboard/src/components/lens/investigations/detail/RunPicker.tsx
index eb4343ff1b5..3cb6470c552 100644
--- a/ui/litellm-dashboard/src/components/lens/investigations/detail/RunPicker.tsx
+++ b/ui/litellm-dashboard/src/components/lens/investigations/detail/RunPicker.tsx
@@ -20,9 +20,10 @@ export interface RunPickerProps {
export function RunPicker({ lens, job }: RunPickerProps) {
const { batchId, selectRun } = useRunRoute();
const history = useRunHistory(lens, 0);
+ if (!job) return null;
const options = history.data ?? lens.jobs;
- const reused = job?.coverage?.reused ?? 0;
- const newlyReviewed = Math.max(0, (job?.coverage?.screened ?? 0) - reused);
+ const reused = job.coverage?.reused ?? 0;
+ const newlyReviewed = Math.max(0, (job.coverage?.screened ?? 0) - reused);
const aggregate = batchId === "latest" || batchId === "all";
const outsideHistory = !aggregate && !options.some((j) => j.id === batchId);
return (
@@ -34,7 +35,7 @@ export function RunPicker({ lens, job }: RunPickerProps) {
onChange={(e) => selectRun(e.target.value)}
>
- {job && outsideHistory && (
+ {outsideHistory && (
@@ -46,7 +47,7 @@ export function RunPicker({ lens, job }: RunPickerProps) {
))}
- {job && batchId !== "all" && (
+ {batchId !== "all" && (
- {label}
@@ -187,18 +187,19 @@ function issueStat(job: Job, findings: readonly Finding[] | null | undefined) {
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 }) {
+function RunDuration({ job }: { job: Job }) {
+ const now = useNow(isActive(job) ? 1000 : 60000);
+ const end = job.finished_at ? Date.parse(job.finished_at) : now;
+ return isActive(job) || job.finished_at ? analysisElapsed(job.created_at, end) : "–";
+}
+
+function RunStats({ job, findings }: { job: Job; findings: readonly Finding[] | null | undefined }) {
const calls = (job.steps ?? []).filter((step) => step.kind === "model").length;
const { screened = 0, selected = 0 } = job.coverage ?? {};
- const end = job.finished_at ? Date.parse(job.finished_at) : now;
return (
-
+ } note={`Started ${shortTime(job.created_at)}`} />
{view.headline(known)}
- {job && picker}
+ {picker}
{action && actions && (
)}
- {job && view.body === "progress" && }
+ {job && view.body === "progress" && }
{job?.error && view.body === "partial" && }
{job?.error && view.body === "error" && }
- {job && }
+ {job && }
{job && }
);
diff --git a/ui/litellm-dashboard/src/components/lens/investigations/detail/useRunHistory.ts b/ui/litellm-dashboard/src/components/lens/investigations/detail/useRunHistory.ts
index 94d1519ddc2..8c9de13f828 100644
--- a/ui/litellm-dashboard/src/components/lens/investigations/detail/useRunHistory.ts
+++ b/ui/litellm-dashboard/src/components/lens/investigations/detail/useRunHistory.ts
@@ -8,13 +8,14 @@ import { activeJob } from "../../model/status";
import type { Lens } from "../../model/types";
/** Run history polls off its own rows, so a job the list poll discovers has to push it to refetch. */
-export function useRunHistory(lens: Lens, historyOffset: number) {
+export function useRunHistory(lens: Lens, historyOffset: number, poll = true) {
const api = useLensApi();
const client = useQueryClient();
const activeId = activeJob(lens.jobs)?.id;
useEffect(() => {
- if (!activeId) return;
+ if (!poll || !activeId) return;
void client.invalidateQueries({ queryKey: lensKeys.histories() }, { cancelRefetch: false });
- }, [activeId, client]);
- return useQuery(lensQueries.history(api, { lensId: lens.id, historyOffset }));
+ }, [activeId, client, poll]);
+ const options = lensQueries.history(api, { lensId: lens.id, historyOffset });
+ return useQuery({ ...options, refetchInterval: poll ? options.refetchInterval : false });
}
diff --git a/ui/litellm-dashboard/src/components/lens/investigations/live/ConclusionsPanel.tsx b/ui/litellm-dashboard/src/components/lens/investigations/live/ConclusionsPanel.tsx
index 53568b08949..e3755d4a119 100644
--- a/ui/litellm-dashboard/src/components/lens/investigations/live/ConclusionsPanel.tsx
+++ b/ui/litellm-dashboard/src/components/lens/investigations/live/ConclusionsPanel.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import { memo, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/cva.config";
@@ -22,7 +22,7 @@ function useFlashing(groups: readonly Conclusion[]): ReadonlySet {
return flashing;
}
-export function ConclusionsPanel({
+export const ConclusionsPanel = memo(function ConclusionsPanel({
groups,
total,
scope,
@@ -102,4 +102,4 @@ export function ConclusionsPanel({
{scope && {scope}
}
);
-}
+});
diff --git a/ui/litellm-dashboard/src/components/lens/investigations/live/LiveDrawer.tsx b/ui/litellm-dashboard/src/components/lens/investigations/live/LiveDrawer.tsx
index 0b2d75328e2..c028d996774 100644
--- a/ui/litellm-dashboard/src/components/lens/investigations/live/LiveDrawer.tsx
+++ b/ui/litellm-dashboard/src/components/lens/investigations/live/LiveDrawer.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, type ReactNode } from "react";
+import { useMemo, useState, type ReactNode } from "react";
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
@@ -48,7 +48,11 @@ function Stage({
}) {
const legacyReading = running && !activities.length && stageName === "Reading executions";
const { stage, now, charMs } = useStage(reviews, reading, legacyReading, slots);
- const listed = legacyReading ? releasedReviews(reviews, stage) : reviews;
+ const listed = useMemo(
+ () => (legacyReading ? releasedReviews(reviews, stage) : reviews),
+ [legacyReading, reviews, stage],
+ );
+ const groups = useMemo(() => conclusions(listed, checks), [listed, checks]);
function currentWork() {
if (!running) return null;
if (activities.length) return ;
@@ -61,7 +65,7 @@ function Stage({
);
}
- return <>{children(listed, conclusions(listed, checks), currentWork())}>;
+ return <>{children(listed, groups, currentWork())}>;
}
export function LiveDrawer({
diff --git a/ui/litellm-dashboard/src/components/lens/investigations/live/LiveRun.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/investigations/live/LiveRun.integration.test.tsx
index 013aee4e896..0412cb3b7b7 100644
--- a/ui/litellm-dashboard/src/components/lens/investigations/live/LiveRun.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/lens/investigations/live/LiveRun.integration.test.tsx
@@ -1,5 +1,5 @@
-import { fireEvent, screen, within } from "@testing-library/react";
-import { beforeEach, expect, it } from "vitest";
+import { act, fireEvent, screen, within } from "@testing-library/react";
+import { beforeEach, expect, it, vi } from "vitest";
import { renderWithLens, stubGateway } from "@/../tests/lens-test-utils";
import { testQueryClient } from "@/../tests/test-utils";
@@ -62,6 +62,28 @@ function activity(overrides: Partial = {}): Activity {
};
}
+it("keeps completed trace rows idle while the reading clock advances", async () => {
+ vi.useFakeTimers();
+ const parseTime = vi.spyOn(Date, "parse");
+ const reviewed = review();
+ const active = job({ stage: "Reading executions", activities: [], reading: [] });
+ const { unmount } = renderWithLens();
+ try {
+ fireEvent.click(screen.getByRole("button", { name: "View run" }));
+ const traces = within(screen.getByRole("list", { name: "Reviewed traces" }));
+ expect(traces.getByRole("button", { name: /research-agent/ })).toBeVisible();
+ parseTime.mockClear();
+ await act(async () => vi.advanceTimersByTime(500));
+ expect(parseTime.mock.calls.filter(([value]) => value === reviewed.at)).toHaveLength(0);
+ fireEvent.click(traces.getByRole("button", { name: /research-agent/ }));
+ expect(traces.getByText("Tool calls: Read × 2 · Python × 1")).toBeVisible();
+ } finally {
+ unmount();
+ parseTime.mockRestore();
+ vi.useRealTimers();
+ }
+});
+
it("shows real candidate and grouping activity, durable tool counts, and preliminary review scope", async () => {
const reviewed = review();
const grouping: Partial = {
diff --git a/ui/litellm-dashboard/src/components/lens/investigations/live/TraceList.tsx b/ui/litellm-dashboard/src/components/lens/investigations/live/TraceList.tsx
index b9883ed38de..997485017b7 100644
--- a/ui/litellm-dashboard/src/components/lens/investigations/live/TraceList.tsx
+++ b/ui/litellm-dashboard/src/components/lens/investigations/live/TraceList.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { memo, useState } from "react";
import { agoLabel } from "../../model/format";
import { useNow } from "@/hooks/useNow";
@@ -95,7 +95,13 @@ function DoneRow({
);
}
-export function TraceList({ reviews, group }: { reviews: readonly Review[]; group: string | null }) {
+export const TraceList = memo(function TraceList({
+ reviews,
+ group,
+}: {
+ reviews: readonly Review[];
+ group: string | null;
+}) {
const now = useNow(5000);
const [expanded, setExpanded] = useState(null);
const rows = newestFirst(reviews, LIMIT).filter((review) => inGroup(review, group));
@@ -117,4 +123,4 @@ export function TraceList({ reviews, group }: { reviews: readonly Review[]; grou
))}
);
-}
+});