diff --git a/examples/desktop/docs/qa-context-topright/01-context-page-before.png b/examples/desktop/docs/qa-context-topright/01-context-page-before.png new file mode 100644 index 0000000000..6b9ec32ff5 Binary files /dev/null and b/examples/desktop/docs/qa-context-topright/01-context-page-before.png differ diff --git a/examples/desktop/docs/qa-context-topright/02-context-topright-open.png b/examples/desktop/docs/qa-context-topright/02-context-topright-open.png new file mode 100644 index 0000000000..d838c00e40 Binary files /dev/null and b/examples/desktop/docs/qa-context-topright/02-context-topright-open.png differ diff --git a/examples/desktop/docs/qa-context-topright/03-context-topright-closed.png b/examples/desktop/docs/qa-context-topright/03-context-topright-closed.png new file mode 100644 index 0000000000..6b9ec32ff5 Binary files /dev/null and b/examples/desktop/docs/qa-context-topright/03-context-topright-closed.png differ diff --git a/examples/desktop/scripts/qa-cdp-shoot-context-topright.mjs b/examples/desktop/scripts/qa-cdp-shoot-context-topright.mjs new file mode 100644 index 0000000000..9cc2fbb127 --- /dev/null +++ b/examples/desktop/scripts/qa-cdp-shoot-context-topright.mjs @@ -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) }) diff --git a/examples/desktop/src/renderer/context-side-drawer.js b/examples/desktop/src/renderer/context-side-drawer.js new file mode 100644 index 0000000000..bd69f6a1da --- /dev/null +++ b/examples/desktop/src/renderer/context-side-drawer.js @@ -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 +})() diff --git a/examples/desktop/src/renderer/index.html b/examples/desktop/src/renderer/index.html index 3f5a3ba062..2d65cbcc99 100644 --- a/examples/desktop/src/renderer/index.html +++ b/examples/desktop/src/renderer/index.html @@ -1192,8 +1192,35 @@
+ +
+ + +