Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 198 additions & 52 deletions src/web/RunDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { useEffect, useState } from "react";
import { fetchRun, type ApiEvent, type ApiRun, type ApiRunDetail } from "./api.js";
import { useEffect, useMemo, useRef, useState } from "react";
import { fetchRun, type ApiEvent, type ApiRun, type ApiRunDetail, type ApiTurn } from "./api.js";
import { StatusBadge } from "./Dashboard.js";
import { useEventStream } from "./useEventStream.js";
import {
ASSISTANT_LINE_THRESHOLD,
collapsedSummary,
eventDomId,
findErrorEvents,
shouldCollapseTurn,
stepCursor,
turnLineCount,
turnLineThreshold,
} from "./runDetailUtils.js";

type LoadState =
| { tag: "loading" }
Expand Down Expand Up @@ -40,6 +50,7 @@ export function RunDetail({ runId }: { runId: string }) {

const { run, turns, events } = state.detail;
const errorEvent = events.find((e) => e.eventType === "error");
const isLive = run.status === "running";

return (
<div className="space-y-6">
Expand All @@ -62,58 +73,192 @@ export function RunDetail({ runId }: { runId: string }) {

<HistoryFacts run={run} />

<section>
<h3 className="text-sm font-semibold uppercase text-slate-400 mb-2">Turns</h3>
<ul className="space-y-3">
{turns.map((t) => (
<li key={t.id} className="rounded border border-slate-800 bg-slate-900/60 p-3">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>
<span className="font-mono text-cyan-400">#{t.turnNumber}</span>{" "}
<span className="uppercase">{t.role}</span>
</span>
{t.finalState && (
<span className="rounded bg-amber-500/10 px-2 py-0.5 text-amber-300">
→ {t.finalState}
</span>
)}
</div>
<pre className="mt-2 whitespace-pre-wrap text-sm text-slate-200">{t.content}</pre>
{t.toolCalls && <ToolCalls raw={t.toolCalls} />}
{t.renderedPrompt && (
<details
open
className="mt-3 rounded border border-slate-800/80 bg-slate-950/40 p-2 text-xs text-slate-400"
>
<summary className="cursor-pointer rounded font-medium text-slate-300 hover:text-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500">
<span aria-hidden="true" className="mr-1 text-slate-500">
</span>
Rendered prompt the model saw
</summary>
<pre className="mt-2 whitespace-pre-wrap text-slate-500 border-l border-slate-800 pl-3">
{t.renderedPrompt}
</pre>
</details>
)}
</li>
))}
</ul>
</section>
<TurnsSection turns={turns} events={events} isLive={isLive} />

<section>
<h3 className="text-sm font-semibold uppercase text-slate-400 mb-2">Events</h3>
<ul className="text-xs font-mono text-slate-400 space-y-0.5">
{events.map((e) => (
<li key={e.id}>
<EventsSection events={events} />
</div>
);
}

function TurnsSection({
turns,
events,
isLive,
}: {
turns: ApiTurn[];
events: ApiEvent[];
isLive: boolean;
}) {
const errorEvents = useMemo(() => findErrorEvents(events), [events]);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const programmaticUntil = useRef<number>(0);
const lastY = useRef<number>(typeof window !== "undefined" ? window.scrollY : 0);
const [autoFollow, setAutoFollow] = useState(false);
const [errorCursor, setErrorCursor] = useState(-1);

useEffect(() => {
const onScroll = () => {
const y = window.scrollY;
if (Date.now() < programmaticUntil.current) {
lastY.current = y;
return;
}
if (autoFollow && y < lastY.current - 4) {
setAutoFollow(false);
}
lastY.current = y;
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [autoFollow]);

useEffect(() => {
if (!autoFollow) return;
programmaticUntil.current = Date.now() + 600;
sentinelRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [autoFollow, turns.length]);

const jumpError = (dir: 1 | -1) => {
const next = stepCursor(errorEvents.length, errorCursor, dir);
if (next < 0) return;
setErrorCursor(next);
const id = eventDomId(errorEvents[next].id);
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "center" });
};

return (
<section>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold uppercase text-slate-400">Turns</h3>
<div className="flex flex-wrap items-center gap-2">
{errorEvents.length > 0 && (
<div className="flex items-center gap-1 rounded border border-slate-800 bg-slate-900/60 px-2 py-1 text-xs">
<span className="text-slate-400">
{errorEvents.length === 1 ? "1 error" : `${errorEvents.length} errors`}
</span>
<button
type="button"
onClick={() => jumpError(-1)}
className="rounded px-2 py-0.5 text-rose-300 hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400"
aria-label="Jump to previous error event"
>
↑ prev
</button>
<button
type="button"
onClick={() => jumpError(1)}
className="rounded px-2 py-0.5 text-rose-300 hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400"
aria-label="Jump to next error event"
>
↓ next
</button>
</div>
)}
{isLive && (
<button
type="button"
onClick={() => setAutoFollow((on) => !on)}
aria-pressed={autoFollow}
className={`rounded border px-2 py-1 text-xs focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500 ${
autoFollow
? "border-cyan-500/50 bg-cyan-500/10 text-cyan-200"
: "border-slate-800 bg-slate-900/60 text-slate-300 hover:text-slate-100"
}`}
>
{autoFollow ? "● Auto-follow on" : "○ Auto-follow"}
</button>
)}
</div>
</div>
<ul className="space-y-3">
{turns.map((t) => (
<TurnCard key={t.id} turn={t} />
))}
</ul>
<div ref={sentinelRef} aria-hidden="true" className="h-px" />
</section>
);
}

function TurnCard({ turn }: { turn: ApiTurn }) {
const threshold = turnLineThreshold(turn.role);
const collapsible = shouldCollapseTurn(turn.content, threshold);
const [expanded, setExpanded] = useState(false);
const showCollapsed = collapsible && !expanded;
const summary = showCollapsed ? collapsedSummary(turn.content, threshold) : null;

return (
<li className="rounded border border-slate-800 bg-slate-900/60 p-3">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>
<span className="font-mono text-cyan-400">#{turn.turnNumber}</span>{" "}
<span className="uppercase">{turn.role}</span>
</span>
{turn.finalState && (
<span className="rounded bg-amber-500/10 px-2 py-0.5 text-amber-300">
→ {turn.finalState}
</span>
)}
</div>
<pre className="mt-2 whitespace-pre-wrap text-sm text-slate-200">
{showCollapsed && summary ? summary.head : turn.content}
</pre>
{collapsible && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="mt-2 rounded border border-slate-800 bg-slate-950/40 px-2 py-1 text-xs text-slate-300 hover:text-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500"
aria-expanded={expanded}
>
{expanded
? `Collapse turn (${turnLineCount(turn.content)} lines)`
: `Show full turn (+${summary?.remaining ?? 0} more lines)`}
</button>
)}
{turn.toolCalls && <ToolCalls raw={turn.toolCalls} />}
{turn.renderedPrompt && (
<details
open
className="mt-3 rounded border border-slate-800/80 bg-slate-950/40 p-2 text-xs text-slate-400"
>
<summary className="cursor-pointer rounded font-medium text-slate-300 hover:text-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500">
<span aria-hidden="true" className="mr-1 text-slate-500">
</span>
Rendered prompt the model saw
</summary>
<pre className="mt-2 whitespace-pre-wrap text-slate-500 border-l border-slate-800 pl-3">
{turn.renderedPrompt}
</pre>
</details>
)}
</li>
);
}

function EventsSection({ events }: { events: ApiEvent[] }) {
return (
<section>
<h3 className="text-sm font-semibold uppercase text-slate-400 mb-2">Events</h3>
<ul className="text-xs font-mono text-slate-400 space-y-0.5">
{events.map((e) => {
const isError = /error/i.test(e.eventType);
return (
<li
key={e.id}
id={eventDomId(e.id)}
className={
isError ? "rounded bg-rose-500/10 px-1 -mx-1 ring-1 ring-rose-500/20" : undefined
}
>
<span className="text-slate-500">{new Date(e.ts).toLocaleTimeString()}</span>{" "}
<span className="text-cyan-400">{e.eventType}</span>
<span className={isError ? "text-rose-300" : "text-cyan-400"}>{e.eventType}</span>
{e.payload && <span className="text-slate-500"> {e.payload}</span>}
</li>
))}
</ul>
</section>
</div>
);
})}
</ul>
</section>
);
}

Expand Down Expand Up @@ -160,13 +305,14 @@ function safeParse(s: string): unknown {
function ToolCalls({ raw }: { raw: string }) {
const parsed = safeParse(raw);
const pretty = parsed === null ? raw : JSON.stringify(parsed, null, 2);
const longOutput = shouldCollapseTurn(pretty, ASSISTANT_LINE_THRESHOLD);
return (
<details
open
open={!longOutput}
className="mt-3 rounded border border-slate-800/80 bg-slate-950/40 p-2 text-xs text-slate-300"
>
<summary className="cursor-pointer rounded font-medium text-slate-300 hover:text-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500">
Tool calls
Tool calls{longOutput ? ` (${turnLineCount(pretty)} lines)` : ""}
</summary>
<pre className="mt-2 whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-slate-300">
{pretty}
Expand Down
Loading
Loading