mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
feat(lens): simplify View run to traces and conclusions
The left pane is the trace list. A soft highlighter carrying the provider and model slides to the trace being reviewed, and clicking a row shows just Lens's reasoning and verdicts. The right pane keeps one conclusion card per check.
This commit is contained in:
parent
55d9425a82
commit
b00cb8d733
5 changed files with 181 additions and 112 deletions
|
|
@ -39,47 +39,7 @@ export function ConclusionsPanel({
|
|||
if (!groups.length) return <p className="text-[12px] text-muted-foreground">Nothing concluded yet.</p>;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{SECTIONS.map(({ issue, title }) => (
|
||||
<GroupList
|
||||
key={title}
|
||||
title={title}
|
||||
groups={groups.filter((group) => group.issue === issue)}
|
||||
total={total}
|
||||
selected={selected}
|
||||
flashing={flashing}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
{scope && <p className="text-[11px] text-muted-foreground">{scope}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SECTIONS = [
|
||||
{ issue: true, title: "Issues" },
|
||||
{ issue: false, title: "Patterns" },
|
||||
] as const;
|
||||
|
||||
function GroupList({
|
||||
title,
|
||||
groups,
|
||||
total,
|
||||
selected,
|
||||
flashing,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string;
|
||||
groups: readonly Conclusion[];
|
||||
total: number;
|
||||
selected: string | null;
|
||||
flashing: ReadonlySet<string>;
|
||||
onSelect: (key: string | null) => void;
|
||||
}) {
|
||||
if (!groups.length) return null;
|
||||
return (
|
||||
<>
|
||||
<h3 className="pt-1 text-[11px] text-muted-foreground">{title}</h3>
|
||||
<ol aria-label={title} className="flex flex-col gap-1.5">
|
||||
<ol aria-label="Conclusions" className="flex flex-col gap-1.5">
|
||||
{groups.map((group) => {
|
||||
const active = selected === group.key;
|
||||
return (
|
||||
|
|
@ -104,10 +64,25 @@ function GroupList({
|
|||
group.issue ? "bg-[#e5484d]" : "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
<span className="line-clamp-2 text-[13px] leading-snug text-foreground">{group.label}</span>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="line-clamp-2 text-[13px] leading-snug font-medium text-foreground">
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="line-clamp-1 text-[11px] text-muted-foreground">{group.latest}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[20px] leading-none font-semibold tabular-nums text-foreground">
|
||||
{group.count}
|
||||
<span className="flex shrink-0 flex-col items-end gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[20px] leading-none font-semibold tabular-nums",
|
||||
group.count ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{group.count}
|
||||
</span>
|
||||
{group.noted > 0 && (
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">{group.noted} noted</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span aria-hidden="true" className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
|
|
@ -124,6 +99,7 @@ function GroupList({
|
|||
);
|
||||
})}
|
||||
</ol>
|
||||
</>
|
||||
{scope && <p className="text-[11px] text-muted-foreground">{scope}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,13 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
import type { Conclusion, Phase, Playback } from "../../model/live";
|
||||
import type { Job, Review } from "../../model/types";
|
||||
import type { Conclusion, Playback } from "../../model/live";
|
||||
import type { Job } from "../../model/types";
|
||||
import { ConclusionsPanel } from "./ConclusionsPanel";
|
||||
import { ModelName } from "./LiveStrip";
|
||||
import { ReadingPanel } from "./ReadingPanel";
|
||||
import { ReviewQueue } from "./ReviewQueue";
|
||||
import { TraceList } from "./TraceList";
|
||||
|
||||
const STATUS: Record<Job["status"], { label: string; tone: StatusTone }> = {
|
||||
queued: { label: "Queued", tone: "neutral" },
|
||||
|
|
@ -21,7 +19,6 @@ const STATUS: Record<Job["status"], { label: string; tone: StatusTone }> = {
|
|||
cancelled: { label: "Cancelled", tone: "neutral" },
|
||||
};
|
||||
const PANE_TITLE = "text-[12px] font-semibold text-foreground";
|
||||
const SETTLED: Phase = { span: -1, typed: Number.MAX_SAFE_INTEGER, verdict: true };
|
||||
|
||||
export function LiveDrawer({
|
||||
open,
|
||||
|
|
@ -31,38 +28,27 @@ export function LiveDrawer({
|
|||
status,
|
||||
reviewed,
|
||||
playback,
|
||||
live,
|
||||
focused,
|
||||
following,
|
||||
phase,
|
||||
groups,
|
||||
decided,
|
||||
scope,
|
||||
waiting,
|
||||
onPick,
|
||||
onFollow,
|
||||
}: {
|
||||
waiting?: ReactNode;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
name: string;
|
||||
model: string;
|
||||
status: Job["status"];
|
||||
reviewed: number;
|
||||
playback: Pick<Playback, "played" | "current">;
|
||||
live: boolean;
|
||||
focused: Review | null;
|
||||
following: boolean;
|
||||
phase: Phase;
|
||||
playback: Pick<Playback, "played" | "current" | "pending">;
|
||||
groups: readonly Conclusion[];
|
||||
decided: number;
|
||||
scope: string;
|
||||
onPick: (review: Review) => void;
|
||||
onFollow: () => void;
|
||||
waiting?: ReactNode;
|
||||
}) {
|
||||
const [group, setGroup] = useState<string | null>(null);
|
||||
const badge = STATUS[status];
|
||||
const shown = playback.played.length + (playback.current ? 1 : 0);
|
||||
const empty = !shown && !playback.pending.length;
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(value) => !value && onClose()}>
|
||||
<SheetContent className="flex h-full w-full flex-col gap-0 data-[side=right]:sm:max-w-[min(1200px,94vw)]">
|
||||
|
|
@ -71,43 +57,33 @@ export function LiveDrawer({
|
|||
<SheetDescription render={<div />} className="flex flex-wrap items-center gap-2.5 pr-10">
|
||||
<StatusBadge tone={badge.tone} label={badge.label} />
|
||||
<ModelName model={model} size="md" />
|
||||
{!following && live && (
|
||||
<Button variant="outline" size="xs" className="ml-auto rounded-full" onClick={onFollow}>
|
||||
Back to live
|
||||
</Button>
|
||||
)}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[minmax(0,58fr)_minmax(0,42fr)]">
|
||||
<section aria-label="Now reviewing" className="min-h-0 overflow-y-auto border-b px-5 py-4 md:border-r md:border-b-0">
|
||||
<h2 className={`${PANE_TITLE} mb-3`}>{following && live ? "Now reviewing" : "Trace"}</h2>
|
||||
{focused ? (
|
||||
<ReadingPanel
|
||||
key={following ? "live" : focused.execution_id}
|
||||
review={focused}
|
||||
phase={following ? phase : SETTLED}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 py-6 text-[12px] text-muted-foreground">
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[minmax(0,55fr)_minmax(0,45fr)]">
|
||||
<section aria-label="Traces" className="min-h-0 overflow-y-auto border-b px-4 py-4 md:border-r md:border-b-0">
|
||||
<div className="mb-2 flex items-baseline justify-between gap-3 px-2">
|
||||
<h2 className={PANE_TITLE}>Traces</h2>
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{reviewed} reviewed{reviewed > shown && shown ? ` · showing latest ${shown}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{empty ? (
|
||||
<div className="flex flex-col gap-1 px-2 py-6 text-[12px] text-muted-foreground">
|
||||
<p className="text-[13px] text-foreground">Waiting for the first trace…</p>
|
||||
{waiting}
|
||||
</div>
|
||||
) : (
|
||||
<TraceList
|
||||
playback={playback}
|
||||
model={model}
|
||||
live={status === "queued" || status === "running"}
|
||||
group={group}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
<section aria-label="Conclusions so far" className="flex min-h-0 flex-col gap-5 overflow-y-auto px-5 py-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className={PANE_TITLE}>Conclusions so far</h2>
|
||||
<ConclusionsPanel groups={groups} total={decided} scope={scope} selected={group} onSelect={setGroup} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h2 className={PANE_TITLE}>Traces</h2>
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{reviewed} reviewed{reviewed > shown ? ` · showing latest ${shown}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<ReviewQueue playback={playback} live={live} focused={focused} group={group} onPick={onPick} />
|
||||
</div>
|
||||
<section aria-label="Conclusions so far" className="flex min-h-0 flex-col gap-3 overflow-y-auto px-5 py-4">
|
||||
<h2 className={PANE_TITLE}>Conclusions so far</h2>
|
||||
<ConclusionsPanel groups={groups} total={decided} scope={scope} selected={group} onSelect={setGroup} />
|
||||
</section>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ import {
|
|||
analysisModel,
|
||||
conclusions,
|
||||
decidedReviews,
|
||||
focusedReview,
|
||||
issueCount,
|
||||
reviewKey,
|
||||
shownCount,
|
||||
stripState,
|
||||
} from "../../model/live";
|
||||
|
|
@ -37,19 +35,13 @@ export function LiveRun({
|
|||
const reason = useQueueReason(job, queue);
|
||||
const [stripOpen, setStripOpen] = useStripOpen();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [pinned, setPinned] = useState<string | null>(null);
|
||||
const playback = useReviewPlayback(reviews, live);
|
||||
const withReviews = { ...job, reviews: [...reviews] };
|
||||
const model = analysisModel([...reviews.map((r) => r.model), ...modelsUsed(job.steps), job.settings.model]);
|
||||
const { review: focused, following } = focusedReview(reviews, pinned, playback.current);
|
||||
const issues = issueCount(withReviews);
|
||||
const reviewed = live ? shownCount(job.reviewed, playback) : job.reviewed;
|
||||
const decided = decidedReviews(playback, playback.phase.verdict);
|
||||
const pin = (review: Review) => setPinned(review === playback.current ? null : reviewKey(review));
|
||||
const open = (review?: Review) => {
|
||||
if (review) pin(review);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
const open = () => setDrawerOpen(true);
|
||||
const waiting = reason && (
|
||||
<>
|
||||
<QueueReasonText reason={reason} onConnect={queue?.onConnect} />
|
||||
|
|
@ -89,16 +81,10 @@ export function LiveRun({
|
|||
status={job.status}
|
||||
reviewed={reviewed}
|
||||
playback={playback}
|
||||
live={live}
|
||||
focused={focused}
|
||||
following={following}
|
||||
phase={playback.phase}
|
||||
groups={conclusions(decided, job.settings.checks)}
|
||||
decided={decided.length}
|
||||
scope={job.reviewed > reviews.length ? `From the latest ${reviews.length} of ${job.reviewed} reviewed traces` : ""}
|
||||
waiting={waiting}
|
||||
onPick={pin}
|
||||
onFollow={() => setPinned(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
"use client";
|
||||
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
import { agoLabel } from "@/components/view_logs/TraceView/lensField";
|
||||
import { useNow } from "@/hooks/useNow";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { briefReasoning, inGroup, outcome, reviewKey, shortVerdict, traceRows, type Playback } from "../../model/live";
|
||||
import type { Review } from "../../model/types";
|
||||
import { ModelName } from "./LiveStrip";
|
||||
|
||||
const LIMIT = 80;
|
||||
const RED = "text-[#e5484d]";
|
||||
|
||||
function Expanded({ review }: { review: Review }) {
|
||||
const verdicts = review.verdicts.length
|
||||
? review.verdicts
|
||||
: [{ check_id: "", kind: "pattern" as const, summary: review.cannot_assess ? "Not enough evidence to judge" : "No issue observed" }];
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 px-7 pt-0.5 pb-2.5 text-[12px] leading-relaxed">
|
||||
{review.reasoning && <p className="text-muted-foreground">{briefReasoning(review.reasoning)}</p>}
|
||||
<ul className="flex flex-col gap-1">
|
||||
{verdicts.map((verdict, index) => (
|
||||
<li key={`${verdict.check_id}-${index}`} className={cn("flex gap-2", verdict.kind === "issue" ? RED : "text-foreground")}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"mt-[0.45rem] size-1.5 shrink-0 rounded-full",
|
||||
verdict.kind === "issue" ? "bg-[#e5484d]" : "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0">{verdict.summary}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useHighlighter(active: string | null, layout: string) {
|
||||
const list = useRef<HTMLOListElement>(null);
|
||||
const [box, setBox] = useState<{ top: number; height: number } | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const row = active ? list.current?.querySelector<HTMLElement>(`[data-row="${CSS.escape(active)}"]`) : null;
|
||||
setBox(row ? { top: row.offsetTop, height: row.offsetHeight } : null);
|
||||
}, [active, layout]);
|
||||
return { list, box };
|
||||
}
|
||||
|
||||
export function TraceList({
|
||||
playback,
|
||||
model,
|
||||
live,
|
||||
group,
|
||||
}: {
|
||||
playback: Pick<Playback, "played" | "current" | "pending">;
|
||||
model: string;
|
||||
live: boolean;
|
||||
group: string | null;
|
||||
}) {
|
||||
const now = useNow(5000);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const rows = traceRows(playback, LIMIT)
|
||||
.map((row) => (live || row.state !== "reviewing" ? row : { ...row, state: "done" as const }))
|
||||
.filter(({ review, state }) => state !== "done" || inGroup(review, group));
|
||||
const active = live && playback.current ? reviewKey(playback.current) : null;
|
||||
const { list, box } = useHighlighter(active, `${expanded}|${rows.map((row) => reviewKey(row.review)).join(",")}`);
|
||||
if (!rows.length) return <p className="py-2 text-[12px] text-muted-foreground">No traces yet.</p>;
|
||||
return (
|
||||
<ol aria-label="Reviewed traces" ref={list} className="relative flex flex-col">
|
||||
{box && (
|
||||
<li
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 rounded-lg bg-muted ring-1 ring-border transition-[transform,height] duration-200 ease-out motion-reduce:transition-none"
|
||||
style={{ transform: `translateY(${box.top}px)`, height: box.height }}
|
||||
/>
|
||||
)}
|
||||
{rows.map(({ review, state }) => {
|
||||
const key = reviewKey(review);
|
||||
const result = outcome(review);
|
||||
const open = expanded === key;
|
||||
const reviewing = state === "reviewing";
|
||||
const queued = state === "queued";
|
||||
return (
|
||||
<li key={key} data-row={key} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
disabled={queued}
|
||||
onClick={() => setExpanded(open ? null : key)}
|
||||
className={cn(
|
||||
"grid w-full grid-cols-[0.75rem_minmax(0,8rem)_minmax(0,1fr)_auto] items-center gap-2 rounded-lg px-2 py-2 text-left text-[12px]",
|
||||
!reviewing && !queued && "hover:bg-muted/50",
|
||||
queued && "opacity-40",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-label={queued ? "queued" : reviewing ? "reviewing" : result}
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
reviewing
|
||||
? "bg-foreground motion-safe:animate-pulse"
|
||||
: result === "issue" && !queued
|
||||
? "bg-[#e5484d]"
|
||||
: "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate text-foreground">{review.agent || review.name}</span>
|
||||
{reviewing ? (
|
||||
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
|
||||
<span className="inline-flex shrink-0 items-center rounded-md bg-background px-1.5 py-0.5 ring-1 ring-border">
|
||||
<ModelName model={model} />
|
||||
</span>
|
||||
<span className="truncate">reading…</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn("truncate", result === "issue" && !queued ? RED : "text-muted-foreground")}>
|
||||
{queued ? "up next" : shortVerdict(review)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{agoLabel(Date.parse(review.at), now)}
|
||||
</span>
|
||||
</button>
|
||||
{open && <Expanded review={review} />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||
|
||||
import { playbackPhase, playbackReducer, startPlayback, type Phase, type Playback } from "../../model/live";
|
||||
import { timeline } from "../../model/spanPreview";
|
||||
import type { Review } from "../../model/types";
|
||||
|
||||
const FRAME_MS = 40;
|
||||
|
|
@ -47,6 +46,6 @@ export function useReviewPlayback(
|
|||
const phase =
|
||||
still || !current
|
||||
? { span: -1, typed: current?.reasoning.length ?? 0, verdict: true }
|
||||
: playbackPhase(now - state.startedAt, state.duration, timeline(current.spans).length, current.reasoning.length);
|
||||
: playbackPhase(now - state.startedAt, state.duration, current.spans.length, current.reasoning.length);
|
||||
return { ...state, phase, replay };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue