feat(desktop): unified right-side Inspector (Pretty/Raw/JSON) + at-bottom auto-follow fix

This commit is contained in:
ZiyaZhang
2026-07-20 01:59:24 -07:00
parent 830579ab50
commit 067691a847
15 changed files with 1942 additions and 9 deletions

View File

@@ -332,10 +332,16 @@ becomes a visualiser of the runtime's actual state.
that exact seq, edit the user turn, and let a different reply
stream in. The child is anchored back in the session tree with a
`⑂ forks from here (N)` card so the branching is legible.
- **`{ }` JSON drawer — zero loss.** Every event card has a `{ }`
button in the corner that swings a drawer holding the raw JSON of
the source `session.event`, verbatim. The pretty renderer is a
projection; the JSON is the source of truth, always one click away.
- **`{ }` Inspector — zero loss.** Every inspectable element in the
chat stream — user + assistant bubbles, reasoning blocks, tool calls
and results, compaction cards, context 📎 injections, subagent
cards — carries an unobtrusive `{ }` badge that opens one unified
right-side Inspector with three tabs: **Pretty** (a readable,
type-specific view of that element), **Raw** (the verbatim
`session.event` from the session log, with a seq/type/time header +
copy), and **JSON** (the same event through the recursive collapsible
Fields tree). The pretty renderer is a projection; the Raw/JSON tabs
are the source of truth, always one click away.
- **Compaction visualisation.** When the runtime emits a
`session/compact` outcome, the compacted range renders as a
collapsed banner inline in chat with the summary + token delta

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

@@ -0,0 +1,342 @@
// QA verification script for lane-p0-inspector. Boots an isolated Electron on
// a private CDP port (≥9280 to dodge the live demo instance on 9223), mints a
// conversation with user + assistant + reasoning + tool events through the
// DSH_QA=1 direct-dispatch seam (window.__dshOnSessionEvent), then drives the
// unified Inspector and captures four proof shots:
//
// 01-pretty-assistant Inspector open on an assistant bubble, Pretty tab
// 02-raw-tool Raw tab on a tool call (verbatim session.event)
// 03-json-tree JSON tab showing the recursive Fields tree
// 04-scroll-chip "↓ 回到底部" chip visible after scrolling up mid-stream
//
// Isolation follows the 2026-07-18 postmortem (scripts/qa-cdp-shoot-affordance
// .mjs / qa-cdp-shoot-nav-optional.mjs):
// 1. --user-data-dir=<tmp> isolates Chromium userdata.
// 2. DSH_DESKTOP_HOME=<tmp> isolates the main-process config root so we
// never write into ~/.dsh-desktop.
// 3. Own CDP port so we never attach to the user's live 9223.
// Electron binary comes from the PARENT repo (dsh-desktop-demo) per the brief.
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, writeFileSync, rmSync, statSync } 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_INSPECTOR_PORT || 9281)
const OUTDIR = join(WORKTREE, 'docs/qa-p0-inspector')
if (!existsSync(ELECTRON)) {
console.error(`electron binary not found at ${ELECTRON}`)
process.exit(2)
}
mkdirSync(OUTDIR, { recursive: true })
function seedHome(dshHome) {
const seedOverlay = [
'# QA p0-inspector-shoot seed overlay (tmp, per-run).',
'plugins:',
' - "@cordisjs/plugin-include":',
` path: ${join(WORKTREE, 'config/daemon-echo.yml')}`,
'',
].join('\n')
writeFileSync(join(dshHome, 'user-overlay.cordis.yml'), seedOverlay)
writeFileSync(join(dshHome, 'config.json'), JSON.stringify({ role: 'coding', approvalMode: 'never' }, null, 2))
writeFileSync(join(dshHome, '.onboarded'), new Date().toISOString())
}
async function bootElectron(dshHome, userData, port) {
const child = spawn(ELECTRON, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${userData}`,
'--disable-gpu',
'--no-sandbox',
'.',
], {
cwd: WORKTREE,
env: {
...process.env,
DSH_DESKTOP_HOME: dshHome,
DSH_MAXIMIZE: '1',
DSH_QA: '1', // exposes window.__dshOnSessionEvent + the Debug popover
},
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:${port}/json/list`)
if (r.ok) return { child, logs }
} catch {}
}
child.kill('SIGKILL')
console.error('electron CDP did not come up in 20s. logs:\n' + logs.join(''))
process.exit(3)
}
async function newCdp(port) {
const targets = await (await fetch(`http://localhost:${port}/json/list`)).json()
const target = targets.find((t) => t.type === 'page')
if (!target) throw new Error('no page target on port ' + 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 data = typeof ev.data === 'string' ? ev.data : String(ev.data)
let msg; try { msg = JSON.parse(data) } catch { return }
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 = 20000) => 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 }
}
// The synthetic turn we inject. seq numbers are monotonic so the inspector's
// data-seq → cachedEvents lookup resolves each bubble to its source event.
const SESSION_ID = 'qa-inspector-sess'
const TURN_EVENTS = [
{ type: 'turn/start', seq: 1, data: { turnId: 't0', trigger: 'user' } },
{ type: 'user/message', seq: 2, data: { text: 'Read package.json and tell me the version.' } },
{ type: 'assistant/chunk', seq: 3, data: { chunk: { type: 'reasoning-delta', text: 'The user wants the version field. I should read package.json first, then parse the JSON and report the "version" key. Let me call the read tool on package.json.' } } },
{ type: 'assistant/chunk', seq: 4, data: { chunk: { type: 'text-delta', text: 'Let me read package.json.' } } },
{ type: 'tool/call', seq: 5, data: { callId: 'call-1', name: 'read', arguments: { path: 'package.json', limit: 40 } } },
{ type: 'tool/result', seq: 6, data: { callId: 'call-1', isError: false, durationMs: 8, content: '{\n "name": "dsh-desktop-demo",\n "version": "0.4.2"\n}\n' } },
{ type: 'assistant/message', seq: 7, data: { content: [{ type: 'text', text: 'The version is 0.4.2 (from package.json).' }], usage: { inputTokens: 812, outputTokens: 46, totalTokens: 858 } } },
{ type: 'turn/end', seq: 8, data: { turnId: 't0' } },
]
async function injectTurn(evj) {
const res = await evj(`
(async () => {
const dispatch = window.__dshOnSessionEvent
if (typeof dispatch !== 'function') return { err: 'no __dshOnSessionEvent seam (DSH_QA=1?)' }
if (window.__dshTabs && typeof window.__dshTabs.switchTo === 'function') window.__dshTabs.switchTo('chat')
const events = ${JSON.stringify(TURN_EVENTS)}
for (const ev of events) dispatch('${SESSION_ID}', ev)
// report what actually landed in the DOM
return {
ok: true,
userBubbles: document.querySelectorAll('.msg.user').length,
asstBubbles: document.querySelectorAll('.msg.assistant').length,
reasoning: document.querySelectorAll('.reasoning-block').length,
toolBlocks: document.querySelectorAll('.tool-block').length,
inspectBadges: document.querySelectorAll('.inspect-badge').length,
}
})()
`)
return res
}
async function screenshot(call, outName) {
// captureBeyondViewport:false — we only ever need the visible frame (the
// Inspector drawer + the chat viewport). Capturing beyond-viewport on the
// tall padded stream (step 04) forces a giant full-page raster that times
// out the throttled QA GPU. The visible frame carries every feature we
// assert.
// Retry: captureScreenshot occasionally times out when the QA GPU is
// saturated (many Electron boots in a row). Retry a couple times before
// giving up rather than failing the whole shoot on one slow frame. Shorter
// per-attempt timeout (25s) so a hung frame fails fast and retries instead
// of blocking 60s×3. PNG preferred; if every PNG attempt stalls we fall back
// to JPEG, which is far cheaper to encode on a starved software rasterizer.
let shot = null
let lastErr = null
const attempts = [
{ format: 'png' }, { format: 'png' },
{ format: 'jpeg', quality: 82 }, { format: 'jpeg', quality: 82 },
]
let usedFormat = 'png'
for (const a of attempts) {
try {
shot = await call('Page.captureScreenshot', { ...a, captureBeyondViewport: false }, 25000)
if (shot && shot.data) { usedFormat = a.format; break }
} catch (e) { lastErr = e; await new Promise((r) => setTimeout(r, 1200)) }
}
if (!shot || !shot.data) throw new Error('captureScreenshot failed for ' + outName + (lastErr ? ': ' + lastErr.message : ''))
// Keep the .png name even for a jpeg fallback body? No — write the honest
// extension so the bytes match the name.
const finalName = usedFormat === 'jpeg' ? outName.replace(/\.png$/, '.jpg') : outName
const outPath = join(OUTDIR, finalName)
writeFileSync(outPath, Buffer.from(shot.data, 'base64'))
const kb = Math.round(statSync(outPath).size / 1024)
console.log(` wrote ${outPath} (${kb} KB, ${usedFormat})`)
if (kb < 20) console.warn(`${outName} is only ${kb} KB (<20 KB) — likely a blank/trivial frame`)
return { path: outPath, kb }
}
async function main() {
const dshHome = join(tmpdir(), 'dsh-p0-inspector-home')
const userData = join(tmpdir(), 'dsh-p0-inspector-userdata')
for (const dir of [dshHome, userData]) {
try { rmSync(dir, { recursive: true, force: true }) } catch {}
mkdirSync(dir, { recursive: true })
}
seedHome(dshHome)
console.log(`[p0-inspector] booting on CDP port ${CDP_PORT}`)
const { child } = await bootElectron(dshHome, userData, CDP_PORT)
const results = []
try {
await sleep(1500)
const { call, evj } = await newCdp(CDP_PORT)
await call('Page.enable')
await call('Runtime.enable')
const injected = await injectTurn(evj)
console.log(' injected:', JSON.stringify(injected))
if (!injected || !injected.ok) throw new Error('turn injection failed: ' + JSON.stringify(injected))
await sleep(500)
// --- 01: Inspector on an assistant bubble, Pretty tab ---
const openAsst = await evj(`
(() => {
const ins = window.__dshInspector
const ev = { type: 'assistant/message', seq: 7, time: Date.now(),
data: { content: [{ type: 'text', text: 'The version is 0.4.2 (from package.json).' }],
usage: { inputTokens: 812, outputTokens: 46, totalTokens: 858 } } }
ins.open({ event: ev, tab: 'pretty' })
const drawer = document.getElementById('inspector-drawer')
return { open: drawer.classList.contains('open'),
title: (drawer.querySelector('.inspector-drawer-title')||{}).textContent,
hasUsage: !!drawer.querySelector('.inspector-meta-chip') }
})()
`)
console.log(' 01 pretty/assistant:', JSON.stringify(openAsst))
await sleep(300)
results.push(await screenshot(call, '01-pretty-assistant.png'))
// --- 02: Raw tab on the tool call (verbatim session.event) ---
const openRaw = await evj(`
(() => {
const ins = window.__dshInspector
const ev = ${JSON.stringify(TURN_EVENTS[4])} // the tool/call event
ins.open({ event: ev, tab: 'raw' })
const drawer = document.getElementById('inspector-drawer')
const pre = drawer.querySelector('.inspector-raw-pre')
return { tab: 'raw', headHasSeq: /seq 5/.test((drawer.querySelector('.inspector-raw-head-label')||{}).textContent||''),
preHasName: /"name": "read"/.test((pre||{}).textContent||'') }
})()
`)
console.log(' 02 raw/tool:', JSON.stringify(openRaw))
await sleep(300)
results.push(await screenshot(call, '02-raw-tool.png'))
// --- 03: JSON tab (recursive Fields tree) ---
const openJson = await evj(`
(() => {
const ins = window.__dshInspector
const ev = ${JSON.stringify(TURN_EVENTS[5])} // the tool/result event (nested content)
ins.open({ event: ev, tab: 'json' })
const drawer = document.getElementById('inspector-drawer')
return { tab: 'json',
hasTree: !!drawer.querySelector('.trace-detail-json-tree'),
branchNodes: drawer.querySelectorAll('.trace-detail-json-node').length }
})()
`)
console.log(' 03 json/tree:', JSON.stringify(openJson))
await sleep(300)
results.push(await screenshot(call, '03-json-tree.png'))
// --- 04: scroll-detached "back to bottom" chip ---
// Under CDP the stream can't be scrolled for real (programmatic scrollTop
// resets on this flex container; synthetic wheel events don't hit-test
// through the offscreen GPU). So we drive the REAL follow controller via
// the DSH_QA=1 seam (window.__dshQaFollow): detach() feeds "reader scrolled
// up 400px" into the same controller the scroll listener uses, then each
// reasoning-delta's followStream() → onContent() fires while detached →
// the real chip element flips visible. This exercises the shipping logic,
// not a faked class toggle.
const chip = await evj(`
(async () => {
const ins = window.__dshInspector; if (ins) ins.close()
const dispatch = window.__dshOnSessionEvent
const qf = window.__dshQaFollow
if (!qf) return { err: 'no __dshQaFollow seam (DSH_QA=1?)' }
// A few tall turns so the composer/stream look like a real session
// behind the chip (not strictly required — the chip is controller-
// driven — but it makes the shot legible).
for (let t = 1; t <= 6; t++) {
dispatch('${SESSION_ID}', { type: 'turn/start', seq: 1000+t*10, data: { turnId: 'p'+t, trigger: 'user' } })
dispatch('${SESSION_ID}', { type: 'user/message', seq: 1001+t*10, data: { text: 'Padding message '+t+'.' } })
dispatch('${SESSION_ID}', { type: 'assistant/message', seq: 1002+t*10, data: { content: [{ type:'text', text: 'Reply '+t+'. '.repeat(12) }] } })
dispatch('${SESSION_ID}', { type: 'turn/end', seq: 1003+t*10, data: { turnId: 'p'+t } })
}
// Open the final turn + streaming bubble (while pinned).
dispatch('${SESSION_ID}', { type: 'turn/start', seq: 2000, data: { turnId: 'tx', trigger: 'user' } })
dispatch('${SESSION_ID}', { type: 'user/message', seq: 2001, data: { text: 'One more, while scrolled up.' } })
dispatch('${SESSION_ID}', { type: 'assistant/chunk', seq: 2002, data: { chunk: { type: 'reasoning-delta', text: 'Opening the reasoning block while pinned. ' } } })
await new Promise(r => setTimeout(r, 200))
// Detach: tell the controller the reader scrolled 400px up.
const d = qf.detach(400)
// Stream reasoning deltas → followStream() → onContent() while detached.
// Stop as soon as the chip is visible and hold it there (don't stream
// extra deltas — under the seam the real streamEl is still at the
// bottom, so a later stray scroll event would re-pin and hide the chip;
// that re-pin is a seam artifact, not a real-app path where the element
// is genuinely scrolled up).
const trace = []
let shown = false
for (let i = 1; i <= 4 && !shown; i++) {
dispatch('${SESSION_ID}', { type: 'assistant/chunk', seq: 2002+i, data: { chunk: { type: 'reasoning-delta', text: 'Streaming chunk '+i+' while the reader is scrolled up — the chip should appear now. ' } } })
await new Promise(r => setTimeout(r, 60))
const el0 = document.getElementById('stream-scroll-chip')
const vis = !!(el0 && !el0.hidden)
trace.push({ i, chip: vis })
if (vis) shown = true
}
const el = document.getElementById('stream-scroll-chip')
return { chipExists: !!el, chipVisible: !!(el && !el.hidden), detach: d, trace,
pinned: qf.isPinned() }
})()
`)
console.log(' 04 scroll-chip:', JSON.stringify(chip))
if (!chip || chip.chipVisible !== true) {
console.warn(' ⚠ step-04 chip not visible — shot will not show the feature:', JSON.stringify(chip))
}
await sleep(150)
results.push(await screenshot(call, '04-scroll-chip.png'))
console.log('\n--- SUMMARY ---')
console.log('inject :', JSON.stringify(injected))
console.log('01 :', JSON.stringify(openAsst))
console.log('02 :', JSON.stringify(openRaw))
console.log('03 :', JSON.stringify(openJson))
console.log('04 :', JSON.stringify(chip))
for (const r of results) console.log(`shot : ${r.path} (${r.kb} KB)`)
} finally {
try { child.kill('SIGKILL') } catch {}
// Best-effort: give the port a moment to free. Electron helper procs can
// linger and hold the debug port, so we cap the wait short and then
// hard-exit rather than block the caller (the shots are already written).
for (let i = 0; i < 6; i++) {
await sleep(500)
try { await fetch(`http://localhost:${CDP_PORT}/json/list`) } catch { break }
}
}
// Force exit so a lingering Electron helper holding the CDP socket can't keep
// the node event loop alive past the work.
process.exit(0)
}
main().catch((err) => { console.error(err); process.exit(1) })

