traces-replay / src /lib /TraceViewer.svelte
mishig's picture
mishig HF Staff
Revert dark mode
f9ff659 verified
<script>
import { tick, onMount, onDestroy } from 'svelte';
import { parseJsonl, toRawUrl } from './parse.js';
import { renderMarkdown } from './markdown.js';
import { getParam, setParam } from './url-sync.js';
const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let spinnerFrame = $state(0);
let spinnerInterval;
const examples = [
{
label: '3D browser game',
url: 'https://huggingface.co/datasets/0xSero/pi-sessions/blob/main/2026-01-28T17-49-25-023Z_cc5bb68b-049d-49c7-84b1-004b47ae7cdc.jsonl',
},
{
label: 'Explain repo',
url: 'https://huggingface.co/datasets/0xSero/pi-sessions/blob/main/2026-01-13T13-24-19-611Z_6622d195-0787-41ac-b9d0-9ea1118a1c6c.jsonl',
},
{
label: 'Fix Swift errors',
url: 'https://huggingface.co/datasets/0xSero/pi-sessions/blob/main/2026-01-30T19-41-29-858Z_16007873-1d9f-4efd-b222-161a55e0183f.jsonl',
},
{
label: 'Triage issue',
url: 'https://huggingface.co/datasets/badlogicgames/pi-mono/blob/main/2026-01-16T02-37-34-075Z_4293a326-81ca-4327-b450-85275e1ca645.jsonl',
},
{
label: 'Release audit',
url: 'https://huggingface.co/datasets/badlogicgames/pi-mono/blob/main/2026-01-16T03-32-51-416Z_cf56c275-9716-42a7-b79e-c3225fe7f6d2.jsonl',
},
];
let url = $state(examples[0].url);
function pickExample(ex) {
url = ex.url;
load();
}
const HF_SPACE_URL = 'https://huggingface.co/spaces/mishig/traces-replay';
let copied = $state(false);
async function shareTrace() {
const link = `${HF_SPACE_URL}?url=${encodeURIComponent(url)}`;
try {
await navigator.clipboard.writeText(link);
} catch {
window.prompt('Copy this link:', link);
return;
}
copied = true;
setTimeout(() => (copied = false), 1800);
}
let loading = $state(false);
let loadedCount = $state(0);
let error = $state('');
let messages = $state([]);
let focusedIdx = $state(-1);
let playing = $state(false);
let skipFlag = false;
let listEl;
async function load() {
loading = true;
error = '';
messages = [];
focusedIdx = -1;
loadedCount = 0;
playing = false;
skipFlag = false;
try {
const res = await fetch(toRawUrl(url));
if (!res.ok) throw new Error(`Failed to fetch (HTTP ${res.status})`);
const text = await res.text();
const parsed = parseJsonl(text);
for (const msg of parsed) {
msg._visible = false;
msg._visibleBlocks = 0;
for (const block of msg.blocks) {
block._typedText = '';
block._typing = false;
}
}
messages = parsed;
loadedCount = parsed.length;
if (parsed.length === 0) {
error = 'No messages parsed from this file.';
} else {
setParam('url', url);
await tick();
playback();
}
} catch (e) {
error = e?.message || String(e);
} finally {
loading = false;
}
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
async function playback() {
playing = true;
skipFlag = false;
userScrolledUp = false;
for (let mi = 0; mi < messages.length; mi++) {
if (skipFlag) break;
const msg = messages[mi];
msg._visible = true;
focusedIdx = mi;
await tick();
followTail();
await wait(40);
for (let bi = 0; bi < msg.blocks.length; bi++) {
if (skipFlag) break;
msg._visibleBlocks = bi + 1;
await tick();
followTail();
const block = msg.blocks[bi];
if (block.kind === 'text' || block.kind === 'thinking') {
await typeBlock(block);
} else {
await wait(90);
await tick();
followTail();
}
}
if (skipFlag) break;
await wait(120);
}
if (skipFlag) revealAll();
playing = false;
}
async function typeBlock(block) {
const full = block.text || '';
const len = full.length;
if (len === 0) return;
const totalMs = Math.max(250, Math.min(1400, len * 10));
const tickMs = 16;
const totalTicks = Math.ceil(totalMs / tickMs);
const step = Math.max(1, Math.ceil(len / totalTicks));
block._typing = true;
for (let c = step; c < len; c += step) {
if (skipFlag) break;
block._typedText = full.slice(0, c);
await tick();
followTail();
await wait(tickMs);
}
block._typedText = full;
await tick();
followTail();
block._typing = false;
}
// Terminal-style tail: during playback, keep the scroll container pinned
// to the bottom so new content scrolls line-by-line into view. If the user
// actively scrolls up during playback, stop tailing until playback ends.
let userScrolledUp = false;
let lastAutoScrollTop = 0;
function followTail() {
if (!listEl || !playing || userScrolledUp) return;
listEl.scrollTop = listEl.scrollHeight;
lastAutoScrollTop = listEl.scrollTop;
}
function onListClick(e) {
const btn = e.target.closest?.('.copy-btn');
if (!btn) return;
const pre =
btn.parentElement?.querySelector('pre code') ||
btn.parentElement?.querySelector('pre');
if (!pre) return;
const text = pre.textContent || '';
const reset = () => {
btn.textContent = 'Copy';
btn.classList.remove('copied');
};
navigator.clipboard.writeText(text).then(
() => {
btn.textContent = 'Copied';
btn.classList.add('copied');
setTimeout(reset, 1500);
},
() => {
window.prompt('Copy this code:', text);
}
);
}
function onListScroll() {
if (!listEl || !playing) return;
const distanceFromBottom =
listEl.scrollHeight - listEl.scrollTop - listEl.clientHeight;
// If the user scrolls back to the bottom, re-engage auto-scroll.
if (distanceFromBottom < 20) {
userScrolledUp = false;
lastAutoScrollTop = listEl.scrollTop;
return;
}
// Otherwise, if the position diverges from our last auto-write, pause
// auto-scroll so the user can read without being yanked.
if (Math.abs(listEl.scrollTop - lastAutoScrollTop) > 40) {
userScrolledUp = true;
}
}
function revealAll() {
for (const msg of messages) {
msg._visible = true;
msg._visibleBlocks = msg.blocks.length;
for (const block of msg.blocks) {
if ('_typedText' in block) block._typedText = block.text || '';
block._typing = false;
}
}
}
function skip() {
skipFlag = true;
}
async function handleKey(e) {
const t = e.target;
if (
t &&
(t.tagName === 'INPUT' ||
t.tagName === 'TEXTAREA' ||
t.isContentEditable)
)
return;
if (
playing &&
['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape', ' '].includes(e.key)
) {
e.preventDefault();
skip();
return;
}
if (messages.length === 0) return;
if (e.key === 'ArrowDown' || e.key === 'j') {
e.preventDefault();
focusedIdx = Math.min(messages.length - 1, focusedIdx + 1);
} else if (e.key === 'ArrowUp' || e.key === 'k') {
e.preventDefault();
focusedIdx = Math.max(0, focusedIdx - 1);
} else if (e.key === 'Home' || (e.key === 'g' && !e.shiftKey)) {
e.preventDefault();
focusedIdx = 0;
} else if (e.key === 'End' || (e.key === 'G' && e.shiftKey)) {
e.preventDefault();
focusedIdx = messages.length - 1;
} else {
return;
}
await tick();
scrollToFocused('smooth');
}
function scrollToFocused(behavior = 'smooth') {
if (focusedIdx < 0 || !listEl) return;
const el = listEl.querySelector(`[data-idx="${focusedIdx}"]`);
if (el) el.scrollIntoView({ behavior, block: 'center' });
}
function formatJson(obj) {
try {
return JSON.stringify(obj, null, 2);
} catch {
return String(obj);
}
}
onMount(() => {
spinnerInterval = setInterval(() => {
spinnerFrame = (spinnerFrame + 1) % spinnerFrames.length;
}, 90);
const shared = getParam('url');
if (shared) {
url = shared;
load();
}
});
onDestroy(() => {
clearInterval(spinnerInterval);
});
const roleColor = {
user: 'text-[#1e40af]',
assistant: 'text-[#0f5a2a]',
tool: 'text-[#6b21a8]',
system: 'text-[#92400e]',
meta: 'text-[#6a6a66]',
unknown: 'text-[#6a6a66]',
};
</script>
<svelte:window onkeydown={handleKey} />
<div
class="frame-bg frame-shadow w-[960px] max-w-[calc(100vw-48px)] rounded-[20px] p-[3px]"
>
<div
class="w-full h-[85vh] bg-[#fbfbf9] rounded-[17px] overflow-hidden flex flex-col font-mono text-[14px] leading-[1.7] text-[#232323]"
>
<!-- chrome -->
<div class="flex items-center gap-2 pt-4 px-[18px] pb-2 shrink-0">
<span class="w-3 h-3 rounded-full bg-[#d7d7d3]"></span>
<span class="w-3 h-3 rounded-full bg-[#d7d7d3]"></span>
<span class="w-3 h-3 rounded-full bg-[#d7d7d3]"></span>
<a
href="https://huggingface.co/datasets?format=format%3Aagent-traces"
target="_blank"
rel="noopener noreferrer"
class="ml-auto text-[12px] text-[#6a6a66] hover:text-[#222220] hover:underline select-none transition-colors"
title="Browse agent-trace datasets on Hugging Face"
>
🤗 traces
</a>
</div>
<!-- url prompt -->
<div
class="flex items-center gap-2 px-5 py-3 border-b border-[#eeeae0] shrink-0"
>
<span class="text-[#6a6a66] select-none"></span>
<input
type="url"
bind:value={url}
onkeydown={(e) => {
if (e.key === 'Enter') load();
}}
placeholder="paste .jsonl dataset URL and press Enter"
class="flex-1 bg-transparent border-none outline-none text-[13px] text-[#222220] placeholder:text-[#b0b0aa]"
/>
<button
type="button"
onclick={load}
disabled={loading}
class="px-3 py-1 bg-[#ffd21e] rounded text-[12px] font-semibold hover:bg-[#ffbb1a] disabled:opacity-50 cursor-pointer"
>
load
</button>
<button
type="button"
onclick={shareTrace}
disabled={messages.length === 0 || loading}
title="Copy a shareable link to this replay"
class="inline-flex items-center gap-1.5 px-3 py-1 bg-white border border-[#ffd21e] rounded text-[12px] font-semibold text-[#8a6b00] hover:bg-[#fffbe6] disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors shrink-0"
>
{#if copied}
<span class="text-[#0f7a3a]"></span>
<span class="text-[#0f5a2a]">copied</span>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3.5 h-3.5"
aria-hidden="true"
>
<path
d="M8.707 2.293a1 1 0 0 0-1.414 0L3.293 6.293a1 1 0 1 0 1.414 1.414L7 5.414V11a1 1 0 1 0 2 0V5.414l2.293 2.293a1 1 0 1 0 1.414-1.414l-4-4z"
/>
<path
d="M3 12a1 1 0 1 0-2 0v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 1 0-2 0v1H3v-1z"
/>
</svg>
<span>Share replay</span>
{/if}
</button>
</div>
<!-- examples -->
<div
class="flex flex-wrap items-center gap-2 px-5 py-2 border-b border-[#eeeae0] shrink-0"
>
<span class="text-[11px] text-[#888] select-none">examples:</span>
{#each examples as ex}
<button
type="button"
onclick={() => pickExample(ex)}
disabled={loading || playing}
class="text-[11px] px-2 py-1 bg-[#fffbe6] border border-[#ffedb0] rounded hover:bg-[#ffd21e] hover:border-[#ffbb1a] disabled:opacity-50 disabled:hover:bg-[#fffbe6] disabled:hover:border-[#ffedb0] transition-colors cursor-pointer"
>
{ex.label}
</button>
{/each}
</div>
<!-- messages / log -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={listEl}
onscroll={onListScroll}
onclick={onListClick}
class="flex-1 overflow-y-auto thin-scrollbar px-5 py-3"
>
{#if loading}
<div class="flex items-baseline gap-2">
<span class="w-[1ch] text-center text-[#5f5f5c]"
>{spinnerFrames[spinnerFrame]}</span
>
<span class="text-[#333331]">Fetching traces...</span>
</div>
{:else if error}
<div class="flex items-baseline gap-2">
<span class="w-[1ch] text-center text-[#991b1b]"></span>
<span class="text-[#991b1b]">{error}</span>
</div>
{:else if messages.length > 0}
<div class="flex items-baseline gap-2 mb-3 animate-fade-in">
<span
class="w-[1ch] text-center {playing
? 'text-[#5f5f5c]'
: 'text-[#0f7a3a] animate-ready-pulse'}"
>
{playing ? spinnerFrames[spinnerFrame] : '●'}
</span>
<span
class="{playing
? 'text-[#333331]'
: 'text-[#0f5a2a]'} font-semibold"
>
{playing
? `Streaming ${focusedIdx + 1} / ${loadedCount}...`
: `Loaded ${loadedCount} messages`}
</span>
</div>
{:else}
<div class="text-[#888] text-[13px] leading-relaxed">
<div class="flex items-baseline gap-2 mb-1">
<span class="w-[1ch] text-center text-[#6b6b68]"></span>
<span>waiting for input</span>
</div>
<div class="pl-[2.2ch] text-[#888]">
paste a <code class="text-[#8b5cf6]">.jsonl</code> dataset URL above,
press
<kbd
class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0] text-[11px]"
>Enter</kbd
> to load.
</div>
<div class="pl-[2.2ch] text-[#888] mt-1">
then <kbd
class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0] text-[11px]"
>↑</kbd
>
<kbd
class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0] text-[11px]"
>↓</kbd
>
to navigate sections,
<kbd
class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0] text-[11px]"
>Home</kbd
>/
<kbd
class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0] text-[11px]"
>End</kbd
> for start/end.
</div>
<div class="pl-[2.2ch] mt-3">
<a
href="https://huggingface.co/changelog/agent-trace-viewer"
target="_blank"
rel="noopener noreferrer"
class="text-[#8b5cf6] hover:underline"
>→ learn more about the agent trace viewer on Hugging Face</a
>
</div>
</div>
{/if}
{#each messages as msg, i (i)}
{#if msg._visible}
{@const focused = i === focusedIdx}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-idx={i}
onclick={() => (focusedIdx = i)}
class="py-1 cursor-default rounded transition-colors animate-fade-in {focused
? 'bg-[#fffbe6]'
: 'hover:bg-[#faf9f5]'}"
>
<!-- header -->
<div class="flex items-baseline gap-2 px-2">
<span
class="w-[1ch] text-center {focused
? 'text-[#0f7a3a] animate-ready-pulse'
: 'text-[#6b6b68]'}"
>
{focused ? '●' : '○'}
</span>
<span
class="text-[11px] uppercase tracking-wider font-semibold {roleColor[
msg.role
] || roleColor.unknown}"
>
{msg.role}
</span>
{#if msg.title}
<span class="text-[12px] text-[#6a6a66] truncate"
>{msg.title}</span
>
{/if}
<span class="ml-auto flex items-baseline gap-3">
{#if msg.model}
<span class="text-[11px] text-[#888]">{msg.model}</span>
{/if}
<span class="text-[11px] text-[#aaa]">#{i}</span>
</span>
</div>
<!-- blocks (only show revealed ones) -->
{#each msg.blocks as block, bi}
{#if bi < msg._visibleBlocks}
{@const isLast = bi === msg.blocks.length - 1}
<div class="flex items-start gap-2 px-2 animate-fade-in">
<span class="w-[1ch] text-[#b3b3ad] shrink-0 mt-[2px]"
>{isLast ? '└' : '├'}</span
>
<div class="flex-1 min-w-0">
{#if block.kind === 'text'}
{#if block._typing || block._typedText !== block.text}
<pre
class="whitespace-pre-wrap break-words text-[13px] text-[#232323] leading-[1.65] font-mono">{block._typedText}{#if block._typing}<span
class="animate-blink text-[#8b5cf6]"
aria-hidden="true">▎</span
>{/if}</pre>
{:else}
<div
class="prose-trace text-[13px] text-[#232323] leading-[1.65]"
>
{@html renderMarkdown(block.text)}
</div>
{/if}
{:else if block.kind === 'thinking'}
<details class="py-0.5" open>
<summary
class="cursor-pointer text-[11px] text-[#8b5cf6] font-semibold select-none hover:underline"
>thinking</summary
>
{#if block._typing || block._typedText !== block.text}
<pre
class="whitespace-pre-wrap break-words text-[12px] text-[#6b21a8] mt-1 leading-[1.65] pl-[1ch] border-l border-[#e9d5ff]">{block._typedText}{#if block._typing}<span
class="animate-blink text-[#8b5cf6]"
aria-hidden="true">▎</span
>{/if}</pre>
{:else}
<div
class="prose-trace prose-thinking text-[12px] text-[#6b21a8] mt-1 leading-[1.65] pl-[1ch] border-l border-[#e9d5ff]"
>
{@html renderMarkdown(block.text)}
</div>
{/if}
</details>
{:else if block.kind === 'tool_call'}
<div class="py-0.5">
<div class="text-[12px] text-[#6b21a8]">
<span class="text-[#aaa]">tool</span>
<span class="font-semibold">{block.name}</span>
</div>
<pre
class="text-[12px] text-[#3a3a38] whitespace-pre-wrap break-words mt-0.5 pl-[1ch] border-l border-[#e9d5ff]">{formatJson(
block.input
)}</pre>
</div>
{:else if block.kind === 'tool_result'}
<div class="py-0.5">
<div
class="text-[12px] {block.isError
? 'text-[#991b1b]'
: 'text-[#6a6a66]'}"
>
<span class="text-[#aaa]">result</span>
{#if block.isError}<span class="font-semibold"
>· error</span
>{/if}
</div>
<pre
class="text-[12px] text-[#3a3a38] whitespace-pre-wrap break-words mt-0.5 pl-[1ch] border-l {block.isError
? 'border-[#fecaca]'
: 'border-[#e5e5e0]'}">{block.text}</pre>
</div>
{:else if block.kind === 'image'}
<div class="text-[12px] text-[#6a6a66] italic">
[image attachment]
</div>
{:else if block.kind === 'raw'}
<details>
<summary
class="cursor-pointer text-[11px] text-[#888] select-none hover:underline"
>raw</summary
>
<pre
class="text-[11px] text-[#555] whitespace-pre-wrap break-words mt-1 pl-[1ch] border-l border-[#e5e5e0]">{formatJson(
block.json
)}</pre>
</details>
{/if}
</div>
</div>
{/if}
{/each}
</div>
{/if}
{/each}
</div>
<!-- footer -->
<div
class="flex items-center gap-4 px-5 py-2 border-t border-[#eeeae0] text-[11px] text-[#888] shrink-0"
>
{#if messages.length > 0}
<span>{focusedIdx + 1} / {messages.length}</span>
{:else}
<span>ready</span>
{/if}
{#if playing}
<button
onclick={skip}
class="px-2 py-0.5 bg-[#f5f5f2] rounded border border-[#e5e5e0] text-[11px] hover:bg-[#eeeae0] cursor-pointer"
>skip</button
>
{:else}
<span class="flex items-center gap-1">
<kbd class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0]"
>↑</kbd
>
<kbd class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0]"
>↓</kbd
> navigate
</span>
<span class="flex items-center gap-1">
<kbd class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0]"
>Home</kbd
>
<kbd class="px-1 py-px bg-[#f5f5f2] rounded border border-[#e5e5e0]"
>End</kbd
> jump
</span>
{/if}
<a
href="https://huggingface.co/changelog/agent-trace-viewer"
target="_blank"
rel="noopener noreferrer"
class="ml-auto text-[#8b5cf6] hover:underline"
title="HF changelog · agent trace viewer"
>changelog →</a
>
</div>
</div>
</div>