const state = { sessions: [], session: null, selected: null, // chat-side selection (drives the inspector) activeDetailTab: 'input', inspectorOpen: false, // chat-only second-level panel expandedTrajectorySeqs: new Set(), annotateOpenIds: new Set(), trajGroups: [], // step-grouped trajectory rows (lifecycle events become group metadata) trajectoryRows: [], // flat row list across all groups turnMeta: new Map(), // turn -> {trigger, reason, startTime, endTime} sessionQuery: '', // Per-session indexes, rebuilt on every loadSession: seqMap: new Map(), // seq -> event (tree nodes reference events by seq) callPairs: new Map(), // callId -> { call, result } firstChunkByStep: new Map(), // "turn:step" -> first assistant/chunk time } const $ = (selector) => document.querySelector(selector) const $$ = (selector) => [...document.querySelectorAll(selector)] async function api(path) { const response = await fetch(path) if (!response.ok) throw new Error(`${response.status} ${response.statusText}`) return response.json() } async function postJson(path, body) { const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }) return response.json() } function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') } function ms(seconds) { if (!Number.isFinite(seconds)) return '0ms' return seconds >= 1 ? `${seconds.toFixed(2)}s` : `${Math.round(seconds * 1000)}ms` } function fmtOffset(seconds) { return `+${ms(Math.max(0, seconds))}` } function dateTime(msValue) { if (!Number.isFinite(msValue)) return 'unknown' return new Date(msValue).toLocaleString() } function truncate(value, limit = 180) { const text = String(value ?? '').replace(/\s+/g, ' ').trim() return text.length > limit ? `${text.slice(0, limit - 1)}...` : text } function nodeGlyph(kind) { return { session: 'S', turn: 'T', step: 'ST', tool: 'TL', llm: 'AI', event: 'EV', }[kind] ?? 'N' } function flatten(node, depth = 0, out = []) { if (!node) return out out.push({ ...node, depth }) for (const child of node.children ?? []) flatten(child, depth + 1, out) return out } function currentNodes() { return flatten(state.session?.tree) } function findNode(id) { return currentNodes().find(node => node.id === id) ?? state.session?.tree } // Tree nodes carry eventSeqs (not embedded events); resolve through the seq index. function eventsOf(node) { if (node?.rawEvents) return node.rawEvents return (node?.eventSeqs ?? []).map(seq => state.seqMap.get(seq)).filter(Boolean) } function resolvePrompt(node) { const seq = node?.detail?.promptSeq if (seq !== undefined) return state.seqMap.get(seq)?.data?.header return node?.detail?.prompt } function contentToText(content) { if (!Array.isArray(content)) return '' return content.map((block) => { if (block.type === 'text' || block.type === 'reasoning') return block.text ?? '' if (block.type === 'tool-call') return `[tool-call ${block.name}] ${block.arguments ?? ''}` if (block.type === 'tool-result') return `[tool-result] ${JSON.stringify(block.content ?? block)}` return JSON.stringify(block) }).filter(Boolean).join('\n') } function contentBlocks(content) { return Array.isArray(content) ? content : [] } function renderValue(value, format) { if (value === undefined || value === null || value === '') return '' if (format === 'json') return JSON.stringify(value, null, 2) if (format === 'jsonl') { const rows = Array.isArray(value) ? value : [value] return rows.map(row => typeof row === 'string' ? row : JSON.stringify(row)).join('\n') } if (format === 'yaml') return toYaml(value) if (typeof value === 'string') return value if (Array.isArray(value) && value.every(item => item?.type)) return contentToText(value) return JSON.stringify(value, null, 2) } function toYaml(value, indent = 0) { const pad = ' '.repeat(indent) if (value === null) return 'null' if (typeof value !== 'object') return JSON.stringify(value) if (Array.isArray(value)) { return value.map(item => { if (item && typeof item === 'object') return `${pad}-\n${toYaml(item, indent + 2)}` return `${pad}- ${toYaml(item)}` }).join('\n') } return Object.entries(value).map(([key, item]) => { if (item && typeof item === 'object') return `${pad}${key}:\n${toYaml(item, indent + 2)}` return `${pad}${key}: ${toYaml(item)}` }).join('\n') } // Minimal safe markdown for the Chat surface: input is escaped FIRST, then a // line-based pass adds structure. Headings, lists, hr, fenced code, inline // bold/code/links (http(s) only). Everything unrecognized stays a paragraph. function mdInline(escaped) { return escaped .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*(\S(?:[^*\n]*\S)?)\*/g, '$1') .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1') } function mdSplitRow(line) { return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim()) } const MD_TABLE_ROW = /^\s*\|.*\|\s*$/ // A |---|:---:| separator row (text is already HTML-escaped, pipes unaffected). const MD_TABLE_SEP = /^\s*\|?[\s:|-]+\|[\s:|-]*$/ function renderMarkdown(text) { const lines = escapeHtml(text).split('\n') const out = [] let inCode = false let listType = null const closeList = () => { if (listType) { out.push(``) listType = null } } for (let i = 0; i < lines.length; i++) { const line = lines[i] if (line.trim().startsWith('```')) { closeList() out.push(inCode ? '' : '
')
      inCode = !inCode
      continue
    }
    if (inCode) {
      out.push(line)
      continue
    }
    // Table: a |...| row whose next line is the |---|---| separator.
    if (MD_TABLE_ROW.test(line) && i + 1 < lines.length && MD_TABLE_SEP.test(lines[i + 1]) && lines[i + 1].includes('-')) {
      closeList()
      const header = mdSplitRow(line)
      const aligns = mdSplitRow(lines[i + 1]).map(cell => {
        if (cell.startsWith(':') && cell.endsWith(':')) return 'center'
        if (cell.endsWith(':')) return 'right'
        return ''
      })
      i += 1
      const rows = []
      while (i + 1 < lines.length && MD_TABLE_ROW.test(lines[i + 1])) {
        i += 1
        rows.push(mdSplitRow(lines[i]))
      }
      const cellHtml = (tag, cells) => cells.map((cell, k) =>
        `<${tag}${aligns[k] ? ` style="text-align:${aligns[k]}"` : ''}>${mdInline(cell)}`).join('')
      out.push('
') out.push(`${cellHtml('th', header)}`) out.push(`${rows.map(row => `${cellHtml('td', row)}`).join('')}`) out.push('
') continue } // Blockquote ('>' is already escaped to >). if (/^\s*>\s?/.test(line)) { closeList() const quote = [] while (i < lines.length && /^\s*>\s?/.test(lines[i])) { quote.push(lines[i].replace(/^\s*>\s?/, '')) i += 1 } i -= 1 out.push(`
${quote.map(q => mdInline(q)).join('
')}
`) continue } const heading = line.match(/^(#{1,4})\s+(.*)$/) if (heading) { closeList() const level = Math.min(heading[1].length + 2, 5) out.push(`${mdInline(heading[2])}`) continue } if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { closeList() out.push('
') continue } if (/^\s*[-*]\s+/.test(line)) { if (listType !== 'ul') { closeList() out.push('
') closeList() return out.join('\n') } function toast(text) { const el = $('#toast') el.textContent = text el.classList.add('show') setTimeout(() => el.classList.remove('show'), 1500) } function backendOpenHint(error) { const currentUrl = window.location.href const expectedUrl = 'http://127.0.0.1:5173/' const fileHint = window.location.protocol === 'file:' ? '

You opened the HTML file directly. The API only works through the local server.

' : '' return `
Backend data did not load in this browser tab. ${fileHint}

Current URL: ${escapeHtml(currentUrl)}

Open this instead: ${expectedUrl}

Error: ${escapeHtml(error.message)}

` } function syncUrl() { if (!state.session) return const params = new URLSearchParams() params.set('session', state.session.header.id) const activeView = document.querySelector('[data-main-view].active')?.dataset.mainView if (activeView && activeView !== 'conversation') params.set('view', activeView) const selId = state.selected?.id if (selId && selId !== state.session.tree?.id) params.set('sel', selId) history.replaceState(null, '', `?${params.toString()}`) } async function loadSessions(preferredId) { const data = await api('/api/sessions') state.sessions = data.sessions $('#sourceLine').textContent = `${data.sessions.length} sessions from ${data.root}` renderSessionList(preferredId) const id = preferredId ?? state.sessions.find(session => !session.parentSession)?.id ?? state.sessions[0]?.id if (id) await loadSession(id) } async function loadSession(id) { document.querySelector('.main-pane')?.classList.add('loading') try { await loadSessionInner(id) } finally { document.querySelector('.main-pane')?.classList.remove('loading') } } async function loadSessionInner(id) { state.session = await api(`/api/sessions/${encodeURIComponent(id)}`) state.selected = state.session.tree state.inspectorOpen = false state.annotateOpenIds = new Set() state.seqMap = new Map(state.session.events.map(event => [event.seq, event])) state.callPairs = new Map() state.firstChunkByStep = new Map() for (const event of state.session.events) { if (event.type === 'tool/call') { state.callPairs.set(event.data?.callId, { call: event }) } else if (event.type === 'tool/result') { const pair = state.callPairs.get(event.data?.callId) if (pair) pair.result = event else state.callPairs.set(event.data?.callId, { result: event }) } else if (event.type === 'assistant/chunk') { const key = `${event.data?.turn}:${event.data?.step}` if (!state.firstChunkByStep.has(key)) state.firstChunkByStep.set(key, event.time) } } const firstAssistant = state.session.events.find(event => event.type === 'assistant/message') state.expandedTrajectorySeqs = new Set(firstAssistant ? [`event:${firstAssistant.seq}`] : []) renderAll() syncUrl() } function sessionRowHtml(session, isChild, kidCount) { return ` ` } function renderSessionList(preferredId = state.session?.header?.id) { const query = state.sessionQuery.toLowerCase().trim() const matches = session => !query || `${session.title} ${session.id} ${session.cwd} ${session.model}`.toLowerCase().includes(query) const ids = new Set(state.sessions.map(session => session.id)) const childrenByParent = new Map() const tops = [] for (const session of state.sessions) { if (session.parentSession && ids.has(session.parentSession)) { if (!childrenByParent.has(session.parentSession)) childrenByParent.set(session.parentSession, []) childrenByParent.get(session.parentSession).push(session) } else { tops.push(session) } } const html = tops.map(parent => { const kids = (childrenByParent.get(parent.id) ?? []).sort((a, b) => a.createdAt - b.createdAt) const anyKidMatches = kids.some(matches) if (!matches(parent) && !anyKidMatches) return '' const visibleKids = matches(parent) ? kids : kids.filter(matches) return sessionRowHtml(parent, Boolean(parent.parentSession), kids.length) + visibleKids.map(kid => sessionRowHtml(kid, true, 0)).join('') }).join('') $('#sessionList').innerHTML = html || '
No sessions match the filter.
' if (preferredId) { $$('#sessionList .session-row').forEach(row => row.classList.toggle('selected', row.dataset.selectSession === preferredId)) } } function renderAll() { renderChrome() renderSessionList() renderConversation() renderTrajectory() renderTrajTree() renderWaterfall() // Apply the requested view BEFORE renderDetails: renderDetails syncs the URL // from the currently-active view button, which would erase ?view= otherwise. applyRequestedView() renderDetails() renderInspectorState() } // One #detailDrawer element, moved into whichever split view is active (Chat or // Waterfall). Trajectory is self-contained and never shows it. function renderInspectorState() { const drawer = $('#detailDrawer') const chatSplit = $('#chatSplit') const wfSplit = $('#wfSplit') const view = document.querySelector('[data-main-view].active')?.dataset.mainView const target = view === 'waterfall' ? wfSplit : view === 'conversation' ? chatSplit : null chatSplit?.classList.remove('inspector-open') wfSplit?.classList.remove('inspector-open') if (drawer && target && drawer.parentElement !== target) target.appendChild(drawer) if (target && state.inspectorOpen) target.classList.add('inspector-open') } function openInspector() { state.inspectorOpen = true renderInspectorState() } function closeInspector() { state.inspectorOpen = false renderInspectorState() } function showMainView(viewName) { const button = document.querySelector(`[data-main-view="${viewName}"]`) if (!button) return $$('[data-main-view]').forEach(item => item.classList.toggle('active', item === button)) $$('.view').forEach(view => view.classList.remove('active')) $(`#${viewName}View`)?.classList.add('active') renderInspectorState() syncUrl() } function applyRequestedView() { const view = new URLSearchParams(window.location.search).get('view') if (['trajectory', 'conversation', 'waterfall'].includes(view)) showMainView(view) } function renderChrome() { const session = state.session if (!session) return const summary = state.sessions.find(item => item.id === session.header.id) $('#conversationTitle').textContent = summary?.title || session.header.id const crumb = $('#sessionCrumb') if (crumb) { crumb.innerHTML = session.parent ? `` : '' } const reveal = $('#revealSession') if (reveal) reveal.title = session.header.path ?? '' } /* ── Trajectory: step-grouped rows + navigation tree ───────────────────────── Lifecycle events (turn/step start/end) are NOT rows: they become the tree nodes and the sticky group headers, so the tree never duplicates the table. */ function buildTrajectory() { const groups = [] const rows = [] state.turnMeta = new Map() let current = null let chunkGroup = null let rowIndex = 0 const pushGroup = (turn, step, startTime) => { current = { key: `g:${turn}:${step ?? 'pre'}`, turn, step, startTime, endTime: startTime, rows: [], hasError: false, } groups.push(current) return current } const pushRow = (event, rawEvents) => { if (!current) pushGroup(event.data?.turn ?? 0, null, event.time) const row = { id: event.type === 'assistant/chunks' ? `chunks:${rawEvents[0].seq}` : `event:${event.seq}`, event, rawEvents, index: ++rowIndex, group: current } current.rows.push(row) rows.push(row) if (eventIsError(event)) current.hasError = true current.endTime = Math.max(current.endTime, rawEvents.at(-1)?.time ?? event.time) } const flushChunks = () => { if (!chunkGroup) return const first = chunkGroup.events[0] pushRow({ seq: first.seq, time: first.time, type: 'assistant/chunks', data: { turn: first.data?.turn, step: first.data?.step, count: chunkGroup.events.length, chunks: chunkGroup.events, }, }, chunkGroup.events) chunkGroup = null } for (const event of state.session.events) { switch (event.type) { case 'turn/start': { flushChunks() state.turnMeta.set(event.data.turn, { trigger: event.data.trigger?.kind, startTime: event.time }) pushGroup(event.data.turn, null, event.time) break } case 'turn/end': { flushChunks() const meta = state.turnMeta.get(event.data.turn) if (meta) { meta.reason = event.data.reason?.kind meta.endTime = event.time } break } case 'step/start': { flushChunks() pushGroup(event.data.turn, event.data.step, event.time) break } case 'step/end': { flushChunks() if (current && current.step === event.data.step) current.endTime = event.time break } case 'assistant/chunk': { const key = `${event.data?.turn}:${event.data?.step}` if (chunkGroup && chunkGroup.key !== key) flushChunks() if (!chunkGroup) chunkGroup = { key, events: [] } chunkGroup.events.push(event) break } default: { flushChunks() pushRow(event, [event]) } } } flushChunks() state.trajGroups = groups.filter(group => group.rows.length || group.step !== null) state.trajectoryRows = rows } function groupDurationSec(group) { return Math.max(0, (group.endTime - group.startTime) / 1000) } function groupToolSummary(group) { const names = group.rows.filter(row => row.event.type === 'tool/call').map(row => row.event.data?.name ?? 'tool') if (!names.length) return '' const counts = new Map() for (const name of names) counts.set(name, (counts.get(name) ?? 0) + 1) return [...counts.entries()].map(([name, n]) => n > 1 ? `${name} ×${n}` : name).join(' · ') } function renderTrajTree() { const root = $('#trajTree') if (!root || !state.session) return const turns = new Map() for (const group of state.trajGroups) { if (!turns.has(group.turn)) turns.set(group.turn, []) turns.get(group.turn).push(group) } root.innerHTML = [...turns.entries()].map(([turn, groups]) => { const meta = state.turnMeta.get(turn) ?? {} const dur = meta.startTime && meta.endTime ? ms((meta.endTime - meta.startTime) / 1000) : '' const steps = groups.map(group => { const label = group.step === null ? `turn ${group.turn} input` : `step ${group.step}` const sub = group.step === null ? truncate(contentToText(group.rows.find(r => r.event.type === 'user/message')?.event.data?.content) || (meta.trigger ?? ''), 34) : groupToolSummary(group) return ` ` }).join('') return `
turn ${turn}${meta.reason ? ` · ${escapeHtml(meta.reason)}` : ''}${dur}
${steps}
` }).join('') || '
No structure.
' } function roleOfEvent(event) { if (event.type === 'assistant/message' || event.type === 'assistant/chunks') return 'assistant' if (event.type === 'tool/call' || event.type === 'tool/result') return 'tool' if (event.type === 'request/header') return 'system' if (event.type === 'context/message' || event.type === 'steering/message') return 'system' if (event.type === 'user/message') return 'user' return 'meta' } function toolCallsFromContent(content) { return contentBlocks(content).filter(block => block.type === 'tool-call') } function usageOfEvent(event) { const usage = event.data?.usage ?? {} return { input: usage.inputTokens ?? usage.input_tokens ?? '', output: usage.outputTokens ?? usage.output_tokens ?? '', think: usage.reasoningTokens ?? usage.reasoning_tokens ?? '', } } function eventIsError(event) { if (event.type === 'tool/result') return Boolean(event.data?.isError) if (event.type === 'tool/call') return Boolean(state.callPairs.get(event.data?.callId)?.result?.data?.isError) return false } function pairedDurationSec(event) { const pair = state.callPairs.get(event.data?.callId) if (!pair?.call || !pair?.result) return 0 return Math.max(0, (pair.result.time - pair.call.time) / 1000) } function trajectoryTitle(event) { if (event.type === 'request/header') { const header = event.data?.header ?? {} return `request envelope · ${header.config?.model ?? 'unknown model'} · ${(header.tools ?? []).length} tools · system ${String(header.system ?? '').length} chars` } if (event.type === 'assistant/chunks') return `${event.data?.count ?? 0} streaming chunks` if (event.type === 'tool/call') return `${event.data?.name ?? 'tool call'} · ${truncate(event.data?.arguments ?? '', 96)}` if (event.type === 'tool/result') return truncate(contentToText(event.data?.content) || event.data?.callId || 'tool result', 96) const toolCalls = toolCallsFromContent(event.data?.content) if (toolCalls.length) return toolCalls.map(call => call.name).join(', ') return truncate(contentToText(event.data?.content) || eventPreview(event), 96) } function feedbackFor(targetId) { return (state.session.feedback ?? []).filter(item => item.data?.targetId === targetId) } function renderTrajectory() { buildTrajectory() if (!state.trajectoryRows.length) { $('#trajectory').innerHTML = '
No trajectory events in this session.
' return } const header = `
# event content in out think time
` const body = state.trajGroups.map(group => { const label = group.step === null ? `turn ${group.turn}${state.turnMeta.get(group.turn)?.trigger ? ` · ${state.turnMeta.get(group.turn).trigger}` : ''}` : `step ${group.step}` const head = `
${escapeHtml(label)} ${ms(groupDurationSec(group))} ${groupToolSummary(group) ? `${escapeHtml(groupToolSummary(group))}` : ''}
` return head + group.rows.map(row => renderTrajectoryRow(row)).join('') }).join('') $('#trajectory').innerHTML = header + body } function renderTrajectoryRow(row) { const { event, index, id } = row const role = roleOfEvent(event) const usage = usageOfEvent(event) const toolCalls = toolCallsFromContent(event.data?.content) const expanded = state.expandedTrajectorySeqs.has(id) const isError = eventIsError(event) const metaChip = trajectoryMetaChip(event, toolCalls, isError) const offsetSec = (event.time - state.session.stats.startTime) / 1000 const fbCount = feedbackFor(id).length return `
#${index} ${escapeHtml(event.type)} ${metaChip}${escapeHtml(trajectoryTitle(event))}${fbCount ? `✎${fbCount}` : ''} ${escapeHtml(String(usage.input ?? ''))} ${escapeHtml(String(usage.output ?? ''))} ${escapeHtml(String(usage.think ?? ''))} ${fmtOffset(offsetSec)} ${expanded ? '▾' : '▸'}
${expanded ? renderTrajectoryBody(row) : ''}
` } function rerenderTrajectoryRow(id) { const row = state.trajectoryRows.find(item => item.id === id) const el = document.querySelector(`[data-select-trajectory-row="${CSS.escape(id)}"]`) if (!row || !el) { renderTrajectory() return } el.outerHTML = renderTrajectoryRow(row) } function trajectoryMetaChip(event, toolCalls, isError) { if (toolCalls.length) return `→ ${escapeHtml(toolCalls.map(call => call.name).join(', '))}` if (event.type === 'tool/result') return isError ? '← error' : '← result' if (event.type === 'tool/call') return `→ ${escapeHtml(event.data?.name ?? 'tool')}` if (event.type === 'request/header') return `${event.data?.header?.tools?.length ?? 0} tools` if (event.type === 'assistant/chunks') return `${event.data?.count ?? 0} chunks` return 'event' } // Child sessions whose creation falls inside this tool call's lifetime are // almost certainly the subagents it spawned (workflow / subagent tools). function spawnedSessionsFor(event) { const kids = state.session?.children ?? [] if (!kids.length || event.type !== 'tool/call') return [] const pair = state.callPairs.get(event.data?.callId) const start = event.time - 2000 const end = (pair?.result?.time ?? event.time + 600000) + 2000 return kids.filter(kid => kid.createdAt >= start && kid.createdAt <= end) } function spawnedSessionsHtml(event) { const spawned = spawnedSessionsFor(event) if (!spawned.length) return '' return `
spawned sessions · ${spawned.length}
${spawned.map(kid => ` `).join('')}
` } /* Inline row utilities: the trajectory is self-contained — copy, the raw event and annotations live in the expanded row instead of a side inspector. */ function rowPayload(row) { const event = row.event if (event.type === 'tool/call' || event.type === 'tool/result') { const pair = state.callPairs.get(event.data?.callId) ?? {} return { call: pair.call ?? null, result: pair.result ?? null } } if (event.type === 'assistant/chunks') return row.rawEvents return event } function rowToolsHtml(row) { const fb = feedbackFor(row.id) const annotateOpen = state.annotateOpenIds.has(row.id) const author = localStorage.getItem('dh-author') || 'shentuni' const rawDetails = row.event.type === 'assistant/chunks' ? '' : `
raw event
${escapeHtml(renderValue(row.rawEvents, 'jsonl'))}
` const fbList = fb.length ? `
${fb.map(item => `
${escapeHtml(item.data.author)}

${escapeHtml(item.data.text)}

`).join('')}
` : '' const fbForm = annotateOpen ? `
针对 #${row.index} ${escapeHtml(row.event.type)}
` : '' return `
${fbForm}${fbList}${rawDetails} ` } const CHUNK_PREVIEW_LIMIT = 200 function renderTrajectoryBody(row) { const event = row.event const tools = rowToolsHtml(row) if (event.type === 'request/header') { const header = event.data?.header ?? {} return `
System prompt ${String(header.system ?? '').length} chars
${escapeHtml(header.system || 'No system prompt recorded.')}
Tool schemas ${(header.tools ?? []).length} tools
${escapeHtml(renderValue(header.tools ?? [], 'json'))}
config and messagePrefix
${escapeHtml(renderValue({ config: header.config, messagePrefix: header.messagePrefix }, 'json'))}
${tools}
` } if (event.type === 'assistant/chunks') { const chunks = event.data?.chunks ?? [] const preview = chunks.slice(0, CHUNK_PREVIEW_LIMIT) return `
raw streaming chunks (${chunks.length})
${escapeHtml(renderValue(preview, 'jsonl'))}
${chunks.length > CHUNK_PREVIEW_LIMIT ? `
… ${chunks.length - CHUNK_PREVIEW_LIMIT} more chunks — use Copy JSON for the full stream
` : ''}
${tools}
` } if (event.type === 'tool/call') { const pair = state.callPairs.get(event.data?.callId) const duration = pairedDurationSec(event) const failed = Boolean(pair?.result?.data?.isError) return `
${escapeHtml(event.data?.name ?? 'tool')} ${escapeHtml(event.data?.callId ?? '')}${duration ? ` · ${ms(duration)}` : ''}${failed ? ' · failed' : ''}
${escapeHtml(renderValue(parseMaybeJson(event.data?.arguments), 'json'))}
${spawnedSessionsHtml(event)} ${tools}
` } if (event.type === 'user/message') { return `
${escapeHtml(contentToText(event.data?.content))}
${tools}
` } if (event.type === 'tool/result') { const pair = state.callPairs.get(event.data?.callId) const duration = pairedDurationSec(event) const failed = Boolean(event.data?.isError) const name = pair?.call?.data?.name return `
${failed ? 'tool error' : 'tool result'}${name ? ` · ${escapeHtml(name)}` : ''} ${escapeHtml(event.data?.callId ?? '')}${duration ? ` · ${ms(duration)}` : ''}
${event.data?.error ? `
${escapeHtml(renderValue(event.data.error, 'plain'))}
` : ''}
${escapeHtml(renderValue(event.data?.content ?? event.data, 'plain'))}
${tools}
` } const blocks = contentBlocks(event.data?.content) const reasoning = blocks.filter(block => block.type === 'reasoning').map(block => block.text).filter(Boolean).join('\n\n') const text = blocks.filter(block => block.type === 'text').map(block => block.text).filter(Boolean).join('\n\n') const calls = toolCallsFromContent(blocks) const usage = event.data?.usage return `
${reasoning ? `
Thinking

${escapeHtml(reasoning)}

` : ''} ${text ? `

${escapeHtml(text)}

` : ''} ${calls.map(call => `
${escapeHtml(call.name)} ${escapeHtml(call.id ?? '')}
${escapeHtml(renderValue(parseMaybeJson(call.arguments), 'json'))}
`).join('')} ${usage ? `
metadata ${escapeHtml(JSON.stringify(usage))}
${escapeHtml(renderValue(usage, 'json'))}
` : ''} ${tools}
` } function parseMaybeJson(value) { if (typeof value !== 'string') return value try { return JSON.parse(value) } catch { return value } } /* ── Chat ──────────────────────────────────────────────────────────────── */ function renderConversation() { const messages = state.session.messages.filter(message => message.role === 'user' || message.role === 'assistant') if (!messages.length) { $('#conversation').innerHTML = '
No surface messages in this session.
' return } $('#conversation').innerHTML = messages.map(message => `
${renderChatBody(message)}
`).join('') } function renderChatBody(message) { const text = chatText(message) const blocks = contentBlocks(message.content) const reasoning = blocks.filter(block => block.type === 'reasoning').map(block => block.text).filter(Boolean).join('\n\n') const calls = toolCallsFromContent(message.content) const activity = [ reasoning ? `
Thinking${escapeHtml(truncate(reasoning, 112))}
${escapeHtml(reasoning)}
` : '', ...calls.map(call => renderChatToolActivity(call)), ].filter(Boolean).join('') if (message.role === 'user') return `
${escapeHtml(text)}
` if (text) return `${activity ? `
${activity}
` : ''}
${renderMarkdown(text)}
` if (calls.length) { return `
${activity}
` } if (reasoning) return `
${activity}
` return '
No visible assistant text.
' } // A short human-readable hint of what the call did, for the collapsed summary // row (lay users should understand a tool line without expanding it). function toolCallPreview(call) { const args = parseMaybeJson(call.arguments) if (args && typeof args === 'object') { const preferred = args.description ?? args.command ?? args.file_path ?? args.path ?? args.name ?? args.url ?? args.query if (typeof preferred === 'string' && preferred) return truncate(preferred, 90) const firstString = Object.values(args).find(value => typeof value === 'string' && value) if (firstString) return truncate(firstString, 90) } return '' } function renderChatToolActivity(call) { const result = state.session.events.find(event => event.type === 'tool/result' && event.data?.callId === call.id) const failed = Boolean(result?.data?.isError) const resultText = result ? renderValue(result.data?.content ?? result.data, 'plain') : '' const preview = toolCallPreview(call) // .activity-body is white-space: pre-wrap (thinking bodies rely on it), so the // markup inside it must be emitted WITHOUT newlines/indentation between tags — // stray template whitespace would render as literal blank space. const inputHtml = `
Input
${escapeHtml(renderValue(parseMaybeJson(call.arguments), 'json'))}
` const outputHtml = result ? `
${failed ? 'Error output' : 'Output'}
${escapeHtml(resultText)}
` : '' const callEvent = state.callPairs.get(call.id)?.call const spawnHtml = callEvent ? spawnedSessionsHtml(callEvent).trim() : '' const openBtn = `` return `
${failed ? 'Tool failed' : 'Tool use'}${escapeHtml(call.name)}${preview ? ` · ${escapeHtml(preview)}` : ''}
${inputHtml}${outputHtml}${spawnHtml}${openBtn}
` } function chatText(message) { if (message.role === 'user') return contentToText(message.content) return contentBlocks(message.content) .filter(block => block.type === 'text') .map(block => block.text ?? '') .filter(Boolean) .join('\n\n') } /* ── Waterfall: find the hotspot, then jump to Trajectory for detail ─────── */ function renderWaterfall() { const total = Math.max(state.session?.stats.durationSec ?? 1, 0.001) const nodes = currentNodes().filter(node => node.kind !== 'session') const llmTime = nodes.filter(n => n.kind === 'llm').reduce((sum, n) => sum + n.durationSec, 0) const toolTime = nodes.filter(n => n.kind === 'tool').reduce((sum, n) => sum + n.durationSec, 0) const errors = nodes.filter(n => n.status === 'error') const slowest = nodes.filter(n => n.kind === 'step').sort((a, b) => b.durationSec - a.durationSec)[0] const summary = `
total${ms(state.session.stats.durationSec)}
llm time${ms(llmTime)}
tool time${ms(toolTime)}
errors${errors.length}
${slowest ? `` : ''}
tokens in/out${sumTokens('input')}/${sumTokens('output')}
` const ticks = [0, .25, .5, .75, 1].map(p => `${ms(total * p)}`).join('') $('#waterfall').innerHTML = summary + `
${ticks}
${nodes.map(node => { const left = Math.max(0, (node.startSec / total) * 100) const width = Math.max(.35, (node.durationSec / total) * 100) const error = node.status === 'error' return `
` }).join('')}
` } function sumTokens(kind) { let total = 0 for (const event of state.session.events) { if (event.type !== 'assistant/message') continue const usage = usageOfEvent(event) total += Number(usage[kind === 'input' ? 'input' : 'output']) || 0 } return total.toLocaleString() } // Select a tree node (waterfall click) and open the inspector on it. function selectNode(id) { state.selected = findNode(id) renderDetails() openInspector() } // Map a waterfall/tree node or a selected event to its trajectory anchor and // navigate there. function jumpToNode(nodeId) { let target = null if (nodeId.startsWith('event:') || nodeId.startsWith('chunks:')) { target = { rowId: nodeId } } else if (nodeId.startsWith('tool:')) { const callId = nodeId.slice('tool:'.length) const call = state.callPairs.get(callId)?.call if (call) target = { rowId: `event:${call.seq}` } } else if (nodeId.startsWith('assistant:')) { target = { rowId: `event:${nodeId.slice('assistant:'.length)}` } } else if (nodeId.startsWith('step:')) { const [turn, step] = nodeId.slice('step:'.length).split(':') target = { groupKey: `g:${turn}:${step}` } } else if (nodeId.startsWith('turn:')) { const turn = nodeId.slice('turn:'.length) const group = state.trajGroups.find(g => String(g.turn) === turn) if (group) target = { groupKey: group.key } } if (!target) return showMainView('trajectory') if (target.rowId) { const row = state.trajectoryRows.find(item => item.id === target.rowId) if (!row) return if (!state.expandedTrajectorySeqs.has(target.rowId)) { state.expandedTrajectorySeqs.add(target.rowId) rerenderTrajectoryRow(target.rowId) } flashAndScroll(`[data-select-trajectory-row="${CSS.escape(target.rowId)}"]`) } else if (target.groupKey) { flashAndScroll(`[data-group-anchor="${CSS.escape(target.groupKey)}"]`, 'start') } } function jumpToGroup(groupKey) { flashAndScroll(`[data-group-anchor="${CSS.escape(groupKey)}"]`, 'start') markActiveGroup(groupKey) } // Scroll-spy: as the trajectory table scrolls, highlight the step the reader is // looking at in the tree (and keep it visible there). function markActiveGroup(groupKey) { let activeStep = null $$('#trajTree .tree-step').forEach(el => { const on = el.dataset.jumpGroup === groupKey el.classList.toggle('active', on) if (on) activeStep = el }) $$('#trajTree .tree-turn').forEach(turn => { turn.querySelector('.tree-turn-head')?.classList.toggle('active', Boolean(turn.querySelector('.tree-step.active'))) }) if (activeStep) { const tree = document.querySelector('#trajTree') const stepRect = activeStep.getBoundingClientRect() const treeRect = tree.getBoundingClientRect() if (stepRect.top < treeRect.top || stepRect.bottom > treeRect.bottom) { activeStep.scrollIntoView({ block: 'nearest' }) } } } function updateScrollSpy() { const main = document.querySelector('.traj-main') if (!main) return const anchors = [...main.querySelectorAll('[data-group-anchor]')] if (!anchors.length) return const topEdge = main.getBoundingClientRect().top + 70 let current = anchors[0] for (const anchor of anchors) { if (anchor.getBoundingClientRect().top <= topEdge) current = anchor else break } markActiveGroup(current.dataset.groupAnchor) } function initScrollSpy() { const main = document.querySelector('.traj-main') if (!main) return let ticking = false main.addEventListener('scroll', () => { if (ticking) return ticking = true requestAnimationFrame(() => { ticking = false updateScrollSpy() }) }) } // Instant scrolling: smooth scrolling is silently dropped during boot-time // layout. Rows center (so the sticky group head never covers them); group // heads go to 'start' — they stick at the top, which is also exactly where the // scroll-spy samples, so the tree highlight agrees with the jump target. function flashAndScroll(selector, block = 'center') { const el = document.querySelector(selector) if (!el) return el.scrollIntoView({ block }) el.classList.remove('flash') void el.offsetWidth el.classList.add('flash') } /* ── Inspector payloads (chat only) ──────────────────────────────────────── */ function eventPayloadText(event) { return JSON.stringify(event?.data ?? event ?? {}, null, 0) } function eventPreview(event) { if (event.type === 'user/message' || event.type === 'assistant/message') { return truncate(textOfEventContent(event), 220) } if (event.type === 'tool/call') return `${event.data?.name ?? 'tool'} ${truncate(event.data?.arguments ?? '', 180)}` if (event.type === 'tool/result') return truncate(textOfEventContent(event) || eventPayloadText(event), 220) if (event.type === 'request/header') { const tools = event.data?.header?.tools?.length ?? 0 const model = event.data?.header?.config?.model ?? 'unknown model' return `${model}, ${tools} tools, full request envelope available` } return truncate(eventPayloadText(event), 220) } function textOfEventContent(event) { return contentToText(event?.data?.content) } function selectedPayloads() { const node = state.selected ?? state.session.tree if (node.kind === 'session') { return { input: { header: state.session.header, stats: state.session.stats, model: state.session.agent.model, tools: (state.session.agent.tools ?? []).map(tool => tool.name), }, output: '', metadata: { id: node.id, kind: node.kind, title: node.title, subtitle: node.subtitle, durationSec: node.durationSec, events: `${state.session.events.length} events — select a message for detail`, children: state.session.children, parent: state.session.parent, }, } } // Steps can own thousands of chunk events; cap what the metadata pane // serializes so selecting a node never stalls on a multi-MB JSON string. const nodeEvents = node.kind === 'event' ? node.rawEvents : eventsOf(node) const events = nodeEvents.length > 50 ? { count: nodeEvents.length, first50: nodeEvents.slice(0, 50) } : nodeEvents return { input: node.detail?.input ?? nodeEvents.slice(0, 50), output: node.detail?.output ?? '', metadata: { id: node.id, kind: node.kind, title: node.title, subtitle: node.subtitle, status: node.status, durationSec: node.durationSec, startSec: node.startSec, events, prompt: resolvePrompt(node), schema: node.detail?.schema, }, } } function renderDetails() { const node = state.selected ?? state.session.tree $('#detailIcon').textContent = nodeGlyph(node.kind) $('#detailTitle').textContent = node.title $('#detailSubtitle').textContent = `${node.kind} · ${node.subtitle ?? ''} · ${ms(node.durationSec)}` const jumpBtn = $('#jumpFromInspector') if (jumpBtn) jumpBtn.style.display = node.kind === 'session' ? 'none' : '' renderFeedback() const payloads = selectedPayloads() for (const [id, key] of Object.entries({ inputText: 'input', outputText: 'output', metadataText: 'metadata', })) { const select = document.querySelector(`[data-format-target="${id}"]`) $(`#${id}`).textContent = renderValue(payloads[key], select?.value ?? 'json') } updateSelectionUI() syncUrl() } // Update chat selection highlighting in place — no view rebuilds. function updateSelectionUI() { const selectedSeq = state.selected?.kind === 'event' ? state.selected.rawEvents?.[0]?.seq : undefined $$('#conversation .message').forEach(el => { el.classList.toggle('selected', Number(el.dataset.selectEventSeq) === selectedSeq) }) } function currentTargetId() { return (state.selected ?? state.session.tree)?.id } function renderFeedback() { const feedback = feedbackFor(currentTargetId()) $('#feedbackList').innerHTML = feedback.length ? feedback.map(item => `
${escapeHtml(item.data.author)}

${escapeHtml(item.data.text)}

`).join('') : '
暂无标注记录
' } function selectEvent(seq) { const event = state.seqMap.get(Number(seq)) ?? state.session.events.find(item => item.seq === Number(seq)) if (!event) return let input = event.data let output = '' let durationSec = 0 let status = 'ok' if (event.type === 'tool/call' || event.type === 'tool/result') { const pair = state.callPairs.get(event.data?.callId) ?? {} input = parseMaybeJson(pair.call?.data?.arguments ?? event.data?.arguments) ?? event.data output = pair.result ? { content: pair.result.data?.content, isError: pair.result.data?.isError, error: pair.result.data?.error, meta: pair.result.data?.meta } : '' durationSec = pairedDurationSec(pair.call ?? event) status = pair.result?.data?.isError ? 'error' : 'ok' } else if (event.type === 'assistant/message') { input = { usage: event.data?.usage, model: state.session.agent.model } output = event.data?.content const firstChunk = state.firstChunkByStep.get(`${event.data?.turn}:${event.data?.step}`) durationSec = firstChunk ? Math.max(0, (event.time - firstChunk) / 1000) : 0 } else if (event.type === 'user/message') { input = '' output = event.data?.content } state.selected = { id: `event:${event.seq}`, kind: 'event', title: event.type, subtitle: `seq ${event.seq}`, startSec: (event.time - state.session.stats.startTime) / 1000, durationSec, status, rawEvents: [event], detail: { input, output, prompt: state.session.agent.latestHeader, schema: state.session.agent.tools }, } renderDetails() openInspector() } function toggleTrajectoryRow(id) { if (state.expandedTrajectorySeqs.has(id)) state.expandedTrajectorySeqs.delete(id) else state.expandedTrajectorySeqs.add(id) rerenderTrajectoryRow(id) } function restoreSelection(id) { const view = new URLSearchParams(window.location.search).get('view') const row = state.trajectoryRows.find(item => item.id === id) if (row && view === 'trajectory') { state.expandedTrajectorySeqs.add(id) renderTrajectory() flashAndScroll(`[data-select-trajectory-row="${CSS.escape(id)}"]`) return } if (id.startsWith('event:')) { const seq = Number(id.slice('event:'.length)) const message = state.session.messages.find(item => item.seq === seq) if (message) selectEvent(seq) } } async function submitInlineFeedback(form) { const rowId = form.dataset.fbRow const text = form.elements.text.value.trim() const author = form.elements.author.value.trim() || 'anonymous' if (!text) return localStorage.setItem('dh-author', author) const row = state.trajectoryRows.find(item => item.id === rowId) const result = await postJson(`/api/sessions/${encodeURIComponent(state.session.header.id)}/feedback`, { author, text, targetId: rowId, targetTitle: row ? `#${row.index} ${row.event.type}` : rowId, targetKind: 'event', }) if (result.feedback) { state.session.feedback = [...(state.session.feedback ?? []), result.feedback] rerenderTrajectoryRow(rowId) toast('标注已保存') } else { toast(result.error ?? '标注保存失败') } } /* ── Event wiring ────────────────────────────────────────────────────────── */ document.addEventListener('click', async (event) => { const loadLink = event.target.closest('[data-load-session]') if (loadLink) { await loadSession(loadLink.dataset.loadSession) return } const sessionRow = event.target.closest('[data-select-session]') if (sessionRow) { await loadSession(sessionRow.dataset.selectSession) return } const copyRow = event.target.closest('[data-copy-row]') if (copyRow) { const row = state.trajectoryRows.find(item => item.id === copyRow.dataset.copyRow) if (row) { await navigator.clipboard.writeText(JSON.stringify(rowPayload(row), null, 2)) toast('Copied') } return } const annotateRow = event.target.closest('[data-annotate-row]') if (annotateRow) { const id = annotateRow.dataset.annotateRow if (state.annotateOpenIds.has(id)) state.annotateOpenIds.delete(id) else state.annotateOpenIds.add(id) rerenderTrajectoryRow(id) return } const inspectNode = event.target.closest('[data-inspect-node]') if (inspectNode) { selectNode(inspectNode.dataset.inspectNode) return } const jumpNode = event.target.closest('[data-jump-node]') if (jumpNode) { jumpToNode(jumpNode.dataset.jumpNode) return } const jumpGroup = event.target.closest('[data-jump-group]') if (jumpGroup) { jumpToGroup(jumpGroup.dataset.jumpGroup) return } if (event.target.closest('#jumpFromInspector')) { if (state.selected && state.selected.kind !== 'session') jumpToNode(state.selected.id) return } // One click, one behavior in chat: a summary toggles its
, a link // navigates, and any OTHER click inside a message opens the inspector — on // the tool call when inside its expanded body, else on the message. A click // that ends a text selection does nothing (copying stays safe). const inspectCall = event.target.closest('[data-inspect-call]') if (inspectCall) { const pair = state.callPairs.get(inspectCall.dataset.inspectCall) if (pair?.call) selectEvent(pair.call.seq) return } if (event.target.closest('#conversation summary') || event.target.closest('#conversation a')) { return } const activityBody = event.target.closest('#conversation .activity-body') if (activityBody) { if (window.getSelection()?.toString()) return const callId = activityBody.closest('[data-call-id]')?.dataset.callId const pair = state.callPairs.get(callId) if (pair?.call) selectEvent(pair.call.seq) return } // Expanded trajectory bodies and their
handle their own clicks; // chat messages are NOT guarded — any click inside a message selects it. if (event.target.closest('.traj-row') && (event.target.closest('details') || event.target.closest('.traj-body'))) { return } const trajectoryToggle = event.target.closest('[data-toggle-trajectory]') if (trajectoryToggle) { toggleTrajectoryRow(trajectoryToggle.dataset.toggleTrajectory) return } const eventNode = event.target.closest('[data-select-event-seq]') if (eventNode) { if (window.getSelection()?.toString()) return selectEvent(eventNode.dataset.selectEventSeq) return } const mainView = event.target.closest('[data-main-view]') if (mainView) { showMainView(mainView.dataset.mainView) return } const tab = event.target.closest('[data-detail-tab]') if (tab) { state.activeDetailTab = tab.dataset.detailTab $$('[data-detail-tab]').forEach(button => button.classList.toggle('active', button === tab)) $$('.detail-panel').forEach(panel => panel.classList.remove('active')) $(`#${state.activeDetailTab}Panel`).classList.add('active') return } if (event.target.closest('#expandAllTrajectory')) { state.expandedTrajectorySeqs = new Set(state.trajectoryRows.map(item => item.id)) renderTrajectory() return } if (event.target.closest('#collapseTrajectory')) { state.expandedTrajectorySeqs = new Set() renderTrajectory() return } if (event.target.closest('#inspectorClose')) { closeInspector() return } if (event.target.closest('#revealSession')) { const result = await postJson(`/api/sessions/${encodeURIComponent(state.session.header.id)}/reveal`, {}) toast(result.ok ? '已在文件管理器中显示' : result.error ?? '无法定位文件') return } if (event.target.closest('#newSessionButton')) { const result = await postJson('/api/sessions', {}) toast(result.error ?? 'New session created') return } }) document.addEventListener('submit', (event) => { const form = event.target.closest('.inline-fb') if (form) { event.preventDefault() submitInlineFeedback(form) } }) document.addEventListener('keydown', (event) => { if ((event.key === 'Enter' || event.key === ' ') && event.target.matches?.('.traj-summary')) { event.preventDefault() toggleTrajectoryRow(event.target.dataset.toggleTrajectory) return } if (event.key === 'Escape' && state.inspectorOpen) { closeInspector() } }) document.addEventListener('change', (event) => { if (event.target.matches('[data-format-target]')) renderDetails() }) $('#sessionSearch')?.addEventListener('input', (event) => { state.sessionQuery = event.target.value renderSessionList() }) $('#feedbackForm')?.addEventListener('submit', async (event) => { event.preventDefault() const text = $('#feedbackText').value.trim() if (!text) return const target = state.selected ?? state.session.tree const result = await postJson(`/api/sessions/${encodeURIComponent(state.session.header.id)}/feedback`, { author: $('#feedbackAuthor').value, text, targetId: target.id, targetTitle: target.title, targetKind: target.kind, }) if (result.feedback) { state.session.feedback = [...(state.session.feedback ?? []), result.feedback] $('#feedbackText').value = '' renderFeedback() toast('Feedback saved') } else { toast(result.error ?? 'Failed to save feedback') } }) $('#feedbackText')?.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.altKey && !event.shiftKey && !event.metaKey) { event.preventDefault() $('#feedbackForm').requestSubmit() } }) $('#feedbackAuthor')?.addEventListener('input', (event) => { localStorage.setItem('dh-author', event.target.value.trim() || 'anonymous') }) /* Pane resizers: shell left divider + chat's inner inspector divider. Widths are clamped so no pane can crush the others and persist across reloads. */ const PANE_WIDTH_KEY = 'dh-pane-widths' function initPaneResizers() { const shell = document.querySelector('.shell') const chatSplit = document.querySelector('#chatSplit') const wfSplit = document.querySelector('#wfSplit') if (!shell) return let saved = {} try { saved = JSON.parse(localStorage.getItem(PANE_WIDTH_KEY) ?? '{}') } catch { // Corrupt localStorage entry: fall back to defaults; next drag rewrites it. } // The inspector width is shared between the chat and waterfall splits. const setInspectorWidth = (width) => { chatSplit?.style.setProperty('--inspector-w', `${width}px`) wfSplit?.style.setProperty('--inspector-w', `${width}px`) } if (saved.left) shell.style.setProperty('--left-w', `${saved.left}px`) if (saved.inspector) setInspectorWidth(saved.inspector) const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value)) const persist = () => { localStorage.setItem(PANE_WIDTH_KEY, JSON.stringify({ left: parseInt(shell.style.getPropertyValue('--left-w')) || undefined, inspector: parseInt(chatSplit?.style.getPropertyValue('--inspector-w')) || undefined, })) } const attach = (divider, apply) => { if (!divider) return divider.addEventListener('pointerdown', (event) => { event.preventDefault() divider.classList.add('dragging') document.body.style.cursor = 'col-resize' document.body.style.userSelect = 'none' const move = (ev) => apply(ev) const up = () => { window.removeEventListener('pointermove', move) window.removeEventListener('pointerup', up) window.removeEventListener('pointercancel', up) divider.classList.remove('dragging') document.body.style.cursor = '' document.body.style.userSelect = '' persist() } window.addEventListener('pointermove', move) window.addEventListener('pointerup', up) window.addEventListener('pointercancel', up) }) } attach($('#dividerLeft'), (ev) => { const rect = shell.getBoundingClientRect() const width = clamp(ev.clientX - rect.left - 12, 220, Math.min(480, rect.width * 0.4)) shell.style.setProperty('--left-w', `${width}px`) }) attach($('#dividerInspector'), (ev) => { if (!chatSplit) return const rect = chatSplit.getBoundingClientRect() setInspectorWidth(clamp(rect.right - ev.clientX - 4, 300, Math.min(640, rect.width * 0.6))) }) attach($('#dividerWfInspector'), (ev) => { if (!wfSplit) return const rect = wfSplit.getBoundingClientRect() setInspectorWidth(clamp(rect.right - ev.clientX - 4, 300, Math.min(640, rect.width * 0.6))) }) } initPaneResizers() initScrollSpy() const bootParams = new URLSearchParams(window.location.search) loadSessions(bootParams.get('session') || undefined) .then(() => { const sel = bootParams.get('sel') if (sel && state.session) restoreSelection(sel) }) .catch((error) => { const hint = backendOpenHint(error) $('#sourceLine').textContent = `Backend failed from ${window.location.href}` $('#sessionList').innerHTML = hint $('.main-pane').innerHTML = hint })