// Session Tree page controller. Renders the "every session as a branchable
// timeline" view described in docs/product-ia-design.md §2.
//
// Data source: server-authoritative session/list snapshot via
// `window.__dshChat.getEntries()`, refreshed on tab switch by
// renderer.js's switchTo('tree') hook. Timeline data comes from
// window.dsh.sessionEvents(id) — same wire the chat pane uses for replay, so
// no new protocol surface.
//
// Rendering model:
// - Left column:
nested . Depth encoded by
// inline padding + a 1px connector column drawn with CSS box-shadow.
// Edge kind (fork vs subagent) is data-edge on the row so CSS picks the
// right connector style (solid + fork glyph / dashed + robot glyph).
// - Right column: preview card for the selected node. Metadata pulled
// straight from the entry; timeline dots computed by classifyEvent from
// session-tree.js so buckets stay aligned with the pure module's contract.
// - Compare mode: ⇧-click a second same-parent row to enter it. Two
// timelines rendered side-by-side with the fork boundary highlighted.
//
// No new IPC — only calls existing renderer.js exports (`__dshChat`,
// `__dshTabs`) and preload's `sessionEvents`/`forkSession` bridges.
'use strict'
;(function () {
if (typeof window === 'undefined' || !window.dsh) return
// The pure tree helpers are exported globally by session-tree.js; abort
// silently if that script somehow failed to load so the page just stays
// in its empty state rather than crashing.
if (!globalThis.SessionTree) return
const state = {
selectedId: null,
// Compare mode: second selection lives here. Cleared on any single-click
// selection change or on Close.
compareId: null,
// Cache of last-fetched event listings so re-selecting a node doesn't hit
// the wire again. Cleared when the entry list changes underneath us.
eventCache: new Map(), // sessionId -> [{seq,type}]
// Track which entry ids we've already seen so we know when to bust the
// event cache (e.g. a new turn landed on a session we already fetched).
entrySignature: '',
// "Earlier sessions" (untitled/smoke leaf) group collapsed by default so
// the first paint stays clean; toggled by the group head.
earlierExpanded: false,
// When set, a mock 3-level demo forest is shown instead of the real
// session list. Cleared by the "Clear demo" button (visible only while
// a demo is loaded).
demoEntries: null,
}
// ---- helpers -------------------------------------------------------------
function $ (id) { return document.getElementById(id) }
function entries() {
if (state.demoEntries) return state.demoEntries
const chat = window.__dshChat
if (!chat || typeof chat.getEntries !== 'function') return []
return chat.getEntries() || []
}
function findEntry(id) {
for (const e of entries()) if (e && e.sessionId === id) return e
return null
}
// Prefer the CN-locale rel-time helper the sidebar HISTORY already uses so
// the tree page and the sidebar don't render two conflicting time formats
// for the same session. Fall back to an in-module EN table only if the
// pure helpers module never loaded (defensive; keeps the page usable).
function relTime(ts) {
if (!ts) return ''
const panels = window.__dshPanelsC
if (panels && typeof panels.relativeTime === 'function') {
return panels.relativeTime(ts, Date.now())
}
const delta = Date.now() - ts
if (delta < 60_000) return 'just now'
if (delta < 3600_000) return `${Math.floor(delta / 60_000)}m ago`
if (delta < 86400_000) return `${Math.floor(delta / 3600_000)}h ago`
return `${Math.floor(delta / 86400_000)}d ago`
}
function shortId(id) {
if (!id) return ''
return id.length > 12 ? id.slice(0, 8) + '…' : id
}
// fix: real user prompts pass through unchanged; smoke fixtures and
// the old `(shortId)` fallback collapse to "Untitled · " via the shared
// smartSessionTitle helper. Result carries an `isUntitled` flag so the row
// can pick the muted-italic style, and it is also the discriminant that
// sends a session into the "Earlier sessions" collapsed group.
function titleInfo(entry) {
if (!entry) return { text: 'Untitled', isUntitled: true }
const panels = window.__dshPanelsC
if (panels && typeof panels.smartSessionTitle === 'function') {
return panels.smartSessionTitle(entry, Date.now())
}
// Fallback path — pure helpers not loaded. Best-effort match on the two
// known placeholder shapes so we don't render "(smoke-...)" as a real
// title even in the degraded case.
const raw = (entry.header && entry.header.title) || entry.title || ''
const trimmed = typeof raw === 'string' ? raw.trim() : ''
const looksPlaceholder = /^\(?smoke-/i.test(trimmed) ||
(typeof entry.sessionId === 'string' && trimmed === `(${entry.sessionId.slice(0, 8)})`)
if (trimmed && !looksPlaceholder) return { text: trimmed, isUntitled: false }
const rel = relTime(entry.lastEventTime)
return { text: rel ? `Untitled · ${rel}` : 'Untitled', isUntitled: true }
}
function displayTitle(entry) {
return titleInfo(entry).text
}
// ---- tree render ---------------------------------------------------------
function renderTree() {
const listEl = $('tree-list')
const emptyEl = $('tree-empty')
const countEl = $('tree-count')
if (!listEl) return
// Demo forest wins over the real list once loaded, so the "Load demo
// tree" button in the empty state produces a self-contained preview even
// when the daemon has 50 smoke rows underneath.
const all = state.demoEntries || entries()
// C-P1-5: the sidebar Recent list reports total-recent count (e.g. 25);
// this page renders the currently-visible tree. Two numbers that count
// different things read as a discrepancy. Match Recent's filter so the
// tree count corresponds to what's actually drawn (ghost sessions with
// no user message ever sent are dropped by the same predicate).
const panels = window.__dshPanelsC
const visible = panels && typeof panels.filterEmptySessions === 'function'
? panels.filterEmptySessions(all)
: all
countEl.textContent = String(visible.length)
// Demo-forest chip in tree-nav-head (fix/demo-labels): once a reader hits
// "Load demo tree" the entries look real (`demo-*` ids + parent+child
// relationships + real-looking titles). The "Clear demo" button in the
// empty-state actions is one hint but disappears once the tree paints,
// so we hang a persistent chip in the meta strip while demoEntries is
// set. Same wording as the load button so a reader connects the two.
const head = document.querySelector('.tree-nav-head')
if (head) {
let chip = head.querySelector('.tree-nav-demo-chip')
if (state.demoEntries) {
if (!chip) {
chip = document.createElement('span')
chip.className = 'demo-tier-chip tree-nav-demo-chip'
chip.textContent = 'demo forest'
chip.title = '3-level example forest (mock; not real sessions). Use "Clear demo" from the empty state to drop back to your real tree.'
head.appendChild(chip)
}
} else if (chip) {
chip.remove()
}
}
listEl.innerHTML = ''
if (all.length === 0) {
emptyEl.hidden = false
renderEmptyState(emptyEl, 'empty')
return
}
// Bust the timeline cache if the entry set changed (new session, new
// event count on a known session) — cheap signature over the fields the
// preview reads.
const sig = all.map((e) => `${e.sessionId}:${e.lastEventTime || 0}:${e.eventCount || 0}`).join('|')
if (sig !== state.entrySignature) {
state.eventCache.clear()
state.entrySignature = sig
}
const forest = globalThis.SessionTree.buildSessionTree(all)
// Roots ordered by recency so the active session floats to the top —
// matches the sidebar's convention so users don't relearn ordering.
forest.sort((a, b) => (b.entry.lastEventTime || 0) - (a.entry.lastEventTime || 0))
// Noise collapse (team-lead's fix): 51 smoke roots with no
// children and no real title were drowning the tree on first open. Now
// split roots into "meaningful" (real title OR has any descendants) and
// "untitled leaves" (flat placeholders). Meaningful ones render inline;
// leaves fold into an expandable "Earlier sessions (N)" group at the
// bottom so the useful signal comes first.
const meaningful = []
const untitledLeaves = []
for (const root of forest) {
const info = titleInfo(root.entry)
const hasChildren = Array.isArray(root.children) && root.children.length > 0
if (!info.isUntitled || hasChildren) meaningful.push(root)
else untitledLeaves.push(root)
}
for (const root of meaningful) emitNode(listEl, root, null)
if (untitledLeaves.length > 0) {
emitEarlierGroup(listEl, untitledLeaves)
}
// If every real entry was untitled + leaf we still want the guided empty
// state visible — otherwise the tree opens with only a collapsed group
// and no explanation.
if (meaningful.length === 0) {
emptyEl.hidden = false
renderEmptyState(emptyEl, 'all-noise')
} else {
emptyEl.hidden = true
}
}
// "Earlier sessions" — a single dedicated
at the bottom of the list
// holding an expandable summary + a nested
of the untitled leaves.
// Keeps DOM shape stable (still a flat
of
s) so hover/click
// handlers below don't need to special-case anything.
function emitEarlierGroup(container, leaves) {
const li = document.createElement('li')
li.className = 'tree-row tree-row-earlier'
li.dataset.earlier = String(state.earlierExpanded ? 'expanded' : 'collapsed')
const head = document.createElement('button')
head.type = 'button'
head.className = 'tree-earlier-head'
head.setAttribute('aria-expanded', String(!!state.earlierExpanded))
const caret = document.createElement('span')
caret.className = 'tree-earlier-caret'
caret.innerHTML = ''
const label = document.createElement('span')
label.className = 'tree-earlier-label'
label.textContent = `Earlier sessions (${leaves.length})`
const hint = document.createElement('span')
hint.className = 'tree-earlier-hint muted'
hint.textContent = 'untitled or smoke fixtures'
head.appendChild(caret)
head.appendChild(label)
head.appendChild(hint)
head.addEventListener('click', () => {
state.earlierExpanded = !state.earlierExpanded
renderTree()
})
li.appendChild(head)
if (state.earlierExpanded) {
const inner = document.createElement('ul')
inner.className = 'tree-list tree-list-earlier'
// Leaves stay at depth 0 in the buildSessionTree sense; we just render
// them one indent step in via CSS on `.tree-list-earlier .tree-row` so
// the fork/subagent connector logic doesn't trip on synthetic depths.
for (const leaf of leaves) emitNode(inner, leaf, null)
li.appendChild(inner)
}
container.appendChild(li)
}
// Guided empty state — different copy for "no sessions at all" vs "every
// session collapsed to noise" so the user knows why the tree looks bare.
// Both variants offer the "Load demo tree" button which injects a 3-level
// canned forest via loadDemoTree(); the button is the safest onboarding
// affordance (mock is clearly labelled once it's on screen).
function renderEmptyState(el, variant) {
if (!el) return
el.innerHTML = ''
const title = document.createElement('div')
title.className = 'tree-empty-title'
const body = document.createElement('div')
body.className = 'tree-empty-body muted'
if (variant === 'all-noise') {
title.textContent = 'Nothing forked yet.'
body.textContent = 'The sessions here are all flat placeholders. Start a chat and use "Fork from here" on any turn to grow a branch.'
} else {
title.textContent = 'No sessions yet.'
body.textContent = 'Every session shows up here as a branchable timeline. Start a chat, then fork any turn to see the tree fill in.'
}
const actions = document.createElement('div')
actions.className = 'tree-empty-actions'
const demoBtn = document.createElement('button')
demoBtn.type = 'button'
demoBtn.className = 'ghost small'
demoBtn.textContent = 'Load demo tree'
demoBtn.title = 'Preview a 3-level example forest (mock; clearly labelled).'
demoBtn.addEventListener('click', () => loadDemoTree())
actions.appendChild(demoBtn)
if (state.demoEntries) {
const dropBtn = document.createElement('button')
dropBtn.type = 'button'
dropBtn.className = 'ghost small'
dropBtn.textContent = 'Clear demo'
dropBtn.addEventListener('click', () => {
state.demoEntries = null
state.selectedId = null
renderTree()
void renderPreview()
})
actions.appendChild(dropBtn)
}
el.appendChild(title)
el.appendChild(body)
el.appendChild(actions)
}
// Demo forest — 3 real roots, 2 with children, one child with a
// grand-child so users see both fork edges and subagent edges without
// touching the daemon.
//
// child titles now DELIBERATELY copy the parent's title
// (empty-title children inherit at seed time in real usage — the daemon
// seeds the fork's title from the parent's mirrored header). The renderer
// derives the row label via forkChildLabel() instead of trusting the
// copied title, so the demo forest exercises that path.
function loadDemoTree() {
const now = Date.now()
const min = (n) => now - n * 60_000
const entries = [
{ sessionId: 'demo-root-1', title: 'Rewrite Session Tree page', lastEventTime: min(2), eventCount: 42, header: { title: 'Rewrite Session Tree page', model: 'claude-fable-5', cwd: '~/harness/dsh-desktop-demo' }, running: true, hasUserMessage: true },
// Both fork children inherit parent's title (same bug shape as the
// screenshot). Only 1b has a new user message (title differs → label
// shows the child's own message); 1a is a fresh fork with no new
// turn (title still equal → label shows "no new messages yet").
{ sessionId: 'demo-fork-1a', title: 'Rewrite Session Tree page', lastEventTime: min(8), eventCount: 3, header: { title: 'Rewrite Session Tree page', parentSession: 'demo-root-1', seedLength: 22, model: 'claude-fable-5' }, hasUserMessage: false },
{ sessionId: 'demo-fork-1b', title: 'try a different palette', lastEventTime: min(14), eventCount: 12, header: { title: 'try a different palette', parentSession: 'demo-root-1', seedLength: 22, model: 'claude-fable-5' }, hasUserMessage: true },
{ sessionId: 'demo-sub-1a1', title: 'run lint report', lastEventTime: min(6), eventCount: 8, header: { title: 'run lint report', parentSession: 'demo-fork-1a', originKind: 'subagent', seedLength: 4, model: 'claude-haiku-4-5' }, hasUserMessage: true },
{ sessionId: 'demo-root-2', title: 'Research memory-plugin candidates', lastEventTime: min(45), eventCount: 87, header: { title: 'Research memory-plugin candidates', model: 'claude-fable-5', cwd: '~/harness/memory-capability-plan.md', awaitingApproval: true }, hasUserMessage: true },
{ sessionId: 'demo-sub-2a', title: 'scrape mem0 README', lastEventTime: min(50), eventCount: 6, header: { title: 'scrape mem0 README', parentSession: 'demo-root-2', originKind: 'subagent', seedLength: 12, model: 'claude-haiku-4-5' }, hasUserMessage: true },
{ sessionId: 'demo-root-3', title: 'Check PR #372 regression', lastEventTime: min(180), eventCount: 33, header: { title: 'Check PR #372 regression', model: 'claude-fable-5' }, hasUserMessage: true },
]
state.demoEntries = entries
state.earlierExpanded = false
// Land the user on the root that's actively running so the preview card
// has interesting metadata on first paint.
state.selectedId = 'demo-root-1'
renderTree()
void renderPreview()
}
function emitNode(container, node, parentEntry) {
const li = document.createElement('li')
li.className = 'tree-row'
li.dataset.sessionId = node.entry.sessionId
li.dataset.depth = String(node.depth)
li.style.setProperty('--depth', String(node.depth))
// Edge kind: header.originKind === 'subagent' → dashed edge with robot
// glyph; otherwise treat any child as a user-initiated fork. Roots (depth
// 0) have no incoming edge.
const originKind = node.entry.header && node.entry.header.originKind
const edge = node.depth === 0 ? 'root'
: originKind === 'subagent' ? 'subagent' : 'fork'
li.dataset.edge = edge
// Row inner: [status dot] [body] [actions]. The connector rail is drawn
// by the nested 's border-left; the horizontal tick
// into this row is a CSS ::before on .tree-row when depth > 0. That way
// the connector is real geometry, not a glyph that promises a line but
// doesn't draw one.
const dot = document.createElement('span')
dot.className = 'tree-status-dot'
const status = deriveStatus(node.entry)
dot.dataset.status = status
dot.title = statusTitle(status)
li.appendChild(dot)
const body = document.createElement('span')
body.className = 'tree-row-body'
const title = document.createElement('span')
title.className = 'tree-row-title'
// Fork/subagent children: never render the copied parent title verbatim
// — the whole point of the row is "this is a branch off X at seq N", not
// "here's the same title again". forkChildLabel derives the display text
// from the fork point + the child's own signal.
let info
if (node.depth > 0) {
const forkSeq = deriveForkSeq(node.entry)
const label = globalThis.SessionTree.forkChildLabel(node.entry, parentEntry, forkSeq)
info = { text: label.text, isUntitled: !label.hasOwnMessage }
title.textContent = label.text
title.title = label.hasOwnMessage
? `Fork of "${displayTitle(parentEntry)}" at seq ${forkSeq === null ? '?' : forkSeq}`
: `Fork of "${displayTitle(parentEntry)}" · no new user messages yet`
} else {
info = titleInfo(node.entry)
title.textContent = info.text
}
if (info.isUntitled) {
title.classList.add('is-untitled')
li.classList.add('is-untitled')
}
body.appendChild(title)
if (node.entry.lastEventTime) {
const rel = document.createElement('span')
rel.className = 'tree-row-rel muted'
rel.textContent = relTime(node.entry.lastEventTime)
body.appendChild(rel)
}
li.appendChild(body)
// Hover actions: three quiet buttons on the right that only appear when
// the row is hovered/focused. They mirror the actions available in the
// preview card so power users don't need to click into the preview.
const actions = document.createElement('span')
actions.className = 'tree-row-actions'
actions.appendChild(makeQuietAction('open', 'Continue this session',
''))
actions.appendChild(makeQuietAction('fork', 'Fork from here',
''))
actions.appendChild(makeQuietAction('compare', 'Compare branches (Shift-click a second row)',
''))
li.appendChild(actions)
if (node.entry.sessionId === state.selectedId) li.classList.add('is-selected')
if (node.entry.sessionId === state.compareId) li.classList.add('is-compare')
// stopPropagation: because children live inside the parent LI (so the
// .tree-children rail hangs off the parent geometrically), a click on a
// child row would bubble up and re-fire the parent's click handler, which
// would then overwrite the child selection with the parent id. Stopping
// propagation on the row itself keeps the innermost row wins.
li.addEventListener('click', (ev) => {
ev.stopPropagation()
onRowClick(node.entry.sessionId, ev)
})
// Delegate action buttons — stopPropagation so a hover-action click
// doesn't also trigger the row's select handler.
for (const btn of actions.querySelectorAll('[data-quiet-action]')) {
btn.addEventListener('click', (ev) => {
ev.stopPropagation()
void onQuietAction(node.entry.sessionId, btn.dataset.quietAction)
})
}
container.appendChild(li)
// Children go into a nested whose border-left is the
// vertical rail (solid for fork edges, dashed for subagent). The rail
// is drawn by geometry, not by a placeholder span, so the legend's
// "solid fork / dashed subagent" line matches what's on screen.
if (Array.isArray(node.children) && node.children.length > 0) {
const kids = document.createElement('ul')
kids.className = 'tree-children'
// Rail kind = kind of the FIRST child edge. In mixed-kind subtrees
// (rare — you'd have to fork AND spawn a subagent from the same node)
// the rail stays solid; individual rows still get their own tick
// dashed via [data-edge="subagent"] on the child row.
const firstChildKind = node.children[0].entry.header && node.children[0].entry.header.originKind === 'subagent'
? 'subagent' : 'fork'
kids.dataset.railKind = firstChildKind
for (const child of node.children) emitNode(kids, child, node.entry)
// Nest kids INSIDE the parent li so the rail hangs off the parent row,
// matching how git-branchless / gh CLI trees are drawn.
li.appendChild(kids)
}
}
// Derive the fork seq from the child's own header — seedLength is the
// count of events replayed from the parent, so seedLength-1 is the highest
// seq the child inherited. Falls back to null when missing so the label
// reads "fork" without a seq (the older mock shape).
function deriveForkSeq(entry) {
const seed = entry && entry.header && entry.header.seedLength
return typeof seed === 'number' && seed > 0 ? seed - 1 : null
}
function makeQuietAction(action, tip, svg) {
const btn = document.createElement('button')
btn.type = 'button'
// Deliberately no `.ghost` class: the generic ghost button style adds a
// 1px border and 6px/12px padding, which turns these into visible bordered
// rounded rects even when the icon is transparent. The row-action class
// owns its full appearance (24×24, no border, transparent background) so
// hover-out is a clean fade to zero and the row stays quiet.
btn.className = 'tree-row-action'
btn.dataset.quietAction = action
btn.title = tip
btn.setAttribute('aria-label', tip)
btn.innerHTML = svg
return btn
}
function deriveStatus(entry) {
if (!entry) return 'idle'
if (entry.header && entry.header.awaitingApproval) return 'awaiting'
if (entry.running) return 'running'
// Best-effort: sessions that finished a turn recently but aren't running
// read as "done" for the last hour, then decay to "idle" so old sessions
// stop shouting at the user.
if (entry.lastEventTime && (Date.now() - entry.lastEventTime) < 3600_000) return 'done'
return 'idle'
}
function statusTitle(s) {
return s === 'running' ? 'turn in flight'
: s === 'awaiting' ? 'waiting for approval'
: s === 'done' ? 'recently active'
: 'idle'
}
// ---- selection + preview -------------------------------------------------
function onRowClick(id, ev) {
// Shift-click enters compare mode if the second pick shares a parent with
// the current selection. Otherwise it's a plain select — clear compare.
if (ev.shiftKey && state.selectedId && id !== state.selectedId) {
if (canCompare(state.selectedId, id)) {
state.compareId = id
renderTree()
renderCompare()
return
}
// Fall through to plain select if compare isn't legal — the toolbar
// button provides the affordance for users who don't know the rule.
}
select(id)
}
function canCompare(aId, bId) {
const a = findEntry(aId)
const b = findEntry(bId)
if (!a || !b) return false
const pa = a.header && a.header.parentSession
const pb = b.header && b.header.parentSession
// Same-parent branches — either both roots (pa/pb undefined) is not a
// useful compare, so require a real shared parent.
return !!pa && pa === pb
}
function select(id) {
state.selectedId = id
state.compareId = null
renderTree()
renderPreview()
}
async function onQuietAction(id, action) {
if (action === 'open') {
openInChat(id)
} else if (action === 'fork') {
await forkFromLatest(id)
} else if (action === 'compare') {
// No-op click on the compare glyph itself — the actual compare enters
// via shift-click on a second row. Prime the title as a hint.
state.selectedId = id
renderTree()
renderPreview()
const tip = document.getElementById('tree-preview-title')
if (tip) tip.title = 'Shift-click another row (same parent) to compare.'
}
}
function openInChat(id) {
// Switch to the chat pane and select the session there. This is what
// "Open session" means everywhere in the app.
if (window.__dshTabs) window.__dshTabs.switchTo('chat')
if (window.__dshChat) void window.__dshChat.selectSession(id)
}
async function forkFromLatest(id) {
try {
const res = await window.dsh.forkSession({ sessionId: id })
if (window.__dshChat) {
await window.__dshChat.refreshSessionList()
if (res && res.childSessionId) {
// Land the user in the fresh child, but stay on the tree page —
// it's the whole point of this surface. Select the child so the
// preview updates too.
state.selectedId = res.childSessionId
renderTree()
void renderPreview()
}
}
} catch (err) {
console.warn('[tree] fork failed', err && err.message)
}
}
async function renderPreview() {
const emptyEl = $('tree-preview-empty')
const cardEl = $('tree-preview-card')
const compareEl = $('tree-preview-compare')
if (compareEl) compareEl.hidden = true
const compareBtn = $('tree-compare-btn')
if (!state.selectedId) {
emptyEl.hidden = false
cardEl.hidden = true
if (compareBtn) compareBtn.disabled = true
return
}
const entry = findEntry(state.selectedId)
if (!entry) {
emptyEl.hidden = false
cardEl.hidden = true
return
}
emptyEl.hidden = true
cardEl.hidden = false
$('tree-preview-title').textContent = displayTitle(entry)
$('tree-preview-id').textContent = shortId(entry.sessionId)
$('tree-preview-rel').textContent = relTime(entry.lastEventTime) || 'no activity'
const statusWrap = $('tree-preview-status')
const status = deriveStatus(entry)
const dot = statusWrap.querySelector('.tree-status-dot')
const label = statusWrap.querySelector('.tree-status-label')
if (dot) dot.dataset.status = status
if (label) label.textContent = statusTitle(status)
$('tree-preview-cwd').textContent = (entry.header && entry.header.cwd) || '—'
$('tree-preview-model').textContent = (entry.header && entry.header.model) || entry.model || '—'
$('tree-preview-events').textContent = String(entry.eventCount || 0)
// Context bar. The per-session tracker (context-meter.js) is the sole
// source — Ticket B §B-3 (2026-07-16) deleted the old header-side
// usage fallback because the wire never shipped a usage field on
// SessionHeader. Background sessions with no tracker snapshot show
// '—' below rather than 0, which is honest ("we haven't tracked this
// yet") vs misleading ("this session has consumed 0% context").
const meter = window.__dshContextMeter
const chat = window.__dshChat
let fraction = null
if (chat && typeof chat.getEntries === 'function' && meter) {
const snap = meter.snapshotFor && meter.snapshotFor(entry.sessionId)
if (snap && typeof snap.fraction === 'number') fraction = snap.fraction
}
const bar = $('tree-preview-context').querySelector('.tree-context-fill')
const cLabel = $('tree-preview-context').querySelector('.tree-context-label')
if (fraction !== null) {
bar.style.width = `${Math.min(100, Math.round(fraction * 100))}%`
cLabel.textContent = `${Math.round(fraction * 100)}%`
} else {
// C-P1-4: `unknown` reads as "broken" to a first-time user. The real
// reason is either "the turn hasn't been compacted yet" (in-flight or
// just started) or "the daemon doesn't ship usageFraction for
// background sessions". Give the count as evidence so the reader sees
// it's not zero data — just not the ratio yet.
bar.style.width = '0%'
const n = Number(entry.eventCount || 0)
cLabel.textContent = n > 0 ? `not compacted · ${n} event${n === 1 ? '' : 's'}` : 'not compacted'
}
// Timeline. Fetch events on demand; cache per-session to keep repeated
// selections instant. Any error just leaves the empty state visible.
const trackEl = $('tree-preview-timeline')
await renderTimeline(entry.sessionId, trackEl)
// Enable Compare toolbar button if this session has same-parent siblings.
if (compareBtn) {
const siblings = findSiblingCandidates(entry.sessionId)
compareBtn.disabled = siblings.length === 0
compareBtn.title = siblings.length === 0
? 'This session has no same-parent siblings to compare with.'
: 'Select two same-parent branches to compare their timelines.'
}
}
function findSiblingCandidates(id) {
const entry = findEntry(id)
if (!entry) return []
const parent = entry.header && entry.header.parentSession
if (!parent) return []
return entries().filter((e) =>
e && e.sessionId !== id
&& e.header && e.header.parentSession === parent)
}
async function fetchEvents(id) {
if (state.eventCache.has(id)) return state.eventCache.get(id)
try {
const listing = await window.dsh.sessionEvents(id, {})
const evs = (listing && Array.isArray(listing.events)) ? listing.events : []
// Keep only what we need for the timeline: seq + type. Full payload
// fetches are the chat pane's job.
const trimmed = evs.map((e) => ({ seq: e.seq, type: e.type }))
state.eventCache.set(id, trimmed)
return trimmed
} catch (_) {
state.eventCache.set(id, [])
return []
}
}
async function renderTimeline(id, trackEl) {
if (!trackEl) return
trackEl.innerHTML = '
Loading…
'
const evs = await fetchEvents(id)
trackEl.innerHTML = ''
if (evs.length === 0) {
const empty = document.createElement('div')
empty.className = 'tree-preview-timeline-empty muted'
empty.textContent = 'No events yet.'
trackEl.appendChild(empty)
return
}
// Fork boundaries: any child session's seedLength - 1 that falls inside
// this session's event range shows up as a fork marker on the parent.
const forkSeqs = new Set()
for (const f of globalThis.SessionTree.findChildForks(id, entries())) {
if (typeof f.forkSeq === 'number') forkSeqs.add(f.forkSeq)
}
const maxSeq = evs[evs.length - 1].seq || 1
for (const e of evs) {
const bucket = bucketFor(e.type)
if (!bucket) continue
const marker = document.createElement('span')
marker.className = `tree-tl-marker tree-tl-marker--${bucket}`
marker.style.left = `${Math.min(100, (e.seq / maxSeq) * 100)}%`
marker.title = `${e.type} · seq ${e.seq}`
trackEl.appendChild(marker)
}
for (const seq of forkSeqs) {
const marker = document.createElement('span')
marker.className = 'tree-tl-marker tree-tl-marker--fork'
marker.style.left = `${Math.min(100, (seq / maxSeq) * 100)}%`
marker.title = `fork boundary · seq ${seq}`
trackEl.appendChild(marker)
}
}
function bucketFor(type) {
const kind = globalThis.SessionTree.classifyEvent({ type })
if (kind === 'turn-boundary') return 'turn'
if (kind === 'tool-call' || kind === 'tool-result') return 'tool'
if (kind === 'compact-begin' || kind === 'compact-end' || kind === 'compact-summary') return 'compact'
return null
}
// ---- compare mode --------------------------------------------------------
async function renderCompare() {
const emptyEl = $('tree-preview-empty')
const cardEl = $('tree-preview-card')
const compareEl = $('tree-preview-compare')
if (!compareEl) return
if (!state.selectedId || !state.compareId) {
compareEl.hidden = true
return
}
emptyEl.hidden = true
cardEl.hidden = true
compareEl.hidden = false
const left = findEntry(state.selectedId)
const right = findEntry(state.compareId)
if (!left || !right) return
$('tree-compare-left-title').textContent = displayTitle(left)
$('tree-compare-right-title').textContent = displayTitle(right)
$('tree-compare-left-sub').textContent = `${shortId(left.sessionId)} · ${relTime(left.lastEventTime)}`
$('tree-compare-right-sub').textContent = `${shortId(right.sessionId)} · ${relTime(right.lastEventTime)}`
await renderTimeline(left.sessionId, $('tree-compare-left-timeline'))
await renderTimeline(right.sessionId, $('tree-compare-right-timeline'))
}
// ---- wiring --------------------------------------------------------------
function wireStaticButtons() {
const openBtn = $('tree-preview-open')
if (openBtn) openBtn.addEventListener('click', () => {
if (state.selectedId) openInChat(state.selectedId)
})
const forkBtn = $('tree-preview-fork')
if (forkBtn) forkBtn.addEventListener('click', () => {
if (state.selectedId) void forkFromLatest(state.selectedId)
})
const compareBtn = $('tree-compare-btn')
if (compareBtn) compareBtn.addEventListener('click', () => {
// Guided compare-mode entry: prompt the user via the preview title's
// title attribute and highlight sibling rows so they know what to
// shift-click. If there's exactly one sibling, enter compare
// immediately (single obvious choice).
if (!state.selectedId) return
const siblings = findSiblingCandidates(state.selectedId)
if (siblings.length === 1) {
state.compareId = siblings[0].sessionId
renderTree()
void renderCompare()
return
}
// Otherwise flag the candidates visibly.
renderTree()
for (const s of siblings) {
const row = document.querySelector(`.tree-row[data-session-id="${cssEscapeId(s.sessionId)}"]`)
if (row) row.classList.add('is-compare-candidate')
}
})
const compareClose = $('tree-compare-close')
if (compareClose) compareClose.addEventListener('click', () => {
state.compareId = null
renderTree()
renderPreview()
})
const refreshBtn = $('tree-refresh')
if (refreshBtn) refreshBtn.addEventListener('click', () => {
state.eventCache.clear()
if (window.__dshChat) {
void window.__dshChat.refreshSessionList().then(() => {
renderTree()
void renderPreview()
})
} else {
renderTree()
void renderPreview()
}
})
}
function cssEscapeId(id) {
if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(id)
return String(id).replace(/[^a-zA-Z0-9_-]/g, (c) => '\\' + c.charCodeAt(0).toString(16) + ' ')
}
// Public surface — renderer.js calls render() when the user opens the tab.
window.__dshTree = {
render() {
renderTree()
void renderPreview()
},
// QA hook: force the demo forest without needing the empty-state button
// (once real sessions exist, the "Load demo tree" button never shows).
// Used by scripts/qa-cdp-shoot-demo-labels.mjs to verify the demo-forest
// chip renders. Not called by product code.
_loadDemoForQA() { loadDemoTree() },
_clearDemoForQA() {
state.demoEntries = null
state.selectedId = null
renderTree()
void renderPreview()
},
}
// Wire up static buttons after the DOM is ready. The controller script sits
// at the end of index.html so DOMContentLoaded has usually fired; use a
// guard for the rare case where it hasn't.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', wireStaticButtons, { once: true })
} else {
wireStaticButtons()
}
})()