Agent Manager
One button on a session row, and archive as the way out
8103fde
Raw
History Blame Contribute Delete
31 kB
// web/src/components/TracePane.tsx
//
// Read-only view of one agent session. Modelled on FilesPane (passive, plain
// fetch through api.ts, no state library) and on the Hub's trace viewer
// (moon-landing server/views/components/TraceViewer/) for the *rendering*
// model: role rows, a model chip, `491↓ 520↑ (1,408 cached)` token counts,
// collapsed thinking with a one-line preview, and consecutive tool calls
// folded into one `3 tool calls (Bash, Read)` summary.
//
// Where we deliberately differ from the Hub viewer: it renders an ever-growing
// window of the whole array (INITIAL 50 + 50 per sentinel hit) and holds every
// message in the DOM, which is exactly why it dies on a 2.13 MB row. Here the
// list is windowed by measured height and the data is read from the END of the
// transcript one byte-window at a time, so a 19 MB session costs one 95 KB
// request and ~30 DOM rows to open.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import type { Session } from '../types';
import * as api from '../api';
import type { TraceBlock, TraceTurn } from '../api';
import { useTraceWindows, type TraceHeadInfo, type TraceSource } from '../lib/traceWindows';
import { renderMarkdown } from '../lib/markdown';
import Logo from './Logo';
import { CloseGlyph, ShareGlyph, HandoverGlyph } from './icons';
const ROW_EST = 44; // unmeasured row height, collapsed
const OVERSCAN_PX = 600;
const NEAR_TOP_PX = 400; // start fetching older turns before the reader arrives
const STICK_PX = 24; // "at the bottom" tolerance for following a live trace
const fmtTs = (ms?: number) => (ms ? new Date(ms).toLocaleTimeString() : '');
const fmtNum = (n: number) => n.toLocaleString();
// Same convention as the Hub viewer's formatTokenUsage.
function fmtUsage(u?: { in: number; out: number; cacheRead?: number }) {
if (!u) return '';
const parts = [`${fmtNum(u.in)}↓`, `${fmtNum(u.out)}↑`];
if (u.cacheRead) parts.push(`(${fmtNum(u.cacheRead)} cached)`);
return parts.join(' ');
}
const oneLine = (s: string, n = 90) => {
const t = s.replace(/\s+/g, ' ').trim();
return t.length > n ? `${t.slice(0, n)}…` : t;
};
// Harness context blobs announce themselves: <app-context>, <recommended_plugins>,
// <environment_context>. Use that as the fold label so a collapsed row still says
// what it is.
const sysLabel = (text: string) => {
const tag = text.trimStart().match(/^<([a-zA-Z][\w -]{1,40})>/);
return tag ? tag[1].trim() : 'harness context';
};
const moreLabel = (more?: number) => (more ? ` +${more > 1024 ? `${Math.round(more / 1024)} KB` : `${more} chars`} not retained` : '');
// ---------- blocks ----------
function Collapsible({ label, preview, children, tone }: {
label: string; preview?: string; tone?: string; children: ReactNode;
}) {
const [open, setOpen] = useState(false); // spec §8: everything collapsed by default
return (
<div className={`tv-fold${open ? ' open' : ''}${tone ? ` ${tone}` : ''}`}>
<button className="tv-fold-head" onClick={() => setOpen((o) => !o)}>
<span className="tv-caret">{open ? '▾' : '▸'}</span>
<span className="tv-fold-label">{label}</span>
{!open && preview && <span className="tv-fold-preview">{preview}</span>}
</button>
{open && <div className="tv-fold-body">{children}</div>}
</div>
);
}
function TextBlock({ text, more, markdown }: { text: string; more?: number; markdown: boolean }) {
const html = useMemo(() => (markdown ? renderMarkdown(text) : ''), [markdown, text]);
if (!markdown) return <pre className="tv-pre">{text}{more ? `\n…${moreLabel(more)}` : ''}</pre>;
// Rendered markdown must still admit what it doesn't have — a silently
// truncated answer reads as a complete one.
return (
<>
<div className="tv-md" dangerouslySetInnerHTML={{ __html: html }} />
{!!more && <div className="tv-msg">…{moreLabel(more)}</div>}
</>
);
}
// A run of tool calls (each optionally followed by its result) collapses into
// one row — the stitcher on the server guarantees a result sits next to its own
// call even when parallel tools finish out of order.
function ToolGroup({ blocks }: { blocks: TraceBlock[] }) {
const names: string[] = [];
let calls = 0;
for (const b of blocks) {
if (b.type === 'tool_use') { calls++; if (!names.includes(b.name)) names.push(b.name); }
}
const failed = blocks.some((b) => b.type === 'tool_result' && b.failed);
const label = `${calls} tool call${calls === 1 ? '' : 's'} (${names.slice(0, 3).join(', ')}${names.length > 3 ? '…' : ''})`;
return (
<Collapsible label={label} tone={failed ? 'failed' : undefined} preview={oneLine(firstArgs(blocks))}>
{blocks.map((b, i) =>
b.type === 'tool_use' ? (
<div key={i} className="tv-tool">
<div className="tv-tool-name">{b.name}</div>
<pre className="tv-pre small">{b.text}{moreLabel(b.more)}</pre>
</div>
) : b.type === 'tool_result' ? (
<div key={i} className={`tv-tool-res${b.failed ? ' failed' : ''}`}>
<pre className="tv-pre small">{b.text}{moreLabel(b.more)}</pre>
</div>
) : null,
)}
</Collapsible>
);
}
const firstArgs = (blocks: TraceBlock[]) => {
const first = blocks.find((b) => b.type === 'tool_use') as Extract<TraceBlock, { type: 'tool_use' }> | undefined;
return first ? first.text : '';
};
function Blocks({ turn }: { turn: TraceTurn }) {
const out: ReactNode[] = [];
const bs = turn.blocks;
for (let i = 0; i < bs.length; i++) {
const b = bs[i];
if (b.type === 'tool_use') {
// swallow the whole consecutive run of calls + results
let j = i;
const run: TraceBlock[] = [];
while (j < bs.length && (bs[j].type === 'tool_use' || bs[j].type === 'tool_result')) run.push(bs[j++]);
out.push(<ToolGroup key={i} blocks={run} />);
i = j - 1;
continue;
}
if (b.type === 'tool_result') {
out.push(<Collapsible key={i} label="tool result" tone={b.failed ? 'failed' : undefined} preview={oneLine(b.text)}>
<pre className="tv-pre small">{b.text}{moreLabel(b.more)}</pre>
</Collapsible>);
} else if (b.type === 'thinking') {
out.push(<Collapsible key={i} label="Thinking" tone="think" preview={oneLine(b.text)}>
<TextBlock text={b.text} more={b.more} markdown={false} />
</Collapsible>);
} else if (b.type === 'compaction') {
out.push(<Collapsible key={i} label="Context compacted" tone="think" preview={oneLine(b.text)}>
<TextBlock text={b.text} more={undefined} markdown={false} />
</Collapsible>);
} else if (b.type === 'shell') {
out.push(<Collapsible key={i} label={`$ ${oneLine(b.command, 60)}`} preview={oneLine(b.stdout || b.stderr || '')}>
<pre className="tv-pre small">{b.stdout}{b.stderr}</pre>
</Collapsible>);
} else if (b.type === 'image') {
out.push(<img key={i} className="tv-img" src={b.src} alt="" />);
} else if (b.type === 'text') {
// System rows are the harness talking to the model — instructions, skills,
// environment. One of them here is 27 KB, so expanded they bury the
// conversation before it starts. Collapsed, and never as markdown.
if (turn.role === 'system') {
out.push(
<Collapsible key={i} label={sysLabel(b.text)} preview={oneLine(b.text)}>
<TextBlock text={b.text} more={b.more} markdown={false} />
</Collapsible>,
);
} else {
out.push(<TextBlock key={i} text={b.text} more={b.more} markdown />);
}
}
}
return <>{out}</>;
}
function Row({ turn, index }: { turn: TraceTurn; index: number }) {
const muted = turn.role === 'system' || turn.kind === 'update';
return (
<div className={`tv-row ${turn.role}${turn.kind ? ` ${turn.kind}` : ''}${muted ? ' muted' : ''}`} data-i={index}>
<div className="tv-meta">
<span className={`tv-badge ${turn.role}`}>{turn.role}</span>
{turn.model && <span className="tv-chip">{turn.model}</span>}
{turn.usage && <span className="tv-tokens">{fmtUsage(turn.usage)}</span>}
<span className="tv-spacer" />
<span className="tv-ts">{fmtTs(turn.ts)}</span>
</div>
<div className="tv-body"><Blocks turn={turn} /></div>
</div>
);
}
// ---------- the pane ----------
// The viewer itself: windows, measurement, search, prompt jumps. It takes a
// source rather than a session, so the same reader serves the Trace pane and a
// transcript opened in the Files pane. Its chrome lives in whichever pane hosts
// it — `onHead` hands up what the toolbar needs to say, `onNav` hands up the
// prompt jump for the host's buttons.
//
// It opens on the END of the conversation. A transcript here reaches tens of MB
// and thousands of turns, and the exchange a reader wants first is always the
// last one — so the first request is a single window of the tail, and older
// turns arrive one window at a time as you scroll back into them. The only read
// that touches the whole trace is the summary, which buys the header an honest
// turn count and is asked for after the first paint.
// The source and the head shape now live with the paging itself, so reader mode
// can use the same ones — see lib/traceWindows.ts.
export type { TraceSource, TraceHeadInfo } from '../lib/traceWindows';
export function TraceView({ src, srcKey, zoom = 100, query = '', live, onHead, onNav }: {
src: TraceSource;
/** Changing this resets the loaded turns — a different session or file. */
srcKey: string;
zoom?: number;
query?: string;
/** The agent behind this trace is running. `false` means it is not, and a
* trace that has also been quiet is then not polled at all. */
live?: boolean;
onHead?: (head: TraceHeadInfo | null) => void;
onNav?: (go: (dir: -1 | 1) => void) => void;
}) {
const scroller = useRef<HTMLDivElement | null>(null);
const heights = useRef<number[]>([]);
// Bumped whenever a row is measured: the values live in a ref (so the
// observer can write them without re-rendering), and this counter is what
// invalidates the prefix sums.
const [heightsVersion, setHeightsVersion] = useState(0);
const [range, setRange] = useState({ start: 0, end: 40 });
// Follow the newest turn while the agent writes — but only while the reader is
// already down there. Scrolling up to read something is a decision.
const stick = useRef(true);
// What must not move across a re-layout: the row at the top of the viewport,
// and how far into it we are. Prepending older turns changes every offset in
// the list, and a row that measures itself after it renders changes the ones
// above the viewport — without an anchor, the paragraph under the reader's
// eyes jumps away mid-sentence.
//
// `top` is that row's real position in the scroller, and it is what the
// restore uses when the row is still rendered: the estimated-height model is
// only ever approximately right, and on rows far taller than ROW_EST (a codex
// rollout's tool groups) restoring from it left the text a few hundred pixels
// off. `delta` is the model-based fallback for a row that has scrolled out of
// the rendered window and has no geometry to read.
const anchor = useRef<{ i: number; delta: number; top: number | null } | null>(null);
// React keys for the rows. Indices move every time older turns are prepended,
// and a key that moves unmounts and remounts the row — which throws away every
// fold the reader had opened (`Collapsible` holds `open` in state) and every
// memoized markdown body, at exactly the moment they were reading a tool call
// and scrolled up for the context that produced it. Keys are therefore
// `keyBase + index`, with the base decremented by each prepend, so a given
// turn keeps the same key for the life of the pane.
const keyBase = useRef(0);
// False until the turns we hold have been MEASURED and the scroller put where
// it belongs. Until then `scrollTop` says nothing about where the reader is:
// before the first layout it is 0 because nothing has been placed, and before
// the first measurement the whole list is 44 px per row, so a window of 19
// dense turns looks 800 px tall when it is really 3,600 — near enough to the
// top to fetch a window of older turns nobody asked for.
const positioned = useRef(false);
const measured = useRef(false);
const wanted = useRef<number | null>(null);
const tries = useRef(0);
// Rendered rows, by index — the ResizeObserver measures through these, and the
// anchor reads its geometry from them.
const rowRefs = useRef(new Map<number, HTMLElement>());
/** First row whose bottom edge is past `top`. */
const rowAt = (acc: Float64Array, top: number) => {
let lo = 0;
let hi = Math.max(0, acc.length - 1);
while (lo < hi) { const mid = (lo + hi) >> 1; if (acc[mid + 1] <= top) lo = mid + 1; else hi = mid; }
return lo;
};
/**
* Remember the row at the top of the viewport so the layout effect can put it
* back there. `shift` is how far this row is about to move down the list
* (the number of turns being prepended). Returns false when there is nothing
* to anchor to yet.
*/
const captureAnchor = (shift: number) => {
const el = scroller.current;
if (!el || !turns.current.length) return false;
const i = rowAt(offsetsRef.current, el.scrollTop);
const node = rowRefs.current.get(i);
anchor.current = {
i: i + shift,
delta: el.scrollTop - (offsetsRef.current[i] || 0),
top: node ? node.getBoundingClientRect().top - el.getBoundingClientRect().top : null,
};
return true;
};
// Everything that names a row BY INDEX moves with a prepend: the keys, a
// pending jump, and the anchor (which captureAnchor is given the shift for).
const onPrepend = useCallback((count: number) => {
// Everything that names a row BY INDEX moves with the prepend: the heights,
// the keys, the rendered window, and a pending jump. (The anchor is given
// the shift by captureAnchor rather than moved afterwards — shifting it
// twice throws the view a whole window forward.)
heights.current = [...new Array(count), ...heights.current];
keyBase.current -= count;
if (wanted.current != null) wanted.current += count;
if (captureAnchor(count)) stick.current = false;
setRange((r) => ({ start: r.start + count, end: r.end + count }));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const onAppend = useCallback((count: number) => {
heights.current = [...heights.current, ...new Array(count)];
}, []);
const onReset = useCallback(() => {
heights.current = [];
anchor.current = null;
stick.current = true;
positioned.current = false;
measured.current = false;
setRange({ start: 0, end: 40 });
}, []);
const { turns, head, error, version: tick, atStart, blocked, loadOlder } =
useTraceWindows(src, srcKey, { onPrepend, onAppend, onReset, live });
// Prefix sums over measured (or estimated) row heights. n is bounded by what
// the reader has actually loaded, so a full recompute is cheap and happens
// only when a row is measured, turns arrive, or the window moves.
const offsets = useMemo(() => {
const n = turns.current.length;
const acc = new Float64Array(n + 1);
for (let i = 0; i < n; i++) acc[i + 1] = acc[i] + (heights.current[i] || ROW_EST);
return acc;
}, [range, tick, heightsVersion]); // eslint-disable-line react-hooks/exhaustive-deps
// The loaders need the offsets as they are NOW, not as they were when the
// callback was made.
const offsetsRef = useRef(offsets);
offsetsRef.current = offsets;
const recompute = useCallback(() => {
const el = scroller.current;
if (!el) return;
const n = turns.current.length;
const top = Math.max(0, el.scrollTop - OVERSCAN_PX);
const bottom = el.scrollTop + el.clientHeight + OVERSCAN_PX;
const lo = rowAt(offsets, top);
let end = lo;
while (end < n && offsets[end] < bottom) end++;
if (lo !== range.start || end !== range.end) setRange({ start: lo, end: Math.max(end, lo + 1) });
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_PX;
// Reading back into the trace: fetch the stretch in front of what we hold,
// before the reader arrives at it. Also covers a tail window too short to
// fill the pane — there is no scrolling to do, so nothing else would ask.
// Not while a jump is settling: goPrompt does its own fetching, and letting
// this fire too turned one ▲ into several windows.
if (positioned.current && wanted.current == null && el.scrollTop < NEAR_TOP_PX) loadOlder();
}, [offsets, range.start, range.end, loadOlder]);
useEffect(() => { recompute(); }, [recompute, head]);
// ---- keeping the view still ----
// Every layout change lands here: a jump to a prompt, a prepended window, a
// row that just measured itself, a new turn while pinned to the bottom.
useLayoutEffect(() => {
const el = scroller.current;
if (!el) return;
// Landing exactly on a row is a two-step problem: offsets are estimates
// until a row has been measured, so the first scroll is approximate and the
// real position is known only once the target has rendered. Re-apply it as
// heights settle, giving up after a few passes so a row that keeps resizing
// can't hold the scroll position hostage.
if (wanted.current != null) {
const target = offsets[wanted.current] || 0;
if (Math.abs(el.scrollTop - target) > 2) el.scrollTop = target;
// An anchor captured on the way here describes a position the jump is
// deliberately leaving; keeping it would re-apply it a layout pass later.
anchor.current = null;
if (++tries.current > 8) wanted.current = null;
} else if (stick.current) {
el.scrollTop = el.scrollHeight;
anchor.current = null;
} else if (anchor.current) {
const a = anchor.current;
const node = rowRefs.current.get(a.i);
if (node && a.top != null) {
// Where that row actually is now, against where it was: exact, and free
// of whatever the height model still gets wrong about rows it has never
// measured.
const now = node.getBoundingClientRect().top - el.getBoundingClientRect().top;
el.scrollTop += now - a.top;
} else {
el.scrollTop = (offsets[a.i] || 0) + a.delta;
}
anchor.current = null;
}
// The turns we hold are measured, on screen, and the view is where it should
// be: `scrollTop` now means what the reader is doing.
if (measured.current && (offsets[turns.current.length] || 0) > 0) positioned.current = true;
}, [offsets]);
const firstVisible = useCallback(() => {
const el = scroller.current;
if (!el || !turns.current.length) return 0;
return rowAt(offsets, el.scrollTop + 1);
}, [offsets]);
const goToTurn = useCallback((i: number) => {
const el = scroller.current;
if (!el) return;
wanted.current = i;
tries.current = 0;
stick.current = false;
el.scrollTop = offsets[i] || 0;
recompute();
}, [offsets, recompute]);
// ---- jump between prompts ----
// The operator's own turns, among those loaded. Reaching past the oldest one
// loads the window in front of it rather than pretending there are no more.
const prompts = useMemo(() => {
const out: number[] = [];
turns.current.forEach((t, i) => { if (t.role === 'user') out.push(i); });
return out;
}, [tick]); // eslint-disable-line react-hooks/exhaustive-deps
const goRef = useRef<(dir: -1 | 1) => void>(() => {});
const goPrompt = async (dir: -1 | 1) => {
const cur = firstVisible();
const next = dir < 0 ? prompts.filter((i) => i < cur).pop() : prompts.find((i) => i > cur);
if (next !== undefined) { goToTurn(next); return; }
if (dir > 0) {
// Past the last prompt, the end of the conversation is the destination.
const el = scroller.current;
if (el) { wanted.current = null; stick.current = true; el.scrollTop = el.scrollHeight; }
return;
}
// Let the prepend commit before looking again: the recursion would
// otherwise read the pre-prepend prompt list and fetch another window.
if (!atStart && await loadOlder()) {
await new Promise((r) => window.setTimeout(r, 0));
goRef.current(dir);
return;
}
if (prompts.length) goToTurn(prompts[0]);
};
goRef.current = goPrompt;
// Measure rendered rows (heights change when a fold is expanded).
useLayoutEffect(() => {
const ro = new ResizeObserver((entries) => {
let changed = false;
for (const e of entries) {
const i = Number((e.target as HTMLElement).dataset.i);
const h = e.contentRect.height;
if (Number.isFinite(i) && Math.abs((heights.current[i] || 0) - h) > 1) { heights.current[i] = h; changed = true; }
}
if (!changed) return;
measured.current = true;
// Measuring changes the offsets of everything below — and of everything
// above, if a row above the viewport grew. Pin the row being read.
// Not if an anchor is already waiting: a prepend captured that one against
// the indices it is about to shift, and a measurement landing in between
// would overwrite it with the OLD indexing — restoring to a row a whole
// window away from the one the reader was looking at.
if (!stick.current && wanted.current == null && !anchor.current) captureAnchor(0);
setHeightsVersion((v) => v + 1);
});
for (const el of rowRefs.current.values()) ro.observe(el);
return () => ro.disconnect();
// `tick` is in here so rows that arrive from a fetch (without the window
// moving) get measured too — otherwise they keep their 44px estimate and the
// scroll height stays wrong. NOT heightsVersion: that's what the observer
// sets, and re-attaching on it would churn on every measurement.
}, [range, tick, head]);
const setRowRef = (i: number) => (el: HTMLDivElement | null) => {
if (el) rowRefs.current.set(i, el); else rowRefs.current.delete(i);
};
// ---- search: filters the turns already fetched, and says so ----
const matches = useMemo(() => {
if (!query.trim()) return null;
const q = query.toLowerCase();
const hits: number[] = [];
turns.current.forEach((t, i) => {
if (t && t.blocks.some((b) => JSON.stringify(b).toLowerCase().includes(q))) hits.push(i);
});
return hits.slice(0, 200);
}, [query, tick, heightsVersion]); // eslint-disable-line react-hooks/exhaustive-deps
const n = turns.current.length;
const rows: ReactNode[] = [];
for (let i = range.start; i < Math.min(range.end, n); i++) {
rows.push(
<div key={keyBase.current + i} ref={setRowRef(i)} data-i={i}>
<Row turn={turns.current[i]} index={i} />
</div>,
);
}
// Hand the host what its toolbar needs. goPrompt is rebuilt every render, so
// the host gets a stable wrapper around the current one.
// What the pane knows about the trace: whatever this window could tell us,
// filled in from the summary for everything a window cannot know. That is not
// only the counts — a window of a codex rollout cannot count the session's
// encrypted reasoning steps (`note`), and a window of an STS file never sees
// the `{type:'session'}` first line that carries the title, the harness and
// the session id. Taking only the numbers left a reader with no idea the
// model's reasoning had been withheld, which is the one thing §12 of the spec
// says must never be left implied.
useEffect(() => { onNav?.((d: -1 | 1) => goRef.current(d)); }, [onNav]);
useEffect(() => { onHead?.(head); }, [head, onHead]);
return (
<>
<div
className="trace-body"
ref={scroller}
onScroll={recompute}
// Any deliberate scroll abandons a pending jump, so a row measuring
// itself a moment later can't yank the view back.
onWheel={() => { wanted.current = null; }}
onTouchStart={() => { wanted.current = null; }}
style={{ fontSize: `${(13 * zoom) / 100}px` }}
>
{error && <div className="tv-msg">{error}</div>}
{!error && !head && <div className="tv-msg">reading…</div>}
{!error && head && matches && (
<div className="tv-matches">
<div className="tv-msg">
{matches.length} match{matches.length === 1 ? '' : 'es'} in the {fmtNum(n)} turns
loaded so far{atStart ? '' : ' — scroll up to load more'}
</div>
{matches.map((i) => <Row key={keyBase.current + i} turn={turns.current[i]} index={i} />)}
</div>
)}
{!error && head && !matches && (
<>
{/* Fixed height whether it is loading, done, or at the beginning:
this line sits above every offset in the list, so changing its
size would move the whole conversation under the reader. */}
<div className="tv-msg tv-top">
{blocked
? 'earlier turns can’t be read — one line here is larger than the reader’s window'
: atStart
? 'beginning of the conversation'
: 'earlier turns load as you scroll up…'}
</div>
{/* State the gaps up front: a reader who can't see the model's
reasoning should know it was withheld, not that there was none. */}
{head?.note && <div className="tv-msg">{head.note}</div>}
<div style={{ height: offsets[range.start] || 0 }} />
{rows}
<div style={{ height: Math.max(0, (offsets[n] || 0) - (offsets[Math.min(range.end, n)] || 0)) }} />
</>
)}
</div>
{head && (head.source || head.cwd) && (
<div className="trace-hint">
{head.source?.repo && (
<>
{head.sharedBy ? `shared by ${head.sharedBy} · ` : ''}
<a href={head.source.url || `https://huggingface.co/datasets/${head.source.repo}`} target="_blank" rel="noreferrer">{head.source.repo}</a>
{' · '}
</>
)}
{head.cwd}
{head?.firstTs ? ` · ${new Date(head.firstTs).toLocaleString()}` : ''}
</div>
)}
</>
);
}
export default function TracePane({
session, focused, zoom = 100, dragId, sourceLive, onDragActive, onFocus, onShare, onHandover, onClose,
}: {
session: Session;
focused?: boolean;
/** Is the session this trace came from still running? */
sourceLive?: boolean;
zoom?: number;
dragId?: string;
onDragActive?: (dragging: boolean) => void;
onFocus?: () => void;
/** Publish this trace, and continue from it in a new agent. Both used to be
* buttons on the sidebar row; they belong to the trace, so they live on the
* pane that shows it. (The reader's `i` panel is ConversationView's, and a
* trace pane does not use ConversationView.) */
onShare?: () => void;
onHandover?: () => void;
onClose: () => void;
}) {
const [head, setHead] = useState<TraceHeadInfo | null>(null);
const [query, setQuery] = useState('');
const nav = useRef<((dir: -1 | 1) => void) | null>(null);
const onNav = useCallback((go: (dir: -1 | 1) => void) => { nav.current = go; }, []);
const src = useMemo<TraceSource>(() => ({
window: (req, bytes, min, signal) => api.getTraceWindow(session.id, req, bytes, min, signal),
summary: (signal) => api.getTraceSummary(session.id, signal),
}), [session.id]);
const prompts = head?.userTurns?.length ?? 0;
const totalTokens = head?.usage ? fmtUsage(head.usage) : '';
// Until the summary lands, the honest count is what the reader is holding.
const count = head && (head.total != null
? `${fmtNum(head.total)} turns`
: `${fmtNum(head.loaded)} turn${head.loaded === 1 ? '' : 's'} loaded`);
return (
<div className={`slot${focused ? ' focused' : ''}`} onMouseDown={onFocus}>
<div
className={`pane-head trace-head${dragId ? ' draggable' : ''}`}
draggable={!!dragId}
onDragStart={dragId ? (e) => { e.dataTransfer.setData('text/plain', dragId); e.dataTransfer.effectAllowed = 'move'; onDragActive?.(true); } : undefined}
onDragEnd={dragId ? () => onDragActive?.(false) : undefined}
>
<Logo cli="trace" size={16} tint="#7c8cf8" />
{/* A received trace is identified by what it IS, not by which CLI wrote
it — so the session's own name leads, with the harness as a chip. */}
<span className="tv-title" title={head?.title || undefined}>{head?.title || head?.harnessLabel || 'trace'}</span>
{head?.title && head.harnessLabel && <span className="tv-chip">{head.harnessLabel}</span>}
{head?.model && <span className="tv-chip">{head.model}</span>}
{count && <span className="tv-count" title={head && head.total != null && head.loaded < head.total ? `${fmtNum(head.loaded)} loadedscroll up for the rest` : undefined}>{count}</span>}
{totalTokens && <span className="tv-tokens">{totalTokens}</span>}
<span className="spacer" />
{/* Prompt-to-prompt navigation: an agent turn can run for dozens of rows,
and what you usually want is the next thing YOU said. Walking back
past the oldest prompt loaded pulls in the window before it. */}
<span className="tv-nav">
<button className="mini-btn" disabled={!head} onClick={() => nav.current?.(-1)}
title={prompts ? `Previous prompt (${prompts} in this session)` : 'Previous prompt'}>▲</button>
<button className="mini-btn" disabled={!head} onClick={() => nav.current?.(1)}
title={prompts ? `Next prompt (${prompts} in this session)` : 'Next prompt'}>▼</button>
</span>
<input
className="tv-search"
placeholder="Search…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{onShare && (
<button className="mini-btn" title="Share this trace"
onClick={(e) => { e.stopPropagation(); onShare(); }}><ShareGlyph /></button>
)}
{onHandover && (
<button className="mini-btn" title="Continue from this trace in a new agent"
onClick={(e) => { e.stopPropagation(); onHandover(); }}><HandoverGlyph /></button>
)}
<button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
</div>
<TraceView src={src} srcKey={session.id} zoom={zoom} query={query} live={sourceLive} onHead={setHead} onNav={onNav} />
</div>
);
}