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:
1 parent
056bed7aeb
commit
45bfae6e94
543 files changed
+92949
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user