View File

@@ -569,7 +569,9 @@
const tc = window.__dshToolCards
if (tc && typeof tc.openJsonDrawer === 'function') {
const label = entry.type ? String(entry.type) : 'event'
tc.openJsonDrawer({ title: label, call: null, result: entry.event })
// lane-p0-inspector: route the devtools raw badge into the inspector's
// Raw tab with the verbatim session.event (was the tool-only drawer).
tc.openJsonDrawer({ title: label, event: entry.event, tab: 'raw' })
}
})
return btn

View File

@@ -510,6 +510,12 @@
data-action="load-sample-trace"></button>
</div>
</section>
<!-- lane-p0-inspector: "back to bottom" chip. Hidden until the reader
scrolls up AND new content streams in below (stream-follow.js
controller in renderer.js toggles [hidden]); click re-pins the
stream to the bottom. -->
<button id="stream-scroll-chip" class="stream-scroll-chip" type="button" hidden
aria-label="Scroll to latest">↓ 回到底部</button>
<footer class="composer composer-shell">
<!-- Next-action suggestion chips. Renderer-side controller
(next-action-controller in renderer.js) fills this row with
@@ -1508,6 +1514,8 @@
<script src="./trace-timeline.js"></script><!-- task #203: view B Gantt/waterfall -->
<script src="./trace-graph.js"></script><!-- task #203: view C agent node graph -->
<script src="./trace-detail-pane.js"></script><!-- task #205: right-side detail pane (4 tabs) -->
<script src="./stream-follow.js"></script><!-- lane-p0-inspector: pure at-bottom auto-follow controller (consumed by renderer stream scroll) -->
<script src="./inspector-drawer.js"></script><!-- lane-p0-inspector: unified right-side Inspector (Pretty/Raw/JSON); reuses trace-detail-pane.buildJsonTree above -->
<script src="./trace-tri-view.js"></script><!-- task #203: view chips + panels -->
<script src="./price-table.js"></script><!-- task #158: cost chip data source -->
<script src="./parse-incremental-json.js"></script><!-- task #162 rec 22: partial-JSON tool row -->
@@ -1621,6 +1629,29 @@
</div>
</aside>
<!-- lane-p0-inspector: unified right-side Inspector. Supersedes the
#tool-json-drawer above as the live surface (openJsonDrawer now routes
here); the legacy drawer DOM is kept in place so its × close-binding
+ unit-test contract still hold. Non-modal (position: fixed) like the
drawer above so the chat stream stays clickable while it is open.
Populated + toggled by inspector-drawer.js. -->
<aside id="inspector-drawer" class="inspector-drawer" aria-hidden="true" aria-label="Inspector">
<div class="inspector-drawer-head">
<div class="inspector-drawer-title">inspector</div>
<button type="button" id="inspector-drawer-close" class="ghost small" aria-label="Close inspector" title="Close (Esc)">×</button>
</div>
<div class="inspector-tabs" role="tablist" aria-label="Inspector views">
<button type="button" class="inspector-tab active" data-tab="pretty" role="tab" aria-selected="true">Pretty</button>
<button type="button" class="inspector-tab" data-tab="raw" role="tab" aria-selected="false">Raw</button>
<button type="button" class="inspector-tab" data-tab="json" role="tab" aria-selected="false">JSON</button>
</div>
<div class="inspector-drawer-body">
<div class="inspector-panel" data-panel="pretty" role="tabpanel"></div>
<div class="inspector-panel" data-panel="raw" role="tabpanel" hidden></div>
<div class="inspector-panel" data-panel="json" role="tabpanel" hidden></div>
</div>
</aside>
<!-- QA harness: no-op unless URL hash contains `qa`. -->
<script src="./qa-harness.js"></script>
</body>

View File

