// The reader shared with `show --spans` owns trace interpretation and ancestry.
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function duration(ms) {
if (ms === null || !Number.isFinite(ms)) return 'Time unknown';
if (ms < 1000) return `${Math.round(ms * 1000) / 1000} ms`;
const seconds = ms / 1000;
if (seconds < 60) return `${Math.round(seconds * 100) / 100} s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds % 3600 / 60)}m`;
}
function clock(ms) { return ms === null ? 'unknown' : new Date(ms).toLocaleTimeString([], { hour12: false }); }
function pretty(text) {
if (text === null || text === undefined) return 'No text retained.';
try { return JSON.stringify(JSON.parse(text), null, 2); } catch { return text; }
}
const quote = text => `'${text.replaceAll("'", "'\\''")}'`;
const kindName = { cmd: 'Command', run: 'Ticket run', stage: 'stage', sess: 'session', model: 'model call', tool: 'tool call', turn: 'turn', agent: 'sub-agent' };
// Translate record-derived labels only when rendering; span JSON stays stable.
function spanName(span) {
if (span.kind === 'cmd' || span.kind === 'run') return span.name.replace(/^Run · /, 'Ticket run · ');
if (span.kind === 'stage' && span.name === 'dispatched Run') return 'dispatched Ticket run';
return span.name;
}
export function initSpanTree({ openCommand, openDetails }) {
const tree = document.getElementById('span-tree');
if (!tree) return null;
let data = JSON.parse(document.getElementById('span-data').textContent);
let nodes, children, roots, axis, ranks, descendants;
const expanded = new Map();
let selected = data.spans[0]?.id, detail = null, zoom = null;
const content = document.getElementById('peek-content');
const peek = document.getElementById('peek');
const header = document.getElementById('span-axis');
const slow = document.querySelector('#slowest-calls ol');
const status = document.getElementById('span-status');
const end = span => span.status === 'running' ? Date.now() : span.end;
const elapsed = span => span.status === 'running' && span.start !== null ? Math.max(0, Date.now() - span.start) : span.duration_ms;
const durationText = span => `${duration(elapsed(span))}${span.estimated ? '*' : ''}`;
const rowFor = id => document.getElementById(`span-${id}`);
const isOpen = span => expanded.get(span.id) ?? ['cmd', 'run', 'stage'].includes(span.kind);
const inScope = span => span.start !== null && end(span) !== null && end(span) >= axis.start && span.start <= axis.end;
const button = (text, action) => {
const node = el('button', '', text);
node.type = 'button';
node.addEventListener('click', action);
return node;
};
function ancestors(span) {
const path = [], seen = new Set([span.id]);
while (nodes.has(span.parent_id) && !seen.has(span.parent_id)) {
span = nodes.get(span.parent_id);
seen.add(span.id);
path.unshift(span);
}
return path;
}
function index() {
tree.dataset.live = String(data.spans.some(span => span.status === 'running'));
nodes = new Map(data.spans.map(span => [span.id, span]));
children = new Map(); roots = [];
for (const span of data.spans) {
if (nodes.has(span.parent_id)) {
if (!children.has(span.parent_id)) children.set(span.parent_id, []);
children.get(span.parent_id).push(span);
} else roots.push(span);
}
for (const list of children.values()) list.sort((a, b) => (a.start ?? Infinity) - (b.start ?? Infinity));
if (!nodes.has(selected)) selected = roots[0]?.id;
if (!nodes.has(detail)) detail = null;
if (!nodes.has(zoom)) zoom = null;
}
function wholeAxis() {
const starts = data.spans.map(span => span.start).filter(time => time !== null);
const ends = data.spans.map(span => end(span) ?? span.start).filter(time => time !== null);
const start = starts.length ? Math.min(...starts) : Date.now();
return { start, end: Math.max(start + 1, ...ends) };
}
function setAxis() {
axis = wholeAxis();
const span = nodes.get(zoom);
if (!span || span.start === null) return;
const finish = end(span) ?? span.start;
if (children.has(span.id)) axis = { start: span.start, end: Math.max(finish, span.start + 1) };
else {
const parent = nodes.get(span.parent_id), pad = Math.max(elapsed(span) ?? 0, 1000) * 1.5;
axis = { start: Math.max(parent?.start ?? axis.start, span.start - pad), end: Math.min(parent ? end(parent) ?? Date.now() : axis.end, finish + pad) };
if (axis.end <= axis.start) axis = { start: span.start - pad, end: finish + pad };
}
}
function rankCalls() {
const calls = data.spans.filter(span => ['tool', 'model'].includes(span.kind) && elapsed(span) !== null && inScope(span))
.sort((a, b) => elapsed(b) - elapsed(a) || a.id.localeCompare(b.id)).slice(0, 10);
ranks = new Map(calls.map((span, i) => [span.id, i + 1]));
descendants = new Map(data.spans.map(span => [span.id, { errors: 0, ranks: [] }]));
for (const span of data.spans) for (const parent of ancestors(span)) {
const summary = descendants.get(parent.id);
if (span.error !== null) summary.errors++;
if (ranks.has(span.id)) summary.ranks.push(ranks.get(span.id));
}
const fragment = document.createDocumentFragment();
for (const span of calls) {
const li = el('li'), reveal = button('', () => revealSpan(span.id));
reveal.dataset.revealSpan = span.id;
reveal.append(el('span', 'span-rank', `#${ranks.get(span.id)}`), el('span', 'slow-name', `${span.kind} · ${spanName(span)}`), el('span', 'slow-clock', `${clock(span.start)} → ${span.status === 'running' ? 'now' : clock(span.end)}`), el('span', 'slow-duration', durationText(span)));
const parent = nodes.get(span.parent_id);
const fraction = parent && elapsed(parent) > 0 ? `${Math.round(elapsed(span) / elapsed(parent) * 100)}% of its ${kindName[parent.kind]}` : 'Parent duration unknown';
reveal.append(el('span', 'slow-where', `${fraction}${span.run ? ` · #${span.run}` : ''}${span.session_kind ? ` · ${span.session_kind} session` : ''}`));
li.append(reveal); fragment.append(li);
}
if (!calls.length) fragment.append(el('li', '', 'No timed tool or model calls in this window.'));
slow.replaceChildren(fragment);
}
function bar(span, range = axis) {
const track = el('span', 'span-track');
const fill = el('i', `span-bar kind-${span.kind}${span.error !== null ? ' span-error' : ''}${span.status === 'running' ? ' span-running' : ''}${span.estimated ? ' span-estimate' : ''}${ranks.has(span.id) ? ' span-slow' : ''}`);
if (span.start !== null && end(span) !== null) {
const scale = 100 / Math.max(1, range.end - range.start);
const left = Math.min(100, Math.max(0, (span.start - range.start) * scale));
const width = Math.min(100 - left, Math.max(0, (Math.min(end(span), range.end) - Math.max(span.start, range.start)) * scale));
fill.style.left = `${left}%`; fill.style.width = `${width}%`;
} else fill.style.width = '0';
track.append(fill); return track;
}
function renderTree() {
const focused = tree.contains(document.activeElement) ? document.activeElement.closest('[data-span-id]')?.dataset.spanId : null;
function item(span, depth, position, count, path) {
if (path.has(span.id)) return document.createDocumentFragment();
const nextPath = new Set(path).add(span.id), nested = children.get(span.id) ?? [], open = isOpen(span);
const wrapper = el('div', 'span-item');
wrapper.id = `span-${span.id}`; wrapper.dataset.spanId = span.id; wrapper.dataset.kind = span.kind;
wrapper.setAttribute('role', 'treeitem'); wrapper.setAttribute('aria-level', depth + 1);
wrapper.setAttribute('aria-labelledby', `span-label-${span.id}`);
wrapper.setAttribute('aria-posinset', position + 1); wrapper.setAttribute('aria-setsize', count);
wrapper.setAttribute('aria-selected', span.id === selected); wrapper.tabIndex = span.id === selected ? 0 : -1;
if (nested.length) wrapper.setAttribute('aria-expanded', open);
const row = el('div', `span-row${span.id === selected ? ' is-selected' : ''}${zoom && !inScope(span) ? ' outside-window' : ''}`);
const label = el('span', 'span-label'); label.id = `span-label-${span.id}`; label.style.setProperty('--span-depth', depth);
const twist = el('span', 'span-twist', nested.length ? open ? '▾' : '▸' : '·');
twist.setAttribute('aria-hidden', 'true'); twist.dataset.discloseSpan = span.id;
label.append(twist, el('span', 'span-kind', span.kind));
if (ranks.has(span.id)) label.append(el('span', 'span-rank', `#${ranks.get(span.id)}`));
const name = el('span', 'span-name', spanName(span)); name.title = spanName(span); label.append(name);
if (span.error !== null || span.status === 'running') {
const mark = el('span', span.error !== null ? 'span-status-error' : 'span-status-running', span.error !== null ? '✕' : '●');
mark.title = span.error ?? 'running'; mark.setAttribute('aria-label', span.error ?? 'running'); label.append(mark);
}
if (!open && nested.length) {
const summary = descendants.get(span.id);
if (summary.errors) label.append(el('span', 'span-fold-hint', `${summary.errors} error${summary.errors === 1 ? '' : 's'}`));
if (summary.ranks.length) label.append(el('span', 'span-fold-hint', summary.ranks.sort((a, b) => a - b).map(rank => `#${rank}`).join(' ')));
}
row.append(label, el('span', 'span-duration', durationText(span)), bar(span)); wrapper.append(row);
if (open && nested.length) {
const group = el('div', 'span-children'); group.setAttribute('role', 'group');
nested.forEach((child, i) => group.append(item(child, depth + 1, i, nested.length, nextPath))); wrapper.append(group);
}
return wrapper;
}
tree.replaceChildren(...roots.map((span, i) => item(span, 0, i, roots.length, new Set())));
if (focused) rowFor(focused)?.focus({ preventScroll: true });
}
function renderAxis() {
document.getElementById('span-ticks').replaceChildren(...[0, 0.5, 1].map(fraction => {
const tick = el('span', '', `+${duration((axis.end - axis.start) * fraction)}`); tick.style.left = `${fraction * 100}%`; return tick;
}));
const notice = document.getElementById('span-zoom'); notice.hidden = !zoom; notice.replaceChildren();
if (zoom) notice.append(el('b', '', `Timeline zoomed around ${spanName(nodes.get(zoom))}: ${clock(axis.start)} → ${clock(axis.end)} (${duration(axis.end - axis.start)})`), button('Zoom out to whole Command', () => zoomTo(null)));
}
function render() { setAxis(); rankCalls(); renderAxis(); renderTree(); if (detail) renderDetails(); }
function centerRow(id) {
requestAnimationFrame(() => {
const row = rowFor(id)?.querySelector(':scope > .span-row'); if (!row) return;
const top = Math.max(0, header.getBoundingClientRect().bottom);
const bottom = innerWidth <= 760 && !peek.hidden ? peek.getBoundingClientRect().top : innerHeight;
const rect = row.getBoundingClientRect();
window.scrollBy({ top: rect.top + rect.height / 2 - (top + bottom) / 2, behavior: 'instant' });
// Scrolling can pin the sticky axis. Keep the row inside the final
// visible space rather than the axis's position before that scroll.
const settled = row.getBoundingClientRect();
const visibleTop = Math.max(0, header.getBoundingClientRect().bottom);
if (settled.bottom > bottom) window.scrollBy({ top: settled.bottom - bottom, behavior: 'instant' });
else if (settled.top < visibleTop) window.scrollBy({ top: settled.top - visibleTop, behavior: 'instant' });
});
}
function choose(id, focus = true) { selected = id; renderTree(); if (focus) rowFor(id)?.focus({ preventScroll: true }); }
function revealSpan(id) {
const span = nodes.get(id); if (!span) return;
ancestors(span).forEach(parent => expanded.set(parent.id, true)); choose(id); detail = id; renderDetails(); centerRow(id);
}
function zoomTo(id) { zoom = id; render(); centerRow(selected); }
function renderDetails() {
const span = nodes.get(detail); if (!span) return;
openDetails();
const folds = new Map([...content.querySelectorAll('details')].map(node => [node.dataset.detailSection, node.open]));
const sameSpan = content.dataset.detailSpanId === span.id, scroll = sameSpan ? peek.scrollTop : 0;
content.dataset.detailSpanId = span.id;
const card = el('article', 'span-card'), path = el('nav', 'span-path'); path.setAttribute('aria-label', 'Span ancestors');
for (const parent of ancestors(span)) {
const link = el('a', '', spanName(parent)); link.href = `#span-${parent.id}`;
link.addEventListener('click', event => { event.preventDefault(); revealSpan(parent.id); }); path.append(link);
}
card.append(path, el('h2', '', `${span.kind} · ${spanName(span)}`), el('p', '', `${span.status}${span.error !== null ? ` · ${span.error}` : ''} · ${clock(span.start)} → ${span.status === 'running' ? 'now' : clock(span.end)}${span.estimated ? ' · estimated*' : ''}`));
const tiles = el('div', 'span-tiles');
const tile = (label, value) => {
if (value === null || value === undefined) return;
const node = el('div', 'span-tile'); node.append(el('b', '', String(value)), el('span', '', label)); tiles.append(node);
};
tile('Duration', elapsed(span) === null ? null : durationText(span));
const parent = nodes.get(span.parent_id);
if (parent && elapsed(parent) > 0 && elapsed(span) !== null) tile(`% of its ${kindName[parent.kind]}`, `${Math.round(elapsed(span) / elapsed(parent) * 100)}%`);
const attrs = span.attributes;
tile('Tokens in', attrs['gen_ai.usage.input_tokens']); tile('Tokens out', attrs['gen_ai.usage.output_tokens']); tile('Cost (USD)', attrs['thirdshift.cost.usd']);
const nested = data.spans.filter(child => ancestors(child).some(parent => parent.id === span.id));
for (const [kind, label] of [['model', 'Model calls'], ['tool', 'Tool calls']]) {
const count = nested.filter(child => child.kind === kind).length; if (count) tile(label, count);
}
const errors = descendants.get(span.id).errors + (span.error !== null ? 1 : 0); if (errors) tile('Errors', errors);
card.append(tiles);
for (const [label, owner] of [['Within parent', parent], ['Within Command', roots[0]]]) {
if (owner && owner.start !== null && end(owner) !== null) {
const position = el('div', 'span-position', label); position.append(bar(span, { start: owner.start, end: end(owner) })); card.append(position);
}
}
const actions = el('div', 'span-actions');
const zoomButton = button(zoom ? 'Zoom out to whole Command' : children.has(span.id) ? `Zoom timeline to this ${kindName[span.kind]}` : 'Zoom timeline around this call', () => zoomTo(zoom ? null : span.id));
zoomButton.disabled = span.start === null; actions.append(zoomButton);
if (span.conversation_url) { const link = el('a', '', 'Open in conversation'); link.href = span.conversation_url; actions.append(link); }
const cli = `thirdshift show ${quote(data.command)} --span ${span.id} --json`;
const copy = button('Copy CLI', async () => {
try { await navigator.clipboard.writeText(cli); status.textContent = 'CLI copied.'; } catch { status.textContent = cli; }
});
copy.dataset.copyCli = cli; actions.append(copy, button('Command details', () => { detail = null; openCommand(span); })); card.append(actions);
function section(label, body, open = false) {
const disclosure = el('details'); disclosure.dataset.detailSection = label;
disclosure.open = sameSpan ? folds.get(label) ?? open : open;
disclosure.append(el('summary', '', label), body); card.append(disclosure);
}
const noModelText = span.kind === 'model' || span.kind === 'turn';
section('Input', el('pre', '', noModelText ? 'The text is in the conversation view.' : pretty(span.input)), true);
section('Output', el('pre', '', noModelText ? 'The text is in the conversation view.' : pretty(span.output)));
section('All attributes', el('pre', '', JSON.stringify(attrs, null, 2)));
const table = el('table', 'span-overview'), body = el('tbody');
const overview = { Kind: kindName[span.kind], Status: span.status, Start: span.start === null ? 'unknown' : new Date(span.start).toISOString(), End: span.status === 'running' ? 'now' : span.end === null ? 'unknown' : new Date(span.end).toISOString(), Model: attrs['gen_ai.response.model'] ?? attrs['gen_ai.request.model'], Harness: attrs['thirdshift.harness'], 'Tokens in': attrs['gen_ai.usage.input_tokens'], 'Tokens out': attrs['gen_ai.usage.output_tokens'], 'Cache read': attrs['thirdshift.usage.cache_read_tokens'], 'Cache write': attrs['thirdshift.usage.cache_write_tokens'], 'Cost (USD)': attrs['thirdshift.cost.usd'], 'Span ID': span.id };
for (const [key, value] of Object.entries(overview)) {
if (value === undefined || value === null) continue;
const row = el('tr'); row.append(el('th', '', key), el('td', '', String(value))); body.append(row);
}
table.append(body); section('Overview table', table); content.replaceChildren(card);
peek.hidden = false; document.body.classList.add('peek-open'); peek.scrollTop = scroll;
}
tree.addEventListener('click', event => {
const row = event.target.closest('.span-row'); if (!row) return;
const id = row.parentElement.dataset.spanId;
if (event.target.closest('[data-disclose-span]') && children.has(id)) { expanded.set(id, !isOpen(nodes.get(id))); choose(id); }
else revealSpan(id);
});
tree.addEventListener('focusin', event => {
const id = event.target.closest('[data-span-id]')?.dataset.spanId;
if (id && selected !== id) {
selected = id;
for (const node of tree.querySelectorAll('[role="treeitem"]')) {
node.tabIndex = node.dataset.spanId === id ? 0 : -1; node.setAttribute('aria-selected', node.dataset.spanId === id);
node.querySelector(':scope > .span-row').classList.toggle('is-selected', node.dataset.spanId === id);
}
}
});
tree.addEventListener('keydown', event => {
const span = nodes.get(selected); if (!span || event.altKey || event.ctrlKey || event.metaKey) return;
const visible = [...tree.querySelectorAll('[role="treeitem"]')].map(node => node.dataset.spanId), index = visible.indexOf(selected);
let target;
switch (event.key) {
case 'ArrowDown': case 'j': target = visible[Math.min(index + 1, visible.length - 1)]; break;
case 'ArrowUp': case 'k': target = visible[Math.max(0, index - 1)]; break;
case 'Home': target = visible[0]; break;
case 'End': target = visible.at(-1); break;
case 'ArrowRight':
if (children.has(selected)) { if (isOpen(span)) target = children.get(selected)[0].id; else { expanded.set(selected, true); renderTree(); } }
break;
case 'ArrowLeft':
if (children.has(selected) && isOpen(span)) { expanded.set(selected, false); renderTree(); } else target = span.parent_id;
break;
case 'Enter': case ' ': revealSpan(selected); break;
default: return;
}
event.preventDefault(); event.stopPropagation();
if (target) { choose(target); centerRow(target); }
});
for (const control of document.querySelectorAll('[data-expand]')) control.addEventListener('click', () => {
const level = control.dataset.expand;
for (const span of nodes.values()) expanded.set(span.id, level === 'all' || span.kind === 'cmd' || (['stage', 'sess'].includes(level) && span.kind === 'run') || (level === 'sess' && span.kind === 'stage'));
const folded = ancestors(nodes.get(selected)).find(parent => !isOpen(parent)); if (folded) selected = folded.id;
render();
});
index(); render();
const timer = setInterval(() => { if (data.spans.some(span => span.status === 'running')) render(); }, 1000);
window.addEventListener('pagehide', () => clearInterval(timer), { once: true });
return {
closeDetails() { detail = null; delete content.dataset.detailSpanId; },
update(document) {
const next = JSON.parse(document.getElementById('span-data').textContent);
const chips = document.querySelector('.command-chips');
tree.closest('main').querySelector('.command-chips').replaceChildren(...chips.childNodes);
const changed = JSON.stringify(next.spans) !== JSON.stringify(data.spans);
data = next;
if (changed) { index(); render(); }
}
};
}