feat(desktop): unified right-side Inspector (Pretty/Raw/JSON) + at-bottom auto-follow fix
This commit is contained in:
@@ -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
|
||||
|
||||
BIN
examples/desktop/docs/qa-p0-inspector/01-pretty-assistant.png
Normal file
BIN
examples/desktop/docs/qa-p0-inspector/01-pretty-assistant.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
BIN
examples/desktop/docs/qa-p0-inspector/02-raw-tool.png
Normal file
BIN
examples/desktop/docs/qa-p0-inspector/02-raw-tool.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
BIN
examples/desktop/docs/qa-p0-inspector/03-json-tree.png
Normal file
BIN
examples/desktop/docs/qa-p0-inspector/03-json-tree.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 209 KiB |
BIN
examples/desktop/docs/qa-p0-inspector/04-scroll-chip.png
Normal file
BIN
examples/desktop/docs/qa-p0-inspector/04-scroll-chip.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
342
examples/desktop/scripts/qa-cdp-shoot-p0-inspector.mjs
Normal file
342
examples/desktop/scripts/qa-cdp-shoot-p0-inspector.mjs
Normal 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) })
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
498
examples/desktop/src/renderer/inspector-drawer.js
Normal file
498
examples/desktop/src/renderer/inspector-drawer.js
Normal 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
|
||||
})()
|
||||
@@ -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) ---
|
||||
|
||||
93
examples/desktop/src/renderer/stream-follow.js
Normal file
93
examples/desktop/src/renderer/stream-follow.js
Normal 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
|
||||
})()
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
514
examples/desktop/test/inspector-drawer.test.js
Normal file
514
examples/desktop/test/inspector-drawer.test.js
Normal 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() }
|
||||
})
|
||||
109
examples/desktop/test/stream-follow.test.js
Normal file
109
examples/desktop/test/stream-follow.test.js
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user