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 changed files with 2705 additions and 4 deletions

View File

@@ -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,
}
}