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:
8 files changed
+818
No files matched your search
@@ -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">
|
||||
<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>
|
||||
<!-- 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>
|
||||
</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):
|
||||
— Empty state: `.is-empty` collapses the grid to a single column so
|
||||
the empty card + SDK card read as one stacked document (page
|
||||
@@ -1488,6 +1515,10 @@
|
||||
<script src="./context-window-breakdown.js"></script>
|
||||
<script src="./intervention-timeline.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
|
||||
__dshChat + __dshTraceAgg + __dshTraceTriView surfaces; the
|
||||
switchTo('tracing') hook in renderer.js drives its refresh on tab
|
||||
|
||||
@@ -12761,3 +12761,146 @@ button.artifact-version:hover {
|
||||
color: var(--text-tertiary);
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user