feat(desktop): context page deepening — window bar + compact Config + intervention marker + subagent drilldown

Four Context-page enhancements (lane-ctx-deep, F1-F4):

- F1 context window breakdown: replace percentage-only card header with
  a stacked-bar breakdown of input / cached / output token buckets,
  plus a right-side gauge showing the live window occupancy ratio.
- F2 compact Config tab: fold the sprawling profile Config editor into
  a Config tab on the Context page card, with the same yml-leaf
  ordering as the top-of-window profile picker.
- F3 intervention marker: on the intervention timeline, emit a marker
  glyph at each user-intervention row (turn-flow-glyph-style) so the
  card scans as a single stream instead of a header + separate list.
- F4 subagent drilldown: when a turn's tool trace hits a subagent, the
  Trace panel's Config + Output tabs get a second row of Subagent
  Config / Subagent Output tabs immediately below, driven by the same
  fold-in-place shape the parent panel already uses.

4 new renderer modules (compact-config-model / context-window-breakdown /
intervention-timeline / subagent-drilldown), 6 new test files (41 tests,
all node --test style), 4 QA shoot scripts for CDP-driven regression
screenshots.
This commit is contained in:
ZiyaZhang
2026-07-19 01:22:28 -07:00
parent c8f5ca3d5b
commit fc558c2db8
18 files changed
+2705 -4

No files matched your search

