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}>`)
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)}${tag}>`).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('')
listType = 'ul'
}
out.push(`- ${mdInline(line.replace(/^\s*[-*]\s+/, ''))}
`)
continue
}
const ordered = line.match(/^\s*(\d+)[.)]\s+(.*)$/)
if (ordered) {
if (listType !== 'ol') {
closeList()
out.push('')
listType = 'ol'
}
out.push(`- ${mdInline(ordered[2])}
`)
continue
}
if (!line.trim()) {
closeList()
continue
}
out.push(`${mdInline(line)}
`)
}
if (inCode) 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 ? `
` : ''
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 `
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 `
${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 `
`
}
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 => `
`).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 ? `
slowest step${escapeHtml(slowest.title)} · ${ms(slowest.durationSec)}
` : ''}
tokens in/out${sumTokens('input')}/${sumTokens('output')}
`
const ticks = [0, .25, .5, .75, 1].map(p => `${ms(total * p)}`).join('')
$('#waterfall').innerHTML = summary + `
${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
})