import { app, navigate, isRouteCurrent } from '../router.js';
import { api } from '../api.js';
import {
escapeHTML, formatDuration, formatTokenCount, formatCost,
getEnvelopeCorrelation, getEnvelopeType,
isCurrentPath, renderCopyButton, statusIcon, extractRunUsage,
} from '../utils.js';
import { subscribeWS } from '../ws.js';
let sessionDetailUnsubscribe = null;
let _sessionReloadTimer = null;
export function cleanup() {
if (sessionDetailUnsubscribe) { sessionDetailUnsubscribe(); sessionDetailUnsubscribe = null; }
clearTimeout(_sessionReloadTimer);
_sessionReloadTimer = null;
}
function renderSessionRunsRows(runs) {
if (!runs || runs.length === 0) {
return '
| No runs |
';
}
return runs.map((r, i) => {
const runDuration = r.ended_at
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
: '-';
const modelLabel = r.model ? escapeHTML(r.model.replace(/^claude-/, '')) : '-';
const spans = r.spans || [];
const spansHTML = spans.length > 0 ? `
${spans.map(sp => {
const body = getSessionSpanSummary(sp);
return `
${escapeHTML(sp.name || sp.kind || 'span')}
${escapeHTML(body)}
`;
}).join('')}
` : 'No spans yet
';
return `
| ${escapeHTML(r.run_id.substring(0, 12))}...${renderCopyButton(r.run_id)} |
${statusIcon(r.status)} |
${modelLabel} |
${r.tool_count || 0} |
${r.span_count} |
${escapeHTML(runDuration)} |
${escapeHTML(new Date(r.started_at).toLocaleTimeString())} |
Spans ${spans.length}
${spansHTML}
|
`;
}).join('');
}
function getSessionSpanSummary(sp) {
const payload = sp.payload || {};
const innerPayload = payload.payload || {};
if (sp.kind === 'tool') {
const result = innerPayload.result_preview || '';
const duration = sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-';
return result ? `${duration} · ${String(result).slice(0, 80)}` : duration;
}
if (sp.kind === 'agent') {
const usage = innerPayload.usage || {};
const totalTokens = usage.total_tokens !== undefined ? `${usage.total_tokens} tok` : '';
const duration = sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-';
return totalTokens ? `${duration} · ${totalTokens}` : duration;
}
return sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-';
}
function bindSessionRunRows() {
document.querySelectorAll('tr.expandable-run').forEach(row => {
row.addEventListener('click', event => {
if (event.metaKey || event.ctrlKey) {
navigate('/runs/' + row.dataset.run);
return;
}
const idx = row.dataset.index;
const detailRow = document.querySelector(`tr.span-detail-row[data-index="${idx}"]`);
const icon = row.querySelector('.expand-icon');
if (!detailRow) return;
if (detailRow.style.display === 'none') {
detailRow.style.display = 'table-row';
if (icon) icon.style.transform = 'rotate(45deg)';
} else {
detailRow.style.display = 'none';
if (icon) icon.style.transform = '';
}
});
row.addEventListener('dblclick', () => navigate('/runs/' + row.dataset.run));
row.setAttribute('tabindex', '0');
row.setAttribute('role', 'button');
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
row.click();
}
});
});
}
function handleSessionWS(sessionID, msg) {
if (msg.type !== 'message') return;
const correlation = getEnvelopeCorrelation(msg.data);
if (correlation?.session_id !== sessionID) return;
const eventType = getEnvelopeType(msg.data);
if (!['run.start', 'run.end', 'span.start', 'span.end', 'session.end', 'error'].includes(eventType)) return;
clearTimeout(_sessionReloadTimer);
_sessionReloadTimer = setTimeout(() => loadSessionData(sessionID), 300);
}
async function loadSessionData(sessionID) {
if (!isCurrentPath('/sessions/' + sessionID)) return;
const data = await api('/v1/sessions/' + sessionID);
const runs = data.runs || [];
const tbody = document.getElementById('session-runs-body');
if (!tbody) return;
tbody.innerHTML = renderSessionRunsRows(runs);
bindSessionRunRows();
const countSpan = document.querySelector('.section-title .count');
if (countSpan) countSpan.textContent = runs.length;
}
export async function renderSession(sessionID, routeToken) {
const data = await api('/v1/sessions/' + sessionID);
if (routeToken && !isRouteCurrent(routeToken)) return;
const s = data.session;
const runs = data.runs || [];
const active = !s.ended_at;
const duration = s.ended_at
? formatDuration(new Date(s.ended_at) - new Date(s.started_at))
: 'ongoing';
// Aggregate token/cost/tool data from runs' spans
let sessionTotalTokens = 0, sessionTotalCost = 0, sessionTotalTools = 0;
runs.forEach(r => {
const usage = extractRunUsage(r.spans || []);
if (usage) { sessionTotalTokens += usage.totalTokens; sessionTotalCost += usage.totalCost; }
sessionTotalTools += (r.tool_count || 0);
});
app.innerHTML = `
← Back to Sessions
Runs ${runs.length}
| Run ID |
Status |
Model |
Tools |
Spans |
Duration |
Started |
${renderSessionRunsRows(runs)}
`;
bindSessionRunRows();
document.querySelector('.back-link').addEventListener('click', e => {
e.preventDefault();
navigate('/sessions');
});
sessionDetailUnsubscribe = subscribeWS((msg) => handleSessionWS(sessionID, msg));
}