Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the packages workspace (cli, core, server, web, docs, landing, skills), build scripts, tooling config, and CI workflows. Includes the data-layout revision made on this branch: the local data root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the installer keeps its binaries in ~/.penguin), and every Agent lives under <project>/agents/<agent>/ — path helpers, the three agent-enumeration scans, the system prompt, built-in Skills, tests and docs all follow the new layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
@@ -0,0 +1,657 @@
|
||||
/**
|
||||
* Trace observation: a
|
||||
* CUDA-profiling-style two-dimensional lane chart.
|
||||
*
|
||||
* Trace records completion times; the server infers segments from "model
|
||||
* messages are produced serially (autoregressive decoding)" and groups by Task:
|
||||
* - Each Task has its own independent timeline (Tasks far apart in time would
|
||||
* leave large gaps if sharing one axis);
|
||||
* - Bars come in only five kinds, each with a fixed color (no gray/black/white):
|
||||
* thinking, model reply, tool-call generation (model lane), approval wait,
|
||||
* tool-call execution (each tool gets its own row);
|
||||
* - Highlighting is always expressed as "fade out the rest", never an outline:
|
||||
* hovering a segment highlights just that segment (by its unique key, so
|
||||
* adjacent segments at the same timestamp aren't highlighted together), and
|
||||
* it's linked by timestamp to the event list below (onHighlight); clicking a
|
||||
* segment jumps to and briefly highlights the corresponding message row (onJump);
|
||||
* - Hovering a legend item highlights matching segments (fading out the rest);
|
||||
* - Time-axis zoom/pan: drag the slider below the legend (drag the body to
|
||||
* pan, drag either handle to zoom, double-click to reset), similar to
|
||||
* Premiere's timeline navigator. Scroll-wheel zoom is deliberately
|
||||
* unsupported — to avoid accidentally changing zoom while scrolling the page.
|
||||
*/
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
||||
import type { TraceModelSegment, TraceToolSpan } from "@prismshadow/penguin-server/api";
|
||||
import { S } from "../../lib/strings";
|
||||
import { humanizeDuration } from "../../lib/format";
|
||||
|
||||
/**
|
||||
* Linked highlighting: `ts` is the anchor shared by both sides; `key` /
|
||||
* `rowKey` uniquely identify a bar / message row.
|
||||
*
|
||||
* **Only one bar and one message light up at a time**: multiple elements
|
||||
* often share the same millisecond (a tool_call and its request_end), so
|
||||
* matching by `ts` alone would highlight adjacent items together. The
|
||||
* initiating side supplies its own unique key; the other side takes the
|
||||
* first element at that timestamp.
|
||||
*/
|
||||
export interface TraceHighlight {
|
||||
ts?: string;
|
||||
/** Unique bar identifier (generated internally by the timeline). */
|
||||
key?: string;
|
||||
/** Unique message-row identifier (generated by the event list). */
|
||||
rowKey?: string;
|
||||
}
|
||||
|
||||
/** Fixed color for each of the five bar kinds (solid fill, clear contrast; no gray/black/white/indigo). */
|
||||
const COLORS = {
|
||||
thinking: "bg-violet-500 dark:bg-violet-400",
|
||||
text: "bg-sky-500 dark:bg-sky-400",
|
||||
toolgen: "bg-amber-500 dark:bg-amber-400",
|
||||
approvalWait: "bg-rose-400 dark:bg-rose-300",
|
||||
exec: "bg-emerald-500 dark:bg-emerald-400",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Left-side label column: sticky-pinned to the far left during horizontal
|
||||
* scroll/zoom, so labels don't scroll away with the timeline.
|
||||
* Must be an opaque block the **same height** as a lane (h-4) — otherwise a
|
||||
* bar scrolled underneath the label would peek out above/below the text's
|
||||
* line height, looking like the bar is overlapping the label text. Width
|
||||
* lines up with the task label row's `pl-[5.5rem]`.
|
||||
*/
|
||||
const LABEL_STICKY =
|
||||
"sticky left-0 z-10 flex h-4 w-[5.5rem] shrink-0 items-center justify-end bg-white pr-2 dark:bg-gray-950";
|
||||
/** Label text (truncated when too long). */
|
||||
const LABEL_TEXT = "truncate font-mono text-[11px]";
|
||||
/** Zoom range (time-axis multiplier). */
|
||||
const ZOOM_MIN = 0.25;
|
||||
const ZOOM_MAX = 24;
|
||||
/** Slider window's max width corresponds to zoom=1 (window = the whole content); zoom<1 (a compressed overview) is triggered via the −/+ buttons or by dragging the slider. */
|
||||
const MIN_WIN = 1 / ZOOM_MAX;
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||||
|
||||
/** Model-lane segment color: thinking / text (model reply) / tool_call (tool-call generation). */
|
||||
function segmentColor(kind: TraceModelSegment["kind"]): string {
|
||||
if (kind === "thinking") return COLORS.thinking;
|
||||
if (kind === "text") return COLORS.text;
|
||||
return COLORS.toolgen;
|
||||
}
|
||||
|
||||
function segmentLabel(kind: TraceModelSegment["kind"]): string {
|
||||
if (kind === "thinking") return S.traces.kindThinking;
|
||||
if (kind === "text") return S.traces.kindModelReply;
|
||||
return S.traces.kindToolGen;
|
||||
}
|
||||
|
||||
/** Legend key a segment belongs to (used for hover-linked fade-out). */
|
||||
function segmentLegendKey(kind: TraceModelSegment["kind"]): string {
|
||||
if (kind === "thinking") return "thinking";
|
||||
if (kind === "text") return "text";
|
||||
return "toolgen";
|
||||
}
|
||||
|
||||
/** Normalize an invalid/missing timestamp to null (treated as unclosed), so a single NaN doesn't propagate. */
|
||||
function msOf(ts: string | undefined): number | null {
|
||||
if (ts === undefined) return null;
|
||||
const ms = Date.parse(ts);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
interface PlacedSegment {
|
||||
kind: TraceModelSegment["kind"];
|
||||
name?: string;
|
||||
ts: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
interface PlacedSpan {
|
||||
name: string;
|
||||
toolCallId: string;
|
||||
callTs: string;
|
||||
approvalTsRaw?: string;
|
||||
outputTsRaw?: string;
|
||||
callMs: number;
|
||||
approvalMs: number | null;
|
||||
outputMs: number | null;
|
||||
failed: boolean;
|
||||
status?: string;
|
||||
decision?: string;
|
||||
}
|
||||
|
||||
interface TaskGroup {
|
||||
taskIndex: number;
|
||||
segs: PlacedSegment[];
|
||||
spans: PlacedSpan[];
|
||||
t0: number;
|
||||
total: number;
|
||||
toolNames: string[];
|
||||
}
|
||||
|
||||
function buildGroups(segments: TraceModelSegment[], toolSpans: TraceToolSpan[]): TaskGroup[] {
|
||||
const byTask = new Map<number, { segs: PlacedSegment[]; spans: PlacedSpan[] }>();
|
||||
const bucket = (i: number) => {
|
||||
let b = byTask.get(i);
|
||||
if (!b) {
|
||||
b = { segs: [], spans: [] };
|
||||
byTask.set(i, b);
|
||||
}
|
||||
return b;
|
||||
};
|
||||
|
||||
for (const s of segments) {
|
||||
const startMs = msOf(s.startTs);
|
||||
const endMs = msOf(s.endTs);
|
||||
if (startMs === null || endMs === null) continue;
|
||||
bucket(s.taskIndex).segs.push({
|
||||
kind: s.kind,
|
||||
...(s.name !== undefined ? { name: s.name } : {}),
|
||||
ts: s.endTs,
|
||||
startMs,
|
||||
endMs,
|
||||
});
|
||||
}
|
||||
for (const t of toolSpans) {
|
||||
const callMs = msOf(t.callTs);
|
||||
if (callMs === null) continue;
|
||||
bucket(t.taskIndex).spans.push({
|
||||
name: t.name || t.toolCallId,
|
||||
toolCallId: t.toolCallId,
|
||||
callTs: t.callTs,
|
||||
...(t.approvalTs !== undefined ? { approvalTsRaw: t.approvalTs } : {}),
|
||||
...(t.outputTs !== undefined ? { outputTsRaw: t.outputTs } : {}),
|
||||
callMs,
|
||||
approvalMs: msOf(t.approvalTs),
|
||||
outputMs: msOf(t.outputTs),
|
||||
failed: t.stopReason !== undefined && t.stopReason !== "completed",
|
||||
...(t.stopReason !== undefined ? { status: t.stopReason } : {}),
|
||||
...(t.decision !== undefined ? { decision: t.decision } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const groups: TaskGroup[] = [];
|
||||
for (const [taskIndex, { segs, spans }] of [...byTask.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
if (segs.length === 0 && spans.length === 0) continue;
|
||||
const starts = [...segs.map((s) => s.startMs), ...spans.map((s) => s.callMs)];
|
||||
const ends = [
|
||||
...segs.map((s) => s.endMs),
|
||||
...spans.map((s) => s.outputMs ?? s.approvalMs ?? s.callMs),
|
||||
];
|
||||
const t0 = Math.min(...starts);
|
||||
const tEnd = Math.max(...ends, ...starts);
|
||||
const total = Math.max(1, tEnd - t0);
|
||||
spans.sort((a, b) => a.callMs - b.callMs);
|
||||
const toolNames = [...new Set(spans.map((s) => s.name))];
|
||||
groups.push({ taskIndex, segs, spans, t0, total, toolNames });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Track: the gray-background container for bars (no rounded corners; overflow-hidden keeps bars from overlapping adjacent text). */
|
||||
function Track({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="relative h-4 min-w-0 flex-1 overflow-hidden bg-gray-100 dark:bg-gray-800/60">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Lane({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<span className={`${LABEL_STICKY} text-gray-400 dark:text-gray-500`} title={label}>
|
||||
<span className={LABEL_TEXT}>{label}</span>
|
||||
</span>
|
||||
<Track>{children}</Track>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-tick row: the first tick left-aligns, the last right-aligns, the rest
|
||||
* center — otherwise `-translate-x-1/2` would overflow the first and last
|
||||
* labels by half a character width, and the horizontal scroll container
|
||||
* would clip the last label (`591ms` truncated to `591`).
|
||||
* This way the default width (zoom=1) already fits the rightmost coordinate, with no extra padding needed.
|
||||
*/
|
||||
function Ticks({ total }: { total: number }) {
|
||||
const fracs = [0, 0.25, 0.5, 0.75, 1];
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<span className={LABEL_STICKY} />
|
||||
<div className="relative h-4 min-w-0 flex-1">
|
||||
{fracs.map((f, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`absolute top-0 font-mono text-[11px] text-gray-400 dark:text-gray-500 ${
|
||||
i === 0 ? "" : i === fracs.length - 1 ? "-translate-x-full" : "-translate-x-1/2"
|
||||
}`}
|
||||
style={{ left: `${f * 100}%` }}
|
||||
>
|
||||
{humanizeDuration(f * total)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimelineChart({
|
||||
segments,
|
||||
toolSpans,
|
||||
highlight,
|
||||
onHighlight,
|
||||
onJump,
|
||||
hideTaskLabel = false,
|
||||
}: {
|
||||
segments: TraceModelSegment[];
|
||||
toolSpans: TraceToolSpan[];
|
||||
highlight?: TraceHighlight | null;
|
||||
onHighlight?: (h: TraceHighlight | null) => void;
|
||||
/** Click a bar: jump to and briefly highlight the message at that moment. */
|
||||
onJump?: (ts: string) => void;
|
||||
/** Hide the "Round N" label when embedded in the "grouped by Task" view (the group header already shows it). */
|
||||
hideTaskLabel?: boolean;
|
||||
}) {
|
||||
const groups = useMemo(() => buildGroups(segments, toolSpans), [segments, toolSpans]);
|
||||
/**
|
||||
* This chart's bar index: timestamp → the **first** bar's key (in render
|
||||
* order), plus the set of every bar's key.
|
||||
* - Highlights initiated by the event list carry only `ts`; `firstBarKeyByTs`
|
||||
* degrades that into "only one bar lights up", so adjacent bars at the
|
||||
* same instant aren't dragged along;
|
||||
* - `barKeys` decides whether the highlight target **belongs to this
|
||||
* chart**: when grouped by Task, each round renders its own TimelineChart
|
||||
* but they share the same highlight — without this scope check, hovering
|
||||
* a bar in round 1 would fade out round 2's entire timeline too.
|
||||
*/
|
||||
const { firstBarKeyByTs, barKeys } = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
const keys = new Set<string>();
|
||||
const put = (ts: string, key: string) => {
|
||||
keys.add(key);
|
||||
if (!m.has(ts)) m.set(ts, key);
|
||||
};
|
||||
for (const g of groups) {
|
||||
g.segs.forEach((s, i) => put(s.ts, `s-${g.taskIndex}-${i}`));
|
||||
for (const s of g.spans) {
|
||||
if (s.approvalMs !== null && s.approvalMs > s.callMs) {
|
||||
put(s.approvalTsRaw ?? s.callTs, `w-${s.toolCallId}`);
|
||||
}
|
||||
if (s.approvalMs === null && s.outputMs === null) put(s.callTs, `p-${s.toolCallId}`);
|
||||
else put(s.outputTsRaw ?? s.callTs, `e-${s.toolCallId}`);
|
||||
}
|
||||
}
|
||||
return { firstBarKeyByTs: m, barKeys: keys };
|
||||
}, [groups]);
|
||||
// Hovering the legend highlights matching segments; null = none.
|
||||
const [legendKey, setLegendKey] = useState<string | null>(null);
|
||||
// Time-axis zoom multiplier + visible window (derived from scroll).
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [view, setView] = useState({ left: 0, width: 1 });
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef(view);
|
||||
viewRef.current = view;
|
||||
const zoomRef = useRef(zoom);
|
||||
zoomRef.current = zoom;
|
||||
// Target scrollLeft to apply after reflow once zoom changes the content width (keeps the anchor point).
|
||||
const pendingScroll = useRef<((el: HTMLDivElement) => number) | null>(null);
|
||||
// Cleanup for an in-progress drag (removes the window listener as a fallback on unmount/interruption).
|
||||
const dragCleanup = useRef<(() => void) | null>(null);
|
||||
|
||||
const updateView = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const sw = el.scrollWidth || 1;
|
||||
setView({ left: el.scrollLeft / sw, width: Math.min(1, el.clientWidth / sw) });
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (pendingScroll.current) {
|
||||
const max = el.scrollWidth - el.clientWidth;
|
||||
el.scrollLeft = clamp(pendingScroll.current(el), 0, Math.max(0, max));
|
||||
pendingScroll.current = null;
|
||||
}
|
||||
updateView();
|
||||
}, [zoom, groups, updateView]);
|
||||
|
||||
// Recompute the slider window after the container size changes (window resize / sidebar collapse), so the thumb's position/width don't get distorted.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => updateView());
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [updateView]);
|
||||
|
||||
// Fallback cleanup of an in-progress drag listener on unmount (pointercancel / mid-drag unmount don't fire pointerup).
|
||||
useLayoutEffect(() => () => dragCleanup.current?.(), []);
|
||||
|
||||
if (groups.length === 0) return <p className="text-xs text-gray-400">{S.common.none}</p>;
|
||||
|
||||
const hlTs = highlight?.ts;
|
||||
const hlKey = highlight?.key;
|
||||
const enter = (key: string, ts: string | undefined) => onHighlight?.(ts ? { ts, key } : null);
|
||||
const leave = () => onHighlight?.(null);
|
||||
|
||||
// The one bar that lights up: use the highlight's own key if it has one;
|
||||
// otherwise, with only ts (from the event list), take the first bar at that instant.
|
||||
// If the target isn't in this chart (the highlight belongs to another Task), treat it as no highlight — otherwise this chart would fade out entirely.
|
||||
const target = hlKey ?? (hlTs !== undefined ? firstBarKeyByTs.get(hlTs) : undefined);
|
||||
const activeBarKey = target !== undefined && barKeys.has(target) ? target : undefined;
|
||||
const hlOn = activeBarKey !== undefined;
|
||||
const isActive = (key: string): boolean => activeBarKey === key;
|
||||
|
||||
/** Highlight/legend filtering is always expressed as "fade out the rest", never an outline. */
|
||||
const dimClass = (active: boolean, legendMatch: boolean) =>
|
||||
(legendKey !== null && !legendMatch) || (hlOn && !active) ? "opacity-15" : "";
|
||||
|
||||
// Position strictly by exact timestamp (no minimum width, no right-edge
|
||||
// pull-back): segments sit flush against each other with no overlap, and
|
||||
// highlighting lands only on the current segment without bleeding into
|
||||
// neighbors (per the user's requirement). left/width are clamped to [0,100] to prevent overflow.
|
||||
const placeExact = (startMs: number, endMs: number, t0: number, total: number): CSSProperties => {
|
||||
const left = clamp(((startMs - t0) / total) * 100, 0, 100);
|
||||
const width = clamp(((endMs - startMs) / total) * 100, 0, 100 - left);
|
||||
// A zero-duration segment landing exactly on the right edge: min-w-[2px]
|
||||
// would push it out of the track, and the track's overflow-hidden would
|
||||
// clip it entirely (invisible, unhoverable). Pull that 2px back into the
|
||||
// track with a -2px margin, while the start point stays exactly aligned.
|
||||
if (left >= 100) return { left: "100%", width: "0%", marginLeft: "-2px" };
|
||||
return { left: `${left}%`, width: `${width}%` };
|
||||
};
|
||||
|
||||
const legendChips: Array<{ key: string; className: string; label: string }> = [
|
||||
{ key: "thinking", className: COLORS.thinking, label: S.traces.kindThinking },
|
||||
{ key: "text", className: COLORS.text, label: S.traces.kindModelReply },
|
||||
{ key: "toolgen", className: COLORS.toolgen, label: S.traces.kindToolGen },
|
||||
{ key: "approvalWait", className: COLORS.approvalWait, label: S.traces.legendApprovalWait },
|
||||
{ key: "exec", className: COLORS.exec, label: S.traces.legendToolExec },
|
||||
];
|
||||
|
||||
// —— Slider (Premiere-style): drag the body to pan, drag either handle to zoom, double-click to reset ——
|
||||
const applyWindow = (left: number, width: number) => {
|
||||
const w = clamp(width, MIN_WIN, 1);
|
||||
const next = clamp(1 / w, ZOOM_MIN, ZOOM_MAX);
|
||||
const l = clamp(left, 0, 1 - w);
|
||||
pendingScroll.current = (el) => l * el.scrollWidth;
|
||||
setZoom(next);
|
||||
};
|
||||
|
||||
const beginPan = (e: ReactPointerEvent) => {
|
||||
e.preventDefault();
|
||||
const track = trackRef.current;
|
||||
const el = scrollRef.current;
|
||||
if (!track || !el) return;
|
||||
const trackW = track.getBoundingClientRect().width || 1;
|
||||
const startX = e.clientX;
|
||||
const startScroll = el.scrollLeft;
|
||||
const max = Math.max(0, el.scrollWidth - el.clientWidth);
|
||||
const move = (ev: PointerEvent) => {
|
||||
const dxFrac = (ev.clientX - startX) / trackW;
|
||||
el.scrollLeft = clamp(startScroll + dxFrac * el.scrollWidth, 0, max);
|
||||
updateView();
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
window.removeEventListener("pointercancel", up);
|
||||
dragCleanup.current = null;
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
window.addEventListener("pointercancel", up);
|
||||
dragCleanup.current = up;
|
||||
};
|
||||
|
||||
const beginResize = (side: "left" | "right") => (e: ReactPointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
const rect = track.getBoundingClientRect();
|
||||
const move = (ev: PointerEvent) => {
|
||||
const frac = clamp((ev.clientX - rect.left) / (rect.width || 1), 0, 1);
|
||||
const cur = viewRef.current;
|
||||
if (side === "right") {
|
||||
applyWindow(cur.left, frac - cur.left);
|
||||
} else {
|
||||
const right = cur.left + cur.width;
|
||||
applyWindow(frac, right - frac);
|
||||
}
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
window.removeEventListener("pointercancel", up);
|
||||
dragCleanup.current = null;
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
window.addEventListener("pointercancel", up);
|
||||
dragCleanup.current = up;
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
pendingScroll.current = () => 0;
|
||||
setZoom(1);
|
||||
};
|
||||
|
||||
/** −/+ buttons: zoom anchored at the viewport center (covers the full range from <1 overview to >1 zoomed-in). */
|
||||
const zoomStep = (factor: number) => {
|
||||
const el = scrollRef.current;
|
||||
const next = clamp(zoomRef.current * factor, ZOOM_MIN, ZOOM_MAX);
|
||||
if (next === zoomRef.current) return;
|
||||
if (el) {
|
||||
const px = el.clientWidth / 2;
|
||||
const contentFrac = (el.scrollLeft + px) / (el.scrollWidth || 1);
|
||||
pendingScroll.current = (node) => contentFrac * node.scrollWidth - px;
|
||||
}
|
||||
setZoom(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Timeline (horizontal scroll container; native scrollbar hidden in favor of the slider below). overflow-y-hidden avoids a spurious vertical scrollbar. */}
|
||||
<div ref={scrollRef} className="no-scrollbar overflow-x-auto overflow-y-hidden">
|
||||
<div className="space-y-4" style={{ width: `${zoom * 100}%` }}>
|
||||
{groups.map((g) => {
|
||||
return (
|
||||
<div key={g.taskIndex} className="space-y-1">
|
||||
{!hideTaskLabel && (
|
||||
<div className="flex items-center gap-2 pl-[5.5rem]">
|
||||
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[11px] font-medium text-gray-500 dark:bg-gray-800 dark:text-gray-400">
|
||||
{S.traces.task(g.taskIndex + 1)}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-gray-400">
|
||||
{humanizeDuration(g.total)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model lane: serial segments (thinking / model reply / tool-call generation) */}
|
||||
<Lane label={S.traces.laneLLM}>
|
||||
{g.segs.map((s, i) => {
|
||||
const lk = segmentLegendKey(s.kind);
|
||||
const legendMatch = legendKey === null || legendKey === lk;
|
||||
const barKey = `s-${g.taskIndex}-${i}`;
|
||||
const active = isActive(barKey);
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
onMouseEnter={() => enter(barKey, s.ts)}
|
||||
onMouseLeave={leave}
|
||||
onClick={() => onJump?.(s.ts)}
|
||||
title={`${segmentLabel(s.kind)}${s.name ? ` ${s.name}` : ""} · ${humanizeDuration(s.endMs - s.startMs)}`}
|
||||
className={`absolute inset-y-0 min-w-[2px] cursor-pointer ${segmentColor(s.kind)} ${dimClass(active, legendMatch)}`}
|
||||
style={placeExact(s.startMs, s.endMs, g.t0, g.total)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Lane>
|
||||
|
||||
{/* Each tool gets its own row: approval wait → execution */}
|
||||
{g.spans.map((s) => {
|
||||
const execStart = s.approvalMs ?? s.callMs;
|
||||
const open = s.outputMs === null;
|
||||
const endMs = s.outputMs ?? g.t0 + g.total;
|
||||
const approvalTs = s.approvalTsRaw ?? s.callTs;
|
||||
const execTs = s.outputTsRaw ?? s.callTs;
|
||||
return (
|
||||
<div key={s.toolCallId} className="flex items-center">
|
||||
<span
|
||||
className={`${LABEL_STICKY} text-gray-500 dark:text-gray-400`}
|
||||
title={s.name}
|
||||
>
|
||||
<span className={LABEL_TEXT}>{s.name}</span>
|
||||
</span>
|
||||
<Track>
|
||||
{/* Approval-wait segment (positioned exactly, flush against the execution segment) */}
|
||||
{s.approvalMs !== null && s.approvalMs > s.callMs && (
|
||||
<span
|
||||
onMouseEnter={() => enter(`w-${s.toolCallId}`, approvalTs)}
|
||||
onMouseLeave={leave}
|
||||
onClick={() => onJump?.(approvalTs)}
|
||||
title={`${s.name} · ${S.traces.legendApprovalWait}${s.decision ? ` (${s.decision})` : ""} · ${humanizeDuration(s.approvalMs - s.callMs)}`}
|
||||
className={`absolute inset-y-0 min-w-[2px] cursor-pointer ${COLORS.approvalWait} ${dimClass(
|
||||
isActive(`w-${s.toolCallId}`),
|
||||
legendKey === null || legendKey === "approvalWait",
|
||||
)}`}
|
||||
style={placeExact(s.callMs, s.approvalMs, g.t0, g.total)}
|
||||
/>
|
||||
)}
|
||||
{/* Whole-segment pulse while approval is pending / execution segment */}
|
||||
{s.approvalMs === null && open
|
||||
? (() => {
|
||||
// animate-pulse's keyframes override the static opacity: the pulse animation must be removed when fading out.
|
||||
const dim = dimClass(
|
||||
isActive(`p-${s.toolCallId}`),
|
||||
legendKey === null || legendKey === "approvalWait",
|
||||
);
|
||||
return (
|
||||
<span
|
||||
onMouseEnter={() => enter(`p-${s.toolCallId}`, s.callTs)}
|
||||
onMouseLeave={leave}
|
||||
onClick={() => onJump?.(s.callTs)}
|
||||
title={`${s.name} · ${S.traces.legendApprovalWait} · ${S.traces.inProgress}`}
|
||||
className={`absolute inset-y-0 min-w-[2px] cursor-pointer ${COLORS.approvalWait} ${
|
||||
dim || "animate-pulse"
|
||||
}`}
|
||||
style={placeExact(s.callMs, endMs, g.t0, g.total)}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
: (() => {
|
||||
// Fade-out and the "running" translucency share the same opacity property: fade-out takes priority to avoid the two overriding each other.
|
||||
const dim = dimClass(
|
||||
isActive(`e-${s.toolCallId}`),
|
||||
legendKey === null || legendKey === "exec",
|
||||
);
|
||||
const running = open && !dim;
|
||||
return (
|
||||
<span
|
||||
onMouseEnter={() => enter(`e-${s.toolCallId}`, execTs)}
|
||||
onMouseLeave={leave}
|
||||
onClick={() => onJump?.(execTs)}
|
||||
title={`${s.name} · ${S.traces.legendToolExec} · ${
|
||||
open ? S.traces.inProgress : humanizeDuration(endMs - execStart)
|
||||
}${s.failed ? ` · ${s.status}` : ""}`}
|
||||
className={`absolute inset-y-0 min-w-[2px] cursor-pointer ${COLORS.exec} ${
|
||||
s.failed ? "ring-1 ring-red-500" : ""
|
||||
} ${running ? "animate-pulse opacity-70" : dim}`}
|
||||
style={placeExact(execStart, endMs, g.t0, g.total)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</Track>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Time-axis ticks */}
|
||||
<Ticks total={g.total} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend: hovering highlights matching segments — same convention as the bars, non-matching items fade out, no background color */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-gray-100 pt-2.5 dark:border-gray-800">
|
||||
{legendChips.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
onMouseEnter={() => setLegendKey(c.key)}
|
||||
onMouseLeave={() => setLegendKey(null)}
|
||||
className={`flex items-center gap-1 px-1 font-mono text-[11px] text-gray-500 transition-opacity duration-150 dark:text-gray-400 ${
|
||||
legendKey !== null && legendKey !== c.key ? "opacity-30" : ""
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block h-2 w-3 rounded-sm ${c.className}`} />
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Premiere-style zoom/pan slider: drag the body to pan, drag either handle to zoom, double-click to reset; −/+ covers the full range from <1 to >1 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="shrink-0 text-[11px] text-gray-400">{S.traces.zoom}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={S.traces.zoomOut}
|
||||
title={S.traces.zoomOut}
|
||||
onClick={() => zoomStep(1 / 1.4)}
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-gray-200 text-gray-500 transition-colors duration-150 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span className="text-[11px] leading-none">−</span>
|
||||
</button>
|
||||
<div
|
||||
ref={trackRef}
|
||||
onDoubleClick={resetZoom}
|
||||
title={S.traces.zoomReset}
|
||||
className="relative h-3 min-w-0 flex-1 rounded bg-gray-100 dark:bg-gray-800"
|
||||
>
|
||||
<div
|
||||
role="scrollbar"
|
||||
aria-label={S.traces.zoom}
|
||||
onPointerDown={beginPan}
|
||||
className="absolute inset-y-0 flex cursor-grab items-center justify-between rounded bg-gray-400/70 active:cursor-grabbing dark:bg-gray-500/70"
|
||||
style={{ left: `${view.left * 100}%`, width: `${Math.max(view.width * 100, 3)}%` }}
|
||||
>
|
||||
<span
|
||||
onPointerDown={beginResize("left")}
|
||||
className="h-full w-1.5 shrink-0 cursor-ew-resize rounded-l bg-gray-500/80 dark:bg-gray-400/80"
|
||||
/>
|
||||
<span
|
||||
onPointerDown={beginResize("right")}
|
||||
className="h-full w-1.5 shrink-0 cursor-ew-resize rounded-r bg-gray-500/80 dark:bg-gray-400/80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={S.traces.zoomIn}
|
||||
title={S.traces.zoomIn}
|
||||
onClick={() => zoomStep(1.4)}
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-gray-200 text-gray-500 transition-colors duration-150 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span className="text-[11px] leading-none">+</span>
|
||||
</button>
|
||||
<span className="w-9 shrink-0 text-right font-mono text-[11px] tabular-nums text-gray-400">
|
||||
{zoom.toFixed(2)}×
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{onHighlight && (
|
||||
<p className="text-[11px] text-gray-400 dark:text-gray-600">{S.traces.linkHint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Trace event row (shared): timestamp + type icon + type badge + one-line
|
||||
* summary; expanding **renders** the content (text/thinking and session_meta's
|
||||
* system prompt go through Markdown, tool arguments/output go through a code
|
||||
* block, token_usage goes through a bucket table, session_meta goes through a
|
||||
* field table + tool definitions) instead of dumping raw JSON;
|
||||
* the stop reason (stop_reason / status) is shown in the bottom-right of the
|
||||
* expanded area.
|
||||
* Linked highlighting with the timeline — a matched row only gets a
|
||||
* background color, never an outline; only one row lights up at a time (each
|
||||
* row has its own unique rowKey, so adjacent messages at the same timestamp
|
||||
* aren't highlighted together).
|
||||
*/
|
||||
import { Fragment, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { S } from "../../lib/strings";
|
||||
import type { OmniMessage } from "@prismshadow/penguin-core/omnimessage";
|
||||
import { formatTime, humanizeTokens } from "../../lib/format";
|
||||
import { Badge, stopReasonTone } from "../../components/ui/badge";
|
||||
import type { BadgeTone } from "../../components/ui/badge";
|
||||
import { ZoomableImage } from "../../components/ui/image-zoom";
|
||||
|
||||
export function typeTone(type: string): BadgeTone {
|
||||
if (type === "session_meta") return "brand";
|
||||
if (type === "model_msg") return "gray";
|
||||
return "amber";
|
||||
}
|
||||
|
||||
/** Icon for each event type (24×24 line path). */
|
||||
const TYPE_ICON: Record<string, string> = {
|
||||
text: "M8 10h8M8 14h5M21 12a9 9 0 1 1-4-7.5",
|
||||
thinking: "M9 18h6M10 21h4M12 3a6 6 0 0 0-3 11v2h6v-2a6 6 0 0 0-3-11z",
|
||||
image_url: "M3 5h18v14H3zM3 15l5-5 4 4 3-3 6 6",
|
||||
inline_data: "M3 5h18v14H3zM3 15l5-5 4 4 3-3 6 6",
|
||||
tool_call: "M14.7 6.3a4 4 0 0 0-5 5L4 17v3h3l5.7-5.7a4 4 0 0 0 5-5l-2.5 2.5-2-2 2.5-2.5z",
|
||||
tool_call_output: "M4 6l4 4-4 4M12 18h8",
|
||||
token_usage: "M4 20V10m6 10V4m6 16v-7m4 7H2",
|
||||
request_begin: "M5 12h14M13 6l6 6-6 6",
|
||||
request_end: "M19 12H5M11 6l-6 6 6 6",
|
||||
approval_decision: "M9 12l2 2 4-4M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z",
|
||||
compaction_begin: "M8 3H4v4M16 3h4v4M8 21H4v-4M16 21h4v-4M9 12h6",
|
||||
compaction_end: "M8 3H4v4M16 3h4v4M8 21H4v-4M16 21h4v-4M9 12h6",
|
||||
abort: "M6 6h12v12H6z",
|
||||
subagent: "M12 3v6m0 0l-5 4v8m5-12l5 4v8M4 21h16",
|
||||
};
|
||||
const DEFAULT_ICON = "M12 8v5m0 3h.01M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z";
|
||||
|
||||
function TypeIcon({ type }: { type: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0 text-gray-400 dark:text-gray-500"
|
||||
aria-hidden
|
||||
>
|
||||
<path d={TYPE_ICON[type] ?? DEFAULT_ICON} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** One-line summary: pull key info per payload type (truncated to one line). */
|
||||
export function summarizeEvent(msg: OmniMessage): string {
|
||||
const p = msg.payload as Record<string, unknown> & { type?: string };
|
||||
switch (p.type) {
|
||||
case undefined:
|
||||
return "";
|
||||
case "text":
|
||||
return `${String(p["role"])}: ${String(p["text"] ?? "").slice(0, 120)}`;
|
||||
case "thinking":
|
||||
return String(p["thinking"] ?? "").slice(0, 120);
|
||||
case "image_url":
|
||||
return "[image]";
|
||||
case "inline_data":
|
||||
return `[image] ${String(p["mime_type"] ?? "")}`;
|
||||
case "tool_call":
|
||||
return `${String(p["name"])} ${String(p["arguments"] ?? "").slice(0, 100)}`;
|
||||
case "tool_call_output": {
|
||||
const text = String(p["output"] ?? "").slice(0, 120);
|
||||
// Tool output carrying an image: prefix the summary with [image] to flag it.
|
||||
return Array.isArray(p["images"]) && p["images"].length > 0 ? `[image] ${text}` : text;
|
||||
}
|
||||
case "token_usage": {
|
||||
const req = p["request"] as { total?: number } | undefined;
|
||||
const ses = p["session"] as { total?: number } | undefined;
|
||||
return `request.total=${req?.total ?? 0} session.total=${ses?.total ?? 0}`;
|
||||
}
|
||||
case "request_end":
|
||||
return `status=${String(p["status"])}`;
|
||||
case "approval_decision":
|
||||
return `${String(p["decision"])} · ${String(p["tool_call_id"])}`;
|
||||
case "compaction_begin":
|
||||
case "compaction_end":
|
||||
return `${String(p["mode"])} (${String(p["reason"])})${p["status"] ? ` · ${String(p["status"])}` : ""}`;
|
||||
case "abort":
|
||||
return p["reason"] != null ? String(p["reason"]) : "";
|
||||
case "subagent":
|
||||
return String(p["session_id"] ?? "");
|
||||
default:
|
||||
if (msg.type === "session_meta") {
|
||||
return `${String(p["session_id"] ?? "")} · ${String(p["model_id"] ?? "")}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop reason (a model message's stop_reason / request_end's status); undefined if absent. */
|
||||
function stopReasonOf(msg: OmniMessage): string | undefined {
|
||||
const p = msg.payload as { stop_reason?: string; status?: string };
|
||||
return p.stop_reason ?? p.status;
|
||||
}
|
||||
|
||||
const codeBlock =
|
||||
"max-h-72 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-100 px-2.5 py-2 text-xs dark:bg-gray-800/70";
|
||||
|
||||
/** token_usage bucket table (a single event only reports exact numbers; the donut ring only appears in the top-right of the Task card, so it isn't redrawn here). */
|
||||
function UsageTable({ p }: { p: Record<string, unknown> }) {
|
||||
const req = (p.request ?? {}) as Record<string, number>;
|
||||
const ses = (p.session ?? {}) as Record<string, number>;
|
||||
const row = (label: string, r: Record<string, number>) => (
|
||||
<tr>
|
||||
<td className="pr-3 text-gray-400">{label}</td>
|
||||
<td className="pr-3 text-right font-mono">{humanizeTokens(r.cache_read ?? 0)}</td>
|
||||
<td className="pr-3 text-right font-mono">{humanizeTokens(r.cache_write ?? 0)}</td>
|
||||
<td className="pr-3 text-right font-mono">{humanizeTokens(r.output ?? 0)}</td>
|
||||
<td className="text-right font-mono">{humanizeTokens(r.total ?? 0)}</td>
|
||||
</tr>
|
||||
);
|
||||
return (
|
||||
<table className="text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-400">
|
||||
<th />
|
||||
<th className="pr-3 text-right font-normal">cacheRead</th>
|
||||
<th className="pr-3 text-right font-normal">cacheWrite</th>
|
||||
<th className="pr-3 text-right font-normal">output</th>
|
||||
<th className="text-right font-normal">total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{row("request", req)}
|
||||
{row("session", ses)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
const summaryClass =
|
||||
"cursor-pointer text-xs font-medium text-gray-500 marker:text-gray-400 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200";
|
||||
|
||||
/** session_meta: a field table + system prompt (Markdown) + tool definition list. */
|
||||
function SessionMetaBody({ p }: { p: Record<string, unknown> }) {
|
||||
const rows: Array<[string, string]> = [
|
||||
["session_id", String(p.session_id ?? "")],
|
||||
["model_id", String(p.model_id ?? "")],
|
||||
["context_window", String(p.model_context_window ?? "")],
|
||||
["thinking_level", String(p.thinking_level ?? "")],
|
||||
["agent_state", String(p.agent_state ?? "")],
|
||||
["workspace", String(p.workspace ?? "")],
|
||||
];
|
||||
const prompt = String(p.system_prompt ?? "");
|
||||
const tools = Array.isArray(p.tools)
|
||||
? (p.tools as Array<{ name?: string; description?: string }>)
|
||||
: [];
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-xs">
|
||||
{rows.map(([k, v]) => (
|
||||
<Fragment key={k}>
|
||||
<dt className="text-gray-400">{k}</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-gray-700 dark:text-gray-300">
|
||||
{v || "—"}
|
||||
</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{prompt.trim() && (
|
||||
<details>
|
||||
<summary className={summaryClass}>{S.traces.systemPrompt}</summary>
|
||||
<div className="md-body mt-1.5 max-h-96 overflow-auto rounded bg-gray-100 px-2.5 py-2 text-sm leading-relaxed text-gray-700 dark:bg-gray-800/70 dark:text-gray-300">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{prompt}</ReactMarkdown>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{tools.length > 0 && (
|
||||
<details>
|
||||
<summary className={summaryClass}>{S.traces.toolDefs(tools.length)}</summary>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{tools.map((t, i) => (
|
||||
<li key={i} className="text-xs">
|
||||
<span className="font-mono font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t.name ?? "—"}
|
||||
</span>
|
||||
{t.description && (
|
||||
<span className="ml-2 text-gray-500 dark:text-gray-400">{t.description}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Expanded-area content: rendered per type (no raw JSON dump). */
|
||||
function EventBody({ msg }: { msg: OmniMessage }) {
|
||||
const p = msg.payload as Record<string, unknown> & { type?: string };
|
||||
if (msg.type === "session_meta") return <SessionMetaBody p={p} />;
|
||||
switch (p.type) {
|
||||
case "text":
|
||||
case "thinking": {
|
||||
const md = String(p.text ?? p.thinking ?? "");
|
||||
if (!md.trim()) return <p className="text-xs text-gray-400">—</p>;
|
||||
return (
|
||||
<div className="md-body text-sm leading-relaxed text-gray-700 dark:text-gray-300">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{md}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "image_url":
|
||||
return (
|
||||
<ZoomableImage
|
||||
src={String(p.image_url ?? "")}
|
||||
alt="trace"
|
||||
className="max-h-48 max-w-full rounded-md"
|
||||
/>
|
||||
);
|
||||
case "inline_data":
|
||||
// base64 bytes + mime → render directly as a data URL.
|
||||
return (
|
||||
<ZoomableImage
|
||||
src={`data:${String(p.mime_type ?? "")};base64,${String(p.data ?? "")}`}
|
||||
alt="trace"
|
||||
className="max-h-48 max-w-full rounded-md"
|
||||
/>
|
||||
);
|
||||
case "tool_call":
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="font-mono text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
{String(p.name ?? "")}
|
||||
</p>
|
||||
<pre className={codeBlock}>{String(p.arguments ?? "")}</pre>
|
||||
</div>
|
||||
);
|
||||
case "tool_call_output": {
|
||||
// Tool output carrying images (e.g. read_image): render images below the text code block.
|
||||
const images = Array.isArray(p.images) ? (p.images as string[]) : [];
|
||||
if (images.length === 0) return <pre className={codeBlock}>{String(p.output ?? "")}</pre>;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<pre className={codeBlock}>{String(p.output ?? "")}</pre>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{images.map((src, i) => (
|
||||
<ZoomableImage
|
||||
key={i}
|
||||
src={src}
|
||||
alt="trace"
|
||||
className="max-h-48 max-w-full rounded-md"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "token_usage":
|
||||
return <UsageTable p={p} />;
|
||||
default:
|
||||
// Short events like compaction / abort / request_*: key-value pairs are enough (session_meta is already rendered separately above).
|
||||
return <pre className={codeBlock}>{JSON.stringify(p, null, 2)}</pre>;
|
||||
}
|
||||
}
|
||||
|
||||
export function EventRow({
|
||||
msg,
|
||||
rowKey,
|
||||
matched,
|
||||
onHighlight,
|
||||
}: {
|
||||
msg: OmniMessage;
|
||||
/** This row's unique identifier (the scroll target for timeline jumps, and also the basis for "only one row lights up"). */
|
||||
rowKey: string;
|
||||
matched: boolean;
|
||||
onHighlight?: (h: { ts: string; rowKey: string } | null) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const payloadType = (msg.payload as { type?: string }).type ?? msg.type;
|
||||
const stopReason = stopReasonOf(msg);
|
||||
return (
|
||||
<li data-trace-row={rowKey}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onMouseEnter={() => onHighlight?.({ ts: msg.timestamp, rowKey })}
|
||||
onMouseLeave={() => onHighlight?.(null)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors duration-150 ${
|
||||
matched
|
||||
? "bg-amber-50 dark:bg-amber-950/40"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0 font-mono text-[11px] text-gray-400">
|
||||
{formatTime(msg.timestamp)}
|
||||
</span>
|
||||
<TypeIcon type={payloadType} />
|
||||
<Badge tone={typeTone(msg.type)}>{payloadType}</Badge>
|
||||
{msg.origin && msg.origin.length > 0 && <Badge tone="brand">origin</Badge>}
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
{summarizeEvent(msg)}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-t border-gray-100 bg-gray-50/60 px-3 py-2 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<EventBody msg={msg} />
|
||||
{/* Stop reason: bottom-right */}
|
||||
{stopReason && (
|
||||
<div className="mt-1.5 flex justify-end">
|
||||
<Badge tone={stopReasonTone(stopReason)}>{stopReason}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* Trace file view (reworked):
|
||||
* grouped by **round (Task)** — a global summary at the top; below it, one
|
||||
* group per Task, with the card's top-right corner showing that round's stats
|
||||
* and a **context-usage donut ring** (upper bound = the session's context
|
||||
* window, default 128000; the three segments are cacheRead / cacheWrite /
|
||||
* output, showing both usage ratio and composition, with exact numbers on
|
||||
* hover), followed by that round's execution timeline and all of its messages.
|
||||
*
|
||||
* Token usage here is **broken down by category** rather than given as one
|
||||
* lump sum (a total alone doesn't show where the money went): this round's
|
||||
* input (with the portion that was a **cache hit** in parentheses, target
|
||||
* icon, hover shows the hit rate = cache hit ÷ input), this round's output,
|
||||
* plus tool-call count / cost / duration / output TPS. The conversation
|
||||
* page's stats row only gives input/output totals — cache composition and
|
||||
* this kind of debugging detail belongs here.
|
||||
*
|
||||
* Task attribution: model segments/tool spans carry their own taskIndex
|
||||
* (computed by the server), and messages fall into a Task's time range by
|
||||
* timestamp. Timeline ↔ message linked highlighting: hovering either side
|
||||
* highlights the other (only one bar / one message lights up at a time);
|
||||
* clicking a bar scrolls to the corresponding message and pins the highlight for PIN_MS.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { OmniMessage } from "@prismshadow/penguin-core/omnimessage";
|
||||
import type {
|
||||
ModelsResponse,
|
||||
TraceAnalysisResponse,
|
||||
TraceModelSegment,
|
||||
TraceTaskStats,
|
||||
TraceToolSpan,
|
||||
} from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import {
|
||||
computeTps,
|
||||
formatMoney,
|
||||
formatPercent,
|
||||
formatTps,
|
||||
humanizeDuration,
|
||||
humanizeTokens,
|
||||
} from "../../lib/format";
|
||||
import { STAT_ICONS } from "../../lib/stat-icons";
|
||||
import { resolveContextWindow } from "../../lib/context";
|
||||
import { useTheme } from "../../state/theme";
|
||||
import { Skeleton } from "../../components/ui/skeleton";
|
||||
import { Chevron } from "../../components/ui/chevron";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import { TokenDonut } from "../../components/ui/token-donut";
|
||||
import { TimelineChart } from "./timeline-chart";
|
||||
import type { TraceHighlight } from "./timeline-chart";
|
||||
import { EventRow } from "./trace-event-row";
|
||||
|
||||
/**
|
||||
* The three Token buckets (token_usage.request). No `total` field: usage is
|
||||
* always shown broken down, and the total = input (cacheRead + cacheWrite) +
|
||||
* output — there's no second convention.
|
||||
*/
|
||||
interface Buckets {
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
output: number;
|
||||
}
|
||||
const zeroBuckets = (): Buckets => ({ cacheRead: 0, cacheWrite: 0, output: 0 });
|
||||
|
||||
interface TaskData {
|
||||
taskIndex: number;
|
||||
segments: TraceModelSegment[];
|
||||
spans: TraceToolSpan[];
|
||||
messages: OmniMessage[];
|
||||
toolCalls: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
const msOf = (ts: string): number => {
|
||||
const n = Date.parse(ts);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
/** How long the target message row stays pinned highlighted after a bar-click jump (milliseconds). */
|
||||
const PIN_MS = 2500;
|
||||
|
||||
/** Unique key for a message row (also the DOM scroll anchor). */
|
||||
const rowKeyOf = (taskIndex: number, i: number): string => `${taskIndex}-${i}`;
|
||||
|
||||
/**
|
||||
* One row of the global summary: name on the left, value on the right
|
||||
* (tabular-nums right-aligned → values line up column-wise across rows, easy to compare at a glance).
|
||||
* Each item takes its own row, with three groups arranged side by side as
|
||||
* columns — laid out horizontally it would read as a blur of digits, while
|
||||
* giving each group a full row would waste the right half of the space.
|
||||
*/
|
||||
function SummaryRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3 py-0.5">
|
||||
<span className="shrink-0 text-[11px] text-gray-400">{label}</span>
|
||||
<span className="truncate font-mono text-sm font-semibold tabular-nums">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** This round's input = cache hit (cacheRead) + cache miss (cacheWrite). */
|
||||
const inputOf = (b: Buckets): number => b.cacheRead + b.cacheWrite;
|
||||
|
||||
/** Cache hit rate = cache hit ÷ this round's input; undefined when input is 0 → null (shown as `—`). */
|
||||
const hitRateOf = (b: Buckets): number | null => {
|
||||
const input = inputOf(b);
|
||||
return input > 0 ? b.cacheRead / input : null;
|
||||
};
|
||||
|
||||
/** Shared style for stat rows (icon + value, tabular figures). */
|
||||
const CHIP_CLASS =
|
||||
"flex shrink-0 items-center font-mono text-[11px] tabular-nums text-gray-500 dark:text-gray-400";
|
||||
|
||||
/** Icon + value; hover shows what this item is (plain text alone doesn't convey the meaning). */
|
||||
function StatChip({ icon, value, label }: { icon: string; value: string; label: string }) {
|
||||
return (
|
||||
<span title={label} aria-label={label} className={`${CHIP_CLASS} gap-1`}>
|
||||
<GlyphIcon d={icon} className="text-gray-400" />
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This round's input chip: `↑ 84k (◎ 60k)` — the parenthesized number is the
|
||||
* portion that was a **cache hit** (target icon), with the hit rate shown on
|
||||
* hovering the parenthesized part. The hit rate is hover-only: cramming a
|
||||
* third number into the row would blow out this row of chips, and "how much
|
||||
* was hit" already gives a rough sense on its own — hover for the exact ratio.
|
||||
*/
|
||||
function InputChip({ buckets }: { buckets: Buckets }) {
|
||||
const input = inputOf(buckets);
|
||||
const hitTitle =
|
||||
`${S.traces.cacheHit} ${humanizeTokens(buckets.cacheRead)}` +
|
||||
` · ${S.traces.hitRate} ${formatPercent(hitRateOf(buckets))}`;
|
||||
return (
|
||||
<span
|
||||
aria-label={`${S.traces.taskInput} ${humanizeTokens(input)} · ${hitTitle}`}
|
||||
className={CHIP_CLASS}
|
||||
>
|
||||
<span title={S.traces.taskInput} className="flex items-center gap-1">
|
||||
<GlyphIcon d={STAT_ICONS.input} className="text-gray-400" />
|
||||
{humanizeTokens(input)}
|
||||
</span>
|
||||
<span title={hitTitle} className="ml-1 flex items-center gap-0.5 text-gray-400">
|
||||
<span>(</span>
|
||||
<GlyphIcon d={STAT_ICONS.cacheHit} />
|
||||
<span>{humanizeTokens(buckets.cacheRead)}</span>
|
||||
<span>)</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TraceFileView({
|
||||
projectId,
|
||||
agentId,
|
||||
sessionId,
|
||||
index,
|
||||
highlight,
|
||||
onHighlight,
|
||||
}: {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
index: number;
|
||||
highlight: TraceHighlight | null;
|
||||
onHighlight: (h: TraceHighlight | null) => void;
|
||||
}) {
|
||||
const { currency } = useTheme();
|
||||
const [analysis, setAnalysis] = useState<TraceAnalysisResponse | null>(null);
|
||||
const [events, setEvents] = useState<OmniMessage[]>([]);
|
||||
/** events' starting index within the file (pagination offset): used to align with analysis.tasks' index ranges. */
|
||||
const [eventsOffset, setEventsOffset] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [models, setModels] = useState<ModelsResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(new Set());
|
||||
/** Message row pinned highlighted after a bar-click jump; auto-clears when its timer fires (independent of hover highlighting, and can stack with it). */
|
||||
const [pinnedRow, setPinnedRow] = useState<string | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const pinTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (pinTimer.current) clearTimeout(pinTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setAnalysis(null);
|
||||
setEvents([]);
|
||||
setError(null);
|
||||
setCollapsed(new Set());
|
||||
setPinnedRow(null);
|
||||
let cancelled = false;
|
||||
Promise.all([
|
||||
api.getAgentTraceAnalysis(projectId, agentId, sessionId, index),
|
||||
api.getAgentTraceEvents(projectId, agentId, sessionId, index, 0, 1000),
|
||||
api.getModels(projectId).catch(() => null),
|
||||
])
|
||||
.then(([a, e, m]) => {
|
||||
if (cancelled) return;
|
||||
setAnalysis(a);
|
||||
setEvents(e.events);
|
||||
setEventsOffset(e.offset);
|
||||
setTotal(e.total);
|
||||
setModels(m);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setError(err instanceof ApiError ? err.message : S.common.unknownError);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, agentId, sessionId, index]);
|
||||
|
||||
// Pricing for the session's Model (main session only; sub-session Tokens
|
||||
// live in their own Trace and aren't part of this file):
|
||||
// session_meta carries a paired reference (provider + model_id), matched against model config by that pair.
|
||||
const pricing = useMemo(() => {
|
||||
const meta = events.find((m) => m.type === "session_meta");
|
||||
const ref = meta ? (meta.payload as { model_id?: string; provider?: string }) : undefined;
|
||||
if (!ref?.model_id) return undefined;
|
||||
return models?.models.find(
|
||||
(m) =>
|
||||
m.modelId === ref.model_id && (ref.provider === undefined || m.provider === ref.provider),
|
||||
)?.pricing;
|
||||
}, [events, models]);
|
||||
|
||||
const costOf = (b: Buckets): number | null => {
|
||||
if (!pricing) return null;
|
||||
return (
|
||||
(b.cacheRead * pricing.cacheRead +
|
||||
b.cacheWrite * pricing.cacheWrite +
|
||||
b.output * pricing.output) /
|
||||
1e6
|
||||
);
|
||||
};
|
||||
|
||||
// Session context window (the upper bound for each round's donut ring): read once from session_meta, falling back to 128000 if unconfigured.
|
||||
const contextMax = useMemo(() => {
|
||||
const meta = events.find((m) => m.type === "session_meta");
|
||||
return resolveContextWindow(
|
||||
meta
|
||||
? (meta.payload as { model_context_window?: number | string }).model_context_window
|
||||
: undefined,
|
||||
);
|
||||
}, [events]);
|
||||
|
||||
const { tasks, global, statsByTask, globalLlmMs } = useMemo(() => {
|
||||
const g = { buckets: zeroBuckets(), toolCalls: 0 };
|
||||
const empty = new Map<number, TraceTaskStats>();
|
||||
if (!analysis)
|
||||
return {
|
||||
tasks: [] as TaskData[],
|
||||
global: g,
|
||||
statsByTask: empty,
|
||||
globalLlmMs: 0,
|
||||
};
|
||||
|
||||
// A round's duration range always comes from the server (analysis.tasks,
|
||||
// computed over the whole file; the start is that round's first
|
||||
// request_begin). For a degenerate round with no Request, startTs is an
|
||||
// empty string → no range is built, and the duration counts as 0.
|
||||
const boundsByTask = new Map<number, { min: number; max: number }>();
|
||||
for (const t of analysis.tasks) {
|
||||
const min = Date.parse(t.startTs);
|
||||
const max = Date.parse(t.endTs);
|
||||
if (Number.isFinite(min) && Number.isFinite(max)) {
|
||||
boundsByTask.set(t.taskIndex, { min, max });
|
||||
}
|
||||
}
|
||||
|
||||
const map = new Map<number, TaskData>();
|
||||
const ensure = (ti: number): TaskData => {
|
||||
let d = map.get(ti);
|
||||
if (!d) {
|
||||
const b = boundsByTask.get(ti);
|
||||
d = {
|
||||
taskIndex: ti,
|
||||
segments: [],
|
||||
spans: [],
|
||||
messages: [],
|
||||
toolCalls: 0,
|
||||
durationMs: b ? Math.max(0, b.max - b.min) : 0,
|
||||
};
|
||||
map.set(ti, d);
|
||||
}
|
||||
return d;
|
||||
};
|
||||
for (const t of analysis.tasks) ensure(t.taskIndex); // empty rounds still need to appear in the list
|
||||
for (const s of analysis.modelSegments) ensure(s.taskIndex).segments.push(s);
|
||||
for (const s of analysis.toolSpans) {
|
||||
const d = ensure(s.taskIndex);
|
||||
d.spans.push(s);
|
||||
d.toolCalls += 1;
|
||||
}
|
||||
// Message attribution: **by the server-given index range**, never guessed
|
||||
// from timestamps. The same millisecond can be crowded with "the previous
|
||||
// round's last reply, compaction_begin, the compaction prompt, the next
|
||||
// round's request_begin" — splitting by a time boundary can't tell them
|
||||
// apart, and this round's reply would get misattributed to the next
|
||||
// round (the server already knows this message-by-message from its
|
||||
// sequential scan, no need to re-guess it here).
|
||||
// events is only used to populate the message list (a list view that
|
||||
// truthfully indicates truncation at the bottom); no **numeric value**
|
||||
// is ever derived from it: events is paginated (limit=1000, not
|
||||
// continued), so using it for aggregation would undercount Token/cost for a long Trace.
|
||||
const taskOfIndex = (k: number): number | null => {
|
||||
for (const t of analysis.tasks) {
|
||||
if (k >= t.messageFrom && k <= t.messageTo) return t.taskIndex;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const msg = events[i]!;
|
||||
if (msg.origin && msg.origin.length > 0) continue; // sub-session messages don't enter this file's grouping
|
||||
const ti = taskOfIndex(eventsOffset + i); // events is fetched starting at offset; recover the global index within the file
|
||||
if (ti !== null) ensure(ti).messages.push(msg);
|
||||
}
|
||||
g.toolCalls = analysis.toolSpans.length;
|
||||
// Numeric values always come from analysis.tasks, computed by the server
|
||||
// over **the whole file**. Note the differing conventions:
|
||||
// - context: a **snapshot** (usage at that round's last non-compaction Request), not an accumulated value;
|
||||
// - tokens: this round's **throughput** (sum across Requests), used for
|
||||
// Token / cost; `tokens.output` doubles as the TPS numerator;
|
||||
// - llmMs: the TPS denominator (this round's LLM generation time, with human approval wait already deducted).
|
||||
// The global summary and the per-round cards below share **the same
|
||||
// scope** (including compaction rounds): every global figure is the sum
|
||||
// across rounds, and they must add up.
|
||||
const statsByTask = new Map(analysis.tasks.map((t) => [t.taskIndex, t]));
|
||||
for (const t of analysis.tasks) {
|
||||
g.buckets.cacheRead += t.tokens.cacheRead;
|
||||
g.buckets.cacheWrite += t.tokens.cacheWrite;
|
||||
g.buckets.output += t.tokens.output;
|
||||
}
|
||||
const gLlm = analysis.tasks.reduce((s, t) => s + t.llmMs, 0);
|
||||
const tasks = [...map.values()].sort((a, b) => a.taskIndex - b.taskIndex);
|
||||
return { tasks, global: g, statsByTask, globalLlmMs: gLlm };
|
||||
}, [analysis, events, eventsOffset]);
|
||||
|
||||
if (error) return <p className="text-xs text-red-600 dark:text-red-400">{error}</p>;
|
||||
if (!analysis) return <Skeleton className="h-40" />;
|
||||
|
||||
// Duration is likewise "the sum across rounds" computed by the server over
|
||||
// the whole file (including compaction rounds, same scope as the per-round
|
||||
// display below): events is paginated, so subtracting first from last
|
||||
// would truncate a long Trace's duration (a 90s span where the first 1000 events only cover the first 30s → showing 30s).
|
||||
const globalMs = analysis.elapsedMs;
|
||||
|
||||
const toggle = (ti: number) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(ti)) next.delete(ti);
|
||||
else next.add(ti);
|
||||
return next;
|
||||
});
|
||||
|
||||
/** The **first** message row at that instant; a bar-initiated highlight/jump uses this to hit only one row. */
|
||||
const firstRowKeyAt = (ts: string): string | null => {
|
||||
for (const t of tasks) {
|
||||
const i = t.messages.findIndex((m) => m.timestamp === ts);
|
||||
if (i >= 0) return rowKeyOf(t.taskIndex, i);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Target row for hover highlighting: use the highlight's own rowKey (from a
|
||||
// message row) if it has one; otherwise, with only ts (from a bar), take the first row.
|
||||
const hoveredRow =
|
||||
highlight?.rowKey ?? (highlight?.ts !== undefined ? firstRowKeyAt(highlight.ts) : null);
|
||||
|
||||
/** Click a bar: scroll to the corresponding message row and pin the highlight — the mouse moving away afterward shouldn't clear it, so this is stored separately from hover highlighting. */
|
||||
const jumpTo = (ts: string) => {
|
||||
const rk = firstRowKeyAt(ts);
|
||||
if (rk === null) return;
|
||||
setPinnedRow(rk);
|
||||
// The target row may have just re-rendered from the highlight; wait for this frame to commit before scrolling.
|
||||
requestAnimationFrame(() => {
|
||||
rootRef.current
|
||||
?.querySelector(`[data-trace-row="${rk}"]`)
|
||||
?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
});
|
||||
if (pinTimer.current) clearTimeout(pinTimer.current);
|
||||
pinTimer.current = setTimeout(() => setPinnedRow(null), PIN_MS);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="space-y-4">
|
||||
{/* Global summary: split into three groups by nature (count / Token
|
||||
usage / duration·cost·TPS), separated by vertical rules — a dozen
|
||||
metrics laid out in one row would read as a blur of digits; grouping lets you spot the kind you want at a glance. */}
|
||||
<div className="rounded-md border border-gray-200 p-3 dark:border-gray-800">
|
||||
<p className="mb-2 text-xs font-semibold text-gray-500">{S.traces.globalSummary}</p>
|
||||
{/* Three groups side by side as columns, each item within a group
|
||||
taking its own row (name on the left, value on the right): laid
|
||||
out in one row it's a blur of digits, while giving each group a
|
||||
full row only uses a small strip on the left and wastes the rest.
|
||||
Splitting into columns fills the width and keeps it to three rows tall. */}
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-3">
|
||||
{/* Counts */}
|
||||
<div>
|
||||
{/* Rounds = number of cards below (a compaction round counts as
|
||||
a round too): the global summary and the per-round display
|
||||
below share **the same scope** — every figure is the sum
|
||||
across rounds and must add up; how many of them are
|
||||
compaction rounds is answered separately by "compaction count". */}
|
||||
<SummaryRow label={S.traces.tasksLabel} value={String(analysis.tasks.length)} />
|
||||
<SummaryRow label={S.traces.toolCalls} value={String(global.toolCalls)} />
|
||||
<SummaryRow label={S.traces.compactions} value={String(analysis.compactionCount)} />
|
||||
</div>
|
||||
{/* Token usage: broken down by category (input / of which cache hit + hit rate / output), never given as a lump sum. */}
|
||||
<div>
|
||||
<SummaryRow label={S.chat.statInput} value={humanizeTokens(inputOf(global.buckets))} />
|
||||
<SummaryRow
|
||||
label={S.traces.cacheHit}
|
||||
value={`${humanizeTokens(global.buckets.cacheRead)} · ${formatPercent(hitRateOf(global.buckets))}`}
|
||||
/>
|
||||
<SummaryRow label={S.chat.statOutput} value={humanizeTokens(global.buckets.output)} />
|
||||
</div>
|
||||
{/* Duration · cost · TPS (cost above duration, same order as the conversation page's stats row). */}
|
||||
<div>
|
||||
<SummaryRow
|
||||
label={S.chat.statCost}
|
||||
value={formatMoney(costOf(global.buckets), currency)}
|
||||
/>
|
||||
<SummaryRow
|
||||
label={S.chat.statElapsed}
|
||||
value={humanizeDuration(Math.max(0, globalMs))}
|
||||
/>
|
||||
{/* Global TPS = the output of every round (including compaction
|
||||
rounds) ÷ the sum of LLM generation time, same scope as the
|
||||
Token and duration above — both numerator and denominator
|
||||
come from analysis.tasks under the server's whole-file convention, so they share the same source. */}
|
||||
<SummaryRow
|
||||
label={S.chat.statTps}
|
||||
value={formatTps(computeTps(global.buckets.output, globalLlmMs))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grouped by Task */}
|
||||
{tasks.map((t) => {
|
||||
const open = !collapsed.has(t.taskIndex);
|
||||
// This round's convention as computed by the server over the whole
|
||||
// file: ctx = context snapshot at the end of this round (last
|
||||
// non-compaction Request), tokens = this round's throughput (used
|
||||
// for Token and cost, output doubles as the TPS numerator), llmMs = the TPS denominator.
|
||||
const st = statsByTask.get(t.taskIndex);
|
||||
const ctx = st?.context;
|
||||
const tokens = st?.tokens ?? zeroBuckets();
|
||||
return (
|
||||
<div
|
||||
key={t.taskIndex}
|
||||
className="overflow-hidden rounded-md border border-gray-200 dark:border-gray-800"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(t.taskIndex)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-2 bg-gray-50 px-3 py-2 text-left transition-colors duration-150 hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-800/60"
|
||||
>
|
||||
<Chevron open={open} size={13} className="text-gray-400" />
|
||||
<span className="shrink-0 text-sm font-semibold">
|
||||
{S.traces.task(t.taskIndex + 1)}
|
||||
</span>
|
||||
{/* Compaction rounds are explicitly flagged: their Token /
|
||||
cost / duration / TPS count toward the global summary just
|
||||
like user rounds do, and this badge answers "this round isn't answering the user, it's compacting context". */}
|
||||
{st?.compaction === true && (
|
||||
<span className="shrink-0 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-800 dark:text-gray-400">
|
||||
{S.traces.compactionRound}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1" />
|
||||
{/* This round's stats: iconified in the top-right corner (hover gives a text explanation) */}
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
||||
<StatChip
|
||||
icon={STAT_ICONS.toolCalls}
|
||||
value={String(t.toolCalls)}
|
||||
label={S.traces.toolCalls}
|
||||
/>
|
||||
{/* Token usage broken down by category: this round's input
|
||||
(parenthesized portion is the cache hit) + this round's
|
||||
output. Uses the server's this-round throughput tokens (whole file, including compaction), not computed from truncated events. */}
|
||||
<InputChip buckets={tokens} />
|
||||
<StatChip
|
||||
icon={STAT_ICONS.output}
|
||||
value={humanizeTokens(tokens.output)}
|
||||
label={S.traces.taskOutput}
|
||||
/>
|
||||
<StatChip
|
||||
icon={STAT_ICONS.cost}
|
||||
value={formatMoney(costOf(tokens), currency)}
|
||||
label={`${S.chat.statCost}(${currency})`}
|
||||
/>
|
||||
<StatChip
|
||||
icon={STAT_ICONS.elapsed}
|
||||
value={humanizeDuration(t.durationMs)}
|
||||
label={S.chat.statElapsed}
|
||||
/>
|
||||
<StatChip
|
||||
icon={STAT_ICONS.tps}
|
||||
value={formatTps(computeTps(tokens.output, st?.llmMs ?? 0))}
|
||||
label={S.chat.statTps}
|
||||
/>
|
||||
</div>
|
||||
{/* Context-usage donut ring at the end of this round (upper
|
||||
bound = the session context window) + the three-segment
|
||||
composition, with exact numbers on hover (see TokenDonut's
|
||||
title). The exact figures are given by the chips on the
|
||||
left; the ring is only a peripheral hint of the usage
|
||||
ratio, hence its small size. It's fed the snapshot ctx
|
||||
rather than the accumulated t.buckets — the latter
|
||||
recounts the history each round carries forward, so a few
|
||||
rounds of tool calls alone could fill the ring. A pure compaction Task has no snapshot and draws no ring. */}
|
||||
{ctx && (
|
||||
<TokenDonut
|
||||
cacheRead={ctx.cacheRead}
|
||||
cacheWrite={ctx.cacheWrite}
|
||||
output={ctx.output}
|
||||
max={contextMax}
|
||||
size={22}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-3 p-3">
|
||||
{/* This round's timeline */}
|
||||
{(t.segments.length > 0 || t.spans.length > 0) && (
|
||||
<div className="rounded-md border border-gray-100 p-2 dark:border-gray-800/60">
|
||||
<p className="mb-1.5 text-[11px] font-medium text-gray-500">
|
||||
{S.traces.timeline}
|
||||
</p>
|
||||
<TimelineChart
|
||||
segments={t.segments}
|
||||
toolSpans={t.spans}
|
||||
highlight={highlight}
|
||||
onHighlight={onHighlight}
|
||||
onJump={jumpTo}
|
||||
hideTaskLabel
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* This round's messages */}
|
||||
<div>
|
||||
<p className="mb-1.5 text-[11px] font-medium text-gray-500">
|
||||
{S.traces.messages}({t.messages.length})
|
||||
</p>
|
||||
{t.messages.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">{S.common.none}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100 rounded-md border border-gray-200 dark:divide-gray-800/60 dark:border-gray-800">
|
||||
{t.messages.map((msg, i) => {
|
||||
const rk = rowKeyOf(t.taskIndex, i);
|
||||
return (
|
||||
<EventRow
|
||||
key={i}
|
||||
msg={msg}
|
||||
rowKey={rk}
|
||||
matched={rk === hoveredRow || rk === pinnedRow}
|
||||
onHighlight={(h) => onHighlight(h)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{events.length < total && (
|
||||
<p className="text-xs text-gray-400">{S.traces.truncatedNote(events.length, total)}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Trace browsing page: the left-side directory lists Sessions grouped only by
|
||||
* Agent (titles come from the Sessions context, with unmanaged CLI/subagent
|
||||
* Sessions falling back to sessionId); the right side shows the selected
|
||||
* Session's Trace files (paged, most recent first by default) + performance
|
||||
* analysis (an execution timeline) + an event timeline.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import type { AgentTracesResponse } from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { useDocumentTitle } from "../../lib/use-document-title";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { agentDisplayName, useProject } from "../../state/project";
|
||||
import { AgentAvatar } from "../../components/ui/agent-avatar";
|
||||
import { Chevron } from "../../components/ui/chevron";
|
||||
import { Truncated } from "../../components/ui/truncated";
|
||||
import { useSessions } from "../../state/sessions";
|
||||
import { EmptyState } from "../../components/ui/empty-state";
|
||||
import { SkeletonList } from "../../components/ui/skeleton";
|
||||
import { TraceFileView } from "./trace-file-view";
|
||||
import type { TraceHighlight } from "./timeline-chart";
|
||||
|
||||
interface TraceFileRef {
|
||||
index: number;
|
||||
date: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
interface SessionGroup {
|
||||
sessionId: string;
|
||||
files: TraceFileRef[];
|
||||
}
|
||||
|
||||
interface Selection {
|
||||
/** Details go through the Agent-level endpoint (not dependent on the sessions table's tracking), so the owning Agent must be carried along. */
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
files: TraceFileRef[];
|
||||
}
|
||||
|
||||
/** Flatten by Session (merging a Session's files across dates); Sessions are sorted by id descending = newest first. */
|
||||
function flattenSessions(data: AgentTracesResponse): SessionGroup[] {
|
||||
const bySession = new Map<string, TraceFileRef[]>();
|
||||
for (const d of data.dates) {
|
||||
for (const s of d.sessions) {
|
||||
const list = bySession.get(s.sessionId) ?? [];
|
||||
for (const f of s.files) list.push({ index: f.index, date: d.date, sizeBytes: f.sizeBytes });
|
||||
bySession.set(s.sessionId, list);
|
||||
}
|
||||
}
|
||||
return [...bySession.entries()]
|
||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||
.map(([sessionId, files]) => ({
|
||||
sessionId,
|
||||
// Files sorted newest first by default (a higher index is newer).
|
||||
files: files.sort((a, b) => b.index - a.index),
|
||||
}));
|
||||
}
|
||||
|
||||
/** A single Agent's expandable tree node (traces are fetched only when expanded; titles are mapped via the Sessions context). */
|
||||
function AgentNode({
|
||||
projectId,
|
||||
agentId,
|
||||
name,
|
||||
defaultOpen,
|
||||
focusSessionId,
|
||||
titleOf,
|
||||
selection,
|
||||
onSelect,
|
||||
}: {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
name: string;
|
||||
/** Initial expanded state: all expanded when there's no deep link; only the target Agent expanded when there's an ?agentId= deep link. */
|
||||
defaultOpen: boolean;
|
||||
/** ?sessionId= deep link (jumped to directly from the evaluation center's runs): auto-selects that Session once the list is ready (only once). */
|
||||
focusSessionId?: string;
|
||||
titleOf: (agentId: string, sessionId: string) => string | undefined;
|
||||
selection: Selection | null;
|
||||
onSelect: (sel: Selection) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [groups, setGroups] = useState<SessionGroup[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || groups) return;
|
||||
api
|
||||
.getAgentTraces(projectId, agentId)
|
||||
.then((data) => setGroups(flattenSessions(data)))
|
||||
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError));
|
||||
}, [open, groups, projectId, agentId]);
|
||||
|
||||
// The Session deep link is applied only once: it selects the target as
|
||||
// soon as the list is first ready (if not found, it just stays in the
|
||||
// list state without erroring); after that, the user's manual switches
|
||||
// are never pulled back by the deep-link parameter.
|
||||
const focusApplied = useRef(false);
|
||||
useEffect(() => {
|
||||
if (focusApplied.current || !focusSessionId || !groups) return;
|
||||
focusApplied.current = true;
|
||||
const target = groups.find((g) => g.sessionId === focusSessionId);
|
||||
if (target) onSelect({ agentId, sessionId: target.sessionId, files: target.files });
|
||||
}, [groups, focusSessionId, agentId, onSelect]);
|
||||
|
||||
// The group header and Session row styling matches the sidebar
|
||||
// (components/layout/sidebar.tsx): the same information appearing in two
|
||||
// places with a different shape would make it look like two different things.
|
||||
return (
|
||||
<li className="pt-2.5">
|
||||
<div className="flex items-center px-1 pb-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label={open ? S.nav.collapseGroup : S.nav.expandGroup}
|
||||
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<AgentAvatar id={agentId} size={18} className="shrink-0 rounded" />
|
||||
<span className="min-w-0 truncate text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{name}
|
||||
</span>
|
||||
{/* Expand/collapse indicator immediately follows the Agent name */}
|
||||
<Chevron open={open} size={12} className="text-gray-400" />
|
||||
<span className="min-w-0 flex-1" />
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="anim-fade">
|
||||
{error && <p className="px-2.5 py-1 text-xs text-red-500">{error}</p>}
|
||||
{!groups && !error && (
|
||||
<p className="px-2.5 py-1 text-xs text-gray-400">{S.common.loading}</p>
|
||||
)}
|
||||
{groups && groups.length === 0 && (
|
||||
<p className="px-2.5 py-1 text-xs text-gray-400 dark:text-gray-600">{S.traces.empty}</p>
|
||||
)}
|
||||
<ul className="space-y-0.5">
|
||||
{groups?.map((g) => {
|
||||
const active = selection?.agentId === agentId && selection.sessionId === g.sessionId;
|
||||
const title = titleOf(agentId, g.sessionId);
|
||||
return (
|
||||
<li key={g.sessionId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect({ agentId, sessionId: g.sessionId, files: g.files })}
|
||||
className={`flex w-full items-center gap-1.5 rounded-md px-2.5 py-1.5 text-left transition-colors duration-150 ${
|
||||
active
|
||||
? "bg-gray-200/70 dark:bg-gray-800"
|
||||
: "hover:bg-gray-200/50 dark:hover:bg-gray-800/70"
|
||||
}`}
|
||||
>
|
||||
<Truncated
|
||||
text={title ?? g.sessionId}
|
||||
className={`min-w-0 flex-1 ${title ? "text-sm" : "font-mono text-xs"} ${
|
||||
active
|
||||
? "font-medium text-gray-900 dark:text-gray-100"
|
||||
: "text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
/>
|
||||
<span className="shrink-0 font-mono text-[11px] text-gray-400">
|
||||
{g.files.length}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function TracesPage() {
|
||||
useDocumentTitle(S.traces.title);
|
||||
const { currentProject, agents, agentsLoading } = useProject();
|
||||
const { byAgent } = useSessions();
|
||||
const projectId = currentProject?.projectId ?? null;
|
||||
// ?agentId= deep link (from the Agents page's "traces" entry point): only
|
||||
// the target Agent defaults to expanded, the rest collapse to keep focus on it.
|
||||
const [searchParams] = useSearchParams();
|
||||
const focusAgentId = searchParams.get("agentId");
|
||||
// ?sessionId= deep link (jumped to directly from the evaluation center's
|
||||
// runs): auto-selects once the target Agent's Session list is ready.
|
||||
const focusSessionId = searchParams.get("sessionId");
|
||||
const [selection, setSelection] = useState<Selection | null>(null);
|
||||
const [fileIndex, setFileIndex] = useState<number | null>(null);
|
||||
// Linked highlighting between the trace observation view and the event list (keyed by tool_call_id).
|
||||
const [highlight, setHighlight] = useState<TraceHighlight | null>(null);
|
||||
|
||||
// Clear the selection when switching Project.
|
||||
useEffect(() => {
|
||||
setSelection(null);
|
||||
setFileIndex(null);
|
||||
}, [projectId]);
|
||||
|
||||
// Clear the linked highlight when switching Session / Trace file.
|
||||
useEffect(() => {
|
||||
setHighlight(null);
|
||||
}, [selection, fileIndex]);
|
||||
|
||||
const titleOf = (agentId: string, sessionId: string): string | undefined =>
|
||||
byAgent.get(agentId)?.find((s) => s.sessionId === sessionId)?.title;
|
||||
|
||||
if (!projectId) return null;
|
||||
|
||||
const activeFile =
|
||||
selection === null
|
||||
? null
|
||||
: (selection.files.find((f) => f.index === fileIndex) ?? selection.files[0] ?? null);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col md:flex-row">
|
||||
{/* Directory tree: Agent → Session title (≥md left column; <md top collapsible area) */}
|
||||
<aside className="max-h-52 shrink-0 overflow-y-auto border-b border-gray-200 bg-gray-50 px-1 py-2 md:max-h-none md:w-72 md:border-b-0 md:border-r dark:border-gray-800 dark:bg-gray-900">
|
||||
<p className="px-3 pb-1 text-xs font-bold uppercase tracking-wide text-gray-500">
|
||||
{S.traces.title}
|
||||
</p>
|
||||
{agentsLoading ? (
|
||||
<SkeletonList rows={4} />
|
||||
) : (
|
||||
<ul>
|
||||
{agents.map((a) => (
|
||||
<AgentNode
|
||||
key={a.agentId}
|
||||
projectId={projectId}
|
||||
agentId={a.agentId}
|
||||
name={agentDisplayName(a)}
|
||||
defaultOpen={focusAgentId === null || focusAgentId === a.agentId}
|
||||
{...(focusSessionId !== null && focusAgentId === a.agentId
|
||||
? { focusSessionId }
|
||||
: {})}
|
||||
titleOf={titleOf}
|
||||
selection={selection}
|
||||
onSelect={(sel) => {
|
||||
setSelection(sel);
|
||||
setFileIndex(sel.files[0]?.index ?? null);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="min-w-0 flex-1 overflow-y-auto p-3 md:p-4">
|
||||
{selection && activeFile ? (
|
||||
<div className="mx-auto max-w-4xl space-y-4">
|
||||
{/* Header: Session title + Trace file pagination (newest first) */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
{titleOf(selection.agentId, selection.sessionId) ?? (
|
||||
<span className="font-mono text-xs font-normal text-gray-500">
|
||||
{selection.sessionId}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="mr-1 text-xs text-gray-400">{S.traces.filesTitle}</span>
|
||||
{selection.files.map((f) => (
|
||||
<button
|
||||
key={f.index}
|
||||
type="button"
|
||||
onClick={() => setFileIndex(f.index)}
|
||||
title={`${f.date} · ${formatBytes(f.sizeBytes)}`}
|
||||
className={`rounded-md border px-2 py-0.5 font-mono text-xs transition-colors duration-150 ${
|
||||
f.index === activeFile.index
|
||||
? "border-gray-400 bg-gray-200/70 font-semibold text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
: "border-gray-200 text-gray-500 hover:bg-gray-100 dark:border-gray-800 dark:text-gray-400 dark:hover:bg-gray-800/60"
|
||||
}`}
|
||||
>
|
||||
#{String(f.index).padStart(3, "0")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="truncate font-mono text-xs text-gray-400">
|
||||
{selection.sessionId} · {activeFile.date} · {formatBytes(activeFile.sizeBytes)}
|
||||
</p>
|
||||
|
||||
<TraceFileView
|
||||
projectId={projectId}
|
||||
agentId={selection.agentId}
|
||||
sessionId={selection.sessionId}
|
||||
index={activeFile.index}
|
||||
highlight={highlight}
|
||||
onHighlight={setHighlight}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title={S.traces.selectSession} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user