Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Session Viewer</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="parsers.js"></script> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@400;500;600;700&family=Source+Code+Pro:wght@400;500&display=swap'); | |
| body { font-family: 'Source Sans 3', ui-sans-serif, system-ui, sans-serif; } | |
| code, pre, .mono { font-family: 'Source Code Pro', monospace; } | |
| pre { white-space: pre-wrap; word-break: break-word; } | |
| details summary { cursor: pointer; user-select: none; } | |
| details summary::-webkit-details-marker { display: none; } | |
| .drop-zone.drag-over { border-color: #6366f1; background: rgba(99, 102, 241, 0.03); } | |
| </style> | |
| </head> | |
| <body class="bg-white text-gray-900 min-h-screen"> | |
| <!-- Empty state --> | |
| <div id="empty-state" class="max-w-5xl mx-auto mt-16 px-4"> | |
| <div id="drop-zone" class="drop-zone border border-gray-200 rounded-lg p-10 text-center transition-colors"> | |
| <div class="text-gray-500 text-sm mb-1">Drop a session file or paste a URL</div> | |
| <div class="text-gray-400 text-xs mb-6">Supports Claude Code, Pi, Codex, and OpenCode JSONL formats</div> | |
| <div class="flex items-center justify-center gap-2 mb-6"> | |
| <input type="text" id="url-input-empty" placeholder="Paste session URL…" | |
| class="text-xs px-2.5 py-1.5 rounded-md bg-white border border-gray-200 text-gray-700 placeholder-gray-400 w-72 focus:outline-none focus:ring-1 focus:ring-gray-300 mono"> | |
| <label class="cursor-pointer text-xs px-3 py-1.5 rounded-md border border-gray-200 bg-white hover:bg-gray-50 transition text-gray-700 font-medium"> | |
| Load File | |
| <input type="file" accept=".jsonl,.json" class="hidden" id="file-input"> | |
| </label> | |
| </div> | |
| <div class="text-gray-400 text-[10px] uppercase tracking-widest mb-2 font-semibold">Samples</div> | |
| <div class="flex flex-wrap justify-center gap-1.5"> | |
| <button onclick="loadUrl('https://huggingface.co/datasets/victor/claude-sample-session/resolve/main/claude-code-session-2026-03-16.jsonl')" | |
| class="text-[11px] px-2 py-1 rounded border border-gray-200 text-gray-600 hover:bg-gray-50 transition">Claude Code</button> | |
| <button onclick="loadUrl('https://huggingface.co/datasets/victor/pi-sample-session/resolve/main/pi-session-2026-03-16.jsonl')" | |
| class="text-[11px] px-2 py-1 rounded border border-gray-200 text-gray-600 hover:bg-gray-50 transition">Pi</button> | |
| <button onclick="loadUrl('https://huggingface.co/datasets/victor/codex-sample-session/resolve/main/codex-session-hello-2026-03-16.jsonl')" | |
| class="text-[11px] px-2 py-1 rounded border border-gray-200 text-gray-600 hover:bg-gray-50 transition">Codex</button> | |
| <button onclick="loadUrl('https://huggingface.co/datasets/victor/opencode-sample-session/resolve/main/opencode-session-2026-03-16.jsonl')" | |
| class="text-[11px] px-2 py-1 rounded border border-gray-200 text-gray-600 hover:bg-gray-50 transition">OpenCode</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Session panel (single bordered container like HF Dataset Viewer) --> | |
| <div id="session-panel" class="hidden max-w-5xl mx-auto my-4 mx-4"> | |
| <!-- Panel header --> | |
| <div class="border border-gray-200 rounded-lg overflow-hidden"> | |
| <!-- Toolbar row --> | |
| <div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 bg-white"> | |
| <div class="flex items-center gap-2"> | |
| <span class="text-sm font-semibold text-gray-900">Session Viewer</span> | |
| <span id="sh-source" class="text-[10px] px-1.5 py-0.5 rounded font-semibold"></span> | |
| <span id="sh-model" class="text-[11px] text-gray-400 mono"></span> | |
| <span id="stat-messages" class="text-[11px] text-gray-400"></span> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <span id="stat-tokens" class="text-[11px] text-gray-400 mono"></span> | |
| <input type="text" id="url-input" placeholder="Paste session URL…" | |
| class="text-[11px] px-2 py-1 rounded-md border border-gray-200 text-gray-600 placeholder-gray-400 w-48 focus:outline-none focus:ring-1 focus:ring-gray-300 mono"> | |
| <label class="cursor-pointer text-[11px] px-2.5 py-1 rounded-md border border-gray-200 bg-white hover:bg-gray-50 transition text-gray-600 font-medium"> | |
| Load File | |
| <input type="file" accept=".jsonl,.json" class="hidden" id="file-input-2"> | |
| </label> | |
| </div> | |
| </div> | |
| <!-- Session info row --> | |
| <div id="session-info" class="px-4 py-2 border-b border-gray-200 bg-gray-50/50"> | |
| <div id="sh-title" class="text-[13px] text-gray-600"></div> | |
| <div id="sh-cwd" class="text-[11px] text-gray-400 mono"></div> | |
| </div> | |
| <!-- Messages --> | |
| <div id="messages-container"></div> | |
| </div> | |
| </div> | |
| <script> | |
| const fileInput = document.getElementById('file-input'); | |
| const fileInput2 = document.getElementById('file-input-2'); | |
| const emptyState = document.getElementById('empty-state'); | |
| const sessionPanel = document.getElementById('session-panel'); | |
| const messagesContainer = document.getElementById('messages-container'); | |
| const SOURCE_COLORS = { | |
| 'claude-code': { bg: 'bg-orange-50', text: 'text-orange-600', label: 'Claude Code' }, | |
| 'pi': { bg: 'bg-emerald-50', text: 'text-emerald-600', label: 'Pi' }, | |
| 'codex': { bg: 'bg-blue-50', text: 'text-blue-600', label: 'Codex' }, | |
| 'opencode': { bg: 'bg-purple-50', text: 'text-purple-600', label: 'OpenCode' }, | |
| }; | |
| // URL inputs (both empty state and panel) | |
| const urlInputEmpty = document.getElementById('url-input-empty'); | |
| const urlInput = document.getElementById('url-input'); | |
| urlInputEmpty.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && urlInputEmpty.value.trim()) loadUrl(urlInputEmpty.value.trim()); | |
| }); | |
| urlInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && urlInput.value.trim()) loadUrl(urlInput.value.trim()); | |
| }); | |
| async function loadUrl(url) { | |
| const hfMatch = url.match(/huggingface\.co\/datasets\/([^/]+\/[^/]+)\/(?:blob|viewer|raw)\/(?:main|refs\/[^/]+)\/(.+)/); | |
| if (hfMatch) { | |
| url = `https://huggingface.co/datasets/${hfMatch[1]}/resolve/main/${hfMatch[2]}`; | |
| } else if (url.includes('huggingface.co/datasets/') && !url.includes('/resolve/')) { | |
| const parts = url.match(/huggingface\.co\/datasets\/([^/]+\/[^/]+)\/?$/); | |
| if (parts) { alert('Paste the URL of a specific .jsonl file.'); return; } | |
| } | |
| for (const inp of [urlInputEmpty, urlInput]) { inp.disabled = true; inp.value = 'Loading\u2026'; } | |
| try { | |
| const res = await fetch(url); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| const text = await res.text(); | |
| renderSession(parseSession(text)); | |
| } catch (err) { alert(`Load error: ${err.message}`); } | |
| finally { for (const inp of [urlInputEmpty, urlInput]) { inp.disabled = false; inp.value = ''; } } | |
| } | |
| const params = new URLSearchParams(location.search); | |
| if (params.get('url')) loadUrl(params.get('url')); | |
| fileInput.addEventListener('change', (e) => { if (e.target.files[0]) loadFile(e.target.files[0]); }); | |
| fileInput2.addEventListener('change', (e) => { if (e.target.files[0]) loadFile(e.target.files[0]); }); | |
| // Drag and drop on empty state | |
| const dropZone = document.getElementById('drop-zone'); | |
| dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); }); | |
| dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); | |
| dropZone.addEventListener('drop', (e) => { | |
| e.preventDefault(); dropZone.classList.remove('drag-over'); | |
| if (e.dataTransfer.files[0]) loadFile(e.dataTransfer.files[0]); | |
| }); | |
| function loadFile(file) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| try { renderSession(parseSession(e.target.result)); } | |
| catch (err) { alert(`Parse error: ${err.message}`); } | |
| }; | |
| reader.readAsText(file); | |
| } | |
| function renderSession(session) { | |
| emptyState.classList.add('hidden'); | |
| sessionPanel.classList.remove('hidden'); | |
| const sc = SOURCE_COLORS[session.source] || SOURCE_COLORS['claude-code']; | |
| const srcEl = document.getElementById('sh-source'); | |
| srcEl.textContent = sc.label; | |
| srcEl.className = `text-[10px] px-1.5 py-0.5 rounded font-semibold ${sc.bg} ${sc.text}`; | |
| document.getElementById('sh-model').textContent = session.model || ''; | |
| document.getElementById('sh-title').textContent = session.title || session.startedAt || ''; | |
| document.getElementById('sh-cwd').textContent = session.cwd || ''; | |
| messagesContainer.innerHTML = ''; | |
| let totalIn = 0, totalOut = 0; | |
| for (const msg of session.messages) { | |
| messagesContainer.appendChild(renderMessage(msg)); | |
| if (msg.usage) { | |
| totalIn += msg.usage.inputTokens || 0; | |
| totalOut += msg.usage.outputTokens || 0; | |
| } | |
| } | |
| document.getElementById('stat-messages').textContent = `${session.messages.length} messages`; | |
| document.getElementById('stat-tokens').textContent = | |
| totalIn || totalOut ? `${totalIn.toLocaleString()} in / ${totalOut.toLocaleString()} out` : ''; | |
| } | |
| function renderMessage(msg) { | |
| const row = document.createElement('div'); | |
| const isUser = msg.role === 'user'; | |
| const isSystem = msg.role === 'system'; | |
| row.className = 'border-b border-gray-200 px-4 py-2.5'; | |
| // Role + meta line | |
| const meta = document.createElement('div'); | |
| meta.className = 'flex items-center gap-1.5 mb-1'; | |
| const roleLabel = document.createElement('span'); | |
| roleLabel.className = `text-[11px] font-bold uppercase tracking-wide ${ | |
| isUser ? 'text-blue-600' : isSystem ? 'text-gray-400' : 'text-green-600' | |
| }`; | |
| roleLabel.textContent = msg.role; | |
| meta.appendChild(roleLabel); | |
| if (msg.model) { | |
| const m = document.createElement('span'); | |
| m.className = 'text-[11px] text-gray-400 mono'; | |
| m.textContent = msg.model; | |
| meta.appendChild(m); | |
| } | |
| if (msg.timestamp) { | |
| const ts = document.createElement('span'); | |
| ts.className = 'text-[11px] text-gray-300'; | |
| ts.textContent = new Date(msg.timestamp).toLocaleTimeString(); | |
| meta.appendChild(ts); | |
| } | |
| row.appendChild(meta); | |
| // Content | |
| const content = document.createElement('div'); | |
| for (const block of msg.blocks) { | |
| content.appendChild(renderBlock(block)); | |
| } | |
| // Usage | |
| if (msg.usage && (msg.usage.inputTokens || msg.usage.outputTokens)) { | |
| const u = document.createElement('div'); | |
| u.className = 'text-[11px] text-gray-400 mono mt-1'; | |
| const parts = []; | |
| if (msg.usage.inputTokens) parts.push(`${msg.usage.inputTokens.toLocaleString()} in`); | |
| if (msg.usage.outputTokens) parts.push(`${msg.usage.outputTokens.toLocaleString()} out`); | |
| if (msg.usage.cacheRead) parts.push(`${msg.usage.cacheRead.toLocaleString()} cached`); | |
| u.textContent = parts.join(' \u00b7 '); | |
| content.appendChild(u); | |
| } | |
| row.appendChild(content); | |
| return row; | |
| } | |
| function renderBlock(block) { | |
| const el = document.createElement('div'); | |
| switch (block.type) { | |
| case 'text': | |
| el.className = 'text-[13px] leading-snug text-gray-800 whitespace-pre-wrap'; | |
| el.innerHTML = renderMarkdown(block.text); | |
| break; | |
| case 'thinking': | |
| el.className = 'my-1'; | |
| const d = document.createElement('details'); | |
| const s = document.createElement('summary'); | |
| s.className = 'text-[11px] text-gray-400 hover:text-gray-500 flex items-center gap-1'; | |
| s.innerHTML = '<span class="text-[9px]">\u25b6</span> Thinking'; | |
| d.appendChild(s); | |
| const c = document.createElement('div'); | |
| c.className = 'mt-1 pl-2.5 border-l border-gray-200 text-[11px] text-gray-500 italic whitespace-pre-wrap'; | |
| c.textContent = block.text; | |
| d.appendChild(c); | |
| el.appendChild(d); | |
| break; | |
| case 'tool_call': | |
| el.className = 'my-1'; | |
| const td = document.createElement('details'); | |
| const ts = document.createElement('summary'); | |
| ts.className = 'text-[11px] inline-flex items-center gap-1 py-0.5 px-1.5 rounded bg-amber-50 border border-amber-200 text-amber-700 hover:bg-amber-100 transition'; | |
| ts.innerHTML = `<span class="font-medium">${escapeHtml(block.toolName)}</span>`; | |
| td.appendChild(ts); | |
| const tc = document.createElement('pre'); | |
| tc.className = 'mt-1 p-2 rounded bg-gray-50 border border-gray-200 text-[11px] text-gray-600 overflow-x-auto mono'; | |
| tc.textContent = typeof block.input === 'string' ? block.input : JSON.stringify(block.input, null, 2); | |
| td.appendChild(tc); | |
| el.appendChild(td); | |
| break; | |
| case 'tool_result': | |
| el.className = 'my-1'; | |
| const rd = document.createElement('details'); | |
| const rs = document.createElement('summary'); | |
| const isErr = block.isError; | |
| rs.className = `text-[11px] inline-flex items-center gap-1 py-0.5 px-1.5 rounded border transition ${ | |
| isErr ? 'bg-red-50 border-red-200 text-red-600 hover:bg-red-100' : 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100' | |
| }`; | |
| const preview = (block.content || '').slice(0, 60).replace(/\n/g, ' '); | |
| rs.innerHTML = `<span class="font-medium">${isErr ? 'Error' : 'Result'}</span><span class="text-gray-400 truncate ml-1">${escapeHtml(preview)}</span>`; | |
| rd.appendChild(rs); | |
| const rc = document.createElement('pre'); | |
| rc.className = 'mt-1 p-2 rounded bg-gray-50 border border-gray-200 text-[11px] text-gray-600 overflow-x-auto max-h-64 overflow-y-auto mono'; | |
| rc.textContent = block.content; | |
| rd.appendChild(rc); | |
| el.appendChild(rd); | |
| break; | |
| } | |
| return el; | |
| } | |
| function renderMarkdown(text) { | |
| if (!text) return ''; | |
| return escapeHtml(text) | |
| .replace(/```(\w*)\n([\s\S]*?)```/g, '<pre class="my-1 p-2 rounded bg-gray-50 border border-gray-200 text-[11px] text-gray-700 overflow-x-auto mono"><code>$2</code></pre>') | |
| .replace(/`([^`]+)`/g, '<code class="px-0.5 py-px rounded bg-gray-100 text-[11px] text-gray-700 mono">$1</code>') | |
| .replace(/\*\*(.+?)\*\*/g, '<strong class="font-semibold">$1</strong>') | |
| .replace(/^### (.+)$/gm, '<div class="text-[13px] font-semibold mt-2 mb-0.5">$1</div>') | |
| .replace(/^## (.+)$/gm, '<div class="text-sm font-semibold mt-2 mb-0.5">$1</div>') | |
| .replace(/^# (.+)$/gm, '<div class="text-[15px] font-semibold mt-2 mb-0.5">$1</div>') | |
| .replace(/\n/g, '<br>'); | |
| } | |
| function escapeHtml(text) { | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| </script> | |
| </body> | |
| </html> | |