@@ -0,0 +1,498 @@
// inspector-drawer.js — unified right-side Inspector for the Chat stream.
//
// Supersedes the two-pane `#tool-json-drawer` (tool-cards.openJsonDrawer):
// instead of a tool-only "raw call + raw result" drawer, every inspectable
// element in the stream — user bubble, assistant bubble, reasoning block,
// tool call, tool result, compact card, context 📎 card, subagent card —
// opens ONE drawer anchored to that element's source session event, with
// three tabs:
//
// Pretty — a readable, type-specific enlarged view (message text, usage,
// reasoning full text, tool args + result summary, …). A clean
// typed projection, not a rebuilt card.
// Raw — the original session-log record: the verbatim session.event
// pretty-printed as JSON, with a seq / type / time header + copy.
// ("session log 里的原始记录".) Reconstructed records (e.g. a tool
// card's call+result, an aggregated reasoning block) are labelled
// as such so the reader is never told a synthesized blob is a
// verbatim wire record.
// JSON — the same event through the app's existing recursive collapsible
// Fields tree (window.__dshTraceDetailPane.buildJsonTree) — the
// zero-drop, per-level-folding grammar used on the trace pane.
//
// Design guard (the `{ }` drawer philosophy this inherits): the pretty
// renderer is a projection; the JSON / Raw tabs are the source of truth.
//
// Split: the projections (projectPretty / formatRaw / normalizeTab) are pure
// and unit-tested from node:test; the DOM render + wiring guard on
// `typeof document`. Dual export mirrors tool-cards.js / trace-detail-pane.js.
'use strict'
;(function () {
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'
const TABS = ['pretty', 'raw', 'json']
// --- pure helpers ------------------------------------------------------
function normalizeTab (tab) {
return TABS.indexOf(tab) >= 0 ? tab : 'pretty'
}
// Local copy of renderer.textFromContentBlocks — the inspector module is
// standalone and can't reach into renderer.js. Concatenates `text` blocks.
function textFromContentBlocks (blocks) {
if (!Array.isArray(blocks)) return ''
let out = ''
for (const b of blocks) {
if (b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string') out += b.text
}
return out
}
// Best-effort readable text for a message-shaped payload: prefer a raw
// `text` string, else fold `content` blocks.
function messageText (data) {
if (!data || typeof data !== 'object') return ''
if (typeof data.text === 'string') return data.text
if (Array.isArray(data.content)) return textFromContentBlocks(data.content)
if (typeof data.content === 'string') return data.content
return ''
}
function describeSource (source) {
if (!source) return 'context'
if (typeof source === 'string') return source
if (typeof source === 'object') {
if (source.kind === 'plugin' && source.plugin) return `plugin:${source.plugin}`
if (source.kind === 'tool' && source.tool) return `tool:${source.tool}`
if (source.kind) return source.kind
}
return 'context'
}
// Normalize an event's `type` into an inspector kind used by projectPretty
// and the drawer title.
function kindForEvent (event) {
const t = (event && event.type) || ''
if (t === 'user/message') return 'user'
if (t === 'assistant/message') return 'assistant'
if (t === 'reasoning') return 'reasoning'
if (t === 'tool/call' || t === 'tool') return 'tool-call'
if (t === 'tool/result') return 'tool-result'
if (t === 'context/message' || t === 'steering/message') return 'context'
if (t.indexOf('compact/') === 0) return 'compact'
if (t.indexOf('subagent') === 0) return 'subagent'
return 'event'
}
function argsText (args) {
if (args == null) return ''
if (typeof args === 'string') {
try { return JSON.stringify(JSON.parse(args), null, 2) } catch { return args }
}
try { return JSON.stringify(args, null, 2) } catch { return String(args) }
}
const USAGE_LABELS = [
['inputTokens', 'input'], ['input_tokens', 'input'], ['promptTokens', 'input'],
['outputTokens', 'output'], ['output_tokens', 'output'], ['completionTokens', 'output'],
['totalTokens', 'total'], ['total_tokens', 'total'],
['reasoningTokens', 'reasoning'], ['cacheReadTokens', 'cache read'],
['cacheWriteTokens', 'cache write'],
]
function usageRows (usage) {
if (!usage || typeof usage !== 'object') return []
const rows = []
const seen = new Set()
for (const [key, label] of USAGE_LABELS) {
if (usage[key] != null && !seen.has(label)) {
rows.push({ label, value: String(usage[key]) })
seen.add(label)
}
}
return rows
}
// Pure projection: event -> a readable, type-specific structure the Pretty
// tab renders. Shape:
// { kind, title, meta: [{label, value}], blocks: [{label, text, mono?}] }
// `meta` = small key/value chips; `blocks` = larger text panels.
function projectPretty (event) {
const ev = event || {}
const data = (ev.data && typeof ev.data === 'object') ? ev.data : ev
const kind = kindForEvent(ev)
const meta = []
const blocks = []
if (typeof ev.seq === 'number') meta.push({ label: 'seq', value: String(ev.seq) })
let title
switch (kind) {
case 'user': {
title = 'User message'
blocks.push({ label: 'text', text: messageText(data) })
break
}
case 'assistant': {
title = 'Assistant message'
for (const r of usageRows(data.usage)) meta.push(r)
blocks.push({ label: 'text', text: messageText(data) })
break
}
case 'reasoning': {
title = 'Reasoning'
blocks.push({ label: 'thinking', text: messageText(data) || (typeof data.text === 'string' ? data.text : '') })
break
}
case 'tool-call': {
const name = data.name || '(tool)'
title = `Tool call · ${name}`
if (data.callId) meta.push({ label: 'callId', value: String(data.callId) })
blocks.push({ label: 'arguments', text: argsText(data.arguments != null ? data.arguments : data.args), mono: true })
const result = data.result
if (result && typeof result === 'object') {
if (result.isError != null) meta.push({ label: 'isError', value: String(!!result.isError) })
if (result.durationMs != null) meta.push({ label: 'durationMs', value: String(result.durationMs) })
const rt = messageText(result) || (typeof result.content === 'string' ? result.content : '')
blocks.push({ label: 'result', text: rt || (result.isError ? '[error]' : '[ok]'), mono: true })
} else {
blocks.push({ label: 'result', text: '(result pending)', mono: true })
}
break
}
case 'tool-result': {
title = 'Tool result'
if (data.callId) meta.push({ label: 'callId', value: String(data.callId) })
if (data.isError != null) meta.push({ label: 'isError', value: String(!!data.isError) })
if (data.durationMs != null) meta.push({ label: 'durationMs', value: String(data.durationMs) })
blocks.push({ label: 'content', text: messageText(data) || (data.isError ? '[error]' : '[ok]'), mono: true })
break
}
case 'context': {
title = ev.type === 'steering/message' ? 'Steering message' : 'Context injection'
meta.push({ label: 'source', value: describeSource(data.source) })
blocks.push({ label: 'payload', text: messageText(data) })
break
}
case 'compact': {
title = 'Compaction'
meta.push({ label: 'phase', value: String(ev.type || '').replace('compact/', '') || 'summary' })
blocks.push({ label: 'summary', text: messageText(data) || (typeof data.summary === 'string' ? data.summary : '') })
break
}
case 'subagent': {
title = 'Subagent'
if (data.agentId) meta.push({ label: 'agentId', value: String(data.agentId) })
if (data.status) meta.push({ label: 'status', value: String(data.status) })
if (data.stopReason) meta.push({ label: 'stopReason', value: String(data.stopReason) })
const msg = Array.isArray(data.lastAssistantMessage)
? textFromContentBlocks(data.lastAssistantMessage)
: messageText(data)
if (msg) blocks.push({ label: 'result', text: msg })
break
}
default: {
title = ev.type ? String(ev.type) : 'Event'
const t = messageText(data)
if (t) blocks.push({ label: 'text', text: t })
break
}
}
return { kind, title, meta, blocks }
}
// Strip inspector-internal markers before a record is shown verbatim /
// fed to the JSON tree, so neither surface leaks `__reconstructed`.
function cleanEvent (event) {
if (!event || typeof event !== 'object') return event
const out = {}
for (const k of Object.keys(event)) {
if (k === '__reconstructed' || k === '__synthesized') continue
out[k] = event[k]
}
return out
}
// Pure projection for the Raw tab: the header line + pretty-printed JSON of
// the (cleaned) event, plus a `reconstructed` flag + note when the record
// is a synthesized combination rather than a single verbatim wire event.
function formatRaw (event) {
const ev = event || {}
const clean = cleanEvent(ev)
let json
try { json = JSON.stringify(clean, null, 2) } catch { json = String(clean) }
const reconstructed = !!(ev.__reconstructed || ev.__synthesized)
return {
header: {
seq: typeof ev.seq === 'number' ? ev.seq : null,
type: ev.type || 'event',
time: ev.time || ev.timestamp || null,
},
json,
reconstructed,
note: reconstructed
? 'reconstructed record (combined / aggregated) — not a single verbatim wire event'
: '',
}
}
// --- DOM: pretty / raw panel renderers (doc-injected for tests) --------
function renderPretty (doc, host, projection) {
if (!host) return
host.textContent = ''
const proj = projection || { title: '', meta: [], blocks: [] }
const title = doc.createElement('div')
title.className = 'inspector-pretty-title'
title.textContent = proj.title || ''
host.appendChild(title)
if (proj.meta && proj.meta.length) {
const metaWrap = doc.createElement('div')
metaWrap.className = 'inspector-pretty-meta'
for (const m of proj.meta) {
const chip = doc.createElement('span')
chip.className = 'inspector-meta-chip'
const k = doc.createElement('span')
k.className = 'inspector-meta-key'
k.textContent = m.label
const v = doc.createElement('span')
v.className = 'inspector-meta-value mono'
v.textContent = m.value
chip.appendChild(k); chip.appendChild(v)
metaWrap.appendChild(chip)
}
host.appendChild(metaWrap)
}
for (const b of (proj.blocks || [])) {
const section = doc.createElement('section')
section.className = 'inspector-pretty-block'
const label = doc.createElement('div')
label.className = 'inspector-pretty-block-label'
label.textContent = b.label
const body = doc.createElement('div')
body.className = 'inspector-pretty-block-body' + (b.mono ? ' mono' : '')
body.textContent = (b.text != null && b.text !== '') ? b.text : '(empty)'
section.appendChild(label); section.appendChild(body)
host.appendChild(section)
}
}
function renderRaw (doc, host, raw) {
if (!host) return
host.textContent = ''
const head = doc.createElement('div')
head.className = 'inspector-raw-head'
const label = doc.createElement('span')
label.className = 'inspector-raw-head-label muted'
const parts = []
if (raw.header.seq != null) parts.push(`seq ${raw.header.seq}`)
parts.push(raw.header.type)
if (raw.header.time != null) parts.push(String(raw.header.time))
label.textContent = parts.join(' · ')
const copy = doc.createElement('button')
copy.type = 'button'
copy.className = 'inspector-raw-copy ghost small'
copy.textContent = 'copy'
copy.title = 'Copy raw JSON'
head.appendChild(label); head.appendChild(copy)
host.appendChild(head)
if (raw.reconstructed && raw.note) {
const note = doc.createElement('div')
note.className = 'inspector-raw-note'
note.textContent = raw.note
host.appendChild(note)
}
const pre = doc.createElement('pre')
pre.className = 'inspector-raw-pre mono'
pre.textContent = raw.json
host.appendChild(pre)
// Wire copy (browser only; the shim doc in tests has no navigator).
if (isBrowser) {
copy.addEventListener('click', (e) => {
if (e && e.stopPropagation) e.stopPropagation()
const text = pre.textContent || ''
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
() => { copy.textContent = 'copied'; setTimeout(() => { copy.textContent = 'copy' }, 900) },
() => { copy.textContent = 'err'; setTimeout(() => { copy.textContent = 'copy' }, 900) },
)
}
})
}
}
function renderJson (doc, host, event) {
if (!host) return
host.textContent = ''
const clean = cleanEvent(event)
const td = (typeof window !== 'undefined') ? window.__dshTraceDetailPane : null
if (td && typeof td.buildJsonTree === 'function') {
// Reuse the app's recursive collapsible Fields tree — do NOT rebuild it.
host.appendChild(td.buildJsonTree(doc, clean, { rootName: null, openDepth: 1 }))
return
}
// Fallback for early boot / headless: flat pre.
const pre = doc.createElement('pre')
pre.className = 'inspector-raw-pre mono'
try { pre.textContent = JSON.stringify(clean, null, 2) } catch { pre.textContent = String(clean) }
host.appendChild(pre)
}
// --- drawer state + wiring (browser only) ------------------------------
const state = { event: null, tab: 'pretty', title: '' }
function drawerEl () {
return (isBrowser && document.getElementById) ? document.getElementById('inspector-drawer') : null
}
function renderActivePanel (drawer) {
if (!drawer || !state.event) return
const doc = drawer.ownerDocument || document
// Reflect the active tab on the buttons + panels.
const tabs = drawer.querySelectorAll ? drawer.querySelectorAll('.inspector-tab') : []
tabs.forEach((btn) => {
const on = btn.dataset && btn.dataset.tab === state.tab
btn.setAttribute('aria-selected', on ? 'true' : 'false')
btn.classList.toggle('active', on)
})
const panels = drawer.querySelectorAll ? drawer.querySelectorAll('.inspector-panel') : []
panels.forEach((p) => { p.hidden = !(p.dataset && p.dataset.panel === state.tab) })
const host = drawer.querySelector(`.inspector-panel[data-panel="${state.tab}"]`)
if (!host) return
if (state.tab === 'pretty') renderPretty(doc, host, projectPretty(state.event))
else if (state.tab === 'raw') renderRaw(doc, host, formatRaw(state.event))
else renderJson(doc, host, state.event)
const titleEl = drawer.querySelector('.inspector-drawer-title')
if (titleEl) titleEl.textContent = state.title || projectPretty(state.event).title || 'inspector'
}
function setTab (tab) {
state.tab = normalizeTab(tab)
const drawer = drawerEl()
if (drawer) renderActivePanel(drawer)
return state.tab
}
// Open the inspector anchored to `event` (a real or reconstructed
// session.event). `tab` selects the initial tab; `title` overrides the
// derived headline.
function open (input) {
const opts = input || {}
if (!opts.event) return null
state.event = opts.event
state.tab = normalizeTab(opts.tab)
state.title = opts.title || ''
const drawer = drawerEl()
if (!drawer) return null
renderActivePanel(drawer)
drawer.classList.add('open')
drawer.setAttribute('aria-hidden', 'false')
const escHandler = (e) => { if (e && e.key === 'Escape') close() }
drawer._escHandler = escHandler
document.addEventListener('keydown', escHandler)
return drawer
}
// Adapter for the legacy tool-cards.openJsonDrawer contract, so its call
// sites (tool-card `{ }` badge, devtools / trace raw badges, context page)
// all route into the one inspector. When `call`/`result` are present it is
// the tool-card path: reconstruct a combined tool/call record so all three
// tabs show call + result together (the legacy two-pane drawer's intent).
// A bare `event` is a verbatim session.event (devtools / trace).
function openFromDrawer (input) {
const opts = input || {}
let event
if (opts.call || opts.result) {
const call = opts.call || {}
event = {
type: 'tool/call',
seq: opts.event && typeof opts.event.seq === 'number' ? opts.event.seq : undefined,
time: opts.event && (opts.event.time || opts.event.timestamp),
data: {
callId: call.callId,
name: call.name,
arguments: call.arguments,
result: opts.result || null,
},
__reconstructed: true,
}
} else if (opts.event && typeof opts.event === 'object') {
event = opts.event
} else {
event = { type: 'event', data: {}, __reconstructed: true }
}
return open({ event, tab: opts.tab || 'json', title: opts.title })
}
function close () {
const drawer = drawerEl()
if (!drawer) return
drawer.classList.remove('open')
drawer.setAttribute('aria-hidden', 'true')
const esc = drawer._escHandler
if (esc) { document.removeEventListener('keydown', esc); drawer._escHandler = null }
}
// Build an unobtrusive "{ }" inspect affordance and hang it on `el`. The
// caller supplies `getTarget()` returning { event, tab, title } resolved at
// click time (so bubbles can synthesize from live DOM text). `opts.hover`
// makes it a hover-revealed badge (bubble / reasoning) rather than an
// always-visible one.
function attachInspectBadge (el, getTarget, opts) {
if (!isBrowser || !el) return null
const o = opts || {}
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'inspect-badge' + (o.hover ? ' inspect-badge-hover' : '')
btn.textContent = '{ }'
btn.title = 'Inspect · Pretty / Raw / JSON'
btn.setAttribute('aria-label', 'Inspect this element (Pretty, Raw, JSON)')
btn.addEventListener('click', (e) => {
if (e && e.stopPropagation) e.stopPropagation()
if (e && e.preventDefault) e.preventDefault()
const target = typeof getTarget === 'function' ? getTarget() : null
if (target && target.event) open(target)
})
el.appendChild(btn)
return btn
}
function install () {
if (!isBrowser) return
const drawer = drawerEl()
if (!drawer || drawer.dataset.wired === '1') return
drawer.dataset.wired = '1'
const closeBtn = document.getElementById('inspector-drawer-close')
if (closeBtn) closeBtn.addEventListener('click', () => close())
const tabs = drawer.querySelectorAll('.inspector-tab')
tabs.forEach((btn) => {
btn.addEventListener('click', () => { if (btn.dataset) setTab(btn.dataset.tab) })
})
}
if (isBrowser) {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', install)
else install()
}
const api = {
// pure
normalizeTab, projectPretty, formatRaw, kindForEvent, TABS,
// dom renderers (doc-injected)
renderPretty, renderRaw, renderJson,
// drawer
open, openFromDrawer, close, setTab, install, attachInspectBadge,
}
if (typeof module !== 'undefined' && module.exports) module.exports = api
if (isBrowser) window.__dshInspector = api
})()

