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:
18 files changed
+2705
-4
No files matched your search
@@ -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 300–500 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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 (F1–F4).
|
||||
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;
|
||||
Reference in New Issue
Block a user