Spaces:
Running
Running
| <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> | |