View File

@@ -775,6 +775,79 @@ function scrollToBottom() {
streamEl.scrollTop = streamEl.scrollHeight
}
// lane-p0-inspector: at-bottom auto-follow. The stream hard-scrolls to the
// tail on discrete events (scrollToBottom above, called from ~18 sites), but
// the PER-CHUNK streaming path (reasoning / assistant text / tool-call
// deltas) routes through followStream() so a reader who scrolled UP to
// re-read an earlier step isn't yanked back to the bottom on every delta.
// The pure decision lives in stream-follow.js; here we own the DOM wiring.
const _followCtrl = (typeof window !== 'undefined' && window.__dshStreamFollow
&& typeof window.__dshStreamFollow.createFollowController === 'function')
? window.__dshStreamFollow.createFollowController({ threshold: 40 })
: null
function _streamMetrics() {
return { scrollTop: streamEl.scrollTop, scrollHeight: streamEl.scrollHeight, clientHeight: streamEl.clientHeight }
}
function _setScrollChip(show) {
const chip = document.getElementById('stream-scroll-chip')
if (chip) chip.hidden = !show
}
// Follow the streaming tail only while the reader is pinned to the bottom.
// When detached, surfaces the "↓ 回到底部" chip instead of scrolling.
function followStream() {
if (!_followCtrl) { scrollToBottom(); return }
const r = _followCtrl.onContent()
if (r.follow) streamEl.scrollTop = streamEl.scrollHeight
_setScrollChip(r.showChip)
}
function bindStreamFollow() {
if (!streamEl || !_followCtrl) return
if (streamEl.dataset.followWired === '1') return
streamEl.dataset.followWired = '1'
streamEl.addEventListener('scroll', () => {
const r = _followCtrl.onScroll(_streamMetrics())
_setScrollChip(r.showChip)
})
const chip = document.getElementById('stream-scroll-chip')
if (chip) {
chip.addEventListener('click', () => {
_followCtrl.repin()
streamEl.scrollTop = streamEl.scrollHeight
_setScrollChip(false)
})
}
}
// QA-only seam (DSH_QA=1 gated, same gate as __dshOnSessionEvent). Under
// CDP the stream's scroll position can't be driven reliably — a programmatic
// scrollTop gets reset on this flex/min-height:0 container and synthetic
// wheel events don't hit-test through the offscreen GPU path. So a driver
// can't reproduce "reader scrolled up" via real scrolling. This seam feeds
// metrics straight into the SAME follow controller the scroll listener uses
// (window.__dshStreamFollow.onScroll), then re-projects the chip via the same
// followStream() path — so a shoot exercises the real detach → chip logic,
// not a faked class toggle.
if (typeof window !== 'undefined' && window.dshQa) {
window.__dshQaFollow = {
// Simulate the reader having scrolled `distancePx` up from the bottom.
detach(distancePx) {
if (!_followCtrl) return { err: 'no follow controller' }
const gap = Number.isFinite(distancePx) ? distancePx : 400
const r = _followCtrl.onScroll({ scrollTop: 0, scrollHeight: gap + 1000, clientHeight: 1000 })
_setScrollChip(r.showChip)
return { pinned: _followCtrl.isPinned(), showChip: r.showChip }
},
// Simulate new streamed content landing (the followStream() decision).
content() {
followStream()
const el = document.getElementById('stream-scroll-chip')
return { pinned: _followCtrl ? _followCtrl.isPinned() : null, chipVisible: !!(el && !el.hidden) }
},
isPinned() { return _followCtrl ? _followCtrl.isPinned() : null },
}
}
// Titlecase mapping for role labels. Kept trivial + covered by
// test assertions: `appendMessage({ role: 'user' })` produces a `.role-label`
// child reading "User" (not "USER" / "HUMAN"). The map is small and
@@ -867,6 +940,13 @@ function appendMessage({ role, text, className, seq, optimistic, target }) {
if (typeof seq === 'number') el.dataset.forkSeq = String(seq)
attachForkHereButton(el)
}
// lane-p0-inspector: universal click-to-inspect on user + assistant
// bubbles (hover-revealed { } badge). Assistant streaming bubbles get
// their inspectSeq stamped later at assistant/message finalize.
if (role === 'user' || role === 'assistant') {
if (typeof seq === 'number') el.dataset.inspectSeq = String(seq)
attachBubbleInspect(el, body, role)
}
// assistant/reasoning bubbles land inside the
// active turn container's `.turn-body` when one is open; user bubbles
// and any explicit target from the caller override. Streams without a
@@ -877,6 +957,63 @@ function appendMessage({ role, text, className, seq, optimistic, target }) {
return body
}
// lane-p0-inspector: hover-revealed "{ }" inspect badge on user/assistant
// bubbles. Resolves the source session.event at click time via the bubble's
// data-inspect-seq (or data-seq) against the active session's cachedEvents;
// falls back to a reconstructed record built from the bubble's own text so
// inspect always works even after the source event rolled off the cache cap.
function attachBubbleInspect(el, body, role) {
const ins = window.__dshInspector
if (!ins || typeof ins.attachInspectBadge !== 'function') return
ins.attachInspectBadge(el, () => {
const seqStr = el.dataset.inspectSeq != null ? el.dataset.inspectSeq : el.dataset.seq
const seq = (seqStr != null && seqStr !== '') ? Number(seqStr) : null
let event = null
if (Number.isFinite(seq) && window.__dshChat && typeof window.__dshChat.getEventsForActive === 'function') {
const events = window.__dshChat.getEventsForActive() || []
event = events.find((e) => e && e.seq === seq) || null
}
if (!event) {
const text = (body && body.textContent) || ''
event = {
type: role === 'user' ? 'user/message' : 'assistant/message',
seq: Number.isFinite(seq) ? seq : undefined,
data: { content: [{ type: 'text', text }] },
__reconstructed: true,
}
}
return { event, tab: 'pretty' }
}, { hover: true })
}
// lane-p0-inspector: inspect badge on a reasoning block. Reasoning spans many
// reasoning-delta chunks (no single verbatim wire event), so the inspect
// target is a reconstructed record carrying the block's full text — labelled
// as reconstructed in the Raw tab.
function attachReasoningInspect(r) {
const ins = window.__dshInspector
if (!ins || typeof ins.attachInspectBadge !== 'function' || !r) return
ins.attachInspectBadge(r, () => {
const bodyEl = r.querySelector ? (r.querySelector('.reasoning-body') || r) : r
const text = (bodyEl && bodyEl.textContent) || ''
return {
event: { type: 'reasoning', data: { text }, __reconstructed: true },
tab: 'pretty',
}
}, { hover: true })
}
// lane-p0-inspector: attach an inspect badge anchored to a concrete
// session.event that is in scope at render time (context / compact / subagent
// cards). Defaults to a visible badge (opts.hover=false) that callers hang on
// a card's summary line, matching the tool-card `{ }` pattern.
function attachEventInspect(el, event, opts) {
const ins = window.__dshInspector
if (!ins || typeof ins.attachInspectBadge !== 'function' || !el || !event) return
const o = opts || {}
ins.attachInspectBadge(el, () => ({ event, tab: o.tab || 'pretty' }), { hover: !!o.hover })
}
// Hover-revealed "fork from here" button on assistant bubbles. The boundary
// is read from the bubble's data-fork-seq at click time: bubbles are born
// with their assistant/message seq, then turn/end re-stamps the latest one
@@ -1784,6 +1921,9 @@ function appendContextCard({ source, content, seq, kind }) {
? `steering: ${summaryText}`
: `context injection: ${summaryText}`
summary.append(src, hint)
// lane-p0-inspector: inspect the injected payload (source + content) — the
// 📎 context card previously had no reachable raw record.
attachEventInspect(summary, { type: kind || 'context/message', seq, data: { source, content } }, { tab: 'pretty' })
const body = document.createElement('div')
body.className = 'body'
body.textContent = textFromContentBlocks(content)
@@ -3052,7 +3192,9 @@ function buildRawJsonBadge(event) {
const tc = window.__dshToolCards
if (tc && typeof tc.openJsonDrawer === 'function') {
const label = event && event.type ? String(event.type) : 'event'
tc.openJsonDrawer({ title: label, call: null, result: event })
// lane-p0-inspector: route the trace-row raw badge into the inspector's
// Raw tab with the verbatim session.event (was the tool-only drawer).
tc.openJsonDrawer({ title: label, event, tab: 'raw' })
}
})
return btn
@@ -3608,6 +3750,9 @@ function appendCompactMarker(event, meta, sessionId) {
summary.appendChild(e)
}
el.appendChild(summary)
// lane-p0-inspector: inspect the compaction — Pretty shows the summary
// text, Raw/JSON the verbatim compact/summary event (shadowedSeqs, tokens).
attachEventInspect(summary, event, { tab: 'pretty' })
// Three-tab body — strategy list §1.7: 压前原文 / 压后摘要 / 策略与账
// as horizontal tabs. Shell owned by compact-card.js (pure module +
// DOM builder). Fallback path preserves the pre-refactor .body +
@@ -4826,9 +4971,32 @@ function buildRunningSubagentCard(rec) {
rec.bodyEl = live
}
rec.cardEl = wrap
// lane-p0-inspector: inspect the RUNNING subagent card. The card spans a
// live subtrajectory (no single verbatim wire event yet), so the target is
// a reconstructed subagent record carrying the lineage + running status.
attachSubagentInspect(wrap, {
type: 'subagent/started',
data: {
parentSessionId: rec.parentSessionId,
childSessionId: rec.childSessionId,
parentCallId: rec.parentCallId,
status: 'running',
},
__reconstructed: true,
})
return wrap
}
// lane-p0-inspector: hang an inspect badge on a subagent card's summary line
// (running or sealed). Anchors to a subagent-shaped event so the Pretty tab
// projects agentId/status/stopReason/result; the badge sits on the summary
// like the tool-card `{ }` so a click doesn't toggle the <details>.
function attachSubagentInspect(cardEl, event) {
if (!cardEl || typeof cardEl.querySelector !== 'function') return
const summary = cardEl.querySelector('.subagent-trace-summary') || cardEl
attachEventInspect(summary, event, { tab: 'pretty' })
}
function onSessionEvent(sessionId, event) {
const meta = ensureSession(sessionId)
// Feed the per-session context meter. The tracker is a pure accumulator
@@ -4974,7 +5142,10 @@ function onSessionEvent(sessionId, event) {
const pending = streamEl.querySelector('.msg.user[data-optimistic="1"]')
if (pending) {
delete pending.dataset.optimistic
if (typeof event.seq === 'number') pending.dataset.seq = String(event.seq)
if (typeof event.seq === 'number') {
pending.dataset.seq = String(event.seq)
pending.dataset.inspectSeq = String(event.seq)
}
} else {
appendMessage({ role: 'user', text, seq: event.seq })
}
@@ -5012,6 +5183,7 @@ function onSessionEvent(sessionId, event) {
r.id = `reasoning-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
turnAppendTarget(sessionId).appendChild(r)
if (bubbleEl) bubbleEl.dataset.reasoningId = r.id
attachReasoningInspect(r)
}
rb.appendReasoningDelta(r, chunk.text)
} else {
@@ -5027,6 +5199,7 @@ function onSessionEvent(sessionId, event) {
r.id = `reasoning-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
turnAppendTarget(sessionId).appendChild(r)
if (bubbleEl) bubbleEl.dataset.reasoningId = r.id
attachReasoningInspect(r)
}
r.textContent += chunk.text
}
@@ -5111,7 +5284,10 @@ function onSessionEvent(sessionId, event) {
else argsEl.textContent = `(${preview})`
}
}
scrollToBottom()
// lane-p0-inspector: per-chunk streaming scroll — follow the tail only
// while the reader is pinned to the bottom (was unconditional
// scrollToBottom(), which yanked a reader who'd scrolled up).
followStream()
return
}
case 'assistant/message': {
@@ -5130,6 +5306,9 @@ function onSessionEvent(sessionId, event) {
// stamp it now.
if (typeof event.seq === 'number' && body.parentElement) {
body.parentElement.dataset.seq = String(event.seq)
// lane-p0-inspector: anchor the inspect badge to the assistant/message
// event (kept distinct from data-fork-seq, which turn/end re-stamps).
body.parentElement.dataset.inspectSeq = String(event.seq)
// Stamp data-fork-seq alongside data-seq. Historically the streaming
// bubble only carried data-seq, and the fork button consulted the
// seq via a closure captured at rebind time. Now that the button
@@ -5218,6 +5397,10 @@ function onSessionEvent(sessionId, event) {
// captured callId; the drawer looks up the latest result on click,
// not at bind time.
meta.toolPayloads.set(callId, { name, args: argStr, result: null })
// lane-p0-inspector: capture the tool/call session.event so the
// inspector can carry its seq/time onto the reconstructed record.
// Default to the JSON (fields tree) tab — the "{ }" badge's mental model.
const inspectSrcEvent = event
const openJson = () => {
const tc = window.__dshToolCards
if (!tc || !tc.openJsonDrawer) return
@@ -5226,6 +5409,8 @@ function onSessionEvent(sessionId, event) {
title: `tool: ${payload.name || name}`,
call: { callId, name: payload.name, arguments: payload.args },
result: payload.result,
event: inspectSrcEvent,
tab: 'json',
})
}
const { el: toolBlockEl, resBox } = appendToolCall({ callId, name, args: argStr, onJsonBadge: openJson, target })
@@ -6472,6 +6657,21 @@ window.dsh.onNotify(({ method, params }) => {
lastAssistantMessage: Array.isArray(params.lastAssistantMessage) ? params.lastAssistantMessage : [],
}
const sealed = view.buildInlineSubagentTrace(document, spec, { collapsed: true })
// lane-p0-inspector: inspect the sealed subagent return card — anchor to
// a subagent/finished-shaped event so Pretty shows status/stopReason +
// the last assistant message.
attachSubagentInspect(sealed, {
type: 'subagent/finished',
data: {
agentId: params.agentId,
childSessionId: params.childSessionId,
parentCallId,
status: params.status || 'ok',
stopReason: typeof params.stopReason === 'string' ? params.stopReason : null,
lastAssistantMessage: Array.isArray(params.lastAssistantMessage) ? params.lastAssistantMessage : [],
},
__reconstructed: true,
})
// If a RUNNING card already sits in the DOM (live path built it on
// subagent.started), swap it for the sealed card in place. Otherwise
// append after the spawn row (the pure fixture-replay path).
@@ -6966,8 +7166,10 @@ function bindJsonDrawerClose() {
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindJsonDrawerClose, { once: true })
document.addEventListener('DOMContentLoaded', bindStreamFollow, { once: true })
} else {
bindJsonDrawerClose()
bindStreamFollow()
}
// -- fixture-loader debug buttons (§1.1 / §1.3 trace-samples) ---

View File

@@ -0,0 +1,93 @@
// stream-follow.js — pure "auto-follow the streaming tail" controller.
//
// The chat stream auto-scrolls to the bottom as reasoning / assistant-text /
// tool-call deltas arrive (renderer.js scrollToBottom(), historically called
// unconditionally per chunk at the assistant/chunk site). That unconditional
// scroll had a bug: a reader who scrolled UP to re-read an earlier step got
// yanked back to the bottom on the very next delta, making it impossible to
// read while the model streams.
//
// This module isolates the "should we follow?" decision as pure logic so it
// is unit-testable without a DOM. renderer.js owns the wiring: it feeds the
// scroll region's metrics in on every scroll event (onScroll) and asks
// onContent() whether to hard-scroll when new streamed content lands. When
// the reader has detached, onContent stops following and signals that the
// "↓ 回到底部" chip should appear.
//
// Dual export: CommonJS for node:test, window.__dshStreamFollow for the
// renderer (same shape as the other small pure renderer modules —
// chat-refresh-throttle.js, html-escape.js).
'use strict'
;(function () {
const DEFAULT_THRESHOLD_PX = 40
// Distance in px from the bottom of the scroll region. 0 = pinned to the
// very bottom; grows as the reader scrolls up. Clamped at 0 so sub-pixel
// rounding (fractional scrollHeight/clientHeight under zoom) can't report a
// tiny negative and read as "not at bottom".
function distanceFromBottom (metrics) {
if (!metrics) return 0
const st = Number(metrics.scrollTop) || 0
const sh = Number(metrics.scrollHeight) || 0
const ch = Number(metrics.clientHeight) || 0
return Math.max(0, sh - st - ch)
}
function isNearBottom (metrics, threshold) {
const t = Number.isFinite(threshold) ? threshold : DEFAULT_THRESHOLD_PX
return distanceFromBottom(metrics) <= t
}
// Stateful controller, but pure w.r.t. the DOM: callers pass metrics in and
// act on the returned intent. Two bits of state:
// pinned — is the view following the bottom right now?
// detachedWithNew — has new streamed content landed since the reader
// scrolled away? (drives the "back to bottom" chip)
function createFollowController (opts) {
const options = opts || {}
const threshold = Number.isFinite(options.threshold) ? options.threshold : DEFAULT_THRESHOLD_PX
let pinned = options.startPinned === false ? false : true
let detachedWithNew = false
// Call on every scroll event. Recomputes `pinned` from the metrics and
// clears the "new while detached" flag once the reader is back at bottom.
function onScroll (metrics) {
const near = isNearBottom(metrics, threshold)
pinned = near
if (near) detachedWithNew = false
return { pinned, showChip: detachedWithNew && !near }
}
// Call when new streamed content is appended. Returns whether the caller
// should hard-scroll to the bottom (only when pinned) and whether the
// "back to bottom" chip should be visible.
function onContent () {
if (pinned) return { follow: true, showChip: false }
detachedWithNew = true
return { follow: false, showChip: true }
}
// Force re-pin: chip click, or a deliberate jump (the reader sent a
// message). Caller hard-scrolls after calling this.
function repin () {
pinned = true
detachedWithNew = false
return { pinned, showChip: false }
}
return {
onScroll,
onContent,
repin,
isPinned: () => pinned,
hasDetachedContent: () => detachedWithNew,
threshold,
}
}
const api = { distanceFromBottom, isNearBottom, createFollowController, DEFAULT_THRESHOLD_PX }
if (typeof module !== 'undefined' && module.exports) module.exports = api
if (typeof window !== 'undefined') window.__dshStreamFollow = api
})()

View File

@@ -1664,6 +1664,130 @@ body.layout-monitor .stream {
max-height: 40vh; overflow-y: auto;
}
/* ===== lane-p0-inspector: unified right-side Inspector ====================
* Geometry mirrors .tool-json-drawer (the surface it supersedes): a
* non-modal fixed right panel that slides in on .open. Adds a three-tab
* header (Pretty / Raw / JSON) between the title bar and the body. */
.inspector-drawer {
position: fixed; top: 0; right: 0; height: 100vh; width: min(560px, 42vw);
background: var(--bg); border-left: 1px solid var(--border);
box-shadow: -6px 0 16px rgba(0,0,0,0.18);
transform: translateX(100%); transition: transform 0.18s ease;
z-index: 41; display: flex; flex-direction: column;
pointer-events: auto;
}
.inspector-drawer.open { transform: translateX(0); }
.inspector-drawer[aria-hidden="true"] { pointer-events: none; }
.inspector-drawer[aria-hidden="false"] { pointer-events: auto; }
.inspector-drawer-head {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-bottom: 1px solid var(--border);
background: var(--bg-elev);
}
.inspector-drawer-title {
font-family: var(--mono); font-size: 13px; color: var(--text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.inspector-tabs {
display: flex; gap: 2px; padding: 6px 10px 0;
border-bottom: 1px solid var(--border); background: var(--bg-elev);
}
.inspector-tab {
appearance: none; background: transparent; border: 0;
border-bottom: 2px solid transparent; cursor: pointer;
padding: 6px 12px 7px; color: var(--muted);
font-size: 12px; letter-spacing: 0.02em;
}
.inspector-tab:hover { color: var(--text); }
.inspector-tab.active {
color: var(--text); border-bottom-color: var(--accent);
}
.inspector-drawer-body {
flex: 1 1 auto; overflow-y: auto; padding: 12px 14px 16px;
}
.inspector-panel[hidden] { display: none; }
/* Pretty tab */
.inspector-pretty-title {
font-size: 13px; color: var(--text); font-weight: 600; margin-bottom: 8px;
}
.inspector-pretty-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.inspector-meta-chip {
display: inline-flex; align-items: baseline; gap: 5px;
padding: 2px 7px; border: 1px solid var(--border); border-radius: 10px;
background: var(--bg-elev); font-size: 11px;
}
.inspector-meta-key { color: var(--muted); }
.inspector-meta-value { color: var(--text); }
.inspector-pretty-block { margin: 0 0 12px; }
.inspector-pretty-block-label {
color: var(--muted); font-size: 11px; letter-spacing: 0.03em;
text-transform: uppercase; margin-bottom: 4px;
}
.inspector-pretty-block-body {
color: var(--text); font-size: 12.5px; line-height: 1.55;
white-space: pre-wrap; word-wrap: break-word;
background: var(--bg-elev); border: 1px solid var(--border);
border-radius: 4px; padding: 8px 10px; max-height: 46vh; overflow-y: auto;
}
.inspector-pretty-block-body.mono { font-family: var(--mono); font-size: 11.5px; }
/* Raw tab */
.inspector-raw-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 6px;
}
.inspector-raw-head-label { font-family: var(--mono); font-size: 11px; }
.inspector-raw-note {
font-size: 11px; color: var(--muted); font-style: italic;
margin-bottom: 6px; padding-left: 2px;
}
.inspector-raw-pre {
margin: 0; padding: 10px 12px;
background: var(--bg-elev); border: 1px solid var(--border); border-radius: 4px;
color: var(--text); font-family: var(--mono); font-size: 11.5px;
line-height: 1.5; white-space: pre-wrap; word-wrap: break-word;
}
/* Universal inspect affordance — same `{ }` glyph as the tool-card badge. */
.inspect-badge {
padding: 1px 6px;
border: 1px solid var(--border); border-radius: 3px;
background: var(--bg-elev); color: var(--muted);
font-family: var(--mono); font-size: 10px; letter-spacing: 0.02em;
cursor: pointer;
}
.inspect-badge:hover { color: var(--accent); border-color: var(--accent); }
/* Hover-revealed variant for bubbles / reasoning blocks — mirrors the
* .fork-here reveal pattern. */
.inspect-badge-hover {
position: absolute; top: 6px; opacity: 0; transition: opacity 0.15s;
z-index: 2;
}
.msg.user { position: relative; }
.msg.user .inspect-badge-hover { right: 8px; }
/* Assistant bubbles already host .fork-here at top-right (right:8px); put the
* inspect badge on the left edge so the two never overlap. */
.msg.assistant .inspect-badge-hover { left: 8px; }
.msg:hover .inspect-badge-hover { opacity: 1; }
.reasoning-block { position: relative; }
.reasoning-block .inspect-badge-hover { right: 8px; top: 4px; }
.reasoning-block:hover .inspect-badge-hover { opacity: 1; }
/* "back to bottom" chip — floats above the composer when the reader has
* scrolled up and new content is streaming in below. */
.pane[data-pane="chat"] { position: relative; }
.stream-scroll-chip {
position: absolute; left: 50%; transform: translateX(-50%);
bottom: 148px; z-index: 30;
padding: 5px 12px; border-radius: 14px;
border: 1px solid var(--border); background: var(--bg-elev);
color: var(--text); font-size: 12px; cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.22);
}
.stream-scroll-chip:hover { border-color: var(--accent); color: var(--accent); }
.stream-scroll-chip[hidden] { display: none; }
/* Code-dispatch sub-call list (fan-out under a run_code tool block) -------- */
.card-code-dispatch {
border-top: 1px dashed var(--border); margin-top: 4px; padding-top: 4px;

View File

@@ -591,7 +591,19 @@ function renderJsonBadge(onClick) {
// Idempotently populate the drawer with call + result JSON and slide it in.
// `title` is a short label ("tool: bash"); either payload may be null/absent
// (call-only if result hasn't landed yet).
function openJsonDrawer({ title, call, result } = {}) {
//
// lane-p0-inspector: this is now a thin adapter. When the unified Inspector
// is loaded (the real app — window.__dshInspector), the call routes there so
// every `{ }` badge / raw-JSON badge lands in the one Pretty/Raw/JSON drawer
// instead of this tool-only two-pane one. `event`/`tab` are the new
// pass-throughs (verbatim source event + initial tab); older callers that
// pass only {title, call, result} still work. The legacy #tool-json-drawer
// path below stays intact for node unit tests + early boot (inspector absent).
function openJsonDrawer({ title, call, result, event, tab } = {}) {
const ins = (typeof window !== 'undefined') ? window.__dshInspector : null
if (ins && typeof ins.openFromDrawer === 'function') {
return ins.openFromDrawer({ title, call, result, event, tab })
}
const drawer = typeof document !== 'undefined' && document.getElementById
? document.getElementById('tool-json-drawer') : null
if (!drawer) return null

View File

@@ -0,0 +1,514 @@
// Tests for lane-p0-inspector — src/renderer/inspector-drawer.js.
//
// The inspector's projections (projectPretty / formatRaw / normalizeTab /
// kindForEvent) are pure and are the contract the three tabs render against;
// the DOM renderers (renderPretty / renderRaw / renderJson) take an injected
// `doc` so we exercise them with a hand-rolled shim (same approach as
// tool-cards.test.js / context-side-drawer.test.js — no jsdom).
//
// Static gates assert the index.html drawer scaffold + style.css geometry are
// present, since the browser wiring (open/close/setTab) resolves the drawer by
// id and toggles classes the CSS keys off.
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const inspector = require('../src/renderer/inspector-drawer.js')
// --- pure: normalizeTab / kindForEvent ------------------------------------
test('normalizeTab: known tabs pass through; unknown falls back to pretty', () => {
assert.equal(inspector.normalizeTab('pretty'), 'pretty')
assert.equal(inspector.normalizeTab('raw'), 'raw')
assert.equal(inspector.normalizeTab('json'), 'json')
assert.equal(inspector.normalizeTab('bogus'), 'pretty')
assert.equal(inspector.normalizeTab(undefined), 'pretty')
})
test('kindForEvent: maps each stream element type to its inspector kind', () => {
assert.equal(inspector.kindForEvent({ type: 'user/message' }), 'user')
assert.equal(inspector.kindForEvent({ type: 'assistant/message' }), 'assistant')
assert.equal(inspector.kindForEvent({ type: 'reasoning' }), 'reasoning')
assert.equal(inspector.kindForEvent({ type: 'tool/call' }), 'tool-call')
assert.equal(inspector.kindForEvent({ type: 'tool/result' }), 'tool-result')
assert.equal(inspector.kindForEvent({ type: 'context/message' }), 'context')
assert.equal(inspector.kindForEvent({ type: 'steering/message' }), 'context')
assert.equal(inspector.kindForEvent({ type: 'compact/summary' }), 'compact')
assert.equal(inspector.kindForEvent({ type: 'subagent/started' }), 'subagent')
assert.equal(inspector.kindForEvent({ type: 'subagent/finished' }), 'subagent')
assert.equal(inspector.kindForEvent({ type: 'dev/heartbeat' }), 'event')
assert.equal(inspector.kindForEvent({}), 'event')
})
// --- pure: projectPretty per event type -----------------------------------
test('projectPretty: user message projects text block + seq chip', () => {
const p = inspector.projectPretty({ type: 'user/message', seq: 4, data: { text: 'hello there' } })
assert.equal(p.kind, 'user')
assert.equal(p.title, 'User message')
assert.deepEqual(p.meta[0], { label: 'seq', value: '4' })
const textBlock = p.blocks.find((b) => b.label === 'text')
assert.ok(textBlock && textBlock.text === 'hello there')
})
test('projectPretty: user message folds content blocks when no raw text', () => {
const p = inspector.projectPretty({
type: 'user/message',
data: { content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] },
})
assert.equal(p.blocks.find((b) => b.label === 'text').text, 'ab')
})
test('projectPretty: assistant message surfaces usage as meta chips', () => {
const p = inspector.projectPretty({
type: 'assistant/message',
seq: 7,
data: { text: 'answer', usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 } },
})
assert.equal(p.title, 'Assistant message')
const labels = p.meta.map((m) => m.label)
assert.ok(labels.includes('input') && labels.includes('output') && labels.includes('total'))
assert.equal(p.meta.find((m) => m.label === 'output').value, '20')
})
test('projectPretty: usage dedupes across alias keys (input_tokens vs inputTokens)', () => {
const p = inspector.projectPretty({
type: 'assistant/message',
data: { text: 'x', usage: { inputTokens: 5, input_tokens: 5 } },
})
const inputs = p.meta.filter((m) => m.label === 'input')
assert.equal(inputs.length, 1, 'the input label appears once even when two alias keys are present')
})
test('projectPretty: reasoning projects the full thinking text', () => {
const p = inspector.projectPretty({ type: 'reasoning', data: { text: 'step by step' } })
assert.equal(p.title, 'Reasoning')
assert.equal(p.blocks[0].label, 'thinking')
assert.equal(p.blocks[0].text, 'step by step')
})
test('projectPretty: tool call shows name in title, args + result blocks (mono)', () => {
const p = inspector.projectPretty({
type: 'tool/call',
data: {
name: 'bash', callId: 'c1',
arguments: { cmd: 'ls' },
result: { content: 'file.txt', isError: false, durationMs: 12 },
},
})
assert.equal(p.title, 'Tool call · bash')
assert.ok(p.meta.some((m) => m.label === 'callId' && m.value === 'c1'))
assert.ok(p.meta.some((m) => m.label === 'isError' && m.value === 'false'))
assert.ok(p.meta.some((m) => m.label === 'durationMs' && m.value === '12'))
const args = p.blocks.find((b) => b.label === 'arguments')
assert.ok(args.mono && /"cmd": "ls"/.test(args.text))
const res = p.blocks.find((b) => b.label === 'result')
assert.ok(res.mono && res.text === 'file.txt')
})
test('projectPretty: tool call with no result yet marks result pending', () => {
const p = inspector.projectPretty({ type: 'tool/call', data: { name: 'read', arguments: {} } })
assert.equal(p.blocks.find((b) => b.label === 'result').text, '(result pending)')
})
test('projectPretty: tool result projects content + isError', () => {
const p = inspector.projectPretty({
type: 'tool/result',
data: { callId: 'c2', isError: true, content: 'boom' },
})
assert.equal(p.title, 'Tool result')
assert.ok(p.meta.some((m) => m.label === 'isError' && m.value === 'true'))
assert.equal(p.blocks.find((b) => b.label === 'content').text, 'boom')
})
test('projectPretty: context injection shows source + payload', () => {
const p = inspector.projectPretty({
type: 'context/message',
data: { source: { kind: 'plugin', plugin: 'skill' }, content: [{ type: 'text', text: 'loaded' }] },
})
assert.equal(p.title, 'Context injection')
assert.equal(p.meta.find((m) => m.label === 'source').value, 'plugin:skill')
assert.equal(p.blocks.find((b) => b.label === 'payload').text, 'loaded')
})
test('projectPretty: steering message titled distinctly from context', () => {
const p = inspector.projectPretty({ type: 'steering/message', data: { content: [{ type: 'text', text: 'go left' }] } })
assert.equal(p.title, 'Steering message')
})
test('projectPretty: compact projects the summary text + phase', () => {
const p = inspector.projectPretty({ type: 'compact/summary', data: { summary: 'kept the gist' } })
assert.equal(p.title, 'Compaction')
assert.equal(p.meta.find((m) => m.label === 'phase').value, 'summary')
assert.equal(p.blocks.find((b) => b.label === 'summary').text, 'kept the gist')
})
test('projectPretty: subagent projects status/stopReason + last assistant message', () => {
const p = inspector.projectPretty({
type: 'subagent/finished',
data: {
agentId: 'a1', status: 'ok', stopReason: 'end_turn',
lastAssistantMessage: [{ type: 'text', text: 'done' }],
},
})
assert.equal(p.title, 'Subagent')
assert.ok(p.meta.some((m) => m.label === 'status' && m.value === 'ok'))
assert.ok(p.meta.some((m) => m.label === 'stopReason' && m.value === 'end_turn'))
assert.equal(p.blocks.find((b) => b.label === 'result').text, 'done')
})
// --- pure: formatRaw -------------------------------------------------------
test('formatRaw: verbatim event → header (seq/type/time) + pretty JSON, not reconstructed', () => {
const raw = inspector.formatRaw({ type: 'user/message', seq: 3, time: 1234, data: { text: 'hi' } })
assert.equal(raw.header.seq, 3)
assert.equal(raw.header.type, 'user/message')
assert.equal(raw.header.time, 1234)
assert.equal(raw.reconstructed, false)
assert.equal(raw.note, '')
assert.ok(/"text": "hi"/.test(raw.json))
})
test('formatRaw: reconstructed record is flagged + noted, and the marker is stripped from JSON', () => {
const raw = inspector.formatRaw({
type: 'tool/call', data: { name: 'bash' }, __reconstructed: true,
})
assert.equal(raw.reconstructed, true)
assert.match(raw.note, /reconstructed/)
assert.ok(!/__reconstructed/.test(raw.json), 'internal marker must not leak into the verbatim JSON')
})
test('formatRaw: missing seq/time degrade to null header fields', () => {
const raw = inspector.formatRaw({ type: 'event', data: {} })
assert.equal(raw.header.seq, null)
assert.equal(raw.header.time, null)
assert.equal(raw.header.type, 'event')
})
// --- DOM shim --------------------------------------------------------------
function makeShim() {
function make(tagName) {
const el = {
tagName: String(tagName).toUpperCase(),
children: [],
attrs: {},
style: {},
dataset: {},
hidden: false,
_listeners: {},
classList: {
_s: new Set(),
add(...names) { for (const n of names) this._s.add(n) },
remove(...names) { for (const n of names) this._s.delete(n) },
contains(n) { return this._s.has(n) },
toggle(n, force) {
const want = force === undefined ? !this._s.has(n) : !!force
if (want) this._s.add(n); else this._s.delete(n)
return want
},
},
_text: '',
get textContent() { return this._text },
set textContent(v) { this._text = String(v); this.children = [] },
set className(v) { this._className = String(v); this.classList._s = new Set(String(v).split(/\s+/).filter(Boolean)) },
get className() { return this._className || '' },
setAttribute(k, v) { this.attrs[k] = String(v) },
getAttribute(k) { return this.attrs[k] },
appendChild(c) { c.parentNode = this; this.children.push(c); return c },
append(...cs) { for (const c of cs) { c.parentNode = this; this.children.push(c) } },
addEventListener(k, fn) { (this._listeners[k] = this._listeners[k] || []).push(fn) },
dispatch(k, ev) { for (const fn of (this._listeners[k] || [])) fn(ev || {}) },
querySelector(sel) { return matchAll(this, sel)[0] || null },
querySelectorAll(sel) { const out = matchAll(this, sel); out.forEach = Array.prototype.forEach.bind(out); return out },
}
el.ownerDocument = null
return el
}
// Minimal selector matcher: '.class', '[data-panel="x"]', and
// '.class[data-panel="x"]' combined.
function matches(node, sel) {
if (!node || !node.tagName) return false
let rest = sel.trim()
// class
const classMatch = rest.match(/^\.([\w-]+)/)
if (classMatch) {
if (!node.classList.contains(classMatch[1])) return false
rest = rest.slice(classMatch[0].length)
}
// attribute [data-x="y"]
const attrMatch = rest.match(/^\[([\w-]+)="([^"]*)"\]/)
if (attrMatch) {
const key = attrMatch[1]
const want = attrMatch[2]
const got = key.startsWith('data-') ? node.dataset[dataKey(key)] : node.attrs[key]
if (String(got) !== want) return false
rest = rest.slice(attrMatch[0].length)
}
return rest.length === 0
}
function dataKey(attr) {
return attr.replace(/^data-/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase())
}
function matchAll(root, sel) {
const out = []
const stack = [...(root.children || [])]
while (stack.length) {
const n = stack.shift()
if (matches(n, sel)) out.push(n)
if (n && n.children) stack.push(...n.children)
}
return out
}
const doc = {
createElement: (t) => { const e = make(t); e.ownerDocument = doc; return e },
createTextNode: (t) => ({ nodeType: 3, textContent: String(t) }),
}
return { doc, make }
}
// --- DOM: renderPretty -----------------------------------------------------
test('renderPretty: writes title, meta chips, and block bodies into the host', () => {
const { doc } = makeShim()
const host = doc.createElement('div')
inspector.renderPretty(doc, host, {
title: 'Assistant message',
meta: [{ label: 'seq', value: '7' }, { label: 'output', value: '20' }],
blocks: [{ label: 'text', text: 'the answer' }],
})
const title = host.children.find((c) => c.className.includes('inspector-pretty-title'))
assert.equal(title.textContent, 'Assistant message')
const metaWrap = host.children.find((c) => c.className.includes('inspector-pretty-meta'))
assert.equal(metaWrap.children.length, 2)
const block = host.children.find((c) => c.tagName === 'SECTION')
assert.ok(block, 'a block section renders')
const body = block.children.find((c) => c.className.includes('inspector-pretty-block-body'))
assert.equal(body.textContent, 'the answer')
})
test('renderPretty: empty block text renders the "(empty)" placeholder', () => {
const { doc } = makeShim()
const host = doc.createElement('div')
inspector.renderPretty(doc, host, { title: 't', meta: [], blocks: [{ label: 'text', text: '' }] })
const body = host.querySelector('.inspector-pretty-block-body')
assert.equal(body.textContent, '(empty)')
})
// --- DOM: renderRaw --------------------------------------------------------
test('renderRaw: header line joins seq/type/time; <pre> holds the JSON; copy button present', () => {
const { doc } = makeShim()
const host = doc.createElement('div')
inspector.renderRaw(doc, host, inspector.formatRaw({ type: 'tool/call', seq: 9, time: 42, data: { name: 'bash' } }))
const label = host.querySelector('.inspector-raw-head-label')
assert.match(label.textContent, /seq 9/)
assert.match(label.textContent, /tool\/call/)
const pre = host.querySelector('.inspector-raw-pre')
assert.match(pre.textContent, /"name": "bash"/)
const copy = host.querySelector('.inspector-raw-copy')
assert.equal(copy.textContent, 'copy')
})
test('renderRaw: reconstructed record renders the "not a verbatim wire event" note', () => {
const { doc } = makeShim()
const host = doc.createElement('div')
inspector.renderRaw(doc, host, inspector.formatRaw({ type: 'reasoning', data: { text: 'x' }, __reconstructed: true }))
const note = host.querySelector('.inspector-raw-note')
assert.ok(note && /reconstructed/.test(note.textContent))
})
// --- DOM: renderJson (fields tree reuse) -----------------------------------
test('renderJson: falls back to a flat <pre> when the trace-detail fields tree is unavailable', () => {
const { doc } = makeShim()
const host = doc.createElement('div')
// No window.__dshTraceDetailPane in node → fallback path.
inspector.renderJson(doc, host, { type: 'user/message', data: { text: 'hi' }, __reconstructed: true })
const pre = host.querySelector('.inspector-raw-pre')
assert.ok(pre, 'fallback flat pre renders when buildJsonTree is absent')
assert.ok(!/__reconstructed/.test(pre.textContent), 'internal marker stripped in the JSON tab too')
})
// --- Static gates: index.html + style.css ---------------------------------
test('index.html: #inspector-drawer aside with three tabs + panels exists', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'index.html'), 'utf8')
assert.match(html, /id="inspector-drawer"/, 'the one drawer must exist so open()/close() can resolve it by id')
assert.match(html, /data-tab="pretty"/, 'Pretty tab button')
assert.match(html, /data-tab="raw"/, 'Raw tab button')
assert.match(html, /data-tab="json"/, 'JSON tab button')
assert.match(html, /data-panel="pretty"/, 'Pretty panel')
assert.match(html, /data-panel="raw"/, 'Raw panel')
assert.match(html, /data-panel="json"/, 'JSON panel')
assert.match(html, /id="inspector-drawer-close"/, 'close button target for the × / Escape bindings')
})
test('index.html: #stream-scroll-chip exists, hidden by default', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'index.html'), 'utf8')
assert.match(html, /id="stream-scroll-chip"[^>]*hidden/, 'the "back to bottom" chip starts hidden')
})
test('index.html: inspector-drawer.js is loaded AFTER trace-detail-pane.js (reuses its buildJsonTree)', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'index.html'), 'utf8')
const traceIdx = html.indexOf('trace-detail-pane.js')
const insIdx = html.indexOf('inspector-drawer.js')
assert.ok(traceIdx >= 0 && insIdx >= 0 && traceIdx < insIdx,
'inspector must load after trace-detail-pane so window.__dshTraceDetailPane.buildJsonTree is ready')
})
test('style.css: .inspector-drawer anchors right + slides via .open', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'style.css'), 'utf8')
const m = css.match(/\.inspector-drawer\s*\{[\s\S]+?\}/)
assert.ok(m, '.inspector-drawer rule missing')
assert.match(m[0], /right:\s*0/, 'drawer must anchor to the right edge')
assert.match(css, /\.inspector-drawer\.open\s*\{[^}]*translateX\(0\)/,
'.open must slide the drawer into view — open() adds this class')
assert.match(css, /\.stream-scroll-chip\[hidden\]\s*\{\s*display:\s*none/,
'the chip [hidden] attribute must collapse it')
})
// --- Drawer wiring: open() event anchoring + setTab() switching -----------
//
// open() / setTab() / renderActivePanel() guard on `typeof document`; we mint a
// global window+document with the same drawer scaffold index.html carries, load
// a FRESH copy of the module so its `isBrowser` closure sees them, then drive
// the state machine. This is the "event anchoring + tab switching" contract.
function buildDrawerDom(doc) {
const drawer = doc.createElement('aside')
drawer.id = 'inspector-drawer'
drawer.setAttribute('aria-hidden', 'true')
const title = doc.createElement('div'); title.className = 'inspector-drawer-title'; title.textContent = 'inspector'
drawer.appendChild(title)
for (const t of ['pretty', 'raw', 'json']) {
const tab = doc.createElement('button')
tab.className = 'inspector-tab' + (t === 'pretty' ? ' active' : '')
tab.dataset.tab = t
tab.setAttribute('aria-selected', t === 'pretty' ? 'true' : 'false')
drawer.appendChild(tab)
}
for (const p of ['pretty', 'raw', 'json']) {
const panel = doc.createElement('div')
panel.className = 'inspector-panel'
panel.dataset.panel = p
panel.hidden = p !== 'pretty'
drawer.appendChild(panel)
}
return drawer
}
function loadInspectorWithDom() {
const { doc } = makeShim()
const drawer = buildDrawerDom(doc)
const byId = { 'inspector-drawer': drawer }
doc.getElementById = (id) => byId[id] || null
doc.readyState = 'complete'
doc.addEventListener = () => {}
doc.removeEventListener = () => {}
const win = { document: doc }
global.window = win
global.document = doc
const p = require.resolve('../src/renderer/inspector-drawer.js')
delete require.cache[p]
const ins = require('../src/renderer/inspector-drawer.js')
return { ins, doc, drawer, byId }
}
function cleanupDom() {
delete global.window
delete global.document
const p = require.resolve('../src/renderer/inspector-drawer.js')
delete require.cache[p]
}
test('open(): anchors to the event, opens the drawer, renders the initial tab', () => {
const { ins, drawer } = loadInspectorWithDom()
try {
const ret = ins.open({ event: { type: 'user/message', seq: 2, data: { text: 'hi' } }, tab: 'pretty' })
assert.ok(ret, 'open returns the drawer node')
assert.equal(drawer.classList.contains('open'), true)
assert.equal(drawer.getAttribute('aria-hidden'), 'false')
const prettyPanel = drawer.querySelector('.inspector-panel[data-panel="pretty"]')
assert.equal(prettyPanel.hidden, false)
assert.ok(prettyPanel.children.length > 0, 'pretty panel got populated from the anchored event')
const title = drawer.querySelector('.inspector-drawer-title')
assert.equal(title.textContent, 'User message')
} finally { cleanupDom() }
})
test('open(): missing event is a no-op (returns null, drawer stays closed)', () => {
const { ins, drawer } = loadInspectorWithDom()
try {
assert.equal(ins.open({}), null)
assert.equal(drawer.classList.contains('open'), false)
} finally { cleanupDom() }
})
test('setTab(): switches active tab, toggles panel visibility + aria-selected', () => {
const { ins, drawer } = loadInspectorWithDom()
try {
ins.open({ event: { type: 'tool/call', data: { name: 'bash', arguments: { cmd: 'ls' } } }, tab: 'pretty' })
assert.equal(ins.setTab('raw'), 'raw')
const rawPanel = drawer.querySelector('.inspector-panel[data-panel="raw"]')
const prettyPanel = drawer.querySelector('.inspector-panel[data-panel="pretty"]')
assert.equal(rawPanel.hidden, false, 'raw panel shows')
assert.equal(prettyPanel.hidden, true, 'pretty panel hides')
const rawTab = drawer.querySelector('.inspector-tab[data-tab="raw"]')
assert.equal(rawTab.getAttribute('aria-selected'), 'true')
assert.equal(rawTab.classList.contains('active'), true)
assert.ok(rawPanel.querySelector('.inspector-raw-pre'), 'raw tab rendered its <pre>')
} finally { cleanupDom() }
})
test('setTab(): unknown tab name falls back to pretty', () => {
const { ins } = loadInspectorWithDom()
try {
ins.open({ event: { type: 'user/message', data: { text: 'x' } } })
assert.equal(ins.setTab('nope'), 'pretty')
} finally { cleanupDom() }
})
test('openFromDrawer(): call+result path reconstructs a combined tool/call record on the JSON tab', () => {
const { ins, drawer } = loadInspectorWithDom()
try {
ins.openFromDrawer({
title: 'tool: bash',
call: { callId: 'c9', name: 'bash', arguments: { cmd: 'ls' } },
result: { content: 'ok', isError: false },
})
assert.equal(drawer.classList.contains('open'), true)
// default tab for the drawer adapter is json
const jsonTab = drawer.querySelector('.inspector-tab[data-tab="json"]')
assert.equal(jsonTab.getAttribute('aria-selected'), 'true')
// switch to Raw and confirm the reconstructed note shows (combined record)
ins.setTab('raw')
const note = drawer.querySelector('.inspector-raw-note')
assert.ok(note && /reconstructed/.test(note.textContent),
'a combined call+result is labelled reconstructed, never sold as a verbatim wire event')
} finally { cleanupDom() }
})
test('openFromDrawer(): bare event path routes verbatim to the Raw tab', () => {
const { ins, drawer } = loadInspectorWithDom()
try {
ins.openFromDrawer({ title: 'user/message', event: { type: 'user/message', seq: 5, data: { text: 'hi' } }, tab: 'raw' })
const rawPanel = drawer.querySelector('.inspector-panel[data-panel="raw"]')
assert.equal(rawPanel.hidden, false)
assert.ok(!rawPanel.querySelector('.inspector-raw-note'), 'a verbatim event carries no reconstructed note')
} finally { cleanupDom() }
})
test('close(): removes the open class + marks aria-hidden', () => {
const { ins, drawer } = loadInspectorWithDom()
try {
ins.open({ event: { type: 'user/message', data: { text: 'x' } } })
ins.close()
assert.equal(drawer.classList.contains('open'), false)
assert.equal(drawer.getAttribute('aria-hidden'), 'true')
} finally { cleanupDom() }
})