+17 -4
View File
@@ -176,7 +176,10 @@ function buildDiffModel(data, extractText) {
* @param {(bodyEl: HTMLElement) => void} [opts.fillPre]
* @param {(bodyEl: HTMLElement) => void} [opts.fillPost]
* @param {(bodyEl: HTMLElement) => void} [opts.fillMeta]
* @returns {{ preBody: HTMLElement, postBody: HTMLElement, metaBody: HTMLElement }}
* @param {(bodyEl: HTMLElement) => void} [opts.fillConfig] lane-ctx-deep F2 —
* optional 4th tab "Config". Omit to keep the pre-fix three-tab shape
* (the strip auto-hides the tab when the fill callback is not passed).
* @returns {{ preBody: HTMLElement, postBody: HTMLElement, metaBody: HTMLElement, configBody: HTMLElement|null }}
*/
function mountTabs(parent, opts) {
// Resolve doc without touching a bare `document` binding (renderer harness
@@ -193,11 +196,13 @@ function mountTabs(parent, opts) {
strip.className = 'compact-card-tabstrip'
strip.setAttribute('role', 'tablist')
const initial = (opts && opts.initial) || 'post'
const hasConfig = opts && typeof opts.fillConfig === 'function'
const tabs = [
{ id: 'pre', label: 'Diff' },
{ id: 'post', label: 'Summary' },
{ id: 'meta', label: 'Policy & accounting' },
]
if (hasConfig) tabs.push({ id: 'config', label: 'Config' })
const bodies = {}
const buttons = {}
for (const t of tabs) {
@@ -232,12 +237,13 @@ function mountTabs(parent, opts) {
if (!target) return
const id = target === buttons.pre ? 'pre'
: target === buttons.post ? 'post'
: target === buttons.meta ? 'meta' : null
: target === buttons.meta ? 'meta'
: (hasConfig && target === buttons.config) ? 'config' : null
if (id) activate(id)
})
strip.addEventListener('keydown', (ev) => {
if (ev.key !== 'ArrowLeft' && ev.key !== 'ArrowRight') return
const order = ['pre', 'post', 'meta']
const order = hasConfig ? ['pre', 'post', 'meta', 'config'] : ['pre', 'post', 'meta']
const active = order.find((id) => buttons[id].getAttribute('aria-selected') === 'true') || 'post'
const idx = order.indexOf(active)
const next = ev.key === 'ArrowRight' ? order[(idx + 1) % order.length] : order[(idx + order.length - 1) % order.length]
@@ -249,11 +255,18 @@ function mountTabs(parent, opts) {
wrap.appendChild(bodies.pre)
wrap.appendChild(bodies.post)
wrap.appendChild(bodies.meta)
if (hasConfig) wrap.appendChild(bodies.config)
parent.appendChild(wrap)
if (opts && typeof opts.fillPre === 'function') opts.fillPre(bodies.pre)
if (opts && typeof opts.fillPost === 'function') opts.fillPost(bodies.post)
if (opts && typeof opts.fillMeta === 'function') opts.fillMeta(bodies.meta)
return { preBody: bodies.pre, postBody: bodies.post, metaBody: bodies.meta }
if (hasConfig) opts.fillConfig(bodies.config)
return {
preBody: bodies.pre,
postBody: bodies.post,
metaBody: bodies.meta,
configBody: hasConfig ? bodies.config : null,
}
}
if (typeof module !== 'undefined' && module.exports) {
@@ -0,0 +1,187 @@
// Pure model for the compact-card "Config" tab (lane-ctx-deep, task #51 F2).
//
// The Config tab is an info-only entrance to the compaction policy: it names
// the current threshold, the strategy, how many times the daemon has fired
// compact this session, and a "distance to next compact" progress bar. It
// is *not* an edit surface — a live editor belongs on the Settings page,
// and the tab tooltip points there ("Adjust in Settings Compaction").
//
// Model shape is a plain object so tests can lock it without a DOM harness.
// The `buildCompactConfigView` function is the single entry point; it takes
// a session's cached events and (optionally) a policy override the shell
// pulls from the Settings profile, and returns:
//
// {
// thresholdTokens, // e.g. 96000 (server-reported) or fallback 96k
// thresholdSource, // 'server'|'assumed'
// strategyName, // e.g. 'summarize-shadowed' / 'unknown'
// model, // summary model, e.g. 'deepseek-chat' or null
// maxSummaryTokens, // policy cap on the summary output
// triggersFired, // total compact/summary events observed
// lastCompactSeq, // seq of the last compact/summary, or null
// currentTokens, // running tokens at end of stream
// tokensSinceLastCompact,// tokens accumulated after the last compact
// tokensUntilNext, // max(threshold tokensSinceLastCompact, 0)
// progressPct, // tokensSinceLastCompact / threshold × 100
// progressLevel, // 'nominal'|'warn'|'high'|'critical'
// }
//
// Threshold source: we prefer the wire (`session/list` entry's
// `context.compact.threshold` if the daemon ever ships one), else fall back
// to 75% of the model's context window ("industry default"), else a hard
// fallback of 96000. The `thresholdSource` field marks which path we took
// so the tab tooltip can be honest.
'use strict'
const DEFAULT_THRESHOLD_TOKENS = 96000
/**
* Return the compact threshold (tokens) plus its provenance.
* Priority order:
* 1. `override.thresholdTokens` (Settings profile / test override).
* 2. `budgetTokens * 0.75` when a wire-reported budget is available.
* 3. DEFAULT_THRESHOLD_TOKENS (96000).
* @param {object} [opts]
* @param {number} [opts.thresholdTokens] explicit override
* @param {number} [opts.budgetTokens] wire-reported model context window
* @returns {{ tokens: number, source: 'server'|'assumed' }}
*/
function resolveThreshold(opts) {
const explicit = opts && Number.isFinite(opts.thresholdTokens) && opts.thresholdTokens > 0
if (explicit) return { tokens: Number(opts.thresholdTokens), source: 'server' }
const budget = opts && Number.isFinite(opts.budgetTokens) && opts.budgetTokens > 0
? Number(opts.budgetTokens) : null
if (budget) return { tokens: Math.round(budget * 0.75), source: 'assumed' }
return { tokens: DEFAULT_THRESHOLD_TOKENS, source: 'assumed' }
}
/**
* Roughly count tokens the same way context-meter's approx mode does:
* bytes ÷ 4 over `event.data` JSON. Duplicated here (not imported) so the
* config model stays a leaf — importing context-meter would require the
* caller to pass a tracker to keep coherent, and every callsite already
* has cachedEvents in hand.
* @param {object} event
* @returns {number}
*/
function approxTokensFor(event) {
if (!event || typeof event !== 'object') return 0
// Precise-mode signal: honour the usage envelope if present.
if (event.type === 'assistant/message') {
const u = event.data && event.data.usage
if (u && typeof u === 'object') {
const inp = Number(u.inputTokens)
const out = Number(u.outputTokens)
const sum = (Number.isFinite(inp) ? inp : 0) + (Number.isFinite(out) ? out : 0)
if (sum > 0) return sum
}
}
const payload = event.data !== undefined ? event.data : event
try { return Math.round(JSON.stringify(payload).length / 4) } catch (_) { return 0 }
}
function levelForPct(pct) {
if (!Number.isFinite(pct)) return 'nominal'
if (pct >= 95) return 'critical'
if (pct >= 80) return 'high'
if (pct >= 50) return 'warn'
return 'nominal'
}
/**
* Build the full config-tab view model.
*
* @param {Array<object>} events
* @param {object} [opts]
* @param {number} [opts.thresholdTokens]
* @param {number} [opts.budgetTokens]
* @param {string} [opts.strategyName]
* @returns {{
* thresholdTokens:number,
* thresholdSource:'server'|'assumed',
* strategyName:string,
* model:string|null,
* maxSummaryTokens:number|null,
* triggersFired:number,
* lastCompactSeq:number|null,
* currentTokens:number,
* tokensSinceLastCompact:number,
* tokensUntilNext:number,
* progressPct:number,
* progressLevel:'nominal'|'warn'|'high'|'critical',
* }}
*/
function buildCompactConfigView(events, opts) {
const threshold = resolveThreshold(opts || {})
let triggers = 0
let lastSeq = null
let lastPolicy = null
let tokensTotal = 0
let tokensSinceLast = 0
if (Array.isArray(events)) {
for (const ev of events) {
if (!ev || typeof ev !== 'object') continue
const tk = approxTokensFor(ev)
tokensTotal += tk
tokensSinceLast += tk
if (ev.type === 'compact/summary') {
triggers++
if (Number.isFinite(ev.seq)) lastSeq = ev.seq
const d = ev.data || {}
lastPolicy = {
model: typeof d.model === 'string' ? d.model : null,
maxTokens: Number.isFinite(d.maxTokens) ? d.maxTokens : null,
}
// A compact resets the "since last" counter; the shadowed range
// just replaced the running budget so tokens after should count
// from zero.
tokensSinceLast = 0
}
}
}
const progressPct = threshold.tokens > 0
? Math.round((tokensSinceLast / threshold.tokens) * 1000) / 10
: 0
const tokensUntilNext = Math.max(0, threshold.tokens - tokensSinceLast)
const strategyName = (opts && typeof opts.strategyName === 'string' && opts.strategyName)
|| (lastPolicy ? 'summarize-shadowed' : 'summarize-shadowed (default)')
return {
thresholdTokens: threshold.tokens,
thresholdSource: threshold.source,
strategyName,
model: lastPolicy && lastPolicy.model,
maxSummaryTokens: lastPolicy && lastPolicy.maxTokens,
triggersFired: triggers,
lastCompactSeq: lastSeq,
currentTokens: tokensTotal,
tokensSinceLastCompact: tokensSinceLast,
tokensUntilNext,
progressPct: Math.max(0, Math.min(progressPct, 999)),
progressLevel: levelForPct(progressPct),
}
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
buildCompactConfigView,
resolveThreshold,
approxTokensFor,
levelForPct,
DEFAULT_THRESHOLD_TOKENS,
}
}
if (typeof window !== 'undefined') {
window.__dshCompactConfigModel = {
buildCompactConfigView,
resolveThreshold,
approxTokensFor,
levelForPct,
DEFAULT_THRESHOLD_TOKENS,
}
}
@@ -79,6 +79,13 @@
saveProfile: pane.querySelector('#context-page-save-profile'),
loadSample: pane.querySelector('#context-page-load-sample'),
loadWorkflow: pane.querySelector('#context-page-load-workflow'),
// lane-ctx-deep additions:
topStrip: pane.querySelector('[data-context-topstrip]'),
windowBarTrack: pane.querySelector('#context-window-bar-track'),
windowBarLegend: pane.querySelector('#context-window-bar-legend'),
windowBarSummary: pane.querySelector('#context-window-bar-summary'),
interventionTrack: pane.querySelector('#context-intervention-track'),
interventionSummary: pane.querySelector('#context-intervention-summary'),
}
if (els.openRail) {
@@ -163,6 +170,12 @@
if (els.body) els.body.classList.remove('is-empty')
if (els.list) els.list.hidden = false
// lane-ctx-deep F1 + F3: render the top strip (window bar +
// intervention markers) alongside the per-turn rows.
renderWindowBar(events)
renderInterventionStrip(events)
if (els.topStrip) els.topStrip.hidden = false
const rows = model.projectTurnRows(events)
state.lastRows = rows
if (els.subtitle) {
@@ -174,8 +187,156 @@
renderRows(rows, events, model)
}
// ---- lane-ctx-deep F1: Window occupancy stacked bar ----------------------
function renderWindowBar (events) {
if (!els || !els.windowBarTrack) return
const api = window.__dshContextWindowBreakdown
if (!api || typeof api.computeWindowBreakdown !== 'function') return
// Pull the wire budget from the active session if we can — mirrors
// context-meter's promotion path so the "% of budget" number reads the
// same number the statusbar shows.
const budgetTokens = readActiveBudgetTokens()
const view = api.computeWindowBreakdown(events, budgetTokens ? { budgetTokens } : undefined)
const track = els.windowBarTrack
track.innerHTML = ''
// Render five stacked segments in FAMILY_ORDER — zero-token slices get
// a 0-width segment so the CSS grid keeps its shape (helps DOM tests
// count the number of segments deterministically).
for (const slice of view.slices) {
const seg = document.createElement('div')
seg.className = `context-window-seg context-window-seg--${slice.family}`
seg.style.setProperty('--seg-pct', `${Math.max(0, slice.pct)}%`)
seg.dataset.family = slice.family
seg.dataset.tokens = String(slice.tokens)
seg.dataset.pct = String(slice.pct)
const suffix = slice.family === 'tool_defs' && view.toolsFromCalls
? ' (estimated from tool/call names)'
: slice.family === 'thinking' && view.mode === 'approx'
? ' (approx)'
: ''
seg.title = `${slice.label}: ${slice.tokens} tok (${slice.pct}%${suffix})`
seg.setAttribute('aria-label', seg.title)
track.appendChild(seg)
}
if (els.windowBarLegend) {
els.windowBarLegend.innerHTML = ''
for (const slice of view.slices) {
const row = document.createElement('span')
row.className = `context-window-legend-item context-window-legend-item--${slice.family}`
const dot = document.createElement('span')
dot.className = `context-window-legend-dot context-window-legend-dot--${slice.family}`
const label = document.createElement('span')
label.className = 'context-window-legend-label'
label.textContent = slice.label
const value = document.createElement('span')
value.className = 'context-window-legend-value muted'
value.textContent = slice.tokens > 0
? `${slice.tokens.toLocaleString()} tok · ${slice.pct}%`
: '0'
row.appendChild(dot); row.appendChild(label); row.appendChild(value)
els.windowBarLegend.appendChild(row)
}
}
if (els.windowBarSummary) {
const bs = view.budgetSource === 'server' ? '' : ' (assumed)'
const modeTag = view.mode === 'precise' ? '' : ' · approx'
els.windowBarSummary.textContent = `${view.totalTokens.toLocaleString()} tok / ${view.budget.toLocaleString()} tok${bs} · ${view.budgetPct}% of budget${modeTag}`
}
}
function readActiveBudgetTokens () {
const meter = window.__dshContextMeter
const chat = window.__dshChat
if (!meter || !chat || typeof chat.getActiveSessionId !== 'function') return null
const sid = chat.getActiveSessionId()
if (!sid) return null
// Renderer stores per-session context trackers on the state map; peek at
// the snapshot when we can, otherwise fall back to null (which the
// model translates to the 128k assumed budget).
if (window.__dshRendererState && window.__dshRendererState.sessions) {
const meta = window.__dshRendererState.sessions.get(sid)
if (meta && meta.contextTracker && typeof meta.contextTracker.snapshot === 'function') {
const snap = meta.contextTracker.snapshot()
if (snap && snap.budgetSource === 'server' && Number.isFinite(snap.budget)) return snap.budget
}
}
return null
}
// ---- lane-ctx-deep F3: Intervention marker strip ------------------------
function renderInterventionStrip (events) {
if (!els || !els.interventionTrack) return
const api = window.__dshInterventionTimeline
if (!api || typeof api.collectInterventions !== 'function') return
const markers = api.collectInterventions(events)
const track = els.interventionTrack
track.innerHTML = ''
if (markers.length === 0) {
if (els.interventionSummary) els.interventionSummary.textContent = 'no interventions this session'
const empty = document.createElement('div')
empty.className = 'context-intervention-empty muted small'
empty.textContent = 'No edit-rerun, fork, or steer events yet.'
track.appendChild(empty)
return
}
// The strip is a timeline: position each marker by its seq relative to
// min/max seq so early interventions cluster left and late ones cluster
// right. Density permitting, this reads like a Perforce swarm marker
// strip — a scannable audit of user overrides.
const minSeq = markers[0].seq
const maxSeq = markers[markers.length - 1].seq
const span = Math.max(1, maxSeq - minSeq)
for (const m of markers) {
const pct = span > 0 ? ((m.seq - minSeq) / span) * 100 : 50
const marker = document.createElement('button')
marker.type = 'button'
marker.className = `context-intervention-marker context-intervention-marker--${m.kind}`
marker.style.setProperty('--marker-pos', `${pct}%`)
marker.dataset.kind = m.kind
marker.dataset.seq = String(m.seq)
marker.dataset.turn = String(m.turn)
marker.textContent = m.glyph
const previewLine = m.preview ? `${m.preview}` : ''
marker.title = `${m.label} · turn ${m.turn} · seq ${m.seq}${previewLine}`
marker.setAttribute('aria-label', marker.title)
marker.addEventListener('click', () => jumpToInterventionSeq(m.seq))
track.appendChild(marker)
}
// Summary line — count per kind. Uses the model's summariser so tests
// can lock the same shape.
if (els.interventionSummary) {
const roll = api.summariseInterventions(markers)
const parts = roll.map((r) => `${r.count} ${r.label.toLowerCase()}${r.count === 1 ? '' : 's'}`)
els.interventionSummary.textContent = parts.length > 0 ? parts.join(' · ') : 'no interventions'
}
}
function jumpToInterventionSeq (seq) {
// Same pattern as buildJumpBtn — switch to Chat, then scroll to the
// stream row with the matching data-seq (or data-first-seq for turn
// headers).
const tabs = window.__dshTabs
if (tabs && typeof tabs.switchTo === 'function') tabs.switchTo('chat')
requestAnimationFrame(() => requestAnimationFrame(() => {
const stream = document.getElementById('stream')
if (!stream) return
const target = stream.querySelector(`[data-seq="${seq}"]`)
|| stream.querySelector(`[data-first-seq="${seq}"]`)
if (target && typeof target.scrollIntoView === 'function') {
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}))
}
function renderEmpty () {
if (!els) return
if (els.topStrip) els.topStrip.hidden = true
if (els.list) {
els.list.innerHTML = ''
// Hide the empty rows container so it doesn't reserve grid track
@@ -0,0 +1,293 @@
// Context-window family breakdown — pure projections for the Context page
// occupancy bar (lane-ctx-deep, task #51 F1).
//
// The Context page's window-occupancy bar splits the current session's
// accumulated context into five families and renders their token shares as
// a stacked horizontal bar with hover tooltips. Because the wire does not
// (yet) tag each event with its context-family, we run a heuristic
// classifier over `cachedEvents`:
//
// - system_prompt — session-start injections and the daemon's own
// system-role seeds (context/message events whose
// source is `{kind:'system'}` or from the compact
// plugin's system seed, plus the running system
// preamble carried by turn/start.data.systemPreamble
// when it lands).
// - tool_defs — the JSON schemas for tool defintions we ship on the
// first turn. Best proxy is turn/start.data.tools (if
// present) or tool/definitions events; otherwise we
// estimate from tool/call event NAMES (schema footprint
// ≈ 400 chars per unique tool, an SDK-typical shape).
// - thinking — assistant/reasoning events. Cost accounting-wise
// these are output tokens the model produced but they
// DO occupy the response prompt on the next turn if
// the adapter round-trips reasoning tokens.
// - responses — assistant/message content the model produced.
// - injections — every OTHER context/message (plugin injects, user
// steer, recall pulls). These are the ones the Context
// Rail already highlights.
//
// The `estimateTokens(x)` primitive uses the same heuristic the
// context-meter approx mode uses (bytes ÷ 4) so a bar whose slices sum to
// the meter's approx-tokens read matches to the token. When
// assistant/message events carry a `usage` envelope we honour it — the
// `responses` slice snaps to precise `outputTokens` and `thinking` to
// `usage.thinking` if the adapter reports it.
//
// Pure module. Tested via node:test. See:
// - test/context-window-breakdown.test.js (this task's coverage)
// - src/renderer/context-page.js (renders the bar)
'use strict'
// Family palette hints — the CSS owns the actual color tokens; this map
// exists so the tooltip renderer and legend agree on one label per family.
const FAMILY_ORDER = ['system_prompt', 'tool_defs', 'thinking', 'responses', 'injections']
const FAMILY_LABELS = Object.freeze({
system_prompt: 'System prompt',
tool_defs: 'Tool definitions',
thinking: 'Reasoning',
responses: 'Assistant messages',
injections: 'Injections & recall',
})
// Rough per-tool schema footprint (chars). Copied from a survey of DSH's
// bundled MCPs — schemas run 300500 chars per tool once JSON-encoded with
// description strings and parameter schemas. 400 sits in the middle and
// keeps the bar honest without pretending we sniffed the actual schema.
const TOOL_SCHEMA_APPROX_CHARS = 400
/**
* Rough byte-count proxy for one event's payload. Mirrors context-meter's
* `estimateEventBytes` (private in that module) so bar arithmetic reads the
* same as the statusbar meter under approx mode.
* @param {object} event
* @returns {number}
*/
function eventBytes(event) {
if (!event || typeof event !== 'object') return 0
const payload = event.data !== undefined ? event.data : event
try { return JSON.stringify(payload).length } catch (_) { return 0 }
}
function tokensFromBytes(bytes) {
return Math.max(0, Math.round((bytes || 0) / 4))
}
/**
* Extract explicit `usage.outputTokens` when the adapter reports one, else
* null. Kept separate from `usage.inputTokens` because outputs are what
* `responses` needs — inputs cover the whole running prompt (which is what
* ALL our slices combined represent).
*/
function outputTokensOf(ev) {
if (!ev || ev.type !== 'assistant/message') return null
const u = ev.data && ev.data.usage
if (!u || typeof u !== 'object') return null
const out = Number(u.outputTokens)
return Number.isFinite(out) ? out : null
}
/**
* Some adapters split reasoning tokens off in `usage.thinking`. When present
* we use it verbatim for the `thinking` slice, otherwise we fall back to
* counting bytes of the reasoning event payload.
*/
function thinkingTokensOf(ev) {
if (!ev || ev.type !== 'assistant/message') return null
const u = ev.data && ev.data.usage
if (!u || typeof u !== 'object') return null
const t = Number(u.thinking) || Number(u.reasoningTokens)
return Number.isFinite(t) ? t : null
}
/**
* Classify one event into a context family. Multi-family events (an
* assistant/message that carries a usage envelope reporting both output and
* reasoning tokens) are handled at the aggregator level — this per-event
* classifier returns the *primary* family so the bar's chunking still lines
* up with the wire event stream.
* @param {object} ev
* @returns {'system_prompt'|'tool_defs'|'thinking'|'responses'|'injections'|null}
*/
function classifyEventFamily(ev) {
if (!ev || typeof ev !== 'object' || typeof ev.type !== 'string') return null
const t = ev.type
const d = ev.data || {}
// System-prompt seed. The daemon emits these as context/message with
// source={kind:'system'} on session start; a system preamble sometimes
// lands as its own event type too.
if (t === 'session/start' || t === 'context/system') return 'system_prompt'
if (t === 'context/message') {
const src = d.source
if (src && (src.kind === 'system' || src.kind === 'session-start')) return 'system_prompt'
// The compact plugin's own summary re-injection is also a system-level
// seed for the next turn — count it against system_prompt rather than
// muddying `injections`.
if (src && src.kind === 'plugin' && src.plugin === 'compact') return 'system_prompt'
return 'injections'
}
if (t === 'compact/summary') return 'system_prompt'
if (t === 'steering/message') return 'injections'
// Tool definitions arrive with these type names on different adapters.
// If nothing ever lands, we synthesize a slice from tool/call event names
// in the aggregator (see toolSchemaEstimate).
if (t === 'tool/definitions' || t === 'tools/available') return 'tool_defs'
// Reasoning vs. response.
if (t === 'assistant/reasoning') return 'thinking'
if (t === 'assistant/message' || t === 'assistant/chunk') return 'responses'
return null
}
/**
* Estimate tool-def slice from unique tool NAMES seen in tool/call events.
* When the wire never ships explicit tool/definitions events, this is the
* fairest proxy: N unique tools × 400-char schema each.
* @param {Array<object>} events
* @returns {number}
*/
function toolSchemaEstimate(events) {
const seen = new Set()
for (const ev of events) {
if (ev && ev.type === 'tool/call' && ev.data && typeof ev.data.name === 'string') {
seen.add(ev.data.name)
}
}
if (seen.size === 0) return 0
return tokensFromBytes(seen.size * TOOL_SCHEMA_APPROX_CHARS)
}
/**
* @typedef {Object} FamilySlice
* @property {string} family
* @property {string} label
* @property {number} tokens
* @property {number} eventCount
* @property {number} pct
*/
/**
* @typedef {Object} WindowBreakdown
* @property {Array<FamilySlice>} slices Five slices in FAMILY_ORDER.
* @property {number} totalTokens Sum across all slices.
* @property {number} budget Wire-reported context window when known, else 128000.
* @property {'server'|'assumed'} budgetSource
* @property {number} budgetPct totalTokens / budget × 100 (clamped 0..999).
* @property {'precise'|'approx'} mode Whether responses/thinking used a usage envelope anywhere.
* @property {boolean} toolsFromCalls True when the tool_defs slice was estimated from tool/call NAMES rather than an explicit tool/definitions event.
*/
/**
* Aggregate cachedEvents into a five-family breakdown with token counts and
* percentages. Pure: no DOM, no window.* reads.
*
* Percentages sum to ≤100 (never > because they normalise against total).
* When total is zero we return zeroed slices with pct=0 so the caller can
* render the empty bar without divide-by-zero guards.
*
* @param {Array<object>} events
* @param {object} [opts]
* @param {number} [opts.budgetTokens] Wire-reported context window; sets budgetSource='server'.
* @returns {WindowBreakdown}
*/
function computeWindowBreakdown(events, opts) {
const budgetOverride = opts && Number.isFinite(opts.budgetTokens) && opts.budgetTokens > 0
? Number(opts.budgetTokens)
: null
const budget = budgetOverride || 128000
const budgetSource = budgetOverride ? 'server' : 'assumed'
const totals = { system_prompt: 0, tool_defs: 0, thinking: 0, responses: 0, injections: 0 }
const counts = { system_prompt: 0, tool_defs: 0, thinking: 0, responses: 0, injections: 0 }
let mode = 'approx'
let toolsFromCalls = false
if (!Array.isArray(events)) events = []
// Walk events, honouring `usage` envelopes when they land. Multi-family
// accounting: an assistant/message with `usage.thinking` splits its
// tokens between the thinking slice and the responses slice; otherwise
// the whole payload byte-count falls into the primary family.
for (const ev of events) {
const fam = classifyEventFamily(ev)
if (fam === null) continue
// Precise-mode split for assistant/message: outputTokens → responses,
// usage.thinking (or reasoningTokens) → thinking.
if (ev && ev.type === 'assistant/message') {
const out = outputTokensOf(ev)
const think = thinkingTokensOf(ev)
if (out !== null || think !== null) {
mode = 'precise'
if (out !== null) { totals.responses += out; counts.responses++ }
if (think !== null) { totals.thinking += think; counts.thinking++ }
continue
}
}
totals[fam] += tokensFromBytes(eventBytes(ev))
counts[fam]++
}
// Tool defs: prefer explicit tool/definitions events (already summed
// above). Fall back to the tool-call-name proxy when the wire didn't ship
// any. We mark `toolsFromCalls` in the return so the UI's hover tooltip
// can honestly say "estimated from N unique tools" instead of "counted".
if (totals.tool_defs === 0) {
const est = toolSchemaEstimate(events)
if (est > 0) {
totals.tool_defs = est
counts.tool_defs = 1 // synthetic single-blob slice
toolsFromCalls = true
}
}
const total = FAMILY_ORDER.reduce((s, f) => s + totals[f], 0)
const slices = FAMILY_ORDER.map((f) => {
const pct = total > 0 ? (totals[f] / total) * 100 : 0
return {
family: f,
label: FAMILY_LABELS[f],
tokens: totals[f],
eventCount: counts[f],
pct: Math.round(pct * 10) / 10, // one decimal so tests can lock a stable shape
}
})
const budgetPct = budget > 0 ? Math.round((total / budget) * 100) : 0
return {
slices,
totalTokens: total,
budget,
budgetSource,
budgetPct: Math.max(0, Math.min(budgetPct, 999)),
mode,
toolsFromCalls,
}
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
computeWindowBreakdown,
classifyEventFamily,
toolSchemaEstimate,
FAMILY_ORDER,
FAMILY_LABELS,
TOOL_SCHEMA_APPROX_CHARS,
}
}
if (typeof window !== 'undefined') {
window.__dshContextWindowBreakdown = {
computeWindowBreakdown,
classifyEventFamily,
toolSchemaEstimate,
FAMILY_ORDER,
FAMILY_LABELS,
TOOL_SCHEMA_APPROX_CHARS,
}
}
+29
View File
@@ -1184,6 +1184,26 @@
top when it shows. The duplicate "No context activity …" head
was removed — the header subtitle already carries that line
(see context-page.js renderEmpty()). -->
<!-- lane-ctx-deep (task #51 F1): window occupancy stacked bar
+ F3: intervention marker strip. Both sit above the two-column
body so the "at a glance" chrome reads top-down. -->
<section class="context-page-topstrip" data-context-topstrip hidden>
<div class="context-window-bar" data-context-window-bar aria-label="Context window occupancy by family">
<div class="context-window-bar-head">
<span class="context-window-bar-title">Window occupancy</span>
<span id="context-window-bar-summary" class="context-window-bar-summary muted small"></span>
</div>
<div id="context-window-bar-track" class="context-window-bar-track" role="img" aria-label="Stacked family proportions"></div>
<div id="context-window-bar-legend" class="context-window-bar-legend"></div>
</div>
<div class="context-intervention-strip" data-context-intervention-strip aria-label="Human intervention markers">
<div class="context-intervention-head">
<span class="context-intervention-title">Interventions</span>
<span id="context-intervention-summary" class="context-intervention-summary muted small">no interventions</span>
</div>
<div id="context-intervention-track" class="context-intervention-track" role="list"></div>
</div>
</section>
<section class="context-page-body" data-context-body>
<div id="context-page-empty" class="context-page-empty" hidden>
<div class="context-page-empty-note muted small">Load a sample session to see the page shape, or start a chat from the Chat tab and come back.</div>
@@ -1360,6 +1380,11 @@
<script src="./context-meter.js"></script>
<script src="./compact-badge.js"></script>
<script src="./compact-card.js"></script>
<!-- lane-ctx-deep (task #51 F2/F4): compact Config tab + subagent
drill-down view models. Loaded before subagent-view.js so
buildInlineSubagentTrace can pick up the drill-down helpers. -->
<script src="./compact-config-model.js"></script>
<script src="./subagent-drilldown.js"></script>
<script src="./context-rail.js"></script>
<script src="./workflow-view.js"></script>
<script src="./subagent-view.js"></script>
@@ -1427,6 +1452,10 @@
window.__dshInjectFamily; the switchTo('context') hook in
renderer.js drives its refresh on tab entry. -->
<script src="./context-page-model.js"></script>
<!-- lane-ctx-deep (task #51 F1/F3): window family breakdown +
intervention marker projections consumed by context-page.js. -->
<script src="./context-window-breakdown.js"></script>
<script src="./intervention-timeline.js"></script>
<script src="./context-page.js"></script>
<!-- Tracing page (#225). Loads after context-page so it can share the
__dshChat + __dshTraceAgg + __dshTraceTriView surfaces; the
@@ -0,0 +1,186 @@
// Human-intervention timeline projections — pure model behind the Context
// page's intervention marker strip (lane-ctx-deep, task #51 F3).
//
// Three intervention kinds surface as markers on the Context page's top
// axis. Each marker carries an anchor (turn number + first seq) so the UI
// can jump the Chat stream to the exact turn on click:
//
// - edit-rerun — a `user/message` event that carries a `data.editRerun`
// envelope (renderer.js seeds this shape when a user hits
// the tool-edit-rerun panel) OR whose plugin source is
// `edit-rerun`. Falls back to a heuristic when the
// envelope's absent: adjacent user/message + user/message
// with identical `data.origSeq` fields.
// - fork — a `session/fork` marker (renderer maintains a
// `forkMarkers` map in state; when the daemon reports
// a fork the event stream carries `context/message` with
// source={kind:'fork', ...}). We also accept the raw
// daemon event type `session/forked` for symmetry.
// - steer — a `steering/message` event.
//
// The projection returns markers in seq order — the marker strip renders
// them left-to-right along a horizontal axis. Multiple markers on the same
// turn stack into a badge; the UI resolves stacking, this model just emits
// each marker once.
//
// Pure module. Coverage in test/intervention-timeline.test.js.
'use strict'
const KIND_LABELS = Object.freeze({
'edit-rerun': 'Edit & re-run',
'fork': 'Fork',
'steer': 'Steer',
})
const KIND_GLYPHS = Object.freeze({
'edit-rerun': '↺',
'fork': 'Y',
'steer': '↷',
})
/**
* @typedef {Object} InterventionMarker
* @property {'edit-rerun'|'fork'|'steer'} kind
* @property {string} label Human-readable name.
* @property {string} glyph 1-2 character glyph for the marker dot.
* @property {number} seq Event seq the marker anchors on.
* @property {number} turn Turn number the marker belongs to (0 for pre-first-turn).
* @property {number} time Wire event time (ms epoch).
* @property {string} preview One-line preview text; empty when nothing sensible to show.
*/
function isEditRerun(ev) {
if (!ev || ev.type !== 'user/message') return false
const d = ev.data || {}
if (d && d.editRerun) return true
const src = d.source
if (src && src.kind === 'plugin' && (src.plugin === 'edit-rerun' || src.plugin === 'tool-edit-rerun')) return true
return false
}
function isFork(ev) {
if (!ev || typeof ev.type !== 'string') return false
if (ev.type === 'session/forked' || ev.type === 'session/fork') return true
if (ev.type === 'context/message') {
const src = ev.data && ev.data.source
if (src && src.kind === 'fork') return true
if (src && src.kind === 'plugin' && src.plugin === 'fork') return true
}
return false
}
function isSteer(ev) {
return !!(ev && ev.type === 'steering/message')
}
function shortText(blocks) {
if (typeof blocks === 'string') return blocks
if (!Array.isArray(blocks)) return ''
const parts = []
for (const b of blocks) {
if (b && b.type === 'text' && typeof b.text === 'string') parts.push(b.text)
}
const joined = parts.join(' ')
const trimmed = joined.replace(/\s+/g, ' ').trim()
return trimmed.length > 80 ? trimmed.slice(0, 77) + '…' : trimmed
}
function previewFor(ev, kind) {
if (!ev) return ''
const d = ev.data || {}
if (kind === 'edit-rerun') {
if (d.editRerun && typeof d.editRerun.reason === 'string') return d.editRerun.reason
if (d.editRerun && typeof d.editRerun.origSeq === 'number') return `orig seq ${d.editRerun.origSeq}`
return shortText(d.content)
}
if (kind === 'fork') {
if (typeof d.parentSeq === 'number') return `from seq ${d.parentSeq}`
const src = d.source
if (src && src.kind === 'fork' && typeof src.parentSeq === 'number') return `from seq ${src.parentSeq}`
return shortText(d.content)
}
if (kind === 'steer') return shortText(d.content)
return ''
}
/**
* Compute the sorted marker list for a session's cached events. Every
* marker's `turn` field reflects the turn window it belongs to (0 for
* events before the first turn/end). The UI uses `turn+firstSeq` to jump
* the Chat stream to the right bubble.
*
* @param {Array<object>} events
* @returns {Array<InterventionMarker>}
*/
function collectInterventions(events) {
if (!Array.isArray(events)) return []
const out = []
let turn = 0
for (const ev of events) {
if (!ev || typeof ev !== 'object') continue
let kind = null
if (isEditRerun(ev)) kind = 'edit-rerun'
else if (isFork(ev)) kind = 'fork'
else if (isSteer(ev)) kind = 'steer'
if (kind) {
const seq = Number.isFinite(ev.seq) ? ev.seq : 0
const time = Number.isFinite(ev.time) ? ev.time : 0
out.push({
kind,
label: KIND_LABELS[kind],
glyph: KIND_GLYPHS[kind],
seq,
turn,
time,
preview: previewFor(ev, kind),
})
}
if (ev.type === 'turn/end') {
const nextTurn = (ev.data && typeof ev.data.turn === 'number') ? (ev.data.turn + 1) : (turn + 1)
turn = nextTurn
}
}
// Deterministic order by seq — assumes cachedEvents is already seq-ordered
// (renderer stores them in order), but sort explicitly for safety.
out.sort((a, b) => a.seq - b.seq)
return out
}
/**
* Roll up the marker list into per-kind totals for a short legend line.
* Empty kinds are omitted so the legend doesn't advertise "0 forks".
* @param {Array<InterventionMarker>} markers
* @returns {Array<{kind:string, label:string, count:number}>}
*/
function summariseInterventions(markers) {
if (!Array.isArray(markers)) return []
const counts = new Map()
for (const m of markers) {
if (!m) continue
counts.set(m.kind, (counts.get(m.kind) || 0) + 1)
}
const out = []
for (const kind of ['edit-rerun', 'fork', 'steer']) {
const n = counts.get(kind) || 0
if (n > 0) out.push({ kind, label: KIND_LABELS[kind], count: n })
}
return out
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
collectInterventions,
summariseInterventions,
isEditRerun, isFork, isSteer,
KIND_LABELS, KIND_GLYPHS,
}
}
if (typeof window !== 'undefined') {
window.__dshInterventionTimeline = {
collectInterventions,
summariseInterventions,
isEditRerun, isFork, isSteer,
KIND_LABELS, KIND_GLYPHS,
}
}
+89
View File
@@ -3543,6 +3543,7 @@ function appendCompactMarker(event, meta, sessionId) {
}
bodyEl.appendChild(dl)
},
fillConfig: buildCompactConfigTabFiller(sessionId),
})
} else {
// Fallback: pre-refactor .body + shadowed-expander layout kept alive
@@ -3568,6 +3569,94 @@ function appendCompactMarker(event, meta, sessionId) {
appendSystem(`${event.type}${suffix}`)
}
// -- lane-ctx-deep F2: compact-card Config tab filler -----------------------
//
// The Config tab is an info-only entrance to compaction policy. Reads from
// __dshCompactConfigModel and renders threshold + strategy + trigger count
// + a progress bar showing "tokens until next compact". A footer note
// points the user at Settings for the actual editor — this surface is a
// window into the policy, not the editor.
//
// Returned callback closes over the sessionId so buildCompactConfigView
// can walk the right cachedEvents at fill time (compact-card mounts tabs
// synchronously today; if that shifts to lazy, the closure keeps working).
function buildCompactConfigTabFiller(sessionId) {
return function fillConfigTab(bodyEl) {
const api = window.__dshCompactConfigModel
if (!api || typeof api.buildCompactConfigView !== 'function') {
const p = document.createElement('div')
p.className = 'compact-card-tab-empty muted small'
p.textContent = 'compact-config-model.js failed to load — Config tab is inert.'
bodyEl.appendChild(p)
return
}
const events = readSessionEventsSafe(sessionId)
const budget = readSessionBudgetSafe(sessionId)
const view = api.buildCompactConfigView(events, budget ? { budgetTokens: budget } : undefined)
// Key/value list, same look as fillMeta so the two tabs read as siblings.
const dl = document.createElement('dl')
dl.className = 'compact-card-tab-meta compact-config-list'
const rows = [
{ label: 'Threshold', value: `${view.thresholdTokens.toLocaleString()} tok${view.thresholdSource === 'assumed' ? ' (assumed)' : ''}` },
{ label: 'Strategy', value: view.strategyName },
{ label: 'Model', value: view.model || 'unknown' },
{ label: 'Summary cap', value: view.maxSummaryTokens != null ? `${view.maxSummaryTokens} tok` : 'unknown' },
{ label: 'Triggers fired', value: `${view.triggersFired} this session` },
{ label: 'Tokens since last compact', value: `${view.tokensSinceLastCompact.toLocaleString()} tok` },
{ label: 'Tokens until next', value: `${view.tokensUntilNext.toLocaleString()} tok` },
]
for (const row of rows) {
const dt = document.createElement('dt'); dt.textContent = row.label
const dd = document.createElement('dd'); dd.textContent = row.value
dl.appendChild(dt); dl.appendChild(dd)
}
bodyEl.appendChild(dl)
// Progress bar: distance to next compact.
const progWrap = document.createElement('div')
progWrap.className = `compact-config-progress compact-config-progress--${view.progressLevel}`
const progHead = document.createElement('div')
progHead.className = 'compact-config-progress-head'
const progTitle = document.createElement('span')
progTitle.className = 'compact-config-progress-title'
progTitle.textContent = 'Progress to next compact'
const progPct = document.createElement('span')
progPct.className = 'compact-config-progress-pct muted small'
progPct.textContent = `${Math.min(100, Math.round(view.progressPct))}%`
progHead.appendChild(progTitle); progHead.appendChild(progPct)
const progTrack = document.createElement('div')
progTrack.className = 'compact-config-progress-track'
const progFill = document.createElement('div')
progFill.className = 'compact-config-progress-fill'
progFill.style.setProperty('--fill-pct', `${Math.min(100, Math.max(0, view.progressPct))}%`)
progTrack.appendChild(progFill)
progWrap.appendChild(progHead)
progWrap.appendChild(progTrack)
bodyEl.appendChild(progWrap)
// Footer note: this tab is a window, not an editor.
const note = document.createElement('div')
note.className = 'compact-config-note muted small'
note.textContent = 'Read-only view of the current policy. Adjust in Settings Compaction (restart-required until session/set-compact-policy lands, gap G2).'
bodyEl.appendChild(note)
}
}
function readSessionEventsSafe(sessionId) {
const meta = state.sessions && state.sessions.get && state.sessions.get(sessionId)
return (meta && Array.isArray(meta.cachedEvents)) ? meta.cachedEvents : []
}
function readSessionBudgetSafe(sessionId) {
const meta = state.sessions && state.sessions.get && state.sessions.get(sessionId)
if (meta && meta.contextTracker && typeof meta.contextTracker.snapshot === 'function') {
const snap = meta.contextTracker.snapshot()
if (snap && snap.budgetSource === 'server' && Number.isFinite(snap.budget)) return snap.budget
}
return null
}
// -- shadowed-events expander -------------------------------
//
// DSH's key differentiator on the context-management line (intent doc §2.1):
+293
View File
@@ -11626,6 +11626,299 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9
/* once JS finishes booting, but robust against the first-paint race. */
.onboarding[hidden] { display: none !important; }
/* ==========================================================================
lane-ctx-deep task #51 Context page deepening (F1F4).
Appended at file tail per team-lead's conflict-face rule; nothing above
this comment is touched by lane-ctx-deep so a rebase against test-real
collapses to a trailing block only. Rules under each F# group.
========================================================================== */
/* F1 + F3: Top strip shell (window bar + intervention markers). Sits above
the two-column body and reads as a "session dashboard" band. */
.context-page-topstrip {
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
@media (max-width: 900px) {
.context-page-topstrip { grid-template-columns: 1fr; }
}
.context-page-topstrip[hidden] { display: none; }
/* --- F1: Window occupancy stacked bar ------------------------------------ */
.context-window-bar {
display: flex;
flex-direction: column;
gap: 6px;
}
.context-window-bar-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-2);
}
.context-window-bar-title {
font-weight: 600;
font-size: 13px;
}
.context-window-bar-summary {
font-family: var(--mono);
font-size: 11px;
}
.context-window-bar-track {
display: flex;
width: 100%;
height: 18px;
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
background: var(--bg-elev);
}
.context-window-seg {
height: 100%;
width: var(--seg-pct, 0%);
min-width: 0;
transition: width 200ms ease;
}
.context-window-seg--system_prompt { background: color-mix(in oklab, var(--tint-purple) 55%, var(--bg-elev)); }
.context-window-seg--tool_defs { background: color-mix(in oklab, var(--tint-blue) 55%, var(--bg-elev)); }
.context-window-seg--thinking { background: color-mix(in oklab, var(--tint-yellow) 55%, var(--bg-elev)); }
.context-window-seg--responses { background: color-mix(in oklab, var(--ok) 55%, var(--bg-elev)); }
.context-window-seg--injections { background: color-mix(in oklab, var(--accent) 55%, var(--bg-elev)); }
.context-window-seg:hover {
filter: brightness(1.1);
outline: 1px solid var(--text);
outline-offset: -1px;
}
.context-window-bar-legend {
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
font-size: 11px;
}
.context-window-legend-item {
display: inline-flex;
align-items: center;
gap: 5px;
}
.context-window-legend-dot {
width: 10px;
height: 10px;
border-radius: 2px;
display: inline-block;
}
.context-window-legend-dot--system_prompt { background: color-mix(in oklab, var(--tint-purple) 55%, var(--bg-elev)); }
.context-window-legend-dot--tool_defs { background: color-mix(in oklab, var(--tint-blue) 55%, var(--bg-elev)); }
.context-window-legend-dot--thinking { background: color-mix(in oklab, var(--tint-yellow) 55%, var(--bg-elev)); }
.context-window-legend-dot--responses { background: color-mix(in oklab, var(--ok) 55%, var(--bg-elev)); }
.context-window-legend-dot--injections { background: color-mix(in oklab, var(--accent) 55%, var(--bg-elev)); }
.context-window-legend-value {
font-family: var(--mono);
font-size: 10px;
}
/* --- F3: Intervention marker strip --------------------------------------- */
.context-intervention-strip {
display: flex;
flex-direction: column;
gap: 6px;
}
.context-intervention-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-2);
}
.context-intervention-title {
font-weight: 600;
font-size: 13px;
}
.context-intervention-track {
position: relative;
height: 32px;
border: 1px dashed var(--border);
border-radius: 4px;
background: var(--bg-elev);
}
.context-intervention-empty {
padding: 6px 8px;
font-style: italic;
}
.context-intervention-marker {
position: absolute;
top: 50%;
left: var(--marker-pos, 50%);
transform: translate(-50%, -50%);
width: 22px;
height: 22px;
padding: 0;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-family: var(--mono);
font-size: 12px;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 100ms ease, box-shadow 100ms ease;
}
.context-intervention-marker:hover,
.context-intervention-marker:focus-visible {
transform: translate(-50%, -50%) scale(1.15);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 25%, transparent);
z-index: 2;
}
.context-intervention-marker--edit-rerun {
background: color-mix(in oklab, var(--tint-yellow) 40%, var(--surface));
border-color: color-mix(in oklab, var(--tint-yellow) 55%, var(--border));
}
.context-intervention-marker--fork {
background: color-mix(in oklab, var(--tint-blue) 40%, var(--surface));
border-color: color-mix(in oklab, var(--tint-blue) 55%, var(--border));
}
.context-intervention-marker--steer {
background: color-mix(in oklab, var(--accent) 40%, var(--surface));
border-color: color-mix(in oklab, var(--accent) 55%, var(--border));
}
/* --- F2: Compact card Config tab ---------------------------------------- */
.compact-config-list {
margin: 0 0 var(--space-3) 0;
}
.compact-config-progress {
margin-top: var(--space-2);
padding: var(--space-2) var(--space-2);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-elev);
}
.compact-config-progress-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.compact-config-progress-title {
font-weight: 600;
font-size: 12px;
}
.compact-config-progress-track {
width: 100%;
height: 10px;
border-radius: 5px;
overflow: hidden;
background: color-mix(in oklab, var(--muted) 15%, var(--bg-elev));
}
.compact-config-progress-fill {
height: 100%;
width: var(--fill-pct, 0%);
transition: width 200ms ease;
background: color-mix(in oklab, var(--ok) 55%, var(--bg-elev));
}
.compact-config-progress--warn .compact-config-progress-fill {
background: color-mix(in oklab, var(--tint-yellow) 65%, var(--bg-elev));
}
.compact-config-progress--high .compact-config-progress-fill {
background: color-mix(in oklab, var(--warn) 65%, var(--bg-elev));
}
.compact-config-progress--critical .compact-config-progress-fill {
background: color-mix(in oklab, var(--err) 65%, var(--bg-elev));
}
.compact-config-note {
margin-top: var(--space-2);
padding: var(--space-1) 0;
border-top: 1px dashed var(--border);
padding-top: var(--space-2);
}
/* --- F4: Subagent drill-down tabs --------------------------------------- */
.subagent-drilldown {
margin-top: var(--space-2);
border-top: 1px dashed var(--border);
padding-top: var(--space-2);
}
.subagent-drilldown-tabstrip {
display: flex;
gap: 4px;
margin-bottom: 4px;
}
.subagent-drilldown-tab {
padding: 3px 8px;
border: 1px solid var(--border);
border-bottom: none;
border-radius: 4px 4px 0 0;
background: var(--bg-elev);
color: var(--muted);
font-size: 11px;
font-family: var(--mono);
cursor: pointer;
}
.subagent-drilldown-tab[aria-selected="true"] {
background: var(--surface);
color: var(--text);
border-color: var(--border);
border-bottom: 1px solid var(--surface);
position: relative;
z-index: 1;
}
.subagent-drilldown-panel {
border: 1px solid var(--border);
padding: 8px 10px;
border-radius: 0 4px 4px 4px;
background: var(--surface);
font-size: 12px;
}
.subagent-drilldown-panel[hidden] { display: none; }
.subagent-drilldown-head { margin-bottom: 4px; }
.subagent-drilldown-toollist {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.subagent-drilldown-toolrow {
display: flex;
gap: 8px;
align-items: baseline;
}
.subagent-drilldown-toolname {
font-family: var(--mono);
color: color-mix(in oklab, blue 60%, var(--text));
}
.subagent-drilldown-toolargs {
font-family: var(--mono);
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 auto;
min-width: 0;
}
.subagent-drilldown-toolseq {
font-family: var(--mono);
flex: 0 0 auto;
}
.subagent-drilldown-query {
margin: 0;
padding: 6px 10px;
border-left: 3px solid var(--accent);
background: color-mix(in oklab, var(--accent) 5%, var(--bg-elev));
font-family: var(--mono);
font-size: 11px;
white-space: pre-wrap;
color: var(--text);
}
/* -- Chat triple view: side drawer + view switcher + session graph -------
* lane-chat-triple. The Chat pane grows a right-side fold-out drawer
* (turn/session metadata + history list) and a top-level view switcher
@@ -0,0 +1,175 @@
// Pure projections for the subagent drill-down tabs (lane-ctx-deep, F4).
//
// Each subagent card gets two tabs at its foot: "Tool defs" (what the
// child was allowed to call at startup) and "Inbound query" (the seed
// prompt the parent handed over). The renderer already has both of these
// buried inside the events array — this module surfaces them as a
// stable-shape view model the DOM builder plops into a tab shell.
//
// Tool defs source order:
// 1. `spec.toolDefs` — explicit array from a synthetic wire event.
// 2. Unique names from all `tool/call` events in `spec.childEvents`.
// This is what today's fixtures ship; we tag each entry with a
// `firstSeq` so a reader can trace where the child first used it.
//
// Inbound query source order:
// 1. `spec.parentQuery` — string or ContentBlock[] passed directly.
// 2. The first `user/message` in `spec.childEvents` (whose `source` is
// `{kind:'plugin', plugin:'subagent-*'}` when the parent auto-seeds
// the child; older fixtures just use a plain user/message).
//
// The returned shape is:
// {
// toolDefs: [
// { name, firstSeq, sampleArgs, source: 'explicit'|'inferred' }
// ],
// toolDefsSource: 'explicit'|'inferred'|'empty',
// inboundQuery: {
// text, // one-string preview
// blocks, // full ContentBlock[] when available, else null
// source: 'explicit'|'seed-event'|'empty',
// seq, // seq of the seed event, or null
// },
// }
//
// Pure module. Tests in test/subagent-drilldown.test.js.
'use strict'
function textFromBlocks(blocks) {
if (typeof blocks === 'string') return blocks
if (!Array.isArray(blocks)) return ''
const parts = []
for (const b of blocks) {
if (b && b.type === 'text' && typeof b.text === 'string') parts.push(b.text)
}
return parts.join('\n')
}
function collectToolDefsFromEvents(childEvents) {
const map = new Map()
if (!Array.isArray(childEvents)) return { list: [], seen: 0 }
for (const ev of childEvents) {
if (!ev || ev.type !== 'tool/call') continue
const name = ev.data && ev.data.name
if (typeof name !== 'string' || !name) continue
if (!map.has(name)) {
const seq = Number.isFinite(ev.seq) ? ev.seq : 0
const args = ev.data.arguments
let sample = null
if (typeof args === 'string' && args.length > 0) {
sample = args.length > 100 ? args.slice(0, 97) + '…' : args
} else if (args && typeof args === 'object') {
try {
const j = JSON.stringify(args)
sample = j.length > 100 ? j.slice(0, 97) + '…' : j
} catch (_) { sample = null }
}
map.set(name, { name, firstSeq: seq, sampleArgs: sample, source: 'inferred' })
}
}
return { list: Array.from(map.values()), seen: map.size }
}
function normaliseExplicitToolDefs(toolDefs) {
if (!Array.isArray(toolDefs)) return []
const out = []
for (const t of toolDefs) {
if (typeof t === 'string') {
out.push({ name: t, firstSeq: 0, sampleArgs: null, source: 'explicit' })
} else if (t && typeof t.name === 'string') {
out.push({
name: t.name,
firstSeq: Number.isFinite(t.firstSeq) ? t.firstSeq : 0,
sampleArgs: (typeof t.sampleArgs === 'string' && t.sampleArgs) || null,
source: 'explicit',
})
}
}
return out
}
function findSeedUserMessage(childEvents) {
if (!Array.isArray(childEvents)) return null
// The daemon seeds the child's turn 0 with a user/message whose source is
// the parent subagent plugin. Fall back to the FIRST user/message if no
// plugin-tagged one exists.
let seedPlugin = null
let seedFirst = null
for (const ev of childEvents) {
if (!ev || ev.type !== 'user/message') continue
if (seedFirst === null) seedFirst = ev
const src = ev.data && ev.data.source
if (src && src.kind === 'plugin' && typeof src.plugin === 'string' && src.plugin.startsWith('subagent')) {
seedPlugin = ev
break
}
}
return seedPlugin || seedFirst
}
/**
* @param {object} spec
* @param {Array<object>} [spec.childEvents]
* @param {Array<string|{name:string}>} [spec.toolDefs]
* @param {string|Array<object>} [spec.parentQuery]
* @returns {{
* toolDefs: Array<{name:string, firstSeq:number, sampleArgs:string|null, source:'explicit'|'inferred'}>,
* toolDefsSource: 'explicit'|'inferred'|'empty',
* inboundQuery: { text:string, blocks: Array<object>|null, source: 'explicit'|'seed-event'|'empty', seq: number|null },
* }}
*/
function buildSubagentDrilldown(spec) {
const s = spec || {}
let toolDefs = normaliseExplicitToolDefs(s.toolDefs)
let toolDefsSource = toolDefs.length > 0 ? 'explicit' : 'empty'
if (toolDefs.length === 0) {
const { list } = collectToolDefsFromEvents(s.childEvents)
if (list.length > 0) {
toolDefs = list
toolDefsSource = 'inferred'
}
}
// Inbound query.
let inboundText = ''
let inboundBlocks = null
let inboundSource = 'empty'
let inboundSeq = null
if (s.parentQuery !== undefined && s.parentQuery !== null) {
if (typeof s.parentQuery === 'string') {
inboundText = s.parentQuery
} else if (Array.isArray(s.parentQuery)) {
inboundBlocks = s.parentQuery
inboundText = textFromBlocks(s.parentQuery)
}
if (inboundText) inboundSource = 'explicit'
}
if (!inboundText) {
const seed = findSeedUserMessage(s.childEvents)
if (seed && seed.data) {
inboundBlocks = Array.isArray(seed.data.content) ? seed.data.content : null
inboundText = textFromBlocks(seed.data.content)
inboundSource = 'seed-event'
inboundSeq = Number.isFinite(seed.seq) ? seed.seq : null
}
}
return {
toolDefs,
toolDefsSource,
inboundQuery: {
text: inboundText,
blocks: inboundBlocks,
source: inboundSource,
seq: inboundSeq,
},
}
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { buildSubagentDrilldown, textFromBlocks }
}
if (typeof window !== 'undefined') {
window.__dshSubagentDrilldown = { buildSubagentDrilldown, textFromBlocks }
}
@@ -403,11 +403,148 @@ function buildInlineSubagentTrace(doc, spec, opts = {}) {
body.className = 'subagent-trace-body';
const card = buildSubagentCard(doc, spec, { ...opts, omitHead: true });
body.appendChild(card);
// lane-ctx-deep F4: drill-down tabs (Tool defs / Inbound query). Appended
// after the card sections so a reader sees the "how the child was
// instrumented" summary at the foot of the subagent trace. Skipped when
// the drilldown module isn't loaded (test harness stubs the shell out).
appendSubagentDrilldownTabs(doc, body, spec);
wrap.appendChild(body);
return wrap;
}
/**
* Render two tabs — "Tool defs" and "Inbound query" — using the pure
* `buildSubagentDrilldown` view model. Exposed for tests via the module
* exports so a headless assertion can build the tabs without wrapping the
* whole inline trace.
*/
function appendSubagentDrilldownTabs(doc, parent, spec) {
const api = (typeof window !== 'undefined' && window.__dshSubagentDrilldown)
|| (typeof require === 'function' ? tryRequireDrilldown() : null);
if (!api || typeof api.buildSubagentDrilldown !== 'function') return;
const view = api.buildSubagentDrilldown(spec || {});
// Skip if we have nothing at all to show — an empty view would just add
// dead chrome to the subagent card.
if (view.toolDefsSource === 'empty' && view.inboundQuery.source === 'empty') return;
const wrap = doc.createElement('div');
wrap.className = 'subagent-drilldown';
const strip = doc.createElement('div');
strip.className = 'subagent-drilldown-tabstrip';
strip.setAttribute('role', 'tablist');
const tabs = [
{ id: 'tooldefs', label: `Tool defs (${view.toolDefs.length})` },
{ id: 'inbound', label: 'Inbound query' },
];
const bodies = {};
const buttons = {};
for (const t of tabs) {
const btn = doc.createElement('button');
btn.type = 'button';
btn.className = 'subagent-drilldown-tab';
btn.textContent = t.label;
btn.setAttribute('role', 'tab');
btn.dataset.tab = t.id;
const isActive = t.id === 'tooldefs';
btn.setAttribute('aria-selected', isActive ? 'true' : 'false');
btn.tabIndex = isActive ? 0 : -1;
strip.appendChild(btn);
buttons[t.id] = btn;
const body = doc.createElement('div');
body.className = 'subagent-drilldown-panel';
body.setAttribute('role', 'tabpanel');
body.hidden = !isActive;
bodies[t.id] = body;
}
// Tool defs panel — list one row per tool. Marks whether the list was
// explicitly reported or inferred from tool/call names so a reader knows
// whether they're seeing "what the child could do" vs "what it happened
// to reach for".
const tdBody = bodies.tooldefs;
const tdHead = doc.createElement('div');
tdHead.className = 'subagent-drilldown-head muted small';
tdHead.textContent = view.toolDefsSource === 'explicit'
? `${view.toolDefs.length} tool${view.toolDefs.length === 1 ? '' : 's'} available at start`
: view.toolDefsSource === 'inferred'
? `${view.toolDefs.length} tool${view.toolDefs.length === 1 ? '' : 's'} observed (inferred from tool/call events)`
: 'no tool activity recorded';
tdBody.appendChild(tdHead);
if (view.toolDefs.length > 0) {
const list = doc.createElement('ul');
list.className = 'subagent-drilldown-toollist';
for (const t of view.toolDefs) {
const li = doc.createElement('li');
li.className = 'subagent-drilldown-toolrow';
const name = doc.createElement('code');
name.className = 'subagent-drilldown-toolname';
name.textContent = t.name;
li.appendChild(name);
if (t.sampleArgs) {
const args = doc.createElement('span');
args.className = 'subagent-drilldown-toolargs muted small';
args.textContent = t.sampleArgs;
li.appendChild(args);
}
if (t.firstSeq) {
const seq = doc.createElement('span');
seq.className = 'subagent-drilldown-toolseq muted small';
seq.textContent = `seq ${t.firstSeq}`;
li.appendChild(seq);
}
list.appendChild(li);
}
tdBody.appendChild(list);
}
// Inbound query panel — verbatim seed prompt text, plus a hint chip
// stating where it came from (explicit spec vs. seed-event mining).
const inb = bodies.inbound;
const inbHead = doc.createElement('div');
inbHead.className = 'subagent-drilldown-head muted small';
inbHead.textContent = view.inboundQuery.source === 'explicit'
? 'From parent invocation (explicit)'
: view.inboundQuery.source === 'seed-event'
? `From child seed user/message${view.inboundQuery.seq ? ` at seq ${view.inboundQuery.seq}` : ''}`
: 'No inbound query recorded';
inb.appendChild(inbHead);
if (view.inboundQuery.text) {
const q = doc.createElement('blockquote');
q.className = 'subagent-drilldown-query';
q.textContent = view.inboundQuery.text;
inb.appendChild(q);
}
const activate = (id) => {
for (const t of tabs) {
const on = t.id === id;
buttons[t.id].setAttribute('aria-selected', on ? 'true' : 'false');
buttons[t.id].tabIndex = on ? 0 : -1;
bodies[t.id].hidden = !on;
}
};
strip.addEventListener('click', (ev) => {
const target = ev && ev.target;
if (!target) return;
const id = target === buttons.tooldefs ? 'tooldefs' : target === buttons.inbound ? 'inbound' : null;
if (id) activate(id);
});
wrap.appendChild(strip);
wrap.appendChild(bodies.tooldefs);
wrap.appendChild(bodies.inbound);
parent.appendChild(wrap);
}
function tryRequireDrilldown() {
try { return require('./subagent-drilldown.js'); } catch (_) { return null; }
}
// Local name is prefixed to avoid the load-time `const api` collision
// with sibling non-IIFE renderer modules (test/renderer-collisions.test.js
// keeps a static gate).
@@ -419,6 +556,7 @@ const subagentViewApi = {
buildInlineSubagentTrace,
renderStatusToken,
subagentLastMessagePreview,
appendSubagentDrilldownTabs,
};
if (typeof module !== 'undefined' && module.exports) module.exports = subagentViewApi;
if (typeof window !== 'undefined') window.__dshSubagentView = subagentViewApi;