mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
refactor(lens): restyle the live run as the native progress panel
This commit is contained in:
parent
25c417d8ba
commit
ac4d11abbc
5 changed files with 184 additions and 288 deletions
|
|
@ -10,26 +10,22 @@ export interface Arrival {
|
|||
text: string;
|
||||
}
|
||||
|
||||
const FLY_MS = 550;
|
||||
const FLY_MS = 400;
|
||||
|
||||
function fly(from: DOMRect, to: DOMRect, text: string) {
|
||||
const node = document.createElement("div");
|
||||
node.textContent = text;
|
||||
node.setAttribute("aria-hidden", "true");
|
||||
node.className =
|
||||
"pointer-events-none fixed z-floating truncate rounded-lg border border-[#e5484d]/30 bg-background px-3 py-2 text-xs font-semibold text-[#e5484d] shadow-lg";
|
||||
"pointer-events-none fixed z-floating truncate rounded-md border bg-background px-3 py-1.5 text-[12px] font-medium text-[#e5484d] shadow-sm";
|
||||
Object.assign(node.style, { left: `${from.left}px`, top: `${from.top}px`, width: `${from.width}px` });
|
||||
document.body.appendChild(node);
|
||||
const animation = node.animate(
|
||||
[
|
||||
{ transform: "translate(0, 0)", width: `${from.width}px`, opacity: 1 },
|
||||
{
|
||||
transform: `translate(${to.left - from.left}px, ${to.top - from.top}px)`,
|
||||
width: `${Math.max(200, to.width)}px`,
|
||||
opacity: 0.2,
|
||||
},
|
||||
{ transform: "translate(0, 0)", opacity: 1 },
|
||||
{ transform: `translate(${to.left - from.left}px, ${to.top - from.top}px)`, opacity: 0 },
|
||||
],
|
||||
{ duration: FLY_MS, easing: "cubic-bezier(.3,.7,.2,1)" },
|
||||
{ duration: FLY_MS, easing: "ease-out" },
|
||||
);
|
||||
animation.onfinish = () => node.remove();
|
||||
return () => node.remove();
|
||||
|
|
@ -57,41 +53,44 @@ export function ConclusionsPanel({
|
|||
arrival: Arrival | null;
|
||||
verdictRef: RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const list = useRef<HTMLOListElement>(null);
|
||||
const list = useRef<HTMLTableSectionElement>(null);
|
||||
useFlyIn(arrival, verdictRef, list);
|
||||
return (
|
||||
<ol ref={list} aria-label="Conclusions" className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-3 py-2.5">
|
||||
{groups.map((group) => (
|
||||
<li
|
||||
key={group.checkId}
|
||||
data-check={group.checkId}
|
||||
data-hit={arrival?.checkId === group.checkId || undefined}
|
||||
className={cn(
|
||||
"rounded-lg border bg-background px-3 py-2 transition-shadow duration-500 motion-safe:animate-in motion-safe:fade-in",
|
||||
"data-hit:border-[#e5484d]/30 data-hit:ring-3 data-hit:ring-[#e5484d]/10",
|
||||
)}
|
||||
>
|
||||
<p className="flex items-start gap-1.5 text-[12.5px] font-semibold">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("mt-1.5 size-1.5 shrink-0 rounded-full", group.issue ? "bg-[#e5484d]" : "bg-muted-foreground/50")}
|
||||
/>
|
||||
<span className="line-clamp-2">{group.label}</span>
|
||||
</p>
|
||||
<p className="mt-1 flex justify-between gap-3 font-mono text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{group.count} {group.count === 1 ? "trace" : "traces"}
|
||||
</span>
|
||||
<span>{Math.round((group.count / Math.max(1, reviewed)) * 100)}% of reviewed</span>
|
||||
</p>
|
||||
{group.latest !== group.label && (
|
||||
<p className="mt-1 line-clamp-1 text-[11px] text-muted-foreground" title={group.latest}>
|
||||
latest: {group.latest}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{!groups.length && <li className="py-4 text-xs text-muted-foreground">Nothing flagged yet.</li>}
|
||||
</ol>
|
||||
<table aria-label="Conclusions" className="w-full table-fixed border-collapse text-left">
|
||||
<tbody ref={list}>
|
||||
{groups.map((group) => (
|
||||
<tr
|
||||
key={group.checkId}
|
||||
data-check={group.checkId}
|
||||
className="border-b border-border/60 text-[12px]"
|
||||
title={group.latest}
|
||||
>
|
||||
<td className="w-6 pl-3 align-top">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"mt-3.5 block size-1.5 rounded-full",
|
||||
group.issue ? "bg-[#e5484d]" : "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<span className="line-clamp-2 text-foreground">{group.label}</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{Math.round((group.count / Math.max(1, reviewed)) * 100)}% of reviewed
|
||||
</span>
|
||||
</td>
|
||||
<td className="w-12 px-3 py-2 text-right align-top font-mono tabular-nums text-foreground">
|
||||
{group.count}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!groups.length && (
|
||||
<tr className="text-[12px] text-muted-foreground">
|
||||
<td className="px-3 py-6">Nothing flagged yet.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
|
||||
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
|
||||
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNow } from "@/hooks/useNow";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { sampleBatch } from "../../__fixtures__/reviews";
|
||||
import { durationText, money } from "../../model/format";
|
||||
import { modelsUsed } from "../../model/inbox";
|
||||
import {
|
||||
|
|
@ -15,27 +15,28 @@ import {
|
|||
isCompact,
|
||||
liveStats,
|
||||
outcome,
|
||||
providerAccent,
|
||||
providerOf,
|
||||
rateLabel,
|
||||
reviewKey,
|
||||
shownCount,
|
||||
tokenLabel,
|
||||
verdictLine,
|
||||
} from "../../model/live";
|
||||
import { analysisProgress } from "../../model/progress";
|
||||
import type { Job, Review } from "../../model/types";
|
||||
import { ConclusionsPanel, type Arrival } from "./ConclusionsPanel";
|
||||
import { ProviderBadge } from "./ProviderBadge";
|
||||
import { ReadingPanel } from "./ReadingPanel";
|
||||
import { ReviewQueue } from "./ReviewQueue";
|
||||
import { useReviewPlayback } from "./useReviewPlayback";
|
||||
|
||||
const NEUTRAL_ACCENT = "var(--foreground)";
|
||||
const SAMPLE_POLL_MS = 2000;
|
||||
const SAMPLE_TOTAL = 60;
|
||||
const STATUS: Record<Job["status"], { label: string; tone: StatusTone }> = {
|
||||
queued: { label: "Queued", tone: "neutral" },
|
||||
running: { label: "Running", tone: "neutral" },
|
||||
completed: { label: "Done", tone: "neutral" },
|
||||
failed: { label: "Failed", tone: "error" },
|
||||
cancelled: { label: "Cancelled", tone: "neutral" },
|
||||
};
|
||||
const COLUMN_HEAD =
|
||||
"flex h-9 shrink-0 items-center justify-between gap-2 border-b px-3.5 text-[10px] tracking-[0.08em] text-muted-foreground uppercase";
|
||||
"sticky top-0 z-sticky flex h-8 shrink-0 items-center justify-between gap-2 border-b border-border bg-muted/40 px-3 text-[10px] tracking-[0.08em] text-muted-foreground uppercase backdrop-blur";
|
||||
const COLUMN = "flex min-h-0 flex-col overflow-y-auto";
|
||||
|
||||
function useArrival(review: Review | null, verdict: boolean, compact: boolean): Arrival | null {
|
||||
const issue = review && verdict && !compact ? review.verdicts.find((v) => v.kind === "issue") : undefined;
|
||||
|
|
@ -45,72 +46,51 @@ function useArrival(review: Review | null, verdict: boolean, compact: boolean):
|
|||
return useMemo(() => (key ? { key, checkId, text } : null), [key, checkId, text]);
|
||||
}
|
||||
|
||||
function Status({ job }: { job: Job }) {
|
||||
if (job.status === "queued") return <span className="text-xs text-muted-foreground">Queued</span>;
|
||||
if (job.status === "running")
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground">
|
||||
<span aria-hidden="true">●</span> Running
|
||||
</span>
|
||||
);
|
||||
if (job.status === "completed")
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2.5 py-1 text-xs font-medium">
|
||||
<span aria-hidden="true">✓</span> Done
|
||||
</span>
|
||||
);
|
||||
return <span className="text-xs text-muted-foreground capitalize">{job.status}</span>;
|
||||
}
|
||||
|
||||
function useSampleReviews(enabled: boolean): readonly Review[] {
|
||||
const now = useNow(SAMPLE_POLL_MS);
|
||||
const [startedAt] = useState(now);
|
||||
return useMemo(() => {
|
||||
if (!enabled) return [];
|
||||
const polls = Math.floor((now - startedAt) / SAMPLE_POLL_MS);
|
||||
return Array.from({ length: polls + 1 }, (_, poll) => sampleBatch(poll, poll < 3 ? 1 : 3))
|
||||
.flat()
|
||||
.slice(0, SAMPLE_TOTAL);
|
||||
}, [enabled, now, startedAt]);
|
||||
function ModelLabel({ model }: { model: string }) {
|
||||
if (!model) return null;
|
||||
const provider = providerOf(model);
|
||||
return (
|
||||
<span data-testid="live-run-model" className="inline-flex min-w-0 items-center gap-1.5 text-muted-foreground">
|
||||
{provider && <ProviderLogo provider={provider} className="size-3.5 shrink-0" />}
|
||||
<span className="truncate font-mono text-[11px]">{model}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveRun({ job, onCancel }: { job: Job; onCancel?: () => void }) {
|
||||
const live = job.status === "queued" || job.status === "running";
|
||||
const sample = live && job.reviews.length === 0;
|
||||
const sampled = useSampleReviews(sample);
|
||||
const reviews = sample ? sampled : job.reviews;
|
||||
const playback = useReviewPlayback(reviews, live);
|
||||
const { current, played, pending, phase, duration } = playback;
|
||||
const playback = useReviewPlayback(job.reviews, live);
|
||||
const { current, played, phase, duration } = playback;
|
||||
const now = useNow(1000);
|
||||
const verdictRef = useRef<HTMLDivElement>(null);
|
||||
const model = analysisModel([current?.model ?? "", ...reviews.map((r) => r.model), ...modelsUsed(job.steps)]);
|
||||
const accent = providerAccent(providerOf(model)) ?? NEUTRAL_ACCENT;
|
||||
const model = analysisModel([...job.reviews.map((r) => r.model), ...modelsUsed(job.steps), job.settings.model]);
|
||||
const decided = phase.verdict && current ? [...played, current] : played;
|
||||
const groups = conclusions(decided, job.settings.checks);
|
||||
const arrival = useArrival(current, phase.verdict, isCompact(duration));
|
||||
const stats = liveStats(job, now);
|
||||
const reviewed = sample ? decided.length : shownCount(job.reviewed, playback);
|
||||
const selected = sample ? SAMPLE_TOTAL : job.coverage.selected;
|
||||
const reviewed = shownCount(job.reviewed, playback);
|
||||
const selected = job.coverage.selected;
|
||||
const percent = live ? Math.min(100, Math.round((reviewed / Math.max(1, selected)) * 100)) : 100;
|
||||
const issues = decided.filter((r) => outcome(r) === "issue").length;
|
||||
const progress = analysisProgress(job);
|
||||
const status = STATUS[job.status];
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Live review"
|
||||
style={{ "--provider": accent } as CSSProperties}
|
||||
className="flex flex-col overflow-hidden rounded-xl border bg-background"
|
||||
>
|
||||
<header className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Status job={job} />
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{live ? progress.title : `Reviewed ${reviewed} ${reviewed === 1 ? "trace" : "traces"}`}
|
||||
{sample && " · preview data until the worker reports reviews"}
|
||||
<section aria-label="Analysis progress" className="overflow-hidden rounded-md border bg-card">
|
||||
<header className="flex min-h-10 flex-wrap items-center justify-between gap-x-4 gap-y-1 border-b px-3 py-1.5 text-[12px]">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<StatusBadge tone={status.tone} label={status.label} dataTestId="live-run-status" />
|
||||
<span role="status" className="truncate text-foreground">
|
||||
{live ? job.stage : "Complete"}
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{reviewed} of {selected} traces
|
||||
</span>
|
||||
<ModelLabel model={model} />
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ProviderBadge model={model} />
|
||||
<div className="flex items-center gap-4 font-mono text-[11px] text-muted-foreground tabular-nums">
|
||||
<span title="Elapsed">{durationText(stats.elapsedSeconds)}</span>
|
||||
<span title="Tokens">{tokenLabel(stats.tokens)}</span>
|
||||
<span title="Cost">{money(stats.cost)}</span>
|
||||
{live && onCancel && (
|
||||
<Button variant="ghost" size="xs" onClick={onCancel}>
|
||||
Cancel
|
||||
|
|
@ -125,47 +105,35 @@ export function LiveRun({ job, onCancel }: { job: Job; onCancel?: () => void })
|
|||
aria-valuemax={100}
|
||||
aria-valuenow={percent}
|
||||
aria-valuetext={`${reviewed} of ${selected} traces reviewed`}
|
||||
className="h-[3px] bg-muted"
|
||||
className="h-0.5 bg-foreground/10"
|
||||
>
|
||||
<div
|
||||
className="h-full bg-(--provider) transition-[width] duration-300 motion-reduce:transition-none"
|
||||
className="h-full bg-foreground transition-[width] duration-300 motion-reduce:transition-none"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid min-h-0 grid-cols-1 md:h-[440px] md:grid-cols-[minmax(0,15rem)_minmax(0,1fr)] xl:grid-cols-[17rem_minmax(0,1fr)_20rem]">
|
||||
<div className="flex min-h-0 flex-col border-b md:border-r md:border-b-0">
|
||||
<div className="grid grid-cols-1 md:h-[420px] md:grid-cols-[minmax(0,15rem)_minmax(0,1fr)] xl:grid-cols-[16rem_minmax(0,1fr)_20rem]">
|
||||
<div className={`${COLUMN} max-h-[420px] border-b md:border-r md:border-b-0`}>
|
||||
<div className={COLUMN_HEAD}>
|
||||
<span>Traces</span>
|
||||
<span className="font-mono tracking-normal normal-case">
|
||||
{reviewed} / {selected}
|
||||
</span>
|
||||
<span className="font-mono tracking-normal normal-case">{reviewed}</span>
|
||||
</div>
|
||||
<ReviewQueue played={played} current={current} pending={pending} live={live} />
|
||||
<ReviewQueue playback={playback} live={live} />
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col border-b xl:border-r xl:border-b-0">
|
||||
<div className={`${COLUMN} max-h-[420px] border-b xl:border-r xl:border-b-0`}>
|
||||
<div className={COLUMN_HEAD}>
|
||||
<span>{live ? "Reading trace" : "Last trace"}</span>
|
||||
<span className="font-mono tracking-normal normal-case">{rateLabel(stats.perSecond)}</span>
|
||||
</div>
|
||||
<ReadingPanel review={current} phase={phase} tokens={tokenLabel(stats.tokens)} verdictRef={verdictRef} />
|
||||
<ReadingPanel review={current} phase={phase} verdictRef={verdictRef} />
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col bg-muted/30 md:col-span-2 xl:col-span-1">
|
||||
<div className={`${COLUMN} max-h-[420px] md:col-span-2 xl:col-span-1`}>
|
||||
<div className={COLUMN_HEAD}>
|
||||
<span>Conclusions</span>
|
||||
<span className={cn("font-mono tracking-normal normal-case", issues > 0 && "text-[#e5484d]")}>
|
||||
{issues} {issues === 1 ? "problem" : "problems"}
|
||||
<span className="font-mono tracking-normal normal-case">
|
||||
{issues} {issues === 1 ? "issue" : "issues"}
|
||||
</span>
|
||||
</div>
|
||||
<ConclusionsPanel groups={groups} reviewed={decided.length} arrival={arrival} verdictRef={verdictRef} />
|
||||
<footer
|
||||
aria-label="Run stats"
|
||||
className="flex shrink-0 flex-wrap justify-between gap-x-4 gap-y-1 border-t bg-background px-3.5 py-2 font-mono text-[11px] text-muted-foreground"
|
||||
>
|
||||
<span>{rateLabel(stats.perSecond)}</span>
|
||||
<span>{tokenLabel(stats.tokens)}</span>
|
||||
<span>{money(stats.cost)}</span>
|
||||
<span>{durationText(stats.elapsedSeconds)}</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { modelName, providerOf } from "../../model/live";
|
||||
|
||||
const MARK_CROP: Readonly<Record<string, string>> = {
|
||||
cerebras: "absolute top-[-8px] left-[-2.4px] h-[30px] w-[40px] max-w-none",
|
||||
};
|
||||
|
||||
export function ProviderLogo({ model, className }: { model: string; className?: string }) {
|
||||
const provider = providerOf(model);
|
||||
const { logo, displayName } = getProviderLogoAndName(provider);
|
||||
if (!provider || !logo) return null;
|
||||
const crop = MARK_CROP[provider];
|
||||
return (
|
||||
<span className={cn("relative inline-block size-3.5 shrink-0 overflow-hidden", className)}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- provider logos are static svgs from /assets/logos */}
|
||||
<img src={logo} alt={displayName} className={crop ?? "size-full object-contain"} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderBadge({ model }: { model: string }) {
|
||||
if (!model) return null;
|
||||
const provider = providerOf(model);
|
||||
const { displayName } = getProviderLogoAndName(provider);
|
||||
return (
|
||||
<span
|
||||
data-testid="provider-badge"
|
||||
className="inline-flex min-w-0 items-center gap-1.5 rounded-full border border-(--provider)/30 bg-(--provider)/5 px-2.5 py-1 text-xs font-medium text-(--provider)"
|
||||
>
|
||||
<ProviderLogo model={model} />
|
||||
<span className="truncate">
|
||||
{provider ? `Analyzed by ${displayName} · ${modelName(model)}` : `Analyzed by ${model}`}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,112 +2,98 @@ import type { Ref } from "react";
|
|||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { modelName, outcome, verdictLine, type Phase } from "../../model/live";
|
||||
import { outcome, verdictLine, type Phase } from "../../model/live";
|
||||
import type { Review } from "../../model/types";
|
||||
import { ProviderLogo } from "./ProviderBadge";
|
||||
|
||||
const VERDICT_TONE = {
|
||||
issue: "border-[#e5484d]/30 bg-[#e5484d]/5 text-[#e5484d]",
|
||||
clear: "border-border bg-muted/60 text-foreground",
|
||||
unknown: "border-border bg-muted/60 text-muted-foreground",
|
||||
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 VERDICT_MARK = { issue: "●", clear: "✓", unknown: "?" } as const;
|
||||
|
||||
function Spans({ review, phase }: { review: Review; phase: Phase }) {
|
||||
return (
|
||||
<section aria-label="Trace" className="overflow-hidden rounded-xl border">
|
||||
<header className="flex justify-between gap-3 border-b bg-muted/40 px-3 py-2 font-mono text-[11px] text-muted-foreground">
|
||||
<span className="truncate">trace {review.trace_id.slice(0, 8)}</span>
|
||||
<span className="truncate">{review.agent || review.name}</span>
|
||||
</header>
|
||||
<ol>
|
||||
<table aria-label="Trace" className="w-full table-fixed border-collapse text-left">
|
||||
<tbody>
|
||||
{review.spans.map((span, index) => {
|
||||
const scanning = phase.span === index;
|
||||
const reading = phase.span === index;
|
||||
const cited = phase.verdict && span.cited;
|
||||
return (
|
||||
<li
|
||||
<tr
|
||||
key={span.span_id}
|
||||
data-state={scanning ? "reading" : cited ? "cited" : "idle"}
|
||||
data-state={reading ? "reading" : cited ? "cited" : "idle"}
|
||||
className={cn(
|
||||
"grid grid-cols-[4.5rem_minmax(0,1fr)] gap-2.5 border-b border-dashed border-border/70 px-3 py-1.5 text-[12.5px] transition-colors duration-200 last:border-b-0 motion-reduce:transition-none",
|
||||
scanning && "bg-(--provider)/8",
|
||||
cited && "bg-[#e5484d]/5",
|
||||
"h-9 border-b border-border/60 text-[12px] transition-colors duration-150 motion-reduce:transition-none",
|
||||
reading && "bg-trace-row-hover",
|
||||
)}
|
||||
>
|
||||
<span className="truncate pt-px font-mono text-[10.5px] text-muted-foreground" title={span.name}>
|
||||
<td className="w-[88px] truncate px-3 font-mono text-[11px] text-muted-foreground" title={span.name}>
|
||||
{span.kind || span.name}
|
||||
</span>
|
||||
<span className={cn("line-clamp-2 break-words", cited && "text-[#e5484d]")}>
|
||||
</td>
|
||||
<td className={cn("truncate px-3", cited ? "text-[#e5484d]" : "text-foreground")} title={span.preview}>
|
||||
{span.preview || span.name}
|
||||
</span>
|
||||
</li>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!review.spans.length && <li className="px-3 py-2 text-xs text-muted-foreground">No trace parts shown</li>}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Reasoning({ review, phase, tokens }: { review: Review; phase: Phase; tokens: string }) {
|
||||
const typing = phase.typed < review.reasoning.length;
|
||||
return (
|
||||
<section aria-label="Reasoning" className="rounded-xl border border-(--provider)/25 bg-(--provider)/3">
|
||||
<header className="flex items-center justify-between gap-3 border-b border-(--provider)/20 px-3 py-2">
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 text-xs font-semibold text-(--provider)">
|
||||
<ProviderLogo model={review.model} />
|
||||
<span className="truncate">{modelName(review.model) || "analysis model"} · reasoning</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">{tokens}</span>
|
||||
</header>
|
||||
<p className="min-h-24 px-3 py-2.5 text-[12.5px] leading-relaxed whitespace-pre-wrap text-foreground/80">
|
||||
{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.5 w-1.5 translate-y-0.5 bg-(--provider) motion-safe:animate-pulse"
|
||||
/>
|
||||
{!review.spans.length && (
|
||||
<tr className="h-9 text-[12px] text-muted-foreground">
|
||||
<td className="px-3">No trace parts were shown to the model</td>
|
||||
</tr>
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadingPanel({
|
||||
review,
|
||||
phase,
|
||||
tokens,
|
||||
verdictRef,
|
||||
}: {
|
||||
review: Review | null;
|
||||
phase: Phase;
|
||||
tokens: string;
|
||||
verdictRef: Ref<HTMLDivElement>;
|
||||
}) {
|
||||
if (!review) {
|
||||
return (
|
||||
<p className="px-4 py-6 text-sm text-muted-foreground">
|
||||
Traces appear here as the worker reviews them.
|
||||
</p>
|
||||
);
|
||||
return <p className="px-3 py-6 text-[12px] text-muted-foreground">Waiting for the first trace review.</p>;
|
||||
}
|
||||
const result = outcome(review);
|
||||
const typing = phase.typed < review.reasoning.length;
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col gap-3 overflow-hidden px-4 py-3.5">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex h-8 items-center justify-between gap-3 border-b border-border px-3 font-mono text-[11px] text-muted-foreground">
|
||||
<span className="truncate">trace {review.trace_id.slice(0, 8)}</span>
|
||||
<span className="truncate">{review.agent || review.name}</span>
|
||||
</div>
|
||||
<Spans review={review} phase={phase} />
|
||||
<Reasoning review={review} phase={phase} tokens={tokens} />
|
||||
<section aria-label="Reasoning" className="m-3 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-16 px-3 py-2 text-[12px] 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" />}
|
||||
</p>
|
||||
</section>
|
||||
<div
|
||||
ref={verdictRef}
|
||||
role="status"
|
||||
aria-label="Verdict"
|
||||
data-outcome={result}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-xl border px-3 py-2.5 text-sm font-semibold transition-all duration-200 motion-reduce:transition-none",
|
||||
"mx-3 mb-3 flex items-center gap-2 text-[12px] font-medium transition-opacity duration-150 motion-reduce:transition-none",
|
||||
VERDICT_TONE[result],
|
||||
phase.verdict ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0",
|
||||
phase.verdict ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true">{VERDICT_MARK[result]}</span>
|
||||
<span aria-hidden="true" className={cn("size-1.5 shrink-0 rounded-full", VERDICT_DOT[result])} />
|
||||
<span className="min-w-0 truncate">{phase.verdict ? verdictLine(review) : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,69 +1,50 @@
|
|||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { outcome, reviewKey } from "../../model/live";
|
||||
import type { Review } from "../../model/types";
|
||||
import { outcome, queueRows, reviewKey, type Playback } from "../../model/live";
|
||||
|
||||
const BEFORE = 6;
|
||||
const AFTER = 12;
|
||||
const MARK = { issue: "●", clear: "✓", unknown: "?" } as const;
|
||||
const LIMIT = 80;
|
||||
|
||||
type Row = { review: Review; state: "done" | "active" | "next" };
|
||||
|
||||
function rows(played: readonly Review[], current: Review | null, pending: readonly Review[], live: boolean): Row[] {
|
||||
const tail = played.slice(live ? -BEFORE : -(BEFORE + AFTER));
|
||||
return [
|
||||
...tail.map((review): Row => ({ review, state: "done" })),
|
||||
...(current ? [{ review: current, state: live ? "active" : "done" } as Row] : []),
|
||||
...pending.slice(0, AFTER).map((review): Row => ({ review, state: "next" })),
|
||||
];
|
||||
}
|
||||
|
||||
export function ReviewQueue({
|
||||
played,
|
||||
current,
|
||||
pending,
|
||||
live,
|
||||
}: {
|
||||
played: readonly Review[];
|
||||
current: Review | null;
|
||||
pending: readonly Review[];
|
||||
live: boolean;
|
||||
}) {
|
||||
export function ReviewQueue({ playback, live }: { playback: Pick<Playback, "played" | "current">; live: boolean }) {
|
||||
return (
|
||||
<ol aria-label="Reviewed traces" className="min-h-0 flex-1 overflow-hidden">
|
||||
{rows(played, current, pending, live).map(({ review, state }) => {
|
||||
const result = outcome(review);
|
||||
return (
|
||||
<li
|
||||
key={reviewKey(review)}
|
||||
data-state={state}
|
||||
aria-current={state === "active" ? "step" : undefined}
|
||||
className={cn(
|
||||
"grid grid-cols-[0.875rem_minmax(0,1fr)_auto] items-center gap-2 border-b border-border/60 px-3.5 py-1.5 text-xs",
|
||||
state === "active" && "bg-(--provider)/5 shadow-[inset_2px_0_0_var(--provider)]",
|
||||
state === "next" && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{state === "active" && (
|
||||
<span
|
||||
aria-label="Reviewing"
|
||||
className="size-2.5 rounded-full border-[1.5px] border-(--provider)/25 border-t-(--provider) motion-safe:animate-spin"
|
||||
/>
|
||||
)}
|
||||
{state === "done" && (
|
||||
<span
|
||||
aria-label={result}
|
||||
className={cn("text-[11px]", result === "issue" ? "text-[#e5484d]" : "text-muted-foreground")}
|
||||
>
|
||||
{MARK[result]}
|
||||
</span>
|
||||
)}
|
||||
{state === "next" && <span className="text-[11px] text-muted-foreground">·</span>}
|
||||
<span className="truncate">{review.agent || review.name}</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{review.trace_id.slice(0, 8)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<table aria-label="Reviewed traces" className="w-full table-fixed border-collapse text-left">
|
||||
<tbody>
|
||||
{queueRows(playback, LIMIT).map((review) => {
|
||||
const active = live && review === playback.current;
|
||||
const result = outcome(review);
|
||||
return (
|
||||
<tr
|
||||
key={reviewKey(review)}
|
||||
data-state={active ? "active" : result}
|
||||
aria-current={active ? "step" : undefined}
|
||||
className={cn(
|
||||
"h-9 border-b border-border/60 text-[12px]",
|
||||
active && "bg-trace-row-hover",
|
||||
review === playback.current && "motion-safe:animate-in motion-safe:fade-in",
|
||||
)}
|
||||
>
|
||||
<td className="w-7 pl-3">
|
||||
{active ? (
|
||||
<Loader2 aria-label="Reviewing" className="size-3 text-muted-foreground motion-safe:animate-spin" />
|
||||
) : (
|
||||
<span
|
||||
aria-label={result}
|
||||
className={cn(
|
||||
"block size-1.5 rounded-full",
|
||||
result === "issue" ? "bg-[#e5484d]" : "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="truncate px-2 text-foreground">{review.agent || review.name}</td>
|
||||
<td className="w-[84px] truncate px-3 text-right font-mono text-[11px] text-muted-foreground">
|
||||
{review.trace_id.slice(0, 8)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue