Files
OpenClaw e623d42eb8 feat: openclaw session dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 15:49:23 +00:00

1187 lines
45 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenClaw Dashboard</title>
<style>
/* ── Variables ────────────────────────────────────────────────────────── */
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #1c2128;
--border: #30363d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--yellow: #d29922;
--orange: #e3b341;
--red: #f85149;
--purple: #bc8cff;
--mono: 'Consolas', 'Monaco', 'Courier New', monospace;
}
/* ── Reset & base ─────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
button {
cursor: pointer;
background: var(--surface2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 12px;
font-size: 13px;
transition: background .15s, border-color .15s;
}
button:hover { background: #222d3a; border-color: var(--accent); }
select, input {
background: var(--surface2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 10px;
font-size: 13px;
outline: none;
}
select:focus, input:focus { border-color: var(--accent); }
pre, code, .mono { font-family: var(--mono); }
details > summary { cursor: pointer; user-select: none; }
details > summary::-webkit-details-marker { display: none; }
/* ── Layout ───────────────────────────────────────────────────────────── */
#app { display: flex; flex-direction: column; min-height: 100vh; }
.header {
position: sticky; top: 0; z-index: 100;
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 20px;
}
.header-inner {
max-width: 1400px; margin: 0 auto;
display: flex; align-items: center; justify-content: space-between;
height: 52px;
}
.logo {
display: flex; align-items: center; gap: 10px;
font-size: 16px; font-weight: 600; color: var(--text);
text-decoration: none;
}
.logo-icon { font-size: 20px; }
.logo-sub { font-weight: 400; color: var(--muted); font-size: 13px; }
.header-right { display: flex; align-items: center; gap: 12px; }
#main {
flex: 1;
max-width: 1400px; width: 100%;
margin: 0 auto;
padding: 20px;
}
/* ── Badges ───────────────────────────────────────────────────────────── */
.badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 2px 8px; border-radius: 12px;
font-size: 11px; font-weight: 600; letter-spacing: .4px;
text-transform: uppercase;
}
.badge::before { content: '●'; font-size: 8px; }
.badge-active { background: #1a3a2a; color: var(--green); border: 1px solid #2d6040; }
.badge-idle { background: #1c2128; color: var(--muted); border: 1px solid var(--border); }
.badge-deleted { background: #3a1a1a; color: var(--red); border: 1px solid #6040408; }
.badge-reset { background: #3a2a0a; color: var(--orange); border: 1px solid #604020; }
/* ── Pill / tag ───────────────────────────────────────────────────────── */
.tag {
display: inline-block;
padding: 1px 7px; border-radius: 4px;
font-size: 11px; font-family: var(--mono);
background: var(--surface2); color: var(--muted);
border: 1px solid var(--border);
}
/* ── Refresh indicator ────────────────────────────────────────────────── */
#refresh-info { font-size: 12px; color: var(--muted); }
#refresh-info.refreshing { color: var(--accent); }
/* ── Filters bar ──────────────────────────────────────────────────────── */
.filters {
display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
margin-bottom: 16px;
}
.filters label { font-size: 12px; color: var(--muted); margin-right: 4px; }
#search { width: 200px; }
/* ── Table ────────────────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; border-radius: 8px; border: 1px solid var(--border); }
table { width: 100%; border-collapse: collapse; }
thead th {
background: var(--surface);
padding: 10px 14px;
text-align: left;
font-size: 11px; font-weight: 600; color: var(--muted);
text-transform: uppercase; letter-spacing: .5px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
tbody tr {
background: var(--surface);
border-bottom: 1px solid var(--border);
transition: background .1s;
cursor: pointer;
}
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: var(--surface2); }
td {
padding: 10px 14px;
font-size: 13px;
vertical-align: middle;
white-space: nowrap;
}
.td-agent { color: var(--accent); font-family: var(--mono); }
.td-id { font-family: var(--mono); font-size: 12px; color: var(--muted); }
.td-model { font-family: var(--mono); font-size: 12px; }
.td-num { text-align: right; font-family: var(--mono); }
.td-time { color: var(--muted); font-size: 12px; }
.td-dur { color: var(--muted); font-size: 12px; font-family: var(--mono); }
.td-cost { font-family: var(--mono); color: var(--yellow); }
.empty-row td { text-align: center; color: var(--muted); padding: 40px; }
/* ── Detail page ──────────────────────────────────────────────────────── */
.detail-header {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 20px;
}
.detail-title {
display: flex; align-items: center; gap: 12px;
margin-bottom: 12px; flex-wrap: wrap;
}
.detail-title h2 {
font-size: 15px; font-weight: 600;
font-family: var(--mono); color: var(--text);
}
.btn-back {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; font-size: 12px;
}
.btn-copy {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 8px; font-size: 11px;
}
.detail-meta {
display: flex; flex-wrap: wrap; gap: 20px;
font-size: 12px; color: var(--muted);
}
.detail-meta span { display: flex; align-items: center; gap: 5px; }
.detail-meta strong { color: var(--text); }
/* ── Live indicator ───────────────────────────────────────────────────── */
.live-dot {
display: inline-block; width: 8px; height: 8px;
border-radius: 50%; background: var(--green);
animation: pulse 1.5s ease-in-out infinite;
}
.live-dot.disconnected { background: var(--muted); animation: none; }
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .5; transform: scale(.8); }
}
#live-status { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
/* ── Timeline ─────────────────────────────────────────────────────────── */
#timeline { display: flex; flex-direction: column; gap: 2px; }
.tl-msg {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
border-left: 3px solid var(--border);
overflow: hidden;
}
.tl-msg.user { border-left-color: var(--accent); }
.tl-msg.assistant { border-left-color: var(--green); }
.tl-msg-header {
display: flex; align-items: center; gap: 10px;
padding: 8px 14px;
border-bottom: 1px solid var(--border);
font-size: 11px;
}
.tl-role {
font-weight: 700; text-transform: uppercase; letter-spacing: .5px;
font-size: 11px;
}
.role-user { color: var(--accent); }
.role-assistant { color: var(--green); }
.tl-ts { color: var(--muted); margin-left: auto; font-family: var(--mono); }
.tl-tokens { color: var(--muted); font-family: var(--mono); }
.tl-cost { color: var(--yellow); font-family: var(--mono); }
.tl-body { padding: 10px 14px; display: flex; flex-direction: column; gap: 8px; }
/* Content types inside messages */
.c-text {
font-size: 13px; white-space: pre-wrap; word-break: break-word;
color: var(--text); line-height: 1.6;
}
.c-text-muted { color: var(--muted); }
.c-thinking {
border-left: 2px solid var(--purple);
padding-left: 10px;
}
.c-thinking summary {
font-size: 11px; color: var(--purple); padding: 4px 0;
display: flex; align-items: center; gap: 6px;
}
.c-thinking-text {
font-size: 12px; color: var(--muted);
white-space: pre-wrap; word-break: break-word;
margin-top: 6px; line-height: 1.5;
max-height: 300px; overflow-y: auto;
}
.c-tool-call {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px; overflow: hidden;
}
.c-tool-call summary {
padding: 7px 12px;
display: flex; align-items: center; gap: 8px;
font-size: 12px; font-family: var(--mono);
background: #1c2128;
}
.c-tool-call summary:hover { background: #222d3a; }
.tool-name { color: var(--orange); font-weight: 600; }
.tool-arrow { color: var(--muted); font-size: 10px; margin-right: auto; }
.tool-id-tag {
font-size: 10px; color: var(--muted);
padding: 1px 5px; border-radius: 3px;
background: var(--border);
}
.c-tool-args {
padding: 10px 12px;
font-size: 12px; font-family: var(--mono); color: #a9d1f7;
white-space: pre-wrap; word-break: break-word;
max-height: 400px; overflow-y: auto;
border-top: 1px solid var(--border);
}
.c-image {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 4px;
background: var(--surface2); border: 1px solid var(--border);
font-size: 12px; color: var(--muted);
}
/* System events (model change, thinking level, session start) */
.tl-sys {
display: flex; align-items: center; gap: 10px;
padding: 6px 14px;
font-size: 11px; color: var(--muted);
}
.tl-sys-line {
flex: 1; height: 1px; background: var(--border);
}
.tl-sys-text {
white-space: nowrap; padding: 3px 8px;
border-radius: 12px; background: var(--surface);
border: 1px solid var(--border);
}
/* ── Loading / error states ───────────────────────────────────────────── */
.loading {
display: flex; align-items: center; justify-content: center;
gap: 10px; color: var(--muted); padding: 60px;
}
.spinner {
width: 18px; height: 18px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.error-box {
background: #3a1a1a; border: 1px solid #6a2020;
border-radius: 8px; padding: 16px 20px;
color: var(--red); font-size: 13px;
}
/* ── Scroll-to-bottom btn ─────────────────────────────────────────────── */
#scroll-btn {
position: fixed; bottom: 24px; right: 24px;
background: var(--accent); color: #0d1117;
border: none; border-radius: 20px;
padding: 7px 14px; font-size: 12px; font-weight: 600;
display: none; z-index: 50;
box-shadow: 0 2px 8px rgba(0,0,0,.5);
}
#scroll-btn:hover { background: #79b8ff; }
/* ── Stats bar ────────────────────────────────────────────────────────── */
.stats-row {
display: flex; flex-wrap: wrap; gap: 10px;
margin-bottom: 16px;
}
.stat-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 16px;
display: flex; flex-direction: column; gap: 3px; min-width: 100px;
}
.stat-label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
.stat-value { font-size: 18px; font-weight: 600; font-family: var(--mono); color: var(--text); }
.stat-value.green { color: var(--green); }
.stat-value.yellow { color: var(--yellow); }
.stat-value.accent { color: var(--accent); }
/* ── Responsive ───────────────────────────────────────────────────────── */
@media (max-width: 768px) {
#main { padding: 12px; }
.filters { gap: 8px; }
#search { width: 140px; }
td { padding: 8px 10px; }
.detail-meta { gap: 12px; }
.stats-row { gap: 8px; }
.stat-card { min-width: 80px; padding: 8px 12px; }
}
</style>
</head>
<body>
<div id="app">
<header class="header">
<div class="header-inner">
<a class="logo" href="#/">
<span class="logo-icon"></span>
<span>OpenClaw</span>
<span class="logo-sub">Dashboard</span>
</a>
<div class="header-right">
<span id="refresh-info"></span>
<span id="live-status" style="display:none">
<span class="live-dot disconnected" id="live-dot"></span>
<span id="live-label">Connecting…</span>
</span>
</div>
</div>
</header>
<main id="main">
<div class="loading"><div class="spinner"></div> Loading…</div>
</main>
</div>
<button id="scroll-btn" onclick="scrollToBottom()">↓ Latest</button>
<script>
// ═══════════════════════════════════════════════════════════════════════════
// State
// ═══════════════════════════════════════════════════════════════════════════
const state = {
sessions: [],
filters: { agent: '', status: '', search: '' },
refreshTimer: null,
refreshAt: null,
ws: null,
autoScroll: true,
currentRoute: null,
};
// ═══════════════════════════════════════════════════════════════════════════
// Utilities
// ═══════════════════════════════════════════════════════════════════════════
function esc(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function fmtNum(n) {
if (n === null || n === undefined || isNaN(n)) return '—';
return Number(n).toLocaleString();
}
function fmtTokens(n) {
if (!n) return '0';
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 10_000) return (n / 1_000).toFixed(1) + 'K';
return fmtNum(n);
}
function fmtCost(n) {
if (!n) return '$0.0000';
if (n < 0.0001) return '$<0.0001';
return '$' + Number(n).toFixed(4);
}
function fmtTimeAgo(ts) {
if (!ts) return '—';
const diff = Date.now() - new Date(ts).getTime();
if (diff < 0) return 'just now';
const s = Math.floor(diff / 1000);
if (s < 60) return s + 's ago';
const m = Math.floor(s / 60);
if (m < 60) return m + 'm ago';
const h = Math.floor(m / 60);
if (h < 24) return h + 'h ago';
return Math.floor(h / 24) + 'd ago';
}
function fmtDate(ts) {
if (!ts) return '—';
const d = new Date(ts);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
+ ' ' + d.toTimeString().slice(0, 5);
}
function fmtDateFull(ts) {
if (!ts) return '—';
const d = new Date(ts);
return d.toLocaleString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
}
function fmtDuration(start, end) {
if (!start) return '—';
const ms = new Date(end || Date.now()).getTime() - new Date(start).getTime();
if (ms < 0) return '—';
const s = Math.floor(ms / 1000);
if (s < 60) return s + 's';
const m = Math.floor(s / 60);
if (m < 60) return m + 'm ' + (s % 60) + 's';
const h = Math.floor(m / 60);
return h + 'h ' + (m % 60) + 'm';
}
function shortId(id) {
if (!id) return '—';
return id.length > 12 ? id.slice(0, 8) + '…' : id;
}
// ═══════════════════════════════════════════════════════════════════════════
// Router
// ═══════════════════════════════════════════════════════════════════════════
function navigate(hash) { window.location.hash = hash; }
function handleRoute() {
const hash = window.location.hash || '#/';
const parts = hash.slice(1).split('/').filter(Boolean);
// Cleanup previous state
stopAutoRefresh();
disconnectWS();
document.getElementById('live-status').style.display = 'none';
document.getElementById('refresh-info').textContent = '';
document.getElementById('scroll-btn').style.display = 'none';
state.currentRoute = hash;
if (parts[0] === 'session' && parts[1] && parts[2]) {
renderDetail(parts[1], parts[2]);
} else {
renderList();
}
}
window.addEventListener('hashchange', handleRoute);
// ═══════════════════════════════════════════════════════════════════════════
// Auto-refresh
// ═══════════════════════════════════════════════════════════════════════════
function stopAutoRefresh() {
if (state.refreshTimer) { clearInterval(state.refreshTimer); state.refreshTimer = null; }
}
function startAutoRefresh(fn, interval = 10000) {
stopAutoRefresh();
state.refreshAt = Date.now() + interval;
state.refreshTimer = setInterval(() => {
state.refreshAt = Date.now() + interval;
fn();
}, interval);
updateRefreshInfo();
const tickTimer = setInterval(() => {
if (!state.refreshTimer) { clearInterval(tickTimer); return; }
updateRefreshInfo();
}, 1000);
}
function updateRefreshInfo() {
const el = document.getElementById('refresh-info');
if (!el || !state.refreshAt) return;
const sec = Math.max(0, Math.ceil((state.refreshAt - Date.now()) / 1000));
el.textContent = `${sec}s`;
}
// ═══════════════════════════════════════════════════════════════════════════
// List view
// ═══════════════════════════════════════════════════════════════════════════
async function renderList() {
const main = document.getElementById('main');
// Show spinner only on first load
if (!state.sessions.length) {
main.innerHTML = `<div class="loading"><div class="spinner"></div> Loading sessions…</div>`;
}
try {
const res = await fetch('/api/sessions');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
state.sessions = await res.json();
} catch (err) {
main.innerHTML = `<div class="error-box">Failed to load sessions: ${esc(err.message)}</div>`;
return;
}
renderListContent();
startAutoRefresh(renderList);
}
function renderListContent() {
// Build unique agent list
const agents = [...new Set(state.sessions.map(s => s.agent))].sort();
const statuses = ['active', 'idle', 'deleted', 'reset'];
const { agent: fa, status: fs, search: fq } = state.filters;
// Filter
const filtered = state.sessions.filter(s => {
if (fa && s.agent !== fa) return false;
if (fs && s.status !== fs) return false;
if (fq) {
const q = fq.toLowerCase();
if (!s.fileSessionId.toLowerCase().includes(q) &&
!s.agent.toLowerCase().includes(q) &&
!(s.cwd || '').toLowerCase().includes(q) &&
!(s.model || '').toLowerCase().includes(q)) return false;
}
return true;
});
const main = document.getElementById('main');
main.innerHTML = `
<div class="filters">
<div>
<label>Agent</label>
<select id="f-agent">
<option value="">All agents</option>
${agents.map(a => `<option value="${esc(a)}" ${a === fa ? 'selected' : ''}>${esc(a)}</option>`).join('')}
</select>
</div>
<div>
<label>Status</label>
<select id="f-status">
<option value="">All statuses</option>
${statuses.map(s => `<option value="${s}" ${s === fs ? 'selected' : ''}>${s}</option>`).join('')}
</select>
</div>
<input id="search" type="search" placeholder="Search…" value="${esc(fq)}">
<span style="margin-left:auto;font-size:12px;color:var(--muted)">
${fmtNum(filtered.length)} / ${fmtNum(state.sessions.length)} sessions
</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Agent</th>
<th>Session ID</th>
<th>Status</th>
<th>Model</th>
<th class="td-num">Messages</th>
<th class="td-num">Tokens</th>
<th class="td-num">Cost</th>
<th>Last activity</th>
<th>Duration</th>
</tr>
</thead>
<tbody id="sessions-tbody">
${filtered.length ? filtered.map(s => sessionRow(s)).join('') : `
<tr class="empty-row"><td colspan="9">No sessions match the current filters</td></tr>
`}
</tbody>
</table>
</div>
`;
// Bind filters
document.getElementById('f-agent').addEventListener('change', e => {
state.filters.agent = e.target.value; renderListContent();
});
document.getElementById('f-status').addEventListener('change', e => {
state.filters.status = e.target.value; renderListContent();
});
document.getElementById('search').addEventListener('input', e => {
state.filters.search = e.target.value; renderListContent();
});
}
function sessionRow(s) {
const href = `#/session/${esc(s.agent)}/${esc(s.fileSessionId)}`;
return `
<tr onclick="navigate('${href}')">
<td class="td-agent">${esc(s.agent)}</td>
<td class="td-id mono">${esc(shortId(s.fileSessionId))}</td>
<td><span class="badge badge-${s.status}">${s.status}</span></td>
<td class="td-model">${esc(s.model || '—')}</td>
<td class="td-num">${fmtNum(s.messageCount)}</td>
<td class="td-num">${fmtTokens(s.totalTokens)}</td>
<td class="td-cost">${fmtCost(s.totalCost)}</td>
<td class="td-time">${fmtTimeAgo(s.lastTimestamp || s.firstTimestamp)}</td>
<td class="td-dur">${fmtDuration(s.firstTimestamp, s.lastTimestamp)}</td>
</tr>
`;
}
// ═══════════════════════════════════════════════════════════════════════════
// Detail view
// ═══════════════════════════════════════════════════════════════════════════
async function renderDetail(agent, id) {
const main = document.getElementById('main');
main.innerHTML = `<div class="loading"><div class="spinner"></div> Loading session…</div>`;
let events;
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(agent)}/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
events = await res.json();
} catch (err) {
main.innerHTML = `
<div style="margin-bottom:12px">
<button class="btn-back" onclick="navigate('#/')">← Back</button>
</div>
<div class="error-box">Failed to load session: ${esc(err.message)}</div>
`;
return;
}
// Compute summary from events
const summary = summariseEvents(events);
main.innerHTML = `
<div class="detail-header">
<div class="detail-title">
<button class="btn-back" onclick="navigate('#/')">← Back</button>
<span class="badge badge-${esc(summary.status)}">${esc(summary.status)}</span>
<h2 class="mono">${esc(agent)} / ${esc(id)}</h2>
<button class="btn-copy" onclick="copyId('${esc(id)}')">⧉ Copy ID</button>
</div>
<div class="detail-meta">
<span>🤖 <strong>${esc(summary.model || '—')}</strong></span>
<span>📁 <strong class="mono" title="${esc(summary.cwd)}">${esc(summary.cwd ? (summary.cwd.length > 40 ? '…' + summary.cwd.slice(-40) : summary.cwd) : '—')}</strong></span>
<span>🕒 Started <strong>${esc(fmtDateFull(summary.firstTimestamp))}</strong></span>
<span>⏱ <strong>${esc(fmtDuration(summary.firstTimestamp, summary.lastTimestamp))}</strong></span>
</div>
</div>
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">Messages</div>
<div class="stat-value accent">${fmtNum(summary.messageCount)}</div>
</div>
<div class="stat-card">
<div class="stat-label">User turns</div>
<div class="stat-value">${fmtNum(summary.userMessages)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Assistant turns</div>
<div class="stat-value">${fmtNum(summary.assistantMessages)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Total tokens</div>
<div class="stat-value green">${fmtTokens(summary.totalTokens)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Total cost</div>
<div class="stat-value yellow">${fmtCost(summary.totalCost)}</div>
</div>
</div>
<div id="timeline"></div>
`;
// Render existing events
const tl = document.getElementById('timeline');
for (const ev of events) {
const el = renderEvent(ev);
if (el) tl.appendChild(el);
}
// Show live status bar
document.getElementById('live-status').style.display = 'flex';
// Connect WebSocket for live tail
connectWS(agent, id);
// Scroll to bottom
setupAutoScroll();
scrollToBottom();
}
function summariseEvents(events) {
let model = null, cwd = null, firstTimestamp = null, lastTimestamp = null;
let messageCount = 0, userMessages = 0, assistantMessages = 0;
let totalTokens = 0, totalCost = 0, status = 'idle';
for (const ev of events) {
if (ev.type === 'session') {
cwd = ev.cwd;
if (!firstTimestamp) firstTimestamp = ev.timestamp;
}
if (ev.type === 'custom' && ev.customType === 'model-snapshot' && ev.data?.modelId) {
model = ev.data.modelId;
}
if (ev.type === 'model_change' && ev.modelId && !model) {
model = ev.modelId;
}
if (ev.type === 'message') {
const msg = ev.message || {};
messageCount++;
if (!firstTimestamp && ev.timestamp) firstTimestamp = ev.timestamp;
if (ev.timestamp) lastTimestamp = ev.timestamp;
if (msg.usage) {
totalTokens += msg.usage.totalTokens || 0;
totalCost += msg.usage.cost?.total || 0;
}
if (!model && msg.model && msg.model !== 'delivery-mirror') model = msg.model;
if (msg.role === 'user') userMessages++;
if (msg.role === 'assistant') assistantMessages++;
}
}
// Determine status from current hash/filename indirectly — approximate from recency
const lastMs = lastTimestamp ? Date.now() - new Date(lastTimestamp).getTime() : Infinity;
status = lastMs < 5 * 60 * 1000 ? 'active' : 'idle';
return { model, cwd, firstTimestamp, lastTimestamp, messageCount, userMessages, assistantMessages, totalTokens, totalCost, status };
}
// ═══════════════════════════════════════════════════════════════════════════
// Event rendering
// ═══════════════════════════════════════════════════════════════════════════
function renderEvent(ev) {
if (!ev || !ev.type) return null;
switch (ev.type) {
case 'session':
return sysEvent(`🚀 Session started · ${esc(fmtDateFull(ev.timestamp))}${ev.cwd ? ' · <span class="mono">' + esc(ev.cwd) + '</span>' : ''}`);
case 'model_change':
return sysEvent(`🤖 Model changed → <strong>${esc(ev.modelId || ev.provider)}</strong>`);
case 'thinking_level_change':
return sysEvent(`💭 Thinking level → <strong>${esc(ev.thinkingLevel)}</strong>`);
case 'custom':
if (ev.customType === 'model-snapshot') {
return sysEvent(`📸 Model snapshot · ${esc(ev.data?.provider)} / <strong>${esc(ev.data?.modelId)}</strong>`);
}
return null; // skip other custom events
case 'message':
return renderMessage(ev);
default:
return null;
}
}
function sysEvent(html) {
const div = document.createElement('div');
div.className = 'tl-sys';
div.innerHTML = `
<div class="tl-sys-line"></div>
<div class="tl-sys-text">${html}</div>
<div class="tl-sys-line"></div>
`;
return div;
}
function renderMessage(ev) {
const msg = ev.message || {};
const role = msg.role || 'unknown';
const content = Array.isArray(msg.content) ? msg.content : [];
const usage = msg.usage;
const ts = ev.timestamp;
const wrap = document.createElement('div');
wrap.className = `tl-msg ${role}`;
// Header row
const tokensHtml = usage?.totalTokens
? `<span class="tl-tokens">${fmtTokens(usage.totalTokens)} tok</span>`
: '';
const costHtml = usage?.cost?.total
? `<span class="tl-cost">${fmtCost(usage.cost.total)}</span>`
: '';
const tsHtml = ts ? `<span class="tl-ts">${esc(fmtDateFull(ts))}</span>` : '';
wrap.innerHTML = `
<div class="tl-msg-header">
<span class="tl-role role-${esc(role)}">${esc(role)}</span>
${tokensHtml}${costHtml}${tsHtml}
</div>
<div class="tl-body" id="body-${esc(ev.id || Math.random())}"></div>
`;
const body = wrap.querySelector('.tl-body');
for (const item of content) {
const el = renderContentItem(item);
if (el) body.appendChild(el);
}
// If body is empty (e.g. no renderable content), hide it
if (!body.children.length) {
body.remove();
wrap.style.borderBottom = 'none';
}
return wrap;
}
function renderContentItem(item) {
if (!item || !item.type) return null;
switch (item.type) {
case 'text': {
if (!item.text) return null;
const div = document.createElement('div');
// Dimmed for very short system-like messages
const isBrief = item.text.length < 80 && item.text.startsWith('✅');
div.className = 'c-text' + (isBrief ? ' c-text-muted' : '');
div.textContent = item.text;
return div;
}
case 'thinking': {
const details = document.createElement('details');
details.className = 'c-thinking';
const thinking = item.thinking || '';
details.innerHTML = `
<summary>💭 Thinking <small style="color:var(--muted)">(${fmtNum(thinking.length)} chars)</small></summary>
<div class="c-thinking-text">${esc(thinking)}</div>
`;
return details;
}
case 'toolCall': {
const details = document.createElement('details');
details.className = 'c-tool-call';
const argsStr = item.arguments
? JSON.stringify(item.arguments, null, 2)
: '{}';
details.innerHTML = `
<summary>
<span class="tool-name">⚙ ${esc(item.name)}</span>
<span class="tool-arrow">▸</span>
<span class="tool-id-tag">${esc((item.id || '').slice(-8))}</span>
</summary>
<div class="c-tool-args">${esc(argsStr)}</div>
`;
return details;
}
case 'image': {
const div = document.createElement('div');
div.className = 'c-image';
div.textContent = '📷 Image';
return div;
}
default: {
const div = document.createElement('div');
div.className = 'c-text c-text-muted';
div.textContent = `[${item.type}]`;
return div;
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// WebSocket
// ═══════════════════════════════════════════════════════════════════════════
function disconnectWS() {
if (state.ws) {
state.ws.onclose = null;
state.ws.close();
state.ws = null;
}
}
function connectWS(agent, id) {
disconnectWS();
setLiveStatus('connecting');
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${location.host}/ws/sessions/${encodeURIComponent(agent)}/${encodeURIComponent(id)}`;
const ws = new WebSocket(url);
state.ws = ws;
ws.onopen = () => setLiveStatus('live');
ws.onmessage = e => {
try {
const ev = JSON.parse(e.data);
// Skip events we already rendered (all existing content was sent on open)
// The server sends ALL existing content first, then new lines.
// We only want to append truly new events (we already rendered on page load).
// Use a counter approach: track how many events we've received and skip first N.
ws._received = (ws._received || 0) + 1;
if (ws._received <= (ws._initialCount || 0)) return;
const tl = document.getElementById('timeline');
if (!tl) return;
const el = renderEvent(ev);
if (el) {
tl.appendChild(el);
if (state.autoScroll) scrollToBottom();
}
} catch { /* ignore */ }
};
ws.onclose = () => {
setLiveStatus('disconnected');
// Attempt reconnect after 3s if still on same route
setTimeout(() => {
if (state.currentRoute === window.location.hash) connectWS(agent, id);
}, 3000);
};
ws.onerror = () => setLiveStatus('disconnected');
}
function setLiveStatus(state_) {
const dot = document.getElementById('live-dot');
const label = document.getElementById('live-label');
if (!dot || !label) return;
if (state_ === 'live') {
dot.className = 'live-dot';
label.textContent = 'Live';
} else if (state_ === 'connecting') {
dot.className = 'live-dot disconnected';
label.textContent = 'Connecting…';
} else {
dot.className = 'live-dot disconnected';
label.textContent = 'Disconnected';
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Auto-scroll
// ═══════════════════════════════════════════════════════════════════════════
function setupAutoScroll() {
state.autoScroll = true;
const btn = document.getElementById('scroll-btn');
window.addEventListener('scroll', () => {
const nearBottom = (window.innerHeight + window.scrollY) >= document.body.scrollHeight - 100;
state.autoScroll = nearBottom;
if (btn) btn.style.display = nearBottom ? 'none' : 'block';
}, { passive: true });
}
function scrollToBottom() {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
}
// ═══════════════════════════════════════════════════════════════════════════
// Misc
// ═══════════════════════════════════════════════════════════════════════════
function copyId(id) {
navigator.clipboard.writeText(id).then(() => {
// Brief visual feedback
const btn = document.querySelector('.btn-copy');
if (btn) { btn.textContent = '✓ Copied'; setTimeout(() => btn.textContent = '⧉ Copy ID', 1200); }
});
}
// Handle the WS "initial dump vs new events" problem:
// We fetch all events via the REST API and render them, then open WS.
// The WS will replay all events from the start. We need to skip already-rendered ones.
// Simple approach: count events at detail load time and set ws._initialCount.
const _origRenderDetail = renderDetail;
// Patch renderDetail to track initial event count via ws._initialCount after WS connects.
// We do this by hooking connectWS: before connecting, count existing timeline children.
const _origConnectWS = connectWS;
// ── Override: after REST load, track initial timeline items count ──────────
// We intercept by observing wss.onopen to set ws._initialCount.
// The server sends all existing lines first; we skip that many messages.
(function patchConnectWS() {
const orig = window.connectWS;
// Count timeline items before WS opens — set after events are rendered.
const origFn = connectWS;
// This is handled in ws.onopen below via _initialCount being set to a large
// number, then reset once the "catch-up" is done.
// Actually: easier solution — track total lines in file via a counter.
// The server sends raw JSON lines. We count how many arrive before a 100ms gap.
})();
// Simpler approach for the WS duplicate problem:
// After connect, the server sends ALL existing lines then stops.
// We know how many events the REST API returned. Set that as skip count.
// Rewrite connectWS to accept initialCount.
function connectWSWithSkip(agent, id, skipCount) {
disconnectWS();
setLiveStatus('connecting');
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${location.host}/ws/sessions/${encodeURIComponent(agent)}/${encodeURIComponent(id)}`;
const ws = new WebSocket(url);
state.ws = ws;
ws._skip = skipCount;
ws._received = 0;
ws.onopen = () => setLiveStatus('live');
ws.onmessage = e => {
ws._received++;
if (ws._received <= ws._skip) return; // skip already-rendered events
try {
const ev = JSON.parse(e.data);
const tl = document.getElementById('timeline');
if (!tl) return;
const el = renderEvent(ev);
if (el) {
tl.appendChild(el);
if (state.autoScroll) scrollToBottom();
}
} catch { /* ignore */ }
};
ws.onclose = () => {
setLiveStatus('disconnected');
setTimeout(() => {
if (state.currentRoute === window.location.hash) {
// On reconnect, skip nothing — we only want new events.
// But file might have grown. Count current timeline items? Too complex.
// Simpler: re-fetch events and rerender, then reconnect with new skip count.
const hash = window.location.hash;
const parts = hash.slice(1).split('/').filter(Boolean);
if (parts[0] === 'session' && parts[1] && parts[2]) {
renderDetail(parts[1], parts[2]);
}
}
}, 3000);
};
ws.onerror = () => setLiveStatus('disconnected');
}
// Override renderDetail to use connectWSWithSkip
async function renderDetail(agent, id) {
const main = document.getElementById('main');
main.innerHTML = `<div class="loading"><div class="spinner"></div> Loading session…</div>`;
let events;
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(agent)}/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
events = await res.json();
} catch (err) {
main.innerHTML = `
<div style="margin-bottom:12px">
<button class="btn-back" onclick="navigate('#/')">← Back</button>
</div>
<div class="error-box">Failed to load session: ${esc(err.message)}</div>
`;
return;
}
const summary = summariseEvents(events);
main.innerHTML = `
<div class="detail-header">
<div class="detail-title">
<button class="btn-back" onclick="navigate('#/')">← Back</button>
<span class="badge badge-${esc(summary.status)}">${esc(summary.status)}</span>
<h2 class="mono">${esc(agent)} / ${esc(id)}</h2>
<button class="btn-copy" onclick="copyId('${esc(id)}')">⧉ Copy ID</button>
</div>
<div class="detail-meta">
<span>🤖 <strong>${esc(summary.model || '—')}</strong></span>
<span>📁 <span class="mono" style="font-size:11px" title="${esc(summary.cwd)}">${esc(summary.cwd ? (summary.cwd.length > 50 ? '…' + summary.cwd.slice(-50) : summary.cwd) : '—')}</span></span>
<span>🕒 <strong>${esc(fmtDateFull(summary.firstTimestamp))}</strong></span>
<span>⏱ <strong>${esc(fmtDuration(summary.firstTimestamp, summary.lastTimestamp))}</strong></span>
</div>
</div>
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">Messages</div>
<div class="stat-value accent">${fmtNum(summary.messageCount)}</div>
</div>
<div class="stat-card">
<div class="stat-label">User turns</div>
<div class="stat-value">${fmtNum(summary.userMessages)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Asst turns</div>
<div class="stat-value">${fmtNum(summary.assistantMessages)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Tokens</div>
<div class="stat-value green">${fmtTokens(summary.totalTokens)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Cost</div>
<div class="stat-value yellow">${fmtCost(summary.totalCost)}</div>
</div>
</div>
<div id="timeline"></div>
`;
const tl = document.getElementById('timeline');
for (const ev of events) {
const el = renderEvent(ev);
if (el) tl.appendChild(el);
}
document.getElementById('live-status').style.display = 'flex';
// Count non-null events the server will replay
const skipCount = events.length;
connectWSWithSkip(agent, id, skipCount);
setupAutoScroll();
scrollToBottom();
}
// ═══════════════════════════════════════════════════════════════════════════
// Boot
// ═══════════════════════════════════════════════════════════════════════════
handleRoute();
</script>
</body>
</html>