View File

@@ -0,0 +1,109 @@
// Tests for lane-p0-inspector — src/renderer/stream-follow.js.
//
// The module is a pure "should we auto-follow the streaming tail?" controller
// with no DOM. We exercise the distance math, the near-bottom threshold, and
// the stateful controller's pin/detach/repin transitions directly — these are
// the exact decisions renderer.js's followStream()/onScroll wiring consumes.
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const sf = require('../src/renderer/stream-follow.js')
// --- distanceFromBottom / isNearBottom (pure math) ------------------------
test('distanceFromBottom: pinned to the very bottom reads 0', () => {
assert.equal(sf.distanceFromBottom({ scrollTop: 900, scrollHeight: 1000, clientHeight: 100 }), 0)
})
test('distanceFromBottom: scrolled up reports the gap', () => {
assert.equal(sf.distanceFromBottom({ scrollTop: 500, scrollHeight: 1000, clientHeight: 100 }), 400)
})
test('distanceFromBottom: clamps negative (sub-pixel rounding under zoom) to 0', () => {
assert.equal(sf.distanceFromBottom({ scrollTop: 950, scrollHeight: 1000, clientHeight: 100 }), 0)
})
test('distanceFromBottom: missing metrics → 0 (fail safe: treat as at-bottom)', () => {
assert.equal(sf.distanceFromBottom(null), 0)
})
test('isNearBottom: within threshold is near; beyond is not', () => {
const m = (top) => ({ scrollTop: top, scrollHeight: 1000, clientHeight: 100 })
assert.equal(sf.isNearBottom(m(870), 40), true, '30px from bottom ≤ 40px threshold')
assert.equal(sf.isNearBottom(m(859), 40), false, '41px from bottom > 40px threshold')
})
test('isNearBottom: default threshold is 40px', () => {
assert.equal(sf.DEFAULT_THRESHOLD_PX, 40)
const m = (top) => ({ scrollTop: top, scrollHeight: 1000, clientHeight: 100 })
assert.equal(sf.isNearBottom(m(861)), true) // 39px
assert.equal(sf.isNearBottom(m(855)), false) // 45px
})
// --- createFollowController state machine ---------------------------------
test('controller: starts pinned by default', () => {
const c = sf.createFollowController()
assert.equal(c.isPinned(), true)
})
test('controller: startPinned:false begins detached', () => {
const c = sf.createFollowController({ startPinned: false })
assert.equal(c.isPinned(), false)
})
test('controller: onContent while pinned → follow, no chip', () => {
const c = sf.createFollowController()
const r = c.onContent()
assert.deepEqual(r, { follow: true, showChip: false })
})
test('controller: scroll up detaches; next content does NOT follow and shows chip', () => {
const c = sf.createFollowController({ threshold: 40 })
// reader scrolls up 400px
const s = c.onScroll({ scrollTop: 500, scrollHeight: 1000, clientHeight: 100 })
assert.equal(s.pinned, false)
assert.equal(s.showChip, false, 'no new content yet, so no chip on the scroll itself')
// new streamed content lands while detached
const r = c.onContent()
assert.equal(r.follow, false, 'must NOT yank a reader who scrolled up')
assert.equal(r.showChip, true, 'chip appears because content arrived while detached')
})
test('controller: onScroll back to bottom clears detached-content flag + chip', () => {
const c = sf.createFollowController({ threshold: 40 })
c.onScroll({ scrollTop: 500, scrollHeight: 1000, clientHeight: 100 }) // detach
c.onContent() // detachedWithNew = true
const back = c.onScroll({ scrollTop: 900, scrollHeight: 1000, clientHeight: 100 }) // re-pin
assert.equal(back.pinned, true)
assert.equal(back.showChip, false, 'returning to bottom hides the chip')
})
test('controller: a scroll event while detached AND with pending content re-shows the chip', () => {
const c = sf.createFollowController({ threshold: 40 })
c.onScroll({ scrollTop: 500, scrollHeight: 1000, clientHeight: 100 }) // detach
c.onContent() // detachedWithNew = true
// reader nudges but stays detached (still 400px up)
const s = c.onScroll({ scrollTop: 480, scrollHeight: 1000, clientHeight: 100 })
assert.equal(s.pinned, false)
assert.equal(s.showChip, true, 'still detached with pending content → chip stays')
})
test('controller: repin() forces pinned + clears chip (chip click / deliberate jump)', () => {
const c = sf.createFollowController({ threshold: 40 })
c.onScroll({ scrollTop: 500, scrollHeight: 1000, clientHeight: 100 })
c.onContent()
const r = c.repin()
assert.deepEqual(r, { pinned: true, showChip: false })
assert.equal(c.isPinned(), true)
// after repin, content follows again
assert.equal(c.onContent().follow, true)
})
test('controller: threshold is exposed for the wiring to mirror', () => {
assert.equal(sf.createFollowController({ threshold: 25 }).threshold, 25)
assert.equal(sf.createFollowController().threshold, 40)
})