session-viewer / index.html
victor's picture
victor HF Staff
Upload index.html with huggingface_hub
7c56675 verified
<!DOCTYPE html>
<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>