mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
feat(lens): show the live run as a two-pane trace and conclusions view
Left pane: the trace being reviewed as a readable timeline, followed by Lens's reasoning and the verdict. Right pane: ranked conclusion groups with share bars, plus a trace list you can filter.
This commit is contained in:
parent
1dfcdf223e
commit
c4fcd4b457
6 changed files with 294 additions and 122 deletions
|
|
@ -1,27 +1,90 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { type Conclusion } from "../../model/live";
|
||||
import { grownGroups, share, type Conclusion } from "../../model/live";
|
||||
|
||||
export function ConclusionsPanel({ groups, scope }: { groups: readonly Conclusion[]; scope: string }) {
|
||||
const FLASH_MS = 150;
|
||||
|
||||
function useFlashing(groups: readonly Conclusion[]): ReadonlySet<string> {
|
||||
const previous = useRef(groups);
|
||||
const [flashing, setFlashing] = useState<ReadonlySet<string>>(new Set());
|
||||
useEffect(() => {
|
||||
const grown = grownGroups(previous.current, groups);
|
||||
previous.current = groups;
|
||||
if (!grown.size) return;
|
||||
setFlashing(grown);
|
||||
const timer = window.setTimeout(() => setFlashing(new Set()), FLASH_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [groups]);
|
||||
return flashing;
|
||||
}
|
||||
|
||||
export function ConclusionsPanel({
|
||||
groups,
|
||||
total,
|
||||
scope,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
groups: readonly Conclusion[];
|
||||
total: number;
|
||||
scope: string;
|
||||
selected: string | null;
|
||||
onSelect: (key: string | null) => void;
|
||||
}) {
|
||||
const flashing = useFlashing(groups);
|
||||
if (!groups.length) return <p className="text-[12px] text-muted-foreground">Nothing concluded yet.</p>;
|
||||
return (
|
||||
<ol aria-label="Conclusions" className="divide-y divide-border/60">
|
||||
{groups.map((group) => (
|
||||
<li
|
||||
key={group.checkId}
|
||||
data-check={group.checkId}
|
||||
title={group.latest}
|
||||
className="grid grid-cols-[0.75rem_minmax(0,1fr)_auto] items-start gap-2 px-4 py-2 text-[12px] motion-safe:animate-in motion-safe:fade-in"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("mt-1.5 size-1.5 rounded-full", group.issue ? "bg-[#e5484d]" : "bg-muted-foreground/40")}
|
||||
/>
|
||||
<span className="line-clamp-2 text-foreground">{group.label}</span>
|
||||
<span className="font-mono tabular-nums text-foreground">{group.count}</span>
|
||||
</li>
|
||||
))}
|
||||
{!groups.length && <li className="px-4 py-3 text-[12px] text-muted-foreground">Nothing flagged yet.</li>}
|
||||
{groups.length > 0 && scope && <li className="px-4 py-1.5 text-[11px] text-muted-foreground">{scope}</li>}
|
||||
</ol>
|
||||
<div 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 (
|
||||
<li key={group.key}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
title={group.latest}
|
||||
onClick={() => onSelect(active ? null : group.key)}
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-1.5 rounded-lg px-3 py-2 text-left transition-colors duration-150 active:scale-[0.99] motion-reduce:transition-none",
|
||||
active ? "bg-background ring-[1.5px] ring-inset ring-foreground" : "bg-muted/50 hover:bg-muted",
|
||||
flashing.has(group.key) && "bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-start justify-between gap-3">
|
||||
<span className="flex min-w-0 items-start gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"mt-[0.4rem] size-1.5 shrink-0 rounded-full",
|
||||
group.issue ? "bg-[#e5484d]" : "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
<span className="line-clamp-2 text-[13px] leading-snug text-foreground">{group.label}</span>
|
||||
</span>
|
||||
<span className="text-[20px] leading-none font-semibold tabular-nums text-foreground">
|
||||
{group.count}
|
||||
</span>
|
||||
</span>
|
||||
<span aria-hidden="true" className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<span
|
||||
className={cn(
|
||||
"block h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none",
|
||||
group.issue ? "bg-[#e5484d]/70" : "bg-muted-foreground/40",
|
||||
)}
|
||||
style={{ width: `${share(group.count, total) * 100}%` }}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{scope && <p className="text-[11px] text-muted-foreground">{scope}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState } 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";
|
||||
|
|
@ -7,9 +9,9 @@ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "
|
|||
import type { Conclusion, Phase, Playback } from "../../model/live";
|
||||
import type { Job, Review } from "../../model/types";
|
||||
import { ConclusionsPanel } from "./ConclusionsPanel";
|
||||
import { ModelName } from "./LiveStrip";
|
||||
import { ReadingPanel } from "./ReadingPanel";
|
||||
import { ReviewQueue } from "./ReviewQueue";
|
||||
import { ModelName } from "./LiveStrip";
|
||||
|
||||
const STATUS: Record<Job["status"], { label: string; tone: StatusTone }> = {
|
||||
queued: { label: "Queued", tone: "neutral" },
|
||||
|
|
@ -18,21 +20,23 @@ const STATUS: Record<Job["status"], { label: string; tone: StatusTone }> = {
|
|||
failed: { label: "Failed", tone: "error" },
|
||||
cancelled: { label: "Cancelled", tone: "neutral" },
|
||||
};
|
||||
const SECTION_HEAD =
|
||||
"flex h-8 items-center justify-between border-y border-border bg-muted/40 px-4 text-[10px] tracking-[0.08em] text-muted-foreground uppercase";
|
||||
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,
|
||||
onClose,
|
||||
name,
|
||||
model,
|
||||
job,
|
||||
status,
|
||||
reviewed,
|
||||
playback,
|
||||
live,
|
||||
focused,
|
||||
following,
|
||||
phase,
|
||||
groups,
|
||||
decided,
|
||||
scope,
|
||||
onPick,
|
||||
onFollow,
|
||||
|
|
@ -41,27 +45,30 @@ export function LiveDrawer({
|
|||
onClose: () => void;
|
||||
name: string;
|
||||
model: string;
|
||||
job: Job;
|
||||
status: Job["status"];
|
||||
reviewed: number;
|
||||
playback: Pick<Playback, "played" | "current">;
|
||||
live: boolean;
|
||||
focused: Review | null;
|
||||
following: boolean;
|
||||
phase: Phase;
|
||||
groups: readonly Conclusion[];
|
||||
decided: number;
|
||||
scope: string;
|
||||
onPick: (review: Review) => void;
|
||||
onFollow: () => void;
|
||||
}) {
|
||||
const status = STATUS[job.status];
|
||||
const shownPhase = following ? phase : { span: -1, typed: Number.MAX_SAFE_INTEGER, verdict: true };
|
||||
const [group, setGroup] = useState<string | null>(null);
|
||||
const badge = STATUS[status];
|
||||
const shown = playback.played.length + (playback.current ? 1 : 0);
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(value) => !value && onClose()}>
|
||||
<SheetContent className="w-full gap-0 overflow-y-auto data-[side=right]:sm:max-w-[600px]">
|
||||
<SheetHeader className="gap-1.5 border-b">
|
||||
<SheetTitle className="pr-8 text-base">{name}</SheetTitle>
|
||||
<SheetDescription render={<div />} className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone={status.tone} label={status.label} />
|
||||
<ModelName model={model} />
|
||||
<SheetContent className="flex h-full w-full flex-col gap-0 data-[side=right]:sm:max-w-[min(1200px,94vw)]">
|
||||
<SheetHeader className="shrink-0 gap-1.5 border-b px-5">
|
||||
<SheetTitle className="pr-10 text-base">{name}</SheetTitle>
|
||||
<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
|
||||
|
|
@ -69,22 +76,35 @@ export function LiveDrawer({
|
|||
)}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="py-3">
|
||||
{focused ? (
|
||||
<ReadingPanel key={following ? "live" : focused.execution_id} review={focused} phase={shownPhase} />
|
||||
) : (
|
||||
<p className="px-4 py-6 text-[12px] text-muted-foreground">Waiting for the first trace review.</p>
|
||||
)}
|
||||
<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}
|
||||
/>
|
||||
) : (
|
||||
<p className="py-6 text-[12px] text-muted-foreground">Waiting for the first trace review.</p>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
<div className={SECTION_HEAD}>
|
||||
<span>Conclusions</span>
|
||||
</div>
|
||||
<ConclusionsPanel groups={groups} scope={scope} />
|
||||
<div className={SECTION_HEAD}>
|
||||
<span>Traces</span>
|
||||
<span className="font-mono tracking-normal normal-case">{job.reviewed}</span>
|
||||
</div>
|
||||
<ReviewQueue playback={playback} live={live} focused={focused} onPick={onPick} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,94 +1,178 @@
|
|||
import { Wrench } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { outcome, verdictLine, type Phase } from "../../model/live";
|
||||
import { spanPreviewLines } from "../../model/spanPreview";
|
||||
import { outcome, type Outcome, type Phase } from "../../model/live";
|
||||
import { timeline, type TimelineItem } from "../../model/spanPreview";
|
||||
import type { Review } from "../../model/types";
|
||||
import { ModelName } from "./LiveStrip";
|
||||
|
||||
const VERDICT_TONE = {
|
||||
issue: "text-[#e5484d]",
|
||||
clear: "text-foreground",
|
||||
unknown: "text-muted-foreground",
|
||||
} as const;
|
||||
const VERDICT_DOT = {
|
||||
issue: "bg-[#e5484d]",
|
||||
clear: "bg-muted-foreground/40",
|
||||
unknown: "bg-muted-foreground/40",
|
||||
} as const;
|
||||
const RED = "text-[#e5484d]";
|
||||
const CHIP: Record<Outcome, { label: string; className: string }> = {
|
||||
issue: { label: "Issue", className: "bg-[#e5484d]/10 text-[#e5484d] ring-1 ring-inset ring-[#e5484d]/30" },
|
||||
clear: { label: "Looks fine", className: "bg-muted text-foreground" },
|
||||
unknown: { label: "Not enough evidence", className: "bg-muted/60 text-muted-foreground" },
|
||||
};
|
||||
const PANE_LABEL = "text-[11px] font-medium text-muted-foreground";
|
||||
|
||||
function Spans({ review, phase }: { review: Review; phase: Phase }) {
|
||||
function VerdictChip({ result, decided }: { result: Outcome; decided: boolean }) {
|
||||
if (!decided) {
|
||||
return <span className="rounded-full bg-muted/60 px-2 py-0.5 text-[11px] text-muted-foreground">Reading…</span>;
|
||||
}
|
||||
const chip = CHIP[result];
|
||||
return (
|
||||
<ol aria-label="Trace" className="divide-y divide-border/60 border-y border-border/60">
|
||||
{review.spans.map((span, index) => {
|
||||
<span
|
||||
data-testid="verdict-chip"
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[11px] font-medium motion-safe:animate-in motion-safe:zoom-in-95 motion-safe:fade-in",
|
||||
chip.className,
|
||||
)}
|
||||
>
|
||||
{chip.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Bubble({ who, text, tone }: { who: string; text: string; tone: "ask" | "reply" }) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", tone === "reply" && "items-end")}>
|
||||
<span className="text-[11px] text-muted-foreground">{who}</span>
|
||||
<p
|
||||
className={cn(
|
||||
"max-w-[92%] rounded-2xl px-3 py-2 text-[13px] leading-relaxed break-words whitespace-pre-wrap text-foreground",
|
||||
tone === "ask" ? "rounded-tl-sm bg-muted/70" : "rounded-tr-sm border bg-background",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolRow({ item }: { item: Extract<TimelineItem, { kind: "tool" }> }) {
|
||||
return (
|
||||
<div className="flex gap-2 text-[12px]">
|
||||
<Wrench aria-hidden="true" className={cn("mt-0.5 size-3.5 shrink-0 text-muted-foreground", item.error && RED)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex min-w-0 gap-1.5">
|
||||
<span className="shrink-0 font-medium text-foreground">{item.name}</span>
|
||||
<span className="truncate text-muted-foreground">{item.args}</span>
|
||||
</p>
|
||||
{item.result && (
|
||||
<p className={cn("line-clamp-2 break-words", item.error ? RED : "text-muted-foreground")}>
|
||||
<span aria-hidden="true">↳ </span>
|
||||
{item.result}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({ item }: { item: TimelineItem }) {
|
||||
switch (item.kind) {
|
||||
case "ask":
|
||||
return <Bubble who="User asked" text={item.text} tone="ask" />;
|
||||
case "reply":
|
||||
return <Bubble who="Agent replied" text={item.text} tone="reply" />;
|
||||
case "tool":
|
||||
return <ToolRow item={item} />;
|
||||
case "failure":
|
||||
return <p className={cn("text-[12px]", RED)}>{item.text}</p>;
|
||||
case "note":
|
||||
return (
|
||||
<p className="line-clamp-2 text-[12px] text-muted-foreground">
|
||||
<span className="mr-1.5 font-medium text-foreground">{item.label}</span>
|
||||
{item.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function WhatHappened({ review, items, phase }: { review: Review; items: readonly TimelineItem[]; phase: Phase }) {
|
||||
if (!items.length) return <p className="text-[12px] text-muted-foreground">No trace parts were shown to the model</p>;
|
||||
return (
|
||||
<ol aria-label="What happened" className="flex flex-col gap-1">
|
||||
{items.map((item, index) => {
|
||||
const reading = phase.span === index;
|
||||
const cited = phase.verdict && span.cited;
|
||||
const cited = phase.verdict && !!review.spans[item.span]?.cited;
|
||||
return (
|
||||
<li
|
||||
key={span.span_id}
|
||||
key={`${item.kind}-${item.span}-${index}`}
|
||||
data-state={reading ? "reading" : cited ? "cited" : "idle"}
|
||||
className={cn(
|
||||
"px-4 py-2 text-[12px] transition-colors duration-150 motion-reduce:transition-none",
|
||||
reading && "bg-trace-row-hover",
|
||||
cited && "shadow-[inset_2px_0_0_#e5484d]",
|
||||
"rounded-lg px-2.5 py-2 transition-colors duration-150 motion-reduce:transition-none",
|
||||
reading && "bg-trace-row-hover ring-1 ring-inset ring-foreground/15",
|
||||
cited && "ring-[1.5px] ring-inset ring-[#e5484d]",
|
||||
)}
|
||||
>
|
||||
<p className="mb-0.5 flex gap-2 font-mono text-[11px] text-muted-foreground">
|
||||
<span>{span.kind}</span>
|
||||
<span className="truncate">{span.name}</span>
|
||||
</p>
|
||||
{spanPreviewLines(span.preview).map((line, n) => (
|
||||
<p
|
||||
key={n}
|
||||
className={cn("line-clamp-3 break-words", line.error ? "text-[#e5484d]" : "text-foreground")}
|
||||
>
|
||||
{line.label && (
|
||||
<span className="mr-1.5 font-mono text-[11px] text-muted-foreground">{line.label}</span>
|
||||
)}
|
||||
{line.text}
|
||||
</p>
|
||||
))}
|
||||
<Step item={item} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{!review.spans.length && (
|
||||
<li className="px-4 py-2 text-[12px] text-muted-foreground">No trace parts were shown to the model</li>
|
||||
)}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadingPanel({ review, phase }: { review: Review; phase: Phase }) {
|
||||
const result = outcome(review);
|
||||
const items = timeline(review.spans);
|
||||
const typing = phase.typed < review.reasoning.length;
|
||||
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-3">
|
||||
<div className="flex items-center justify-between gap-3 px-4 font-mono text-[11px] text-muted-foreground">
|
||||
<span className="truncate text-foreground">{review.agent || review.name}</span>
|
||||
<span className="truncate">trace {review.trace_id.slice(0, 8)}</span>
|
||||
</div>
|
||||
<Spans review={review} phase={phase} />
|
||||
<section aria-label="Reasoning" className="mx-4 rounded-md border bg-muted/40">
|
||||
<header className="flex h-8 items-center justify-between gap-3 border-b px-3">
|
||||
<span className="text-[10px] tracking-[0.08em] text-muted-foreground uppercase">Reasoning</span>
|
||||
<span className="truncate font-mono text-[11px] text-muted-foreground">{review.model}</span>
|
||||
</header>
|
||||
<p className="min-h-12 px-3 py-2 text-[12px] leading-relaxed whitespace-pre-wrap text-foreground">
|
||||
<div className="flex flex-col gap-5">
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-[14px] font-semibold text-foreground">{review.agent || review.name}</h3>
|
||||
<p className="truncate font-mono text-[11px] text-muted-foreground">trace {review.trace_id}</p>
|
||||
</div>
|
||||
<VerdictChip result={result} decided={phase.verdict} />
|
||||
</header>
|
||||
<section className="flex flex-col gap-2">
|
||||
<h4 className={PANE_LABEL}>What happened</h4>
|
||||
<WhatHappened review={review} items={items} phase={phase} />
|
||||
</section>
|
||||
<section aria-label="Reasoning" className="flex flex-col gap-2">
|
||||
<h4 className={cn(PANE_LABEL, "flex items-center gap-2")}>
|
||||
<span>Lens's reasoning</span>
|
||||
<ModelName model={review.model} />
|
||||
</h4>
|
||||
<p className="min-h-10 text-[13px] leading-relaxed whitespace-pre-wrap text-foreground">
|
||||
{review.reasoning ? review.reasoning.slice(0, phase.typed) : !typing && "No reasoning was returned."}
|
||||
{typing && <span aria-hidden="true" className="ml-px inline-block h-3 w-px translate-y-0.5 bg-foreground" />}
|
||||
{typing && <span aria-hidden="true" className="ml-px inline-block h-3.5 w-px translate-y-0.5 bg-foreground" />}
|
||||
</p>
|
||||
</section>
|
||||
<div
|
||||
<ul
|
||||
role="status"
|
||||
aria-label="Verdict"
|
||||
data-outcome={result}
|
||||
className={cn(
|
||||
"mx-4 flex items-center gap-2 text-[12px] font-medium transition-opacity duration-150 motion-reduce:transition-none",
|
||||
VERDICT_TONE[result],
|
||||
"flex flex-col gap-1.5 transition-opacity duration-150 motion-reduce:transition-none",
|
||||
phase.verdict ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true" className={cn("size-1.5 shrink-0 rounded-full", VERDICT_DOT[result])} />
|
||||
<span className="min-w-0">{phase.verdict ? verdictLine(review) : ""}</span>
|
||||
</div>
|
||||
{phase.verdict &&
|
||||
verdicts.map((verdict, index) => (
|
||||
<li
|
||||
key={`${verdict.check_id}-${index}`}
|
||||
className={cn(
|
||||
"flex gap-2 text-[13px] leading-snug",
|
||||
verdict.kind === "issue" ? RED : result === "unknown" ? "text-muted-foreground" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"mt-1.5 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { agoLabel } from "@/components/view_logs/TraceView/lensField";
|
||||
import { useNow } from "@/hooks/useNow";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { outcome, queueRows, reviewKey, type Playback } from "../../model/live";
|
||||
import { inGroup, outcome, queueRows, reviewKey, shortVerdict, type Playback } from "../../model/live";
|
||||
import type { Review } from "../../model/types";
|
||||
|
||||
const LIMIT = 60;
|
||||
|
|
@ -11,28 +13,33 @@ export function ReviewQueue({
|
|||
playback,
|
||||
live,
|
||||
focused,
|
||||
group,
|
||||
onPick,
|
||||
}: {
|
||||
playback: Pick<Playback, "played" | "current">;
|
||||
live: boolean;
|
||||
focused: Review | null;
|
||||
group: string | null;
|
||||
onPick: (review: Review) => void;
|
||||
}) {
|
||||
const now = useNow(5000);
|
||||
const rows = queueRows(playback, LIMIT).filter((review) => inGroup(review, group));
|
||||
if (!rows.length) return <p className="py-2 text-[12px] text-muted-foreground">No traces in this group yet.</p>;
|
||||
return (
|
||||
<ol aria-label="Reviewed traces" className="divide-y divide-border/60">
|
||||
{queueRows(playback, LIMIT).map((review) => {
|
||||
<ol aria-label="Reviewed traces" className="flex flex-col">
|
||||
{rows.map((review) => {
|
||||
const reading = live && review === playback.current;
|
||||
const result = outcome(review);
|
||||
const selected = review === focused;
|
||||
return (
|
||||
<li key={reviewKey(review)} className={review === playback.current ? "motion-safe:animate-in motion-safe:fade-in" : ""}>
|
||||
<li key={reviewKey(review)} className={reading ? "motion-safe:animate-in motion-safe:fade-in" : ""}>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={selected ? "true" : undefined}
|
||||
onClick={() => onPick(review)}
|
||||
className={cn(
|
||||
"grid h-8 w-full grid-cols-[1.25rem_minmax(0,1fr)_auto] items-center gap-2 px-4 text-left text-[12px] hover:bg-trace-row-hover",
|
||||
selected && "bg-trace-row-hover",
|
||||
"grid w-full grid-cols-[0.75rem_minmax(0,7.5rem)_minmax(0,1fr)_auto] items-center gap-2 rounded-md px-2 py-1.5 text-left text-[12px] hover:bg-muted",
|
||||
selected && "bg-background ring-[1.5px] ring-inset ring-foreground",
|
||||
)}
|
||||
>
|
||||
{reading ? (
|
||||
|
|
@ -44,7 +51,12 @@ export function ReviewQueue({
|
|||
/>
|
||||
)}
|
||||
<span className="truncate text-foreground">{review.agent || review.name}</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{review.trace_id.slice(0, 8)}</span>
|
||||
<span className={cn("truncate", result === "issue" ? "text-[#e5484d]" : "text-muted-foreground")}>
|
||||
{reading ? "reading…" : shortVerdict(review)}
|
||||
</span>
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{agoLabel(Date.parse(review.at), now)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
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;
|
||||
|
|
@ -46,6 +47,6 @@ export function useReviewPlayback(
|
|||
const phase =
|
||||
still || !current
|
||||
? { span: -1, typed: current?.reasoning.length ?? 0, verdict: true }
|
||||
: playbackPhase(now - state.startedAt, state.duration, current.spans.length, current.reasoning.length);
|
||||
: playbackPhase(now - state.startedAt, state.duration, timeline(current.spans).length, current.reasoning.length);
|
||||
return { ...state, phase, replay };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,11 +151,3 @@ export function timeline(spans: readonly Span[]): TimelineItem[] {
|
|||
];
|
||||
return items.length ? items : fallback(spans);
|
||||
}
|
||||
|
||||
export function spanPreviewLines(preview: string): { label: string; text: string; error: boolean }[] {
|
||||
const { input, output } = parts(preview);
|
||||
return [
|
||||
{ label: "in", text: compact(input), error: false },
|
||||
{ label: "out", text: compact(output), error: !!stepFailure(preview) },
|
||||
].filter((line) => line.text);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue