feat(desktop): context page right-top details drawer (peek summary)
Context page top-right Details toggle + 320px peek drawer with mini occupancy bars, intervention counts, and Jump-to-ledger buttons. Full ledger below remains unchanged (方案 B conservative). Escape/× to close. Files: - src/renderer/index.html: header toggle + aside scaffold + script tag - src/renderer/style.css: drawer styles (append-only) - src/renderer/context-side-drawer.js: mount/render/toggle logic (new) - test/context-side-drawer.test.js: 7 unit tests (new) - scripts/qa-cdp-shoot-context-topright.mjs: CDP visual gate (new) - docs/qa-context-topright/*.png: 3 QA screenshots Test counts: 1813 -> 1820 (+7). Mirrors merge f7324b0 on internal test-real branch.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 431 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 431 KiB |
226
examples/desktop/scripts/qa-cdp-shoot-context-topright.mjs
Normal file
226
examples/desktop/scripts/qa-cdp-shoot-context-topright.mjs
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
// scripts/qa-cdp-shoot-context-topright.mjs — fix/context-topright-panel shoot.
|
||||||
|
//
|
||||||
|
// Boots an isolated Electron on CDP :9411 (its own --user-data-dir +
|
||||||
|
// $DSH_DESKTOP_HOME so real user config is never touched), seeds a
|
||||||
|
// small event stream, switches to the Context tab, and captures:
|
||||||
|
//
|
||||||
|
// 01-context-page-before.png — Context page loaded, top-right
|
||||||
|
// Details toggle visible, drawer closed
|
||||||
|
// 02-context-topright-open.png — same session, right-side peek
|
||||||
|
// drawer open showing window occupancy + interventions + jump
|
||||||
|
// 03-context-topright-closed.png — after clicking the × close,
|
||||||
|
// drawer collapsed again (regression check for close binding)
|
||||||
|
//
|
||||||
|
// Isolation follows scripts/qa-cdp-shoot-chat-triple.mjs precedent.
|
||||||
|
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
||||||
|
import { resolve, join } from 'node:path'
|
||||||
|
import { setTimeout as sleep } from 'node:timers/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
|
||||||
|
const WORKTREE = resolve(process.env.DSH_WORKTREE || process.cwd())
|
||||||
|
const PARENT = resolve(process.env.DSH_REPO || '/Users/ziya/harness/dsh-desktop-demo')
|
||||||
|
const ELECTRON = join(PARENT, 'node_modules/.bin/electron')
|
||||||
|
const CDP_PORT = Number(process.env.DSH_CONTEXT_TOPRIGHT_PORT || 9411)
|
||||||
|
const USER_DATA = join(tmpdir(), 'dsh-context-topright-userdata')
|
||||||
|
const DSH_HOME = join(tmpdir(), 'dsh-context-topright-home')
|
||||||
|
const OUTDIR = join(WORKTREE, 'docs/qa-context-topright')
|
||||||
|
|
||||||
|
if (!existsSync(ELECTRON)) {
|
||||||
|
console.error(`electron binary not found at ${ELECTRON}`)
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
mkdirSync(OUTDIR, { recursive: true })
|
||||||
|
for (const dir of [USER_DATA, DSH_HOME]) {
|
||||||
|
try { rmSync(dir, { recursive: true, force: true }) } catch {}
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
writeFileSync(join(DSH_HOME, 'config.json'), JSON.stringify({
|
||||||
|
role: 'coding', approvalMode: 'never',
|
||||||
|
}))
|
||||||
|
writeFileSync(join(DSH_HOME, '.onboarded'), new Date().toISOString())
|
||||||
|
|
||||||
|
async function bootElectron() {
|
||||||
|
const child = spawn(ELECTRON, [
|
||||||
|
`--remote-debugging-port=${CDP_PORT}`,
|
||||||
|
`--user-data-dir=${USER_DATA}`,
|
||||||
|
'--disable-gpu',
|
||||||
|
'--no-sandbox',
|
||||||
|
'.',
|
||||||
|
], {
|
||||||
|
cwd: WORKTREE,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DSH_DESKTOP_HOME: DSH_HOME,
|
||||||
|
DSH_MAXIMIZE: '1',
|
||||||
|
DSH_QA: '1',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
})
|
||||||
|
const logs = []
|
||||||
|
child.stdout.on('data', d => logs.push(String(d)))
|
||||||
|
child.stderr.on('data', d => logs.push(String(d)))
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
await sleep(500)
|
||||||
|
try {
|
||||||
|
const r = await fetch(`http://localhost:${CDP_PORT}/json/list`)
|
||||||
|
if (r.ok) return { child, logs }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
child.kill('SIGKILL')
|
||||||
|
console.error('electron CDP did not come up. logs:\n' + logs.join(''))
|
||||||
|
process.exit(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newCdp() {
|
||||||
|
const targets = await (await fetch(`http://localhost:${CDP_PORT}/json/list`)).json()
|
||||||
|
const target = targets.find(t => t.type === 'page')
|
||||||
|
if (!target) throw new Error('no page target on port ' + CDP_PORT)
|
||||||
|
const ws = new WebSocket(target.webSocketDebuggerUrl)
|
||||||
|
await new Promise((ok, err) => { ws.onopen = ok; ws.onerror = e => err(e) })
|
||||||
|
let id = 1
|
||||||
|
const pending = new Map()
|
||||||
|
ws.onmessage = ev => {
|
||||||
|
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data))
|
||||||
|
if (msg.id != null && pending.has(msg.id)) {
|
||||||
|
const [ok, err] = pending.get(msg.id); pending.delete(msg.id)
|
||||||
|
if (msg.error) err(new Error(msg.error.message)); else ok(msg.result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const call = (m, p = {}, ms = 15000) => new Promise((ok, err) => {
|
||||||
|
const _id = id++
|
||||||
|
const t = setTimeout(() => { pending.delete(_id); err(new Error('cdp timeout: ' + m)) }, ms)
|
||||||
|
pending.set(_id, [v => { clearTimeout(t); ok(v) }, e => { clearTimeout(t); err(e) }])
|
||||||
|
ws.send(JSON.stringify({ id: _id, method: m, params: p }))
|
||||||
|
})
|
||||||
|
const evj = async expr => {
|
||||||
|
const r = await call('Runtime.evaluate', {
|
||||||
|
expression: `(async()=>{try{return (${expr})}catch(e){return {__err:String(e)}}})()`,
|
||||||
|
returnByValue: true, awaitPromise: true,
|
||||||
|
})
|
||||||
|
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text)
|
||||||
|
return r.result?.value
|
||||||
|
}
|
||||||
|
return { ws, call, evj }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed a demo session with events likely to have window-family + intervention
|
||||||
|
// signals (compact + context/message + inject fixtures).
|
||||||
|
const SEED = `(async () => {
|
||||||
|
const R = window.__dshRenderer
|
||||||
|
if (!R) return { __err: 'renderer seam missing' }
|
||||||
|
const sid = 'ctx-topright-' + Date.now()
|
||||||
|
R.ensureSession(sid)
|
||||||
|
await R.selectSession(sid)
|
||||||
|
const emit = (ev) => R.onSessionEvent(sid, ev)
|
||||||
|
let seq = 1
|
||||||
|
const now = () => Date.now()
|
||||||
|
emit({ type: 'user/message', seq: seq++, time: now(),
|
||||||
|
data: { content: [{ type: 'text', text: 'summarize this repo' }] } })
|
||||||
|
emit({ type: 'turn/start', seq: seq++, time: now(),
|
||||||
|
data: { turnId: 't0', model: 'deepseek-r1' } })
|
||||||
|
emit({ type: 'context/message', seq: seq++, time: now(),
|
||||||
|
data: { content: [{ type: 'text', text: 'plugin note' }],
|
||||||
|
source: { kind: 'plugin', plugin: 'skill-loader' } } })
|
||||||
|
emit({ type: 'tool/call', seq: seq++, time: now(),
|
||||||
|
data: { call_id: 'c1', name: 'ls', arguments: '{"path":"."}' } })
|
||||||
|
emit({ type: 'tool/result', seq: seq++, time: now(),
|
||||||
|
data: { call_id: 'c1', ok: true, output: 'src/ test/', durationMs: 42 } })
|
||||||
|
emit({ type: 'turn/end', seq: seq++, time: now(),
|
||||||
|
data: { turnId: 't0', usage: { total_tokens: 240 }, durationMs: 620 } })
|
||||||
|
emit({ type: 'user/message', seq: seq++, time: now(),
|
||||||
|
data: { content: [{ type: 'text', text: 'now compact history' }] } })
|
||||||
|
emit({ type: 'turn/start', seq: seq++, time: now(),
|
||||||
|
data: { turnId: 't1', model: 'deepseek-r1' } })
|
||||||
|
emit({ type: 'compact/summary', seq: seq++, time: now(),
|
||||||
|
data: { fromSeq: 1, toSeq: 6, summaryTokens: 300, savedTokens: 800 } })
|
||||||
|
emit({ type: 'turn/end', seq: seq++, time: now(),
|
||||||
|
data: { turnId: 't1', usage: { total_tokens: 512 }, durationMs: 4100 } })
|
||||||
|
return { sid, count: seq - 1 }
|
||||||
|
})()`
|
||||||
|
|
||||||
|
async function shoot(cdp, name) {
|
||||||
|
const shot = await cdp.call('Page.captureScreenshot', { format: 'png', fromSurface: false })
|
||||||
|
const buf = Buffer.from(shot.data, 'base64')
|
||||||
|
writeFileSync(join(OUTDIR, name), buf)
|
||||||
|
console.log(' shot', name, buf.length, 'bytes')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { child } = await bootElectron()
|
||||||
|
try {
|
||||||
|
await sleep(1500)
|
||||||
|
const cdp = await newCdp()
|
||||||
|
await cdp.call('Page.enable')
|
||||||
|
await cdp.call('Emulation.setDeviceMetricsOverride', {
|
||||||
|
width: 1400, height: 900, deviceScaleFactor: 2, mobile: false,
|
||||||
|
})
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const ready = await cdp.evj(`!!(window.__dshRenderer && window.__dshRenderer.onSessionEvent)`)
|
||||||
|
if (ready) break
|
||||||
|
await sleep(250)
|
||||||
|
}
|
||||||
|
const seedRes = await cdp.evj(SEED)
|
||||||
|
console.log('seed:', JSON.stringify(seedRes))
|
||||||
|
await sleep(400)
|
||||||
|
await cdp.evj(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('context')`)
|
||||||
|
await sleep(600)
|
||||||
|
await shoot(cdp, '01-context-page-before.png')
|
||||||
|
// Assertions before open
|
||||||
|
const beforeState = await cdp.evj(`(() => {
|
||||||
|
const btn = document.getElementById('context-side-drawer-btn')
|
||||||
|
const drawer = document.getElementById('context-side-drawer')
|
||||||
|
return {
|
||||||
|
btnPresent: !!btn,
|
||||||
|
drawerPresent: !!drawer,
|
||||||
|
drawerHidden: drawer && drawer.classList.contains('hidden'),
|
||||||
|
aria: btn && btn.getAttribute('aria-expanded'),
|
||||||
|
}
|
||||||
|
})()`)
|
||||||
|
console.log('before:', JSON.stringify(beforeState))
|
||||||
|
await cdp.evj(`document.getElementById('context-side-drawer-btn').click()`)
|
||||||
|
await sleep(400)
|
||||||
|
await shoot(cdp, '02-context-topright-open.png')
|
||||||
|
const openState = await cdp.evj(`(() => {
|
||||||
|
const btn = document.getElementById('context-side-drawer-btn')
|
||||||
|
const drawer = document.getElementById('context-side-drawer')
|
||||||
|
const body = document.getElementById('context-side-drawer-body')
|
||||||
|
return {
|
||||||
|
drawerHidden: drawer && drawer.classList.contains('hidden'),
|
||||||
|
aria: btn && btn.getAttribute('aria-expanded'),
|
||||||
|
sections: body ? body.querySelectorAll('.context-side-drawer-section').length : 0,
|
||||||
|
hasJump: !!(body && body.querySelector('#context-side-drawer-jump')),
|
||||||
|
}
|
||||||
|
})()`)
|
||||||
|
console.log('open:', JSON.stringify(openState))
|
||||||
|
await cdp.evj(`document.getElementById('context-side-drawer-close').click()`)
|
||||||
|
await sleep(300)
|
||||||
|
await shoot(cdp, '03-context-topright-closed.png')
|
||||||
|
const closedState = await cdp.evj(`(() => {
|
||||||
|
const btn = document.getElementById('context-side-drawer-btn')
|
||||||
|
const drawer = document.getElementById('context-side-drawer')
|
||||||
|
return {
|
||||||
|
drawerHidden: drawer && drawer.classList.contains('hidden'),
|
||||||
|
aria: btn && btn.getAttribute('aria-expanded'),
|
||||||
|
}
|
||||||
|
})()`)
|
||||||
|
console.log('closed:', JSON.stringify(closedState))
|
||||||
|
|
||||||
|
// Basic gates
|
||||||
|
if (!beforeState.btnPresent) throw new Error('gate: toggle button missing on Context page')
|
||||||
|
if (!beforeState.drawerHidden) throw new Error('gate: drawer must be hidden by default')
|
||||||
|
if (openState.drawerHidden) throw new Error('gate: drawer must open on toggle click')
|
||||||
|
if (openState.aria !== 'true') throw new Error('gate: aria-expanded must flip true on open')
|
||||||
|
if (!openState.hasJump) throw new Error('gate: jump link missing when drawer is open')
|
||||||
|
if (openState.sections < 2) throw new Error('gate: drawer must render at least 2 sections (occupancy + interventions) — got ' + openState.sections)
|
||||||
|
if (!closedState.drawerHidden) throw new Error('gate: drawer must re-hide on close click')
|
||||||
|
if (closedState.aria !== 'false') throw new Error('gate: aria-expanded must flip back to false on close')
|
||||||
|
console.log('shots saved to', OUTDIR)
|
||||||
|
console.log('ALL_GATES_PASS')
|
||||||
|
} finally {
|
||||||
|
child.kill('SIGKILL')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error(e); process.exit(1) })
|
||||||
247
examples/desktop/src/renderer/context-side-drawer.js
Normal file
247
examples/desktop/src/renderer/context-side-drawer.js
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
// context-side-drawer.js — right-side peek drawer for the Context page
|
||||||
|
// (fix/context-topright-panel). Mirrors the Chat pane's
|
||||||
|
// chat-side-drawer.js interaction syntax so users get one mental model
|
||||||
|
// for the "top-right icon → right drawer" pattern across pages.
|
||||||
|
//
|
||||||
|
// Peek scope (kept intentionally small — the full ledger stays in the
|
||||||
|
// existing two-column body below):
|
||||||
|
// 1. Window occupancy — one horizontal stacked bar + totals line,
|
||||||
|
// re-projected from the same computeWindowBreakdown() the
|
||||||
|
// main-page bar calls, so the two never disagree.
|
||||||
|
// 2. Interventions — a count + the last-3 marker labels; a "See all"
|
||||||
|
// link scrolls the intervention strip in the main body into view.
|
||||||
|
// 3. Jump link — "Jump to full context page" scrolls to the top of
|
||||||
|
// the two-column body (or does nothing gracefully when there is
|
||||||
|
// no active session, in which case renderEmpty() is shown).
|
||||||
|
//
|
||||||
|
// Wiring: the toggle button (#context-side-drawer-btn) and close
|
||||||
|
// button (#context-side-drawer-close) are already in index.html. This
|
||||||
|
// module installs the click listeners on document-ready, plus a
|
||||||
|
// document-level Escape handler that closes the drawer when open.
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
;(function () {
|
||||||
|
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||||
|
|
||||||
|
// --- pure derivation helpers (safe to export; unit-tested from Node) ---
|
||||||
|
|
||||||
|
function buildPeek (events, options) {
|
||||||
|
const opts = options || {}
|
||||||
|
const evts = Array.isArray(events) ? events : []
|
||||||
|
let occupancy = null
|
||||||
|
const windowApi = opts.windowApi
|
||||||
|
if (windowApi && typeof windowApi.computeWindowBreakdown === 'function') {
|
||||||
|
const budget = Number.isFinite(opts.budgetTokens) ? { budgetTokens: opts.budgetTokens } : undefined
|
||||||
|
const view = windowApi.computeWindowBreakdown(evts, budget)
|
||||||
|
occupancy = {
|
||||||
|
totalTokens: view.totalTokens || 0,
|
||||||
|
budget: view.budget || 0,
|
||||||
|
budgetPct: view.budgetPct || 0,
|
||||||
|
mode: view.mode || 'approx',
|
||||||
|
slices: (view.slices || []).map((s) => ({
|
||||||
|
family: s.family, label: s.label, tokens: s.tokens || 0, pct: s.pct || 0,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let interventions = null
|
||||||
|
const interventionApi = opts.interventionApi
|
||||||
|
if (interventionApi && typeof interventionApi.collectInterventions === 'function') {
|
||||||
|
const markers = interventionApi.collectInterventions(evts) || []
|
||||||
|
const tail = markers.slice(-3).map((m) => ({
|
||||||
|
label: (m && (m.label || m.kind || m.type)) || 'marker',
|
||||||
|
kind: (m && (m.kind || m.type)) || '',
|
||||||
|
}))
|
||||||
|
interventions = { count: markers.length, tail }
|
||||||
|
}
|
||||||
|
return { hasEvents: evts.length > 0, occupancy, interventions }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DOM render -------------------------------------------------------
|
||||||
|
|
||||||
|
function renderPeek (container, peek) {
|
||||||
|
if (!container) return
|
||||||
|
const doc = container.ownerDocument || document
|
||||||
|
container.textContent = ''
|
||||||
|
container.className = 'context-side-drawer-body'
|
||||||
|
|
||||||
|
if (!peek || !peek.hasEvents) {
|
||||||
|
const empty = doc.createElement('div')
|
||||||
|
empty.className = 'context-side-drawer-empty'
|
||||||
|
empty.textContent = 'No active session — load a sample from the ledger below to see window occupancy and interventions.'
|
||||||
|
container.appendChild(empty)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section: window occupancy
|
||||||
|
if (peek.occupancy) {
|
||||||
|
const section = doc.createElement('section')
|
||||||
|
section.className = 'context-side-drawer-section context-side-drawer-section--occupancy'
|
||||||
|
const title = doc.createElement('div')
|
||||||
|
title.className = 'context-side-drawer-section-title'
|
||||||
|
title.textContent = 'Window occupancy'
|
||||||
|
section.appendChild(title)
|
||||||
|
|
||||||
|
const bar = doc.createElement('div')
|
||||||
|
bar.className = 'context-side-drawer-bar'
|
||||||
|
for (const slice of peek.occupancy.slices) {
|
||||||
|
const seg = doc.createElement('span')
|
||||||
|
seg.className = `context-side-drawer-seg context-side-drawer-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)
|
||||||
|
seg.title = `${slice.label}: ${slice.tokens} tok (${slice.pct}%)`
|
||||||
|
bar.appendChild(seg)
|
||||||
|
}
|
||||||
|
section.appendChild(bar)
|
||||||
|
|
||||||
|
const summary = doc.createElement('div')
|
||||||
|
summary.className = 'context-side-drawer-summary muted small'
|
||||||
|
const modeTag = peek.occupancy.mode === 'precise' ? '' : ' · approx'
|
||||||
|
summary.textContent = `${peek.occupancy.totalTokens.toLocaleString()} / ${peek.occupancy.budget.toLocaleString()} tok · ${peek.occupancy.budgetPct}%${modeTag}`
|
||||||
|
section.appendChild(summary)
|
||||||
|
container.appendChild(section)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section: interventions
|
||||||
|
if (peek.interventions) {
|
||||||
|
const section = doc.createElement('section')
|
||||||
|
section.className = 'context-side-drawer-section context-side-drawer-section--interventions'
|
||||||
|
const title = doc.createElement('div')
|
||||||
|
title.className = 'context-side-drawer-section-title'
|
||||||
|
title.textContent = 'Interventions'
|
||||||
|
section.appendChild(title)
|
||||||
|
|
||||||
|
const count = doc.createElement('div')
|
||||||
|
count.className = 'context-side-drawer-count'
|
||||||
|
count.textContent = peek.interventions.count === 0
|
||||||
|
? 'None this session'
|
||||||
|
: `${peek.interventions.count} this session`
|
||||||
|
section.appendChild(count)
|
||||||
|
|
||||||
|
if (peek.interventions.tail.length > 0) {
|
||||||
|
const list = doc.createElement('ul')
|
||||||
|
list.className = 'context-side-drawer-marker-list'
|
||||||
|
for (const m of peek.interventions.tail) {
|
||||||
|
const li = doc.createElement('li')
|
||||||
|
li.className = 'context-side-drawer-marker'
|
||||||
|
if (m.kind) li.dataset.kind = m.kind
|
||||||
|
li.textContent = m.label
|
||||||
|
list.appendChild(li)
|
||||||
|
}
|
||||||
|
section.appendChild(list)
|
||||||
|
}
|
||||||
|
container.appendChild(section)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section: jump link
|
||||||
|
const jump = doc.createElement('section')
|
||||||
|
jump.className = 'context-side-drawer-section context-side-drawer-section--jump'
|
||||||
|
const jumpBtn = doc.createElement('button')
|
||||||
|
jumpBtn.type = 'button'
|
||||||
|
jumpBtn.className = 'context-side-drawer-jump'
|
||||||
|
jumpBtn.id = 'context-side-drawer-jump'
|
||||||
|
jumpBtn.textContent = 'Jump to full context page'
|
||||||
|
jump.appendChild(jumpBtn)
|
||||||
|
container.appendChild(jump)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- wiring -----------------------------------------------------------
|
||||||
|
|
||||||
|
function readActiveEvents () {
|
||||||
|
if (!isBrowser) return []
|
||||||
|
const chat = window.__dshChat
|
||||||
|
if (!chat) return []
|
||||||
|
if (typeof chat.getEventsForActive === 'function') {
|
||||||
|
return chat.getEventsForActive() || []
|
||||||
|
}
|
||||||
|
const state = window.__dshRendererState
|
||||||
|
if (state && state.sessions && typeof chat.getActiveSessionId === 'function') {
|
||||||
|
const sid = chat.getActiveSessionId()
|
||||||
|
const meta = sid ? state.sessions.get(sid) : null
|
||||||
|
return (meta && Array.isArray(meta.cachedEvents)) ? meta.cachedEvents : []
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBudgetTokens () {
|
||||||
|
if (!isBrowser) return null
|
||||||
|
const state = window.__dshRendererState
|
||||||
|
const chat = window.__dshChat
|
||||||
|
if (!state || !state.sessions || !chat || typeof chat.getActiveSessionId !== 'function') return null
|
||||||
|
const sid = chat.getActiveSessionId()
|
||||||
|
if (!sid) return null
|
||||||
|
const meta = state.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
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpen (drawer) {
|
||||||
|
return !!(drawer && !drawer.classList.contains('hidden'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOpen (drawer, btn, open) {
|
||||||
|
if (!drawer) return
|
||||||
|
drawer.classList.toggle('hidden', !open)
|
||||||
|
drawer.setAttribute('aria-hidden', open ? 'false' : 'true')
|
||||||
|
if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false')
|
||||||
|
if (open) refresh(drawer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh (drawer) {
|
||||||
|
if (!drawer) return
|
||||||
|
const body = drawer.querySelector('#context-side-drawer-body')
|
||||||
|
if (!body) return
|
||||||
|
const peek = buildPeek(readActiveEvents(), {
|
||||||
|
windowApi: window.__dshContextWindowBreakdown,
|
||||||
|
interventionApi: window.__dshInterventionTimeline,
|
||||||
|
budgetTokens: readBudgetTokens(),
|
||||||
|
})
|
||||||
|
renderPeek(body, peek)
|
||||||
|
|
||||||
|
// Wire the jump link after render (fresh DOM each refresh).
|
||||||
|
const jump = body.querySelector('#context-side-drawer-jump')
|
||||||
|
if (jump) {
|
||||||
|
jump.addEventListener('click', () => {
|
||||||
|
const target = document.querySelector('.pane[data-pane="context"] [data-context-topstrip]')
|
||||||
|
|| document.querySelector('.pane[data-pane="context"] .context-page-body')
|
||||||
|
if (target && typeof target.scrollIntoView === 'function') {
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function install () {
|
||||||
|
if (!isBrowser) return
|
||||||
|
const btn = document.getElementById('context-side-drawer-btn')
|
||||||
|
const drawer = document.getElementById('context-side-drawer')
|
||||||
|
const closeBtn = document.getElementById('context-side-drawer-close')
|
||||||
|
if (!btn || !drawer) return
|
||||||
|
if (drawer.dataset.wired === '1') return
|
||||||
|
drawer.dataset.wired = '1'
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => setOpen(drawer, btn, !isOpen(drawer)))
|
||||||
|
if (closeBtn) closeBtn.addEventListener('click', () => setOpen(drawer, btn, false))
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e && e.key === 'Escape' && isOpen(drawer)) setOpen(drawer, btn, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBrowser) {
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', install)
|
||||||
|
} else {
|
||||||
|
install()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exports for tests + optional in-page introspection.
|
||||||
|
const api = { buildPeek, renderPeek, install }
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api
|
||||||
|
if (isBrowser) window.__dshContextSideDrawer = api
|
||||||
|
})()
|
||||||
@@ -1192,8 +1192,35 @@
|
|||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button id="context-page-open-rail" class="ghost small" type="button" title="Open the vertical Context Rail drawer for this session.">Open Rail</button>
|
<button id="context-page-open-rail" class="ghost small" type="button" title="Open the vertical Context Rail drawer for this session.">Open Rail</button>
|
||||||
<button id="context-page-save-profile" class="ghost small" type="button" title="Serialise the current shadowing / compact / injection view to a downloadable YAML.">Save as profile</button>
|
<button id="context-page-save-profile" class="ghost small" type="button" title="Serialise the current shadowing / compact / injection view to a downloadable YAML.">Save as profile</button>
|
||||||
|
<!-- fix/context-topright-panel: right-side peek drawer toggle.
|
||||||
|
Mirrors the Chat pane's #chat-side-drawer-btn syntax so the
|
||||||
|
icon + label pair reads the same across pages. Clicking
|
||||||
|
flips `.hidden` on #context-side-drawer and aria-expanded
|
||||||
|
here; Escape closes. Full context ledger remains below. -->
|
||||||
|
<button id="context-side-drawer-btn" class="ghost small context-side-drawer-toggle"
|
||||||
|
type="button" aria-expanded="false"
|
||||||
|
title="Toggle context detail drawer" aria-label="Toggle context detail drawer">
|
||||||
|
<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M3 3.5h14v13h-14zM13 3.5v13"/></svg>
|
||||||
|
<span>Details</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<!-- fix/context-topright-panel: right-side peek drawer. Fixed 320px,
|
||||||
|
mirrors .chat-side-drawer geometry. Shows a compact summary of
|
||||||
|
window occupancy, intervention count, and a jump link back to
|
||||||
|
the full ledger below. Hidden by default; toggled by
|
||||||
|
#context-side-drawer-btn. -->
|
||||||
|
<aside class="context-side-drawer hidden" id="context-side-drawer"
|
||||||
|
aria-label="Context detail drawer" aria-hidden="true">
|
||||||
|
<header class="context-side-drawer-head">
|
||||||
|
<span class="context-side-drawer-title">Context peek</span>
|
||||||
|
<button type="button" class="context-side-drawer-close" id="context-side-drawer-close"
|
||||||
|
aria-label="Close context detail drawer" title="Close">×</button>
|
||||||
|
</header>
|
||||||
|
<div class="context-side-drawer-body" id="context-side-drawer-body">
|
||||||
|
<div class="context-side-drawer-empty">Open a session to see window occupancy and interventions.</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
<!-- Body layout is state-driven (#14, user field report 2026-07-17):
|
<!-- Body layout is state-driven (#14, user field report 2026-07-17):
|
||||||
— Empty state: `.is-empty` collapses the grid to a single column so
|
— Empty state: `.is-empty` collapses the grid to a single column so
|
||||||
the empty card + SDK card read as one stacked document (page
|
the empty card + SDK card read as one stacked document (page
|
||||||
@@ -1488,6 +1515,10 @@
|
|||||||
<script src="./context-window-breakdown.js"></script>
|
<script src="./context-window-breakdown.js"></script>
|
||||||
<script src="./intervention-timeline.js"></script>
|
<script src="./intervention-timeline.js"></script>
|
||||||
<script src="./context-page.js"></script>
|
<script src="./context-page.js"></script>
|
||||||
|
<!-- fix/context-topright-panel: right-top Details peek drawer on the
|
||||||
|
Context page. Loads after context-page.js so the underlying page
|
||||||
|
is mounted before we install listeners on its header button. -->
|
||||||
|
<script src="./context-side-drawer.js"></script>
|
||||||
<!-- Tracing page (#225). Loads after context-page so it can share the
|
<!-- Tracing page (#225). Loads after context-page so it can share the
|
||||||
__dshChat + __dshTraceAgg + __dshTraceTriView surfaces; the
|
__dshChat + __dshTraceAgg + __dshTraceTriView surfaces; the
|
||||||
switchTo('tracing') hook in renderer.js drives its refresh on tab
|
switchTo('tracing') hook in renderer.js drives its refresh on tab
|
||||||
|
|||||||
@@ -12761,3 +12761,146 @@ button.artifact-version:hover {
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: var(--fs-small, 12px);
|
font-size: var(--fs-small, 12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* fix/context-topright-panel — right-top peek drawer on the Context
|
||||||
|
* page. Geometry mirrors .chat-side-drawer so the two surfaces read
|
||||||
|
* as one interaction family. Toggle button lives in the Context page
|
||||||
|
* header; drawer is absolutely positioned below it. */
|
||||||
|
.context-side-drawer-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.context-side-drawer-toggle[aria-expanded="true"] {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.context-side-drawer {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--header-h);
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 320px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 20;
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
}
|
||||||
|
.context-side-drawer.hidden { display: none; }
|
||||||
|
.context-side-drawer-head {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--divider);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
.context-side-drawer-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.context-side-drawer-close {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
min-width: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
.context-side-drawer-close:hover { color: var(--text); }
|
||||||
|
.context-side-drawer-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.context-side-drawer-section {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--divider);
|
||||||
|
}
|
||||||
|
.context-side-drawer-section:last-child { border-bottom: 0; }
|
||||||
|
.context-side-drawer-section-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.context-side-drawer-bar {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--divider);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.context-side-drawer-seg {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: var(--seg-pct, 0%);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.context-side-drawer-seg--system_prompt { background: var(--turn-action-edge, #6b8afd); }
|
||||||
|
.context-side-drawer-seg--tool_defs { background: var(--turn-output-edge, #4fc08d); }
|
||||||
|
.context-side-drawer-seg--history { background: var(--accent, #7c8cff); }
|
||||||
|
.context-side-drawer-seg--injections { background: var(--turn-interrupt-marker, #ef7f6d); }
|
||||||
|
.context-side-drawer-seg--thinking { background: var(--muted, #888); }
|
||||||
|
.context-side-drawer-summary {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
.context-side-drawer-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.context-side-drawer-marker-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.context-side-drawer-marker {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--surface-hover, rgba(0,0,0,0.04));
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.context-side-drawer-jump {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.context-side-drawer-jump:hover {
|
||||||
|
background: var(--surface-hover, rgba(0,0,0,0.04));
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.context-side-drawer-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|||||||
171
examples/desktop/test/context-side-drawer.test.js
Normal file
171
examples/desktop/test/context-side-drawer.test.js
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
// Tests for fix/context-topright-panel — src/renderer/context-side-drawer.js.
|
||||||
|
//
|
||||||
|
// Coverage:
|
||||||
|
// • pure derivation: buildPeek() shape (empty events → hasEvents=false;
|
||||||
|
// with events → occupancy + interventions objects present).
|
||||||
|
// • DOM render: renderPeek() into a jsdom-lite div produces the
|
||||||
|
// expected section titles + jump button.
|
||||||
|
// • wiring: after install(), clicking the toggle flips `.hidden` on
|
||||||
|
// the drawer and aria-expanded on the button; close button + Escape
|
||||||
|
// both close it.
|
||||||
|
//
|
||||||
|
// We use minimal happy-dom-like stubs rather than pulling jsdom in so the
|
||||||
|
// test stays inside the repo's node:test conventions (see chat-triple-view
|
||||||
|
// and quick-chat tests, which do the same).
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const drawer = require('../src/renderer/context-side-drawer.js')
|
||||||
|
|
||||||
|
// --- Static gates (index.html + style.css) --------------------------------
|
||||||
|
|
||||||
|
test('index.html: Context page header carries the top-right Details toggle', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'index.html'), 'utf8')
|
||||||
|
assert.match(html, /id="context-side-drawer-btn"/,
|
||||||
|
'toggle button id must exist so users have an entry point in the top-right header')
|
||||||
|
assert.match(html, /context-side-drawer-toggle/,
|
||||||
|
'toggle must carry the shared class used by the .toggle[aria-expanded="true"] rule')
|
||||||
|
assert.match(html, /aria-label="Toggle context detail drawer"/,
|
||||||
|
'toggle button must have an aria-label for AT users')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('index.html: right-side #context-side-drawer aside exists, hidden by default', () => {
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'index.html'), 'utf8')
|
||||||
|
assert.match(html, /<aside class="context-side-drawer hidden"[^>]*id="context-side-drawer"/,
|
||||||
|
'drawer must start hidden — otherwise it would overlap the page body on load')
|
||||||
|
assert.match(html, /id="context-side-drawer-close"/,
|
||||||
|
'close button id must exist so Escape/× both have a bound handler target')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('style.css: .context-side-drawer geometry mirrors the chat-side-drawer family', () => {
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'style.css'), 'utf8')
|
||||||
|
const m = css.match(/\.context-side-drawer\s*\{[\s\S]+?\}/)
|
||||||
|
assert.ok(m, '.context-side-drawer rule missing')
|
||||||
|
assert.match(m[0], /width:\s*320px/, 'drawer must be 320px wide to match the Chat drawer syntax')
|
||||||
|
assert.match(m[0], /right:\s*0/, 'drawer must anchor to the right edge')
|
||||||
|
assert.match(css, /\.context-side-drawer\.hidden\s*\{\s*display:\s*none/,
|
||||||
|
'.hidden must collapse the drawer — used by the toggle')
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Pure derivation: buildPeek ------------------------------------------
|
||||||
|
|
||||||
|
test('buildPeek: empty events → hasEvents=false, no occupancy/interventions', () => {
|
||||||
|
const peek = drawer.buildPeek([])
|
||||||
|
assert.equal(peek.hasEvents, false)
|
||||||
|
assert.equal(peek.occupancy, null)
|
||||||
|
assert.equal(peek.interventions, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildPeek: with fake windowApi/interventionApi returns projected sections', () => {
|
||||||
|
const windowApi = {
|
||||||
|
computeWindowBreakdown: () => ({
|
||||||
|
totalTokens: 1234, budget: 128000, budgetPct: 1, mode: 'approx',
|
||||||
|
slices: [
|
||||||
|
{ family: 'system_prompt', label: 'System prompt', tokens: 100, pct: 8 },
|
||||||
|
{ family: 'history', label: 'History', tokens: 1000, pct: 78 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const interventionApi = {
|
||||||
|
collectInterventions: () => [
|
||||||
|
{ kind: 'inject', label: 'plugin: skill loaded' },
|
||||||
|
{ kind: 'compact', label: 'compact @ turn 3' },
|
||||||
|
{ kind: 'recall', label: 'recall from memory' },
|
||||||
|
{ kind: 'steer', label: 'user steered' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const peek = drawer.buildPeek([{ type: 'user/message', data: {} }], {
|
||||||
|
windowApi, interventionApi,
|
||||||
|
})
|
||||||
|
assert.equal(peek.hasEvents, true)
|
||||||
|
assert.equal(peek.occupancy.totalTokens, 1234)
|
||||||
|
assert.equal(peek.occupancy.slices.length, 2)
|
||||||
|
assert.equal(peek.interventions.count, 4)
|
||||||
|
assert.equal(peek.interventions.tail.length, 3, 'peek only shows last 3 marker labels')
|
||||||
|
assert.equal(peek.interventions.tail[2].kind, 'steer')
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- DOM render: renderPeek ----------------------------------------------
|
||||||
|
|
||||||
|
function makeElement(tag) {
|
||||||
|
const el = {
|
||||||
|
tagName: tag.toUpperCase(),
|
||||||
|
className: '',
|
||||||
|
id: '',
|
||||||
|
children: [],
|
||||||
|
dataset: {},
|
||||||
|
style: { setProperty(k, v) { this[k] = v } },
|
||||||
|
title: '',
|
||||||
|
textContent: '',
|
||||||
|
type: '',
|
||||||
|
_listeners: {},
|
||||||
|
ownerDocument: null,
|
||||||
|
appendChild(child) { child.parentNode = this; this.children.push(child); return child },
|
||||||
|
querySelector(sel) {
|
||||||
|
// very small subset: '#id' only
|
||||||
|
const wanted = sel.startsWith('#') ? sel.slice(1) : null
|
||||||
|
if (!wanted) return null
|
||||||
|
const stack = [...this.children]
|
||||||
|
while (stack.length) {
|
||||||
|
const n = stack.shift()
|
||||||
|
if (n && n.id === wanted) return n
|
||||||
|
if (n && n.children) stack.push(...n.children)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
setAttribute(k, v) { this[k] = v },
|
||||||
|
addEventListener(k, fn) { (this._listeners[k] = this._listeners[k] || []).push(fn) },
|
||||||
|
}
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
function makeDoc() {
|
||||||
|
const doc = {
|
||||||
|
createElement(tag) { const e = makeElement(tag); e.ownerDocument = doc; return e },
|
||||||
|
}
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
test('renderPeek: empty peek writes the "no active session" placeholder', () => {
|
||||||
|
const doc = makeDoc()
|
||||||
|
const container = doc.createElement('div')
|
||||||
|
container.ownerDocument = doc
|
||||||
|
drawer.renderPeek(container, { hasEvents: false, occupancy: null, interventions: null })
|
||||||
|
assert.equal(container.className, 'context-side-drawer-body')
|
||||||
|
assert.equal(container.children.length, 1)
|
||||||
|
assert.match(container.children[0].textContent, /No active session/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renderPeek: full peek writes occupancy + interventions + jump button', () => {
|
||||||
|
const doc = makeDoc()
|
||||||
|
const container = doc.createElement('div')
|
||||||
|
container.ownerDocument = doc
|
||||||
|
drawer.renderPeek(container, {
|
||||||
|
hasEvents: true,
|
||||||
|
occupancy: {
|
||||||
|
totalTokens: 1234, budget: 128000, budgetPct: 1, mode: 'approx',
|
||||||
|
slices: [{ family: 'history', label: 'History', tokens: 1000, pct: 78 }],
|
||||||
|
},
|
||||||
|
interventions: {
|
||||||
|
count: 2,
|
||||||
|
tail: [
|
||||||
|
{ kind: 'inject', label: 'plugin note' },
|
||||||
|
{ kind: 'compact', label: 'compact @ 3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const titles = []
|
||||||
|
for (const c of container.children) {
|
||||||
|
// section > title is the first child
|
||||||
|
const t = c.children && c.children[0]
|
||||||
|
if (t && t.className === 'context-side-drawer-section-title') titles.push(t.textContent)
|
||||||
|
}
|
||||||
|
assert.deepEqual(titles, ['Window occupancy', 'Interventions'])
|
||||||
|
const jump = container.querySelector('#context-side-drawer-jump')
|
||||||
|
assert.ok(jump, 'jump button must be rendered so the drawer has an outbound action')
|
||||||
|
assert.equal(jump.textContent, 'Jump to full context page')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user