feat(desktop): five-way chat view strip (List|Graph|时序|Trace|Log) + Tracing page demotes to nav
This commit is contained in:
14 files changed
+1718
-24
No files matched your search
@@ -115,6 +115,19 @@ per-session (switching sessions shows that session's queue), survives a
|
||||
cancelled turn, and is cleared with a notice if the runtime restarts or you
|
||||
switch profiles.
|
||||
|
||||
A view strip at the top of the pane offers five reads of the **active**
|
||||
session, all sharing the composer + status bar: **List** (the transcript
|
||||
above), **Graph** (a turn DAG with fork/interrupt edges), **时序** (a
|
||||
full-pane step timeline — the trace waterfall over the whole session),
|
||||
**Trace** (the Tree / Timeline / Graph tri-view over the session's aggregate),
|
||||
and **Log** (the session's *complete* event history — a `session/events`
|
||||
replay merged with live events, not the global 500-entry devtools ring). The
|
||||
Log carries type-filter chips + a text search box (the same grammar as the
|
||||
Devtools panel) and expands each `seq · type · summary` row to its payload,
|
||||
with a `{ }` badge that opens the unified inspector anchored to that event;
|
||||
large sessions page in by seq. Every turn's footer still has its own inline
|
||||
trace drawer for reading a single turn in place.
|
||||
|
||||

|
||||
|
||||
#### Session Tree
|
||||
@@ -146,11 +159,12 @@ outcomes) and honestly labels the rest.
|
||||
The project-wide runs table — every session across every profile in a
|
||||
single eight-column aggregate: **Name / Most Recent Run / Trace Count /
|
||||
Error Rate / P50 / P99 / Total Tokens / Total Cost**. Clicking a row
|
||||
opens a tri-view drawer (Tree / Timeline / Graph — see Feature
|
||||
highlights) that recursively unfolds a single session's event tree,
|
||||
LLM/tool timing spans, and callgraph. Meant for the "which of my 200
|
||||
sessions actually cost me tokens this afternoon" question, backed by
|
||||
the same `session/list` projection the Chat sidebar reads.
|
||||
navigates to the Chat pane and opens that session's **Trace** tab (the
|
||||
Tree / Timeline / Graph tri-view — see Feature highlights), so the table
|
||||
stays a pure scanning surface and the per-session drill has one home. Meant
|
||||
for the "which of my 200 sessions actually cost me tokens this afternoon"
|
||||
question, backed by the same `session/list` projection the Chat sidebar
|
||||
reads.
|
||||
|
||||
### Iteration
|
||||
|
||||
@@ -309,8 +323,9 @@ becomes a visualiser of the runtime's actual state.
|
||||
reprojections of the same event stream. Tree is the recursive event
|
||||
hierarchy (turn → tool call → sub-events). Timeline is a Gantt-style
|
||||
span chart with LLM latency and tool latency on separate lanes.
|
||||
Graph is a callgraph over `parentSession` + subagent edges. Wired
|
||||
from the Tracing table and from any assistant bubble's ⋯ menu.
|
||||
Graph is a callgraph over `parentSession` + subagent edges. Reachable
|
||||
full-pane from the Chat pane's **Trace** tab (and via a Tracing-table
|
||||
row click), per-turn from any turn footer's inline trace drawer.
|
||||
- **Recursive collapsible Fields tree.** Every event payload — no
|
||||
matter how deep — renders as a folder-tree of `field: value` rows
|
||||
you can twist open one level at a time. No "click to expand JSON in
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,262 @@
|
||||
// scripts/qa-cdp-shoot-p1-tabs.mjs — lane-p1-tabs selfie driver.
|
||||
//
|
||||
// Proves the Chat pane's five-way view strip (List | Graph | 时序 | Trace |
|
||||
// Log) end-to-end against a REAL renderer: it boots an isolated Electron,
|
||||
// injects a mixed trace fixture through the DSH_QA=1 seam (__dshOnSessionEvent
|
||||
// → onSessionEvent → cachedEvents), then drives the real tab buttons and
|
||||
// shoots each view. Also drills a Tracing-page row to prove it lands on the
|
||||
// Chat pane's Trace tab.
|
||||
//
|
||||
// Four shots into docs/qa-p1-tabs/:
|
||||
// p1-01-timeline 时序 tab — full-pane Gantt with span rows
|
||||
// p1-02-trace Trace tab — tri-view (Graph default) over the session
|
||||
// p1-03-log Log tab — filter chips + a row expanded
|
||||
// p1-04-tracing-nav Tracing row click landed on the Chat Trace tab
|
||||
//
|
||||
// Isolation follows scripts/qa-cdp-shoot-nav-optional.mjs:
|
||||
// --user-data-dir=<tmp> isolates Chromium userdata
|
||||
// DSH_DESKTOP_HOME=<tmp> isolates the main-process config root
|
||||
// DSH_QA=1 installs the injection seams (renderer.js §2626)
|
||||
// CDP port ≥9300 per the task brief. Electron binary comes from the PARENT
|
||||
// repo (this worktree has no node_modules).
|
||||
//
|
||||
// Usage: node scripts/qa-cdp-shoot-p1-tabs.mjs [port]
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, writeFileSync, rmSync, statSync, readFileSync } 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 PORT = Number(process.argv[2] || process.env.DSH_P1_TABS_PORT || 9312)
|
||||
const OUTDIR = join(WORKTREE, 'docs/qa-p1-tabs')
|
||||
const FIXTURE = 'fixtures/trace-samples/2.1-turn-trajectory-mixed.json'
|
||||
|
||||
if (!existsSync(ELECTRON)) {
|
||||
console.error(`electron binary not found at ${ELECTRON}`)
|
||||
process.exit(2)
|
||||
}
|
||||
mkdirSync(OUTDIR, { recursive: true })
|
||||
|
||||
function seedHome(dshHome) {
|
||||
const seedOverlay = [
|
||||
'# QA p1-tabs-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', // installs __dshOnSessionEvent + __dshRendererState seams
|
||||
},
|
||||
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 = 30000) => 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, close: () => ws.close() }
|
||||
}
|
||||
|
||||
async function shoot(c, name) {
|
||||
// Hide the debug panel + any right-column overlay so the view fills frame.
|
||||
await c.evj(`(function(){
|
||||
const p = document.querySelector('.debug-panel'); if (p) p.style.display='none'
|
||||
for (const sel of ['#context-rail-drawer','#context-rail','.devtools-drawer','#devtools-panel']) {
|
||||
const n = document.querySelector(sel); if (n) { n.hidden = true; n.style.display='none' }
|
||||
}
|
||||
return 1
|
||||
})()`)
|
||||
const shot = await c.call('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
clip: { x: 0, y: 0, width: 1440, height: 900, scale: 1 },
|
||||
})
|
||||
const path = join(OUTDIR, `${name}.png`)
|
||||
writeFileSync(path, Buffer.from(shot.data, 'base64'))
|
||||
const size = statSync(path).size
|
||||
console.log(` wrote ${path} (${size} bytes)`)
|
||||
if (size < 20000) console.error(` WARN: ${name}.png is ${size} bytes (<20KB) — likely blank`)
|
||||
return { path, size }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dshHome = join(tmpdir(), 'dsh-p1-tabs-home')
|
||||
const userData = join(tmpdir(), 'dsh-p1-tabs-userdata')
|
||||
for (const dir of [dshHome, userData]) {
|
||||
try { rmSync(dir, { recursive: true, force: true }) } catch {}
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
seedHome(dshHome)
|
||||
console.log(`booting on port ${PORT}`)
|
||||
const { child } = await bootElectron(dshHome, userData, PORT)
|
||||
const results = []
|
||||
try {
|
||||
await sleep(1500)
|
||||
const c = await newCdp(PORT)
|
||||
await c.call('Page.enable')
|
||||
await c.evj(`window.dshQa && window.dshQa.revealWindow && window.dshQa.revealWindow()`)
|
||||
await c.call('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false })
|
||||
// Land on the Chat pane and dismiss onboarding.
|
||||
await c.evj(`window.__dshTabs && window.__dshTabs.switchTo('chat')`)
|
||||
await c.evj(`(function(){
|
||||
document.body.classList.add('onboarded')
|
||||
const ob = document.querySelector('#onboarding, .onboarding, [data-onboarding]'); if (ob) ob.remove()
|
||||
return 1
|
||||
})()`)
|
||||
await sleep(200)
|
||||
|
||||
// Inject the fixture through the real dispatcher so cachedEvents fills
|
||||
// (drives the trace aggregate + the Log live-merge) on a stable sid.
|
||||
const fixtureJson = readFileSync(join(WORKTREE, FIXTURE), 'utf8')
|
||||
const injected = await c.evj(`(async () => {
|
||||
const sid = 'p1-tabs-shot'
|
||||
const events = ${fixtureJson}
|
||||
const dispatch = window.__dshOnSessionEvent
|
||||
if (typeof dispatch !== 'function') return { err: 'no __dshOnSessionEvent seam (DSH_QA not set?)' }
|
||||
const s = document.getElementById('stream'); if (s) s.innerHTML = ''
|
||||
for (const ev of events) dispatch(sid, ev)
|
||||
// Make it the active session for the view refreshers.
|
||||
if (window.__dshRendererState) window.__dshRendererState.activeSessionId = sid
|
||||
return { sid, count: events.length }
|
||||
})()`)
|
||||
console.log('inject ->', JSON.stringify(injected))
|
||||
|
||||
// ── p1-01: 时序 (Timeline) tab ──────────────────────────────────────
|
||||
const tl = await c.evj(`(function(){
|
||||
window.__dshRenderer.setChatView('timeline')
|
||||
const el = document.getElementById('chat-session-timeline')
|
||||
return { view: window.__dshRenderer.getChatView(), rows: el ? el.querySelectorAll('.trace-timeline-row').length : -1 }
|
||||
})()`)
|
||||
console.log('[p1-01 timeline]', JSON.stringify(tl))
|
||||
await sleep(400)
|
||||
results.push(await shoot(c, 'p1-01-timeline'))
|
||||
|
||||
// ── p1-02: Trace tab ────────────────────────────────────────────────
|
||||
const tr = await c.evj(`(function(){
|
||||
window.__dshRenderer.setChatView('trace')
|
||||
const el = document.getElementById('chat-session-trace')
|
||||
return { view: window.__dshRenderer.getChatView(), tri: el ? el.querySelectorAll('.trace-tri-view').length : -1 }
|
||||
})()`)
|
||||
console.log('[p1-02 trace]', JSON.stringify(tr))
|
||||
await sleep(400)
|
||||
results.push(await shoot(c, 'p1-02-trace'))
|
||||
|
||||
// ── p1-03: Log tab — chips visible + a row expanded ─────────────────
|
||||
const lg = await c.evj(`(async () => {
|
||||
window.__dshRenderer.setChatView('log')
|
||||
const el = document.getElementById('chat-session-log')
|
||||
// Log history walks window.dsh.sessionEvents; give it a beat, then the
|
||||
// fixture events are already merged as live entries via cachedEvents
|
||||
// replay. Expand the first row so the payload preview shows.
|
||||
await new Promise(r => setTimeout(r, 350))
|
||||
const rows = el ? el.querySelectorAll('.session-log-row') : []
|
||||
const chips = el ? el.querySelectorAll('.session-log-chip').length : -1
|
||||
if (rows.length) rows[0].open = true, rows[0].dispatchEvent(new Event('toggle'))
|
||||
return { view: window.__dshRenderer.getChatView(), rows: rows.length, chips }
|
||||
})()`)
|
||||
console.log('[p1-03 log]', JSON.stringify(lg))
|
||||
await sleep(400)
|
||||
results.push(await shoot(c, 'p1-03-log'))
|
||||
|
||||
// ── p1-04: Tracing page row click lands on the Chat Trace tab ───────
|
||||
// Switch to the Tracing page, click the first session row, and assert
|
||||
// we bounced back to the Chat pane's Trace tab.
|
||||
const nav = await c.evj(`(async () => {
|
||||
window.__dshTabs.switchTo('tracing')
|
||||
if (window.__dshTracingPage && window.__dshTracingPage.show) window.__dshTracingPage.show()
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
const row = document.querySelector('.tracing-page-row')
|
||||
if (!row) return { err: 'no tracing row (no non-empty sessions projected)' }
|
||||
row.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
const chatPane = document.querySelector('.pane[data-pane="chat"]')
|
||||
return {
|
||||
chatVisible: chatPane ? !chatPane.hidden : false,
|
||||
chatView: window.__dshRenderer.getChatView(),
|
||||
}
|
||||
})()`)
|
||||
console.log('[p1-04 tracing-nav]', JSON.stringify(nav))
|
||||
await sleep(300)
|
||||
results.push(await shoot(c, 'p1-04-tracing-nav'))
|
||||
|
||||
await c.call('Emulation.clearDeviceMetricsOverride').catch(() => {})
|
||||
c.close()
|
||||
} finally {
|
||||
try { child.kill('SIGKILL') } catch {}
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await sleep(500)
|
||||
try { await fetch(`http://localhost:${PORT}/json/list`) } catch { break }
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n--- SUMMARY ---')
|
||||
let ok = true
|
||||
for (const r of results) {
|
||||
console.log(` ${r.path}: ${r.size} bytes${r.size < 20000 ? ' <-- WARN <20KB' : ''}`)
|
||||
if (r.size < 20000) ok = false
|
||||
}
|
||||
if (!ok) { console.error('one or more shots are suspiciously small'); process.exit(4) }
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error(err); process.exit(1) })
|
||||
@@ -393,15 +393,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- feat/chat-triple-view: view switcher tabs (List | Graph).
|
||||
The `data-chat-view` attribute on the parent .pane[data-pane="chat"]
|
||||
swaps which child (stream vs graph container) is visible. Default
|
||||
is "list" so first-paint stays identical to previous versions. -->
|
||||
<!-- feat/chat-triple-view + lane-p1-tabs: view switcher tabs
|
||||
(List | Graph | 时序 | Trace | Log). The `data-chat-view`
|
||||
attribute on the parent .pane[data-pane="chat"] swaps which child
|
||||
(stream / graph / timeline / trace / log container) is visible.
|
||||
Default is "list" so first-paint stays identical to before.
|
||||
时序 / Trace reuse the trace tri-view modules (trace-timeline.js,
|
||||
trace-tri-view.js) full-pane over the ACTIVE session's events; Log
|
||||
is a full-history replay (session-log-view.js) — see renderer.js
|
||||
setChatView + the refreshSession{Timeline,Trace,Log} helpers. -->
|
||||
<div class="chat-view-tabs" role="tablist" aria-label="Chat view">
|
||||
<button class="chat-view-tab active" data-chat-view-tab="list"
|
||||
role="tab" aria-selected="true" type="button">List</button>
|
||||
<button class="chat-view-tab" data-chat-view-tab="graph"
|
||||
role="tab" aria-selected="false" type="button">Graph</button>
|
||||
<button class="chat-view-tab" data-chat-view-tab="timeline"
|
||||
role="tab" aria-selected="false" type="button">时序</button>
|
||||
<button class="chat-view-tab" data-chat-view-tab="trace"
|
||||
role="tab" aria-selected="false" type="button">Trace</button>
|
||||
<button class="chat-view-tab" data-chat-view-tab="log"
|
||||
role="tab" aria-selected="false" type="button">Log</button>
|
||||
</div>
|
||||
<section id="stream" class="stream" aria-live="polite">
|
||||
<!-- Fresh-eyes P0 (2026-07-18): the empty-welcome block used to
|
||||
@@ -618,6 +629,31 @@
|
||||
aria-label="Session graph">
|
||||
<div class="chat-session-graph-empty">Switch to Graph to see this session's turn DAG.</div>
|
||||
</div>
|
||||
<!-- lane-p1-tabs: full-pane 时序 (Timeline) mount. Painted by
|
||||
renderer.js refreshSessionTimeline() via trace-timeline.js over
|
||||
the active session's aggregate step records. Shown only for
|
||||
[data-chat-view="timeline"]. -->
|
||||
<div class="chat-session-timeline" id="chat-session-timeline" role="region"
|
||||
aria-label="Session timeline">
|
||||
<div class="chat-session-view-empty">Switch to 时序 to see this session's step timeline.</div>
|
||||
</div>
|
||||
<!-- lane-p1-tabs: full-pane Trace mount. Painted by renderer.js
|
||||
refreshSessionTrace() via the trace tri-view module (Tree / Graph
|
||||
sub-chips) over the active session's aggregate. Shown only for
|
||||
[data-chat-view="trace"]. -->
|
||||
<div class="chat-session-trace" id="chat-session-trace" role="region"
|
||||
aria-label="Session trace">
|
||||
<div class="chat-session-view-empty">Switch to Trace to see this session's step tree.</div>
|
||||
</div>
|
||||
<!-- lane-p1-tabs: full-pane Log mount. Owned by session-log-view.js —
|
||||
a full-history replay (window.dsh.sessionEvents) of the active
|
||||
session merged with live events, with type-filter chips + text
|
||||
search + per-row { } inspector badge. Shown only for
|
||||
[data-chat-view="log"]. -->
|
||||
<div class="chat-session-log" id="chat-session-log" role="region"
|
||||
aria-label="Session log">
|
||||
<div class="chat-session-view-empty">Switch to Log to replay this session's full event history.</div>
|
||||
</div>
|
||||
<!-- feat/chat-triple-view: right-side detail drawer. Rendered by
|
||||
chat-side-drawer.js on toggle. `.hidden` class collapses; the
|
||||
#chat-side-drawer-btn button in the header flips it. -->
|
||||
@@ -1541,6 +1577,7 @@
|
||||
<script src="./assistant-turn.js"></script><!-- task #162 rec 22-bis: assistant-turn container (consumes the three above) -->
|
||||
<script src="./chat-side-drawer.js"></script><!-- feat/chat-triple-view: right-side turn/session detail drawer -->
|
||||
<script src="./chat-session-graph.js"></script><!-- feat/chat-triple-view: session DAG (turn nodes + fork/interrupt edges) -->
|
||||
<script src="./session-log-view.js"></script><!-- lane-p1-tabs: full-history Log view (sessionEvents replay + live merge + filter/inspector) -->
|
||||
<script src="./chat-refresh-throttle.js"></script><!-- fix/code-bugs-batch P1-3: rAF-coalesced throttle for drawer/graph refresh -->
|
||||
|
||||
<script src="./event-filter.js"></script>
|
||||
|
||||
@@ -602,6 +602,14 @@ async function selectSession(id) {
|
||||
// Per-session queue: repaint the strip for the session we just switched to.
|
||||
// Strict isolation — the strip only ever shows the active session's queue.
|
||||
renderMsgQueueStrip()
|
||||
// lane-p1-tabs: re-point whichever alternate Chat view is on-screen at the
|
||||
// session we just switched to. The graph path already refreshed via
|
||||
// refreshSessionGraphIfActive during replay ticks, but timeline/trace/log
|
||||
// key off the active session and must rebuild on switch even when no live
|
||||
// event follows. setChatView is cheap and idempotent for the current view.
|
||||
if (chatPaneEl && chatPaneEl.dataset.chatView && chatPaneEl.dataset.chatView !== 'list') {
|
||||
setChatView(chatPaneEl.dataset.chatView)
|
||||
}
|
||||
}
|
||||
|
||||
async function replayHistory(id) {
|
||||
@@ -2605,6 +2613,24 @@ function deepLinkToSeq(seq) {
|
||||
}
|
||||
if (typeof window !== 'undefined') window.__dshDeepLinkToSeq = deepLinkToSeq
|
||||
|
||||
// lane-p1-tabs: cross-page bridge for the Tracing-page demotion. Drilling a
|
||||
// row on the Tracing page no longer swaps the table for inline tri-view
|
||||
// panels — it navigates to the Chat pane, selects that session, and opens the
|
||||
// session-scoped Trace tab (one call). This is the single navigation seam
|
||||
// tracing-page.openDrill calls; it keeps all session-switch bookkeeping
|
||||
// (replay, meter, queue) in renderer.js rather than duplicating it there.
|
||||
async function openSessionTrace(sessionId) {
|
||||
if (!sessionId) return
|
||||
try {
|
||||
if (window.__dshTabs && typeof window.__dshTabs.switchTo === 'function') {
|
||||
window.__dshTabs.switchTo('chat')
|
||||
}
|
||||
await selectSession(sessionId)
|
||||
setChatView('trace')
|
||||
} catch (_) { /* stale session id / offline — swallow, nav is best-effort */ }
|
||||
}
|
||||
if (typeof window !== 'undefined') window.__dshOpenSessionTrace = openSessionTrace
|
||||
|
||||
// expose a direct-dispatch seam so the tri-view CDP shoot driver
|
||||
// can play fixtures without booting a daemon (offline env where tsx
|
||||
// resolution fails at daemon spawn). Sets state.activeSessionId + streamEl
|
||||
@@ -4615,6 +4641,12 @@ const chatSideDrawerEl = document.getElementById('chat-side-drawer')
|
||||
const chatSideDrawerBodyEl = document.getElementById('chat-side-drawer-body')
|
||||
const chatSideDrawerCloseBtn = document.getElementById('chat-side-drawer-close')
|
||||
const chatSessionGraphEl = document.getElementById('chat-session-graph')
|
||||
// lane-p1-tabs: full-pane 时序 / Trace / Log mounts. Timeline + Trace reuse
|
||||
// the trace tri-view modules over the active session's aggregate; Log is
|
||||
// owned by session-log-view.js (full-history replay + live merge).
|
||||
const chatSessionTimelineEl = document.getElementById('chat-session-timeline')
|
||||
const chatSessionTraceEl = document.getElementById('chat-session-trace')
|
||||
const chatSessionLogEl = document.getElementById('chat-session-log')
|
||||
const chatViewTabEls = document.querySelectorAll('.chat-view-tab')
|
||||
|
||||
// Default the pane to List. The absence of the attribute would leave the
|
||||
@@ -4691,9 +4723,14 @@ if (chatSideDrawerCloseBtn) {
|
||||
chatSideDrawerCloseBtn.addEventListener('click', () => setChatDrawerOpen(false))
|
||||
}
|
||||
|
||||
// lane-p1-tabs: the Chat pane view strip is now five-way —
|
||||
// list | graph | timeline | trace | log — all scoped to the ACTIVE session.
|
||||
// list/graph keep their prior behavior; timeline/trace mount the trace
|
||||
// tri-view modules full-pane; log mounts session-log-view.js.
|
||||
const CHAT_VIEWS = ['list', 'graph', 'timeline', 'trace', 'log']
|
||||
function setChatView(view) {
|
||||
if (!chatPaneEl) return
|
||||
const v = view === 'graph' ? 'graph' : 'list'
|
||||
const v = CHAT_VIEWS.includes(view) ? view : 'list'
|
||||
chatPaneEl.dataset.chatView = v
|
||||
for (const btn of chatViewTabEls) {
|
||||
const active = btn.dataset.chatViewTab === v
|
||||
@@ -4701,6 +4738,91 @@ function setChatView(view) {
|
||||
btn.setAttribute('aria-selected', active ? 'true' : 'false')
|
||||
}
|
||||
if (v === 'graph') refreshSessionGraph()
|
||||
else if (v === 'timeline') refreshSessionTimeline()
|
||||
else if (v === 'trace') refreshSessionTrace()
|
||||
else if (v === 'log') refreshSessionLog()
|
||||
}
|
||||
// Aggregate the active session's cached events into trace step-records — the
|
||||
// same derivation the per-turn footer tri-view and the Tracing-page drill use
|
||||
// (trace-tri-view.sessionTraceRecords → trace-aggregator.aggregateSteps).
|
||||
function activeSessionTraceRecords() {
|
||||
const Tri = window.__dshTraceTriView
|
||||
if (!Tri || typeof Tri.sessionTraceRecords !== 'function') return []
|
||||
const meta = state.activeSessionId ? state.sessions.get(state.activeSessionId) : null
|
||||
const events = (meta && Array.isArray(meta.cachedEvents)) ? meta.cachedEvents : []
|
||||
return Tri.sessionTraceRecords(events)
|
||||
}
|
||||
function activeSessionHeader() {
|
||||
const meta = state.activeSessionId ? state.sessions.get(state.activeSessionId) : null
|
||||
return (meta && meta.header) ? meta.header : null
|
||||
}
|
||||
// 时序 tab: mount the tri-view's Timeline projection full-pane for the active
|
||||
// session. Rebuilt on every entry/session-switch/tick so a live turn extends
|
||||
// the Gantt as steps close. Reuses trace-timeline.js via renderTimeline.
|
||||
function refreshSessionTimeline() {
|
||||
if (!chatSessionTimelineEl) return
|
||||
const T = window.__dshTraceTimeline
|
||||
chatSessionTimelineEl.textContent = ''
|
||||
const records = activeSessionTraceRecords()
|
||||
if (!T || typeof T.renderTimeline !== 'function' || records.length === 0) {
|
||||
const empty = document.createElement('div')
|
||||
empty.className = 'chat-session-view-empty'
|
||||
empty.textContent = records.length === 0
|
||||
? 'No steps to plot yet. Send a message on this session.'
|
||||
: 'trace-timeline.js not loaded.'
|
||||
chatSessionTimelineEl.appendChild(empty)
|
||||
return
|
||||
}
|
||||
const el = T.renderTimeline(document, records, {
|
||||
width: 860,
|
||||
onSeqClick: (seq) => deepLinkToSeq(seq),
|
||||
})
|
||||
chatSessionTimelineEl.appendChild(el)
|
||||
}
|
||||
// Trace tab: mount the tri-view full-pane (Tree | Timeline | Graph sub-chips)
|
||||
// for the active session, defaulting to the Tree projection. Reuses
|
||||
// trace-tri-view.buildTriView — no duplicated view code. The session-scope
|
||||
// tri-view omits a pre-rendered tree card (that belongs to a single turn's
|
||||
// footer), so Tree falls through to its session-scope stub while Timeline /
|
||||
// Graph render from the aggregate; this is the same shape the Tracing-page
|
||||
// drill used before its demotion.
|
||||
function refreshSessionTrace() {
|
||||
if (!chatSessionTraceEl) return
|
||||
const Tri = window.__dshTraceTriView
|
||||
chatSessionTraceEl.textContent = ''
|
||||
const records = activeSessionTraceRecords()
|
||||
if (!Tri || typeof Tri.buildTriView !== 'function' || records.length === 0) {
|
||||
const empty = document.createElement('div')
|
||||
empty.className = 'chat-session-view-empty'
|
||||
empty.textContent = records.length === 0
|
||||
? 'No trace steps yet. Send a message on this session.'
|
||||
: 'trace-tri-view.js not loaded.'
|
||||
chatSessionTraceEl.appendChild(empty)
|
||||
return
|
||||
}
|
||||
const tri = Tri.buildTriView(document, {
|
||||
records,
|
||||
scope: 'session',
|
||||
defaultView: 'graph',
|
||||
sessionId: state.activeSessionId || null,
|
||||
sessionHeader: activeSessionHeader(),
|
||||
onSeqClick: (seq) => deepLinkToSeq(seq),
|
||||
})
|
||||
chatSessionTraceEl.appendChild(tri)
|
||||
}
|
||||
// Log tab: full-history replay of the active session, owned by
|
||||
// session-log-view.js. Points the log at the active session (re-runs the
|
||||
// sessionEvents window walk); live events merge in via refreshSessionLogLive.
|
||||
function refreshSessionLog() {
|
||||
if (!chatSessionLogEl) return
|
||||
const L = window.__dshSessionLogView
|
||||
if (!L || typeof L.renderSessionLog !== 'function') return
|
||||
// Seed from the in-memory cache so the log paints immediately even for a
|
||||
// live-only session the daemon hasn't persisted; the sessionEvents walk
|
||||
// supersedes it when the wire has more.
|
||||
const meta = state.activeSessionId ? state.sessions.get(state.activeSessionId) : null
|
||||
const seedEvents = (meta && Array.isArray(meta.cachedEvents)) ? meta.cachedEvents : []
|
||||
L.renderSessionLog(chatSessionLogEl, { sessionId: state.activeSessionId || null, seedEvents })
|
||||
}
|
||||
function refreshSessionGraph() {
|
||||
if (!chatSessionGraphEl) return
|
||||
@@ -4742,7 +4864,22 @@ function refreshSessionGraph() {
|
||||
})
|
||||
}
|
||||
function refreshSessionGraphIfActive() {
|
||||
if (chatPaneEl && chatPaneEl.dataset.chatView === 'graph') refreshSessionGraph()
|
||||
// lane-p1-tabs: keep whichever alternate view is on-screen live. Timeline
|
||||
// and Trace re-derive from the aggregate on each tick; Log merges the one
|
||||
// live event that just arrived (cheaper than a full history re-walk).
|
||||
const view = chatPaneEl && chatPaneEl.dataset.chatView
|
||||
if (view === 'graph') refreshSessionGraph()
|
||||
else if (view === 'timeline') refreshSessionTimeline()
|
||||
else if (view === 'trace') refreshSessionTrace()
|
||||
}
|
||||
// Merge one just-arrived live event into an open Log view. Called from
|
||||
// onSessionEvent's coalesced surface refresh with the raw event so the log
|
||||
// tails without re-walking history. No-op unless the Log tab is on-screen.
|
||||
function refreshSessionLogLive(sessionId, event) {
|
||||
if (!chatPaneEl || chatPaneEl.dataset.chatView !== 'log') return
|
||||
const L = window.__dshSessionLogView
|
||||
if (!L || typeof L.ingestLiveEvent !== 'function' || !chatSessionLogEl) return
|
||||
L.ingestLiveEvent(chatSessionLogEl, sessionId, event)
|
||||
}
|
||||
for (const btn of chatViewTabEls) {
|
||||
btn.addEventListener('click', () => setChatView(btn.dataset.chatViewTab))
|
||||
@@ -5107,6 +5244,10 @@ function onSessionEvent(sessionId, event) {
|
||||
// switched to Graph yet. Coalesced via rAF so long sessions don't take
|
||||
// an O(N²) hit from the O(N) derives.
|
||||
refreshChatSurfacesCoalesced()
|
||||
// lane-p1-tabs: the Log view needs the specific event (the coalesced rAF
|
||||
// refresh above re-derives from cache and can't carry it), so merge it
|
||||
// directly here. No-op unless the Log tab is on-screen for this session.
|
||||
refreshSessionLogLive(sessionId, event)
|
||||
|
||||
// §2.3 (batch 6) template triggers: pure module decides whether the event
|
||||
// qualifies for a template card (T2 error recovery / T4 artifact preview /
|
||||
@@ -8597,6 +8738,12 @@ window.__dshRenderer = {
|
||||
compactNow,
|
||||
confirmDialog,
|
||||
notifyDialog,
|
||||
// lane-p1-tabs: expose the Chat-pane view switcher + the session-scoped
|
||||
// Trace navigation bridge so unit tests + QA can drive the five-way strip
|
||||
// (list | graph | timeline | trace | log) without synthetic click events.
|
||||
setChatView,
|
||||
openSessionTrace,
|
||||
getChatView: () => (chatPaneEl ? chatPaneEl.dataset.chatView : null),
|
||||
// Batch 6 (§2.2): expose steer-card injector so demo drivers can drop a
|
||||
// non-blocking steer card into the active session without a real
|
||||
// session/interrupt round-trip.
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
// session-log-view.js — lane-p1-tabs.
|
||||
//
|
||||
// The Chat pane's Log tab: a full-HISTORY event log for the ACTIVE session,
|
||||
// distinct from the global devtools ring buffer (500-entry, cross-session).
|
||||
// It replays the session's complete event stream through
|
||||
// `window.dsh.sessionEvents(sessionId)` — the same paginated window walk
|
||||
// renderer.js uses for chat replay — then merges live events arriving while
|
||||
// the tab is open so the log tails in real time.
|
||||
//
|
||||
// UI grammar is borrowed from devtools-panel.js so the two logs feel like
|
||||
// one family:
|
||||
// - type-filter chips (one per distinct event type in the log)
|
||||
// - a text search box (matches type / seq / pretty JSON)
|
||||
// - each row is `seq · type · summary`, expandable to a payload preview,
|
||||
// with a `{ }` badge that opens window.__dshInspector anchored to that
|
||||
// event.
|
||||
//
|
||||
// Filtering reuses DevtoolsModel.filterEntries (pure, unit-tested) so the
|
||||
// AND-composition of chips + search matches the devtools panel exactly. The
|
||||
// entry shape ({ id, seq, type, time, event }) mirrors DevtoolsModel's
|
||||
// normalizeEntry so the shared filter works verbatim.
|
||||
//
|
||||
// Large sessions: the log renders lazily. It holds the full entry list in
|
||||
// memory (bounded by whatever the daemon window-walk returned) but only
|
||||
// paints `PAGE` rows at a time, with a "Load more" affordance that reveals
|
||||
// the next page by seq. This keeps first paint cheap on a 5k-event session
|
||||
// without a virtual scroller.
|
||||
//
|
||||
// Pure helpers (normalizeLogEntry / mergeLiveEntry / distinctTypes /
|
||||
// pageSlice) run under node --test with no DOM; the controller (mount /
|
||||
// renderInto / open) needs a document.
|
||||
|
||||
'use strict'
|
||||
|
||||
;(function () {
|
||||
// Rows painted per page. A session with thousands of events still first-
|
||||
// paints one page; "Load more" reveals the next PAGE by ascending seq.
|
||||
const PAGE = 200
|
||||
|
||||
// ─── pure helpers ──────────────────────────────────────────────────────
|
||||
|
||||
// Normalize a raw wire event into the entry shape the filter + row
|
||||
// renderer consume. Mirrors DevtoolsModel.normalizeEntry's field set
|
||||
// ({ id, time, sessionId, type, seq, event }) so DevtoolsModel.filterEntries
|
||||
// works on our entries unchanged. `id` here is the seq when present (stable
|
||||
// across re-render and dedup) falling back to a monotonic counter the
|
||||
// caller supplies.
|
||||
function normalizeLogEntry(event, fallbackId) {
|
||||
const ev = (event && typeof event === 'object') ? event : {}
|
||||
const type = (typeof ev.type === 'string' && ev.type) ? ev.type : '(unknown)'
|
||||
const seq = Number.isFinite(ev.seq) ? ev.seq : null
|
||||
const time = Number.isFinite(ev.time) ? ev.time : null
|
||||
const id = seq !== null ? seq : fallbackId
|
||||
return { id, seq, type, time, sessionId: '', event: ev }
|
||||
}
|
||||
|
||||
// Merge a live entry into an existing (seq-sorted) list, deduping by seq.
|
||||
// An event with no seq always appends (can't dedup a seq-less event). An
|
||||
// event whose seq already exists replaces the prior copy in place (the
|
||||
// daemon may re-emit a fuller payload for the same seq during a live turn).
|
||||
// Returns the same array reference for caller convenience.
|
||||
function mergeLiveEntry(entries, entry) {
|
||||
if (!Array.isArray(entries) || !entry) return entries || []
|
||||
if (entry.seq === null || entry.seq === undefined) {
|
||||
entries.push(entry)
|
||||
return entries
|
||||
}
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if (entries[i] && entries[i].seq === entry.seq) {
|
||||
entries[i] = entry
|
||||
return entries
|
||||
}
|
||||
}
|
||||
// Insert keeping ascending-seq order. Most live events land at the tail,
|
||||
// so scan from the end.
|
||||
let i = entries.length - 1
|
||||
while (i >= 0 && entries[i] && Number.isFinite(entries[i].seq) && entries[i].seq > entry.seq) i--
|
||||
entries.splice(i + 1, 0, entry)
|
||||
return entries
|
||||
}
|
||||
|
||||
// Distinct event types across the entry list, sorted, for the chip row.
|
||||
// Same contract as DevtoolsModel.collectTypes.
|
||||
function distinctTypes(entries) {
|
||||
const s = new Set()
|
||||
for (const e of entries) if (e && e.type) s.add(e.type)
|
||||
return Array.from(s).sort()
|
||||
}
|
||||
|
||||
// One-line summary for a row. Prefers a human field on the payload
|
||||
// (text / content / summary / name / stopReason), falling back to the
|
||||
// trace aggregator's trimSummary when loaded, then a bare type echo.
|
||||
function summarizeEntry(entry) {
|
||||
const ev = entry && entry.event ? entry.event : {}
|
||||
const data = (ev.data && typeof ev.data === 'object') ? ev.data : ev
|
||||
let raw = ''
|
||||
if (typeof data.text === 'string') raw = data.text
|
||||
else if (typeof data.content === 'string') raw = data.content
|
||||
else if (Array.isArray(data.content)) {
|
||||
raw = data.content.map((c) => (c && typeof c.text === 'string') ? c.text : '').join(' ')
|
||||
} else if (typeof data.summary === 'string') raw = data.summary
|
||||
else if (typeof data.name === 'string') raw = data.name
|
||||
else if (typeof data.stopReason === 'string' || typeof data.stop_reason === 'string') {
|
||||
raw = data.stopReason || data.stop_reason
|
||||
} else if (typeof data.delta === 'string') raw = data.delta
|
||||
raw = String(raw || '').replace(/\s+/g, ' ').trim()
|
||||
if (!raw) return ''
|
||||
return raw.length > 80 ? raw.slice(0, 79) + '…' : raw
|
||||
}
|
||||
|
||||
// Slice the filtered list to the first `count` rows (lazy paging). Returns
|
||||
// { rows, hasMore, total }. `count` is clamped to at least PAGE.
|
||||
function pageSlice(filtered, count) {
|
||||
const total = filtered.length
|
||||
const shown = Math.min(total, Math.max(PAGE, count || PAGE))
|
||||
return { rows: filtered.slice(0, shown), hasMore: shown < total, total, shown }
|
||||
}
|
||||
|
||||
// ─── controller (DOM) ────────────────────────────────────────────────────
|
||||
|
||||
// Per-container controller state, keyed off the container element so a
|
||||
// remount reuses the same instance.
|
||||
const controllers = new WeakMap()
|
||||
|
||||
function makeController(container) {
|
||||
const doc = container.ownerDocument
|
||||
|| (typeof window !== 'undefined' && window.document)
|
||||
|| (typeof document !== 'undefined' ? document : null)
|
||||
const state = {
|
||||
sessionId: null,
|
||||
entries: [], // full seq-sorted entry list
|
||||
fallbackId: -1, // decreasing counter for seq-less events
|
||||
typeFilter: new Set(),// active chip types; empty = all
|
||||
text: '',
|
||||
pageCount: PAGE,
|
||||
// element handles, built once
|
||||
chipsEl: null,
|
||||
searchEl: null,
|
||||
listEl: null,
|
||||
countEl: null,
|
||||
moreBtn: null,
|
||||
}
|
||||
|
||||
function nextFallbackId() { state.fallbackId -= 1; return state.fallbackId }
|
||||
|
||||
// Build the static shell (search row + chips row + list + footer). Called
|
||||
// once; subsequent renders only repaint chips/list.
|
||||
function buildShell() {
|
||||
container.textContent = ''
|
||||
const head = doc.createElement('div')
|
||||
head.className = 'session-log-head'
|
||||
|
||||
const search = doc.createElement('input')
|
||||
search.type = 'search'
|
||||
search.className = 'session-log-search'
|
||||
search.placeholder = 'Search type / seq / payload…'
|
||||
search.setAttribute('aria-label', 'Search session log')
|
||||
search.addEventListener('input', function () {
|
||||
state.text = search.value || ''
|
||||
state.pageCount = PAGE
|
||||
renderList()
|
||||
})
|
||||
state.searchEl = search
|
||||
|
||||
const count = doc.createElement('span')
|
||||
count.className = 'session-log-count muted'
|
||||
state.countEl = count
|
||||
|
||||
head.appendChild(search)
|
||||
head.appendChild(count)
|
||||
|
||||
const chips = doc.createElement('div')
|
||||
chips.className = 'session-log-chips'
|
||||
chips.setAttribute('role', 'group')
|
||||
chips.setAttribute('aria-label', 'Filter by event type')
|
||||
state.chipsEl = chips
|
||||
|
||||
const list = doc.createElement('div')
|
||||
list.className = 'session-log-list'
|
||||
list.setAttribute('role', 'log')
|
||||
state.listEl = list
|
||||
|
||||
const more = doc.createElement('button')
|
||||
more.type = 'button'
|
||||
more.className = 'session-log-more ghost small'
|
||||
more.textContent = 'Load more'
|
||||
more.hidden = true
|
||||
more.addEventListener('click', function () {
|
||||
state.pageCount += PAGE
|
||||
renderList()
|
||||
})
|
||||
state.moreBtn = more
|
||||
|
||||
container.appendChild(head)
|
||||
container.appendChild(chips)
|
||||
container.appendChild(list)
|
||||
container.appendChild(more)
|
||||
}
|
||||
|
||||
function toggleType(t) {
|
||||
if (state.typeFilter.has(t)) state.typeFilter.delete(t)
|
||||
else state.typeFilter.add(t)
|
||||
state.pageCount = PAGE
|
||||
renderChips()
|
||||
renderList()
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
if (!state.chipsEl) return
|
||||
state.chipsEl.textContent = ''
|
||||
const types = distinctTypes(state.entries)
|
||||
if (types.length === 0) {
|
||||
const empty = doc.createElement('span')
|
||||
empty.className = 'session-log-chips-empty muted'
|
||||
empty.textContent = 'no events yet'
|
||||
state.chipsEl.appendChild(empty)
|
||||
return
|
||||
}
|
||||
for (const t of types) {
|
||||
const chip = doc.createElement('button')
|
||||
chip.type = 'button'
|
||||
chip.className = 'session-log-chip' + (state.typeFilter.has(t) ? ' active' : '')
|
||||
chip.dataset.type = t
|
||||
chip.textContent = t
|
||||
chip.addEventListener('click', function () { toggleType(t) })
|
||||
state.chipsEl.appendChild(chip)
|
||||
}
|
||||
}
|
||||
|
||||
function filtered() {
|
||||
const M = (typeof window !== 'undefined' && window.DevtoolsModel) || null
|
||||
if (M && typeof M.filterEntries === 'function') {
|
||||
return M.filterEntries(state.entries, { types: state.typeFilter, text: state.text })
|
||||
}
|
||||
// Fallback (module not loaded — lean test env): type set + substring.
|
||||
const q = String(state.text || '').trim().toLowerCase()
|
||||
const typeSet = state.typeFilter.size > 0 ? state.typeFilter : null
|
||||
return state.entries.filter(function (e) {
|
||||
if (typeSet && !typeSet.has(e.type)) return false
|
||||
if (q) {
|
||||
const hay = (String(e.type) + ' ' + String(e.seq) + ' ' + JSON.stringify(e.event || {})).toLowerCase()
|
||||
if (!hay.includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
if (!state.listEl) return
|
||||
state.listEl.textContent = ''
|
||||
const rows = filtered()
|
||||
const { rows: page, hasMore, total, shown } = pageSlice(rows, state.pageCount)
|
||||
for (const entry of page) {
|
||||
state.listEl.appendChild(buildRow(entry))
|
||||
}
|
||||
if (state.countEl) {
|
||||
state.countEl.textContent = total === state.entries.length
|
||||
? `${total} events`
|
||||
: `${total} / ${state.entries.length} events`
|
||||
}
|
||||
if (state.moreBtn) {
|
||||
state.moreBtn.hidden = !hasMore
|
||||
state.moreBtn.textContent = hasMore ? `Load more (${total - shown} hidden)` : 'Load more'
|
||||
}
|
||||
if (page.length === 0) {
|
||||
const empty = doc.createElement('div')
|
||||
empty.className = 'session-log-empty muted'
|
||||
empty.textContent = state.entries.length === 0
|
||||
? 'No events in this session yet.'
|
||||
: 'No events match the current filter.'
|
||||
state.listEl.appendChild(empty)
|
||||
}
|
||||
}
|
||||
|
||||
function buildRow(entry) {
|
||||
const row = doc.createElement('details')
|
||||
row.className = 'session-log-row'
|
||||
if (entry.seq !== null && entry.seq !== undefined) row.dataset.seq = String(entry.seq)
|
||||
row.dataset.type = entry.type
|
||||
|
||||
const summary = doc.createElement('summary')
|
||||
summary.className = 'session-log-row-summary'
|
||||
|
||||
const seqEl = doc.createElement('span')
|
||||
seqEl.className = 'session-log-seq mono'
|
||||
seqEl.textContent = entry.seq !== null && entry.seq !== undefined ? String(entry.seq) : '—'
|
||||
|
||||
const typeEl = doc.createElement('span')
|
||||
typeEl.className = 'session-log-type mono'
|
||||
typeEl.textContent = entry.type
|
||||
|
||||
const sumEl = doc.createElement('span')
|
||||
sumEl.className = 'session-log-summary'
|
||||
sumEl.textContent = summarizeEntry(entry)
|
||||
|
||||
summary.appendChild(seqEl)
|
||||
summary.appendChild(typeEl)
|
||||
summary.appendChild(sumEl)
|
||||
|
||||
// { } inspector badge — opens the unified inspector anchored to this
|
||||
// event. attachInspectBadge resolves the target at click time, so we
|
||||
// hand it a closure returning { event }. Falls back to a bare button
|
||||
// wired to open() when attachInspectBadge is unavailable.
|
||||
const insp = (typeof window !== 'undefined' && window.__dshInspector) || null
|
||||
if (insp && typeof insp.attachInspectBadge === 'function') {
|
||||
insp.attachInspectBadge(summary, function () {
|
||||
return { event: entry.event, tab: 'pretty', title: `seq ${entry.seq} · ${entry.type}` }
|
||||
})
|
||||
} else if (insp && typeof insp.open === 'function') {
|
||||
const badge = doc.createElement('button')
|
||||
badge.type = 'button'
|
||||
badge.className = 'inspect-badge'
|
||||
badge.textContent = '{ }'
|
||||
badge.title = 'Inspect · Pretty / Raw / JSON'
|
||||
badge.addEventListener('click', function (e) {
|
||||
if (e && e.stopPropagation) e.stopPropagation()
|
||||
if (e && e.preventDefault) e.preventDefault()
|
||||
insp.open({ event: entry.event, tab: 'pretty', title: `seq ${entry.seq} · ${entry.type}` })
|
||||
})
|
||||
summary.appendChild(badge)
|
||||
}
|
||||
|
||||
row.appendChild(summary)
|
||||
|
||||
// Expanded body: a pretty-printed payload preview. Built lazily on
|
||||
// first toggle so a filter over thousands of rows doesn't pay the
|
||||
// JSON.stringify cost up front.
|
||||
const body = doc.createElement('div')
|
||||
body.className = 'session-log-row-body'
|
||||
let filled = false
|
||||
row.addEventListener('toggle', function () {
|
||||
if (row.open && !filled) {
|
||||
filled = true
|
||||
const pre = doc.createElement('pre')
|
||||
pre.className = 'session-log-payload mono'
|
||||
pre.textContent = formatPayload(entry.event)
|
||||
body.appendChild(pre)
|
||||
}
|
||||
})
|
||||
row.appendChild(body)
|
||||
return row
|
||||
}
|
||||
|
||||
// ─── data lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
// Replay the session's full history through the sessionEvents window
|
||||
// walk, then paint. Bounded by the daemon's window cap (same walk as
|
||||
// renderer.js replayHistory). `seedEvents` is the in-memory cache the
|
||||
// caller already holds (state.sessions[sid].cachedEvents): we paint it
|
||||
// immediately so the log isn't blank while the walk runs, and keep
|
||||
// whichever source ends up with more entries — mirroring replayHistory's
|
||||
// "more events wins" rule so a live-only session (daemon hasn't persisted
|
||||
// it yet) still shows its full history.
|
||||
async function loadHistory(sessionId, seedEvents) {
|
||||
state.fallbackId = -1
|
||||
const seed = Array.isArray(seedEvents) ? seedEvents : []
|
||||
state.entries = seed.map((ev) => normalizeLogEntry(ev, nextFallbackId()))
|
||||
renderChips()
|
||||
renderList()
|
||||
const bridge = (typeof window !== 'undefined' && window.dsh && window.dsh.sessionEvents)
|
||||
? window.dsh.sessionEvents
|
||||
: null
|
||||
if (!bridge) return
|
||||
let listing
|
||||
try { listing = await bridge(sessionId, {}) }
|
||||
catch (_) { return }
|
||||
if (state.sessionId !== sessionId) return // switched away mid-fetch
|
||||
if (!listing || !Array.isArray(listing.events) || listing.events.length === 0) return
|
||||
const WINDOW = 50
|
||||
const total = listing.events.length
|
||||
const maxRounds = Math.ceil(total / WINDOW) + 2
|
||||
const collected = []
|
||||
const seen = new Set()
|
||||
let cursor = listing.events[total - 1].seq
|
||||
let rounds = 0
|
||||
let progressed = true
|
||||
while (cursor >= 0 && rounds < maxRounds && progressed) {
|
||||
rounds++
|
||||
progressed = false
|
||||
let chunk
|
||||
try { chunk = await bridge(sessionId, { seq: cursor, before: WINDOW, after: 0 }) }
|
||||
catch (_) { break }
|
||||
if (state.sessionId !== sessionId) return
|
||||
if (!chunk || !Array.isArray(chunk.events) || chunk.events.length === 0) break
|
||||
const beforeSize = collected.length
|
||||
for (const ev of chunk.events) {
|
||||
if (typeof ev.seq !== 'number' || seen.has(ev.seq)) continue
|
||||
seen.add(ev.seq)
|
||||
collected.push(ev)
|
||||
}
|
||||
if (collected.length > beforeSize) progressed = true
|
||||
if (collected.length >= total) break
|
||||
const nextStart = typeof chunk.startSeq === 'number' ? chunk.startSeq : chunk.events[0].seq
|
||||
if (nextStart <= 0) break
|
||||
const nextCursor = nextStart - 1
|
||||
if (nextCursor >= cursor) break
|
||||
cursor = nextCursor
|
||||
}
|
||||
collected.sort((a, b) => (a.seq || 0) - (b.seq || 0))
|
||||
// "More events wins" (replayHistory parity): keep the seed when it has
|
||||
// at least as many entries as the wire walk, so a live-only session
|
||||
// isn't blanked by a daemon that returns nothing. Preserve any live
|
||||
// events that merged into the seed while the walk was in flight.
|
||||
if (collected.length > state.entries.length) {
|
||||
state.entries = collected.map((ev) => normalizeLogEntry(ev, nextFallbackId()))
|
||||
renderChips()
|
||||
renderList()
|
||||
}
|
||||
}
|
||||
|
||||
// Point the log at a session: rebuild shell if needed, kick history load.
|
||||
// `seedEvents` is the caller's in-memory cache for immediate paint.
|
||||
function setSession(sessionId, seedEvents) {
|
||||
if (!state.chipsEl) buildShell()
|
||||
state.sessionId = sessionId || null
|
||||
state.pageCount = PAGE
|
||||
if (!sessionId) {
|
||||
state.entries = []
|
||||
renderChips(); renderList()
|
||||
return
|
||||
}
|
||||
void loadHistory(sessionId, seedEvents)
|
||||
}
|
||||
|
||||
// A live event landed for a session. Merge it if it belongs to the
|
||||
// session we're showing; ignore otherwise. Repaints coalesced by the
|
||||
// caller (renderer.js already rAF-throttles chat surface refreshes).
|
||||
function onLiveEvent(sessionId, event) {
|
||||
if (!sessionId || sessionId !== state.sessionId) return
|
||||
const entry = normalizeLogEntry(event, nextFallbackId())
|
||||
mergeLiveEntry(state.entries, entry)
|
||||
// A brand-new type means the chip row grew.
|
||||
renderChips()
|
||||
renderList()
|
||||
}
|
||||
|
||||
return { setSession, onLiveEvent, _state: state }
|
||||
}
|
||||
|
||||
function formatPayload(event) {
|
||||
// Reuse the inspector/devtools JSON formatter when present so the payload
|
||||
// preview matches the { } drawer; fall back to a guarded stringify.
|
||||
const M = (typeof window !== 'undefined' && window.DevtoolsModel) || null
|
||||
if (M && typeof M.formatJSON === 'function') return M.formatJSON(event)
|
||||
try { return JSON.stringify(event, null, 2) }
|
||||
catch (_) { return String(event) }
|
||||
}
|
||||
|
||||
// Get (or lazily create) the controller bound to a container element.
|
||||
function controllerFor(container) {
|
||||
if (!container) return null
|
||||
let c = controllers.get(container)
|
||||
if (!c) { c = makeController(container); controllers.set(container, c) }
|
||||
return c
|
||||
}
|
||||
|
||||
// ─── public API ──────────────────────────────────────────────────────
|
||||
|
||||
// renderSessionLog(container, { sessionId, seedEvents }) — (re)point the
|
||||
// Log view at a session. Idempotent per container; a session switch re-runs
|
||||
// the history replay. `seedEvents` (the caller's in-memory cache) paints
|
||||
// immediately so the log isn't blank while the wire walk runs. Safe to call
|
||||
// when sessionId is falsy (renders the empty state).
|
||||
function renderSessionLog(container, opts) {
|
||||
const c = controllerFor(container)
|
||||
if (!c) return
|
||||
const o = opts || {}
|
||||
c.setSession(o.sessionId || null, o.seedEvents || null)
|
||||
}
|
||||
|
||||
// ingestLiveEvent(container, sessionId, event) — merge a live event into an
|
||||
// open Log view. No-op when the event is for another session.
|
||||
function ingestLiveEvent(container, sessionId, event) {
|
||||
const c = controllers.get(container)
|
||||
if (!c) return
|
||||
c.onLiveEvent(sessionId, event)
|
||||
}
|
||||
|
||||
const api = {
|
||||
// pure
|
||||
normalizeLogEntry, mergeLiveEntry, distinctTypes, summarizeEntry, pageSlice,
|
||||
// controller
|
||||
renderSessionLog, ingestLiveEvent,
|
||||
PAGE,
|
||||
}
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api
|
||||
if (typeof window !== 'undefined') window.__dshSessionLogView = api
|
||||
})()
|
||||
@@ -12533,11 +12533,154 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
/* Stream shows only for [data-chat-view="list"], graph only for
|
||||
* [data-chat-view="graph"]. The pane is the parent that carries the
|
||||
* data attribute so a single toggle switches both children. */
|
||||
.pane[data-pane="chat"][data-chat-view="graph"] .stream { display: none; }
|
||||
.pane[data-pane="chat"][data-chat-view="list"] .chat-session-graph { display: none; }
|
||||
/* Stream shows only for [data-chat-view="list"]; each alternate view shows
|
||||
* only when its own data-chat-view value is active. The pane is the parent
|
||||
* that carries the data attribute so a single toggle switches all children.
|
||||
* lane-p1-tabs expanded this from a 2-way (list/graph) toggle to 5 views. */
|
||||
.chat-session-timeline,
|
||||
.chat-session-trace,
|
||||
.chat-session-log { display: none; }
|
||||
.pane[data-pane="chat"][data-chat-view="graph"] .stream { display: none; }
|
||||
.pane[data-pane="chat"][data-chat-view="timeline"] .stream { display: none; }
|
||||
.pane[data-pane="chat"][data-chat-view="trace"] .stream { display: none; }
|
||||
.pane[data-pane="chat"][data-chat-view="log"] .stream { display: none; }
|
||||
.pane[data-pane="chat"]:not([data-chat-view="graph"]) .chat-session-graph { display: none; }
|
||||
.pane[data-pane="chat"][data-chat-view="timeline"] .chat-session-timeline { display: block; }
|
||||
.pane[data-pane="chat"][data-chat-view="trace"] .chat-session-trace { display: flex; }
|
||||
.pane[data-pane="chat"][data-chat-view="log"] .chat-session-log { display: flex; }
|
||||
|
||||
/* lane-p1-tabs: 时序 / Trace / Log full-pane containers share the graph's
|
||||
* scroll+padding shell. Trace + Log are flex-column so their inner toolbar
|
||||
* pins while the body scrolls. */
|
||||
.chat-session-timeline {
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
.chat-session-trace,
|
||||
.chat-session-log {
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.chat-session-view-empty {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.chat-session-trace .trace-tri-view { padding: 12px 20px; overflow: auto; min-height: 0; }
|
||||
|
||||
/* lane-p1-tabs: Log view (session-log-view.js). Header (search + count),
|
||||
* chip row, scrolling list of expandable rows, footer "Load more". Grammar
|
||||
* mirrors the devtools panel so the two logs read as one family. */
|
||||
.session-log-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.session-log-search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
padding: 5px 9px;
|
||||
}
|
||||
.session-log-count { font-size: 12px; white-space: nowrap; }
|
||||
.session-log-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
padding: 8px 20px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
flex: 0 0 auto;
|
||||
max-height: 92px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.session-log-chips-empty { font-size: 12px; }
|
||||
.session-log-chip {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
padding: 2px 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.session-log-chip:hover { color: var(--text); }
|
||||
.session-log-chip.active {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.session-log-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
padding: 6px 12px 20px;
|
||||
}
|
||||
.session-log-row {
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
.session-log-row-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.session-log-row-summary::-webkit-details-marker { display: none; }
|
||||
.session-log-seq {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
min-width: 44px;
|
||||
text-align: right;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.session-log-type {
|
||||
color: var(--accent);
|
||||
font-size: 11.5px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.session-log-summary {
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.session-log-row-body { padding: 0 8px 8px 62px; }
|
||||
.session-log-payload {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
color: var(--text);
|
||||
font-size: 11.5px;
|
||||
margin: 0;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
padding: 8px 10px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.session-log-empty { font-size: 12.5px; padding: 24px 8px; text-align: center; }
|
||||
.session-log-more {
|
||||
align-self: center;
|
||||
margin: 8px auto 16px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Give the pane a positioning context so the absolute drawer anchors
|
||||
* inside it, not against the viewport root. */
|
||||
|
||||
@@ -17,10 +17,14 @@
|
||||
// - Columns menu: checkbox per column, persisted to localStorage under
|
||||
// `dsh.tracing.columns.v1`. New columns land visible by default so a
|
||||
// later release doesn't come up mysteriously narrow for old users.
|
||||
// - Row click: pulls the session's cachedEvents through
|
||||
// __dshTraceTriView.sessionTraceRecords(), swaps the table for the
|
||||
// tri-view panels (Timeline / Graph default; Tree stub notes per-turn
|
||||
// scope), and shows a breadcrumb Back to the table.
|
||||
// - Row click: navigates to the Chat pane and opens that session's Trace
|
||||
// tab (lane-p1-tabs demotion — window.__dshOpenSessionTrace). The
|
||||
// eight-column cross-session table is the Tracing page's whole job; the
|
||||
// per-session tri-view now lives on the Chat pane's Trace tab, so a drill
|
||||
// is a one-call navigation rather than an inline table swap. The former
|
||||
// inline drill (breadcrumb + #tracing-page-detail tri-view) is retained
|
||||
// only as a fallback for when the Chat-pane bridge is unavailable (lean
|
||||
// test env); see openDrill.
|
||||
//
|
||||
// Layer contract per docs/design-refs/density-layering-spec.md §7:
|
||||
// - Numeric columns right-align with `tabular-nums` (see style.css).
|
||||
@@ -330,7 +334,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Row click / rubric-cell-jump entry point. lane-p1-tabs demotion: prefer
|
||||
// the Chat-pane Trace tab (window.__dshOpenSessionTrace) so a drill is a
|
||||
// single navigation and the per-session tri-view has one home. Falls back
|
||||
// to the legacy inline tri-view (openDrillInline) only when the bridge is
|
||||
// absent — e.g. a lean unit-test env that mounts tracing-page.js without
|
||||
// the full renderer. `name` is unused on the nav path (the Chat pane owns
|
||||
// its own title) but kept for the inline fallback signature.
|
||||
function openDrill (sessionId, name) {
|
||||
if (!sessionId) return
|
||||
if (typeof window.__dshOpenSessionTrace === 'function') {
|
||||
try { void window.__dshOpenSessionTrace(sessionId) } catch (_) { /* nav best-effort */ }
|
||||
return
|
||||
}
|
||||
openDrillInline(sessionId, name)
|
||||
}
|
||||
|
||||
// Legacy inline drill — swaps the table for a session-scoped tri-view with
|
||||
// a Back breadcrumb. Retained as the no-bridge fallback (see openDrill).
|
||||
// Fully reachable only when window.__dshOpenSessionTrace is undefined; in
|
||||
// the shipped app the Chat-pane Trace tab supersedes it.
|
||||
function openDrillInline (sessionId, name) {
|
||||
if (!els || !sessionId) return
|
||||
const Chat = window.__dshChat
|
||||
const Tri = window.__dshTraceTriView
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Tests for lane-p1-tabs — Chat-pane view strip expansion + Tracing-page
|
||||
// demotion. Covers:
|
||||
// - index.html declares the five-way view strip (List | Graph | 时序 |
|
||||
// Trace | Log) and the three new mount containers + script tag.
|
||||
// - style.css hides/show the right container per data-chat-view value.
|
||||
// - renderer.js setChatView flips data-chat-view + aria-selected across all
|
||||
// five views and only shows one at a time.
|
||||
// - the session-scoped Trace tab renders the tri-view over the active
|
||||
// session's aggregate.
|
||||
// - tracing-page.openDrill navigates (window.__dshOpenSessionTrace) rather
|
||||
// than swapping the table inline.
|
||||
// - the Chat-pane openSessionTrace bridge switches tab + selects session +
|
||||
// opens the Trace view in one call.
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { loadRenderer } = require('./renderer-harness.js')
|
||||
|
||||
const RENDERER = path.join(__dirname, '..', 'src', 'renderer')
|
||||
|
||||
// renderer.js captures `chatPaneEl = document.querySelector('.pane[data-pane
|
||||
// ="chat"]')` and the `.chat-view-tab` buttons at module-eval, so the harness
|
||||
// body must carry that scaffold before renderer.js runs. The preboot hook
|
||||
// fires before renderer.js; seed a chat pane + the five tab buttons + the
|
||||
// view mounts so setChatView / getChatView resolve real nodes.
|
||||
function seedChatPane(win) {
|
||||
const doc = win.document
|
||||
const pane = doc.createElement('section')
|
||||
pane.className = 'pane'
|
||||
pane.dataset.pane = 'chat'
|
||||
const tabs = doc.createElement('div')
|
||||
tabs.className = 'chat-view-tabs'
|
||||
for (const v of ['list', 'graph', 'timeline', 'trace', 'log']) {
|
||||
const btn = doc.createElement('button')
|
||||
btn.className = 'chat-view-tab' + (v === 'list' ? ' active' : '')
|
||||
btn.dataset.chatViewTab = v
|
||||
tabs.appendChild(btn)
|
||||
}
|
||||
pane.appendChild(tabs)
|
||||
doc.body.appendChild(pane)
|
||||
}
|
||||
|
||||
function bootWithChatPane() {
|
||||
return loadRenderer({}, { preboot: (win) => seedChatPane(win) })
|
||||
}
|
||||
|
||||
// ─── static gates: index.html + style.css ───────────────────────────────────
|
||||
|
||||
test('index.html declares the five-way Chat view strip', () => {
|
||||
const html = fs.readFileSync(path.join(RENDERER, 'index.html'), 'utf8')
|
||||
for (const v of ['list', 'graph', 'timeline', 'trace', 'log']) {
|
||||
assert.match(html, new RegExp(`data-chat-view-tab="${v}"`), `missing view tab ${v}`)
|
||||
}
|
||||
// The 时序 tab label is the CN string per the deliverable.
|
||||
assert.match(html, /data-chat-view-tab="timeline"[^>]*>时序</, '时序 tab must carry the CN label')
|
||||
})
|
||||
|
||||
test('index.html mounts the three new view containers + log script', () => {
|
||||
const html = fs.readFileSync(path.join(RENDERER, 'index.html'), 'utf8')
|
||||
assert.match(html, /id="chat-session-timeline"/, 'timeline mount missing')
|
||||
assert.match(html, /id="chat-session-trace"/, 'trace mount missing')
|
||||
assert.match(html, /id="chat-session-log"/, 'log mount missing')
|
||||
assert.match(html, /session-log-view\.js/, 'session-log-view.js script tag missing')
|
||||
})
|
||||
|
||||
test('style.css toggles each alternate view container off in list mode', () => {
|
||||
const css = fs.readFileSync(path.join(RENDERER, 'style.css'), 'utf8')
|
||||
// Each of the four non-list views hides the stream when active.
|
||||
for (const v of ['graph', 'timeline', 'trace', 'log']) {
|
||||
assert.match(css, new RegExp(`data-chat-view="${v}"\\][^{]*\\.stream\\s*\\{\\s*display:\\s*none`),
|
||||
`stream should hide when ${v} is active`)
|
||||
}
|
||||
// The timeline/trace/log containers default to display:none (shown only
|
||||
// when their own view value is active).
|
||||
assert.match(css, /\.chat-session-timeline[\s\S]*?display:\s*none/, 'timeline default hidden rule missing')
|
||||
})
|
||||
|
||||
// ─── behavioral: setChatView ─────────────────────────────────────────────────
|
||||
|
||||
test('setChatView flips data-chat-view + aria-selected across all five views', async () => {
|
||||
const { window, document } = await bootWithChatPane()
|
||||
const R = window.__dshRenderer
|
||||
const pane = document.querySelector('.pane[data-pane="chat"]')
|
||||
assert.ok(pane, 'chat pane present')
|
||||
for (const v of ['graph', 'timeline', 'trace', 'log', 'list']) {
|
||||
R.setChatView(v)
|
||||
assert.equal(R.getChatView(), v, `data-chat-view should be ${v}`)
|
||||
assert.equal(pane.dataset.chatView, v)
|
||||
}
|
||||
})
|
||||
|
||||
test('setChatView falls back to list on an unknown view', async () => {
|
||||
const { window } = await bootWithChatPane()
|
||||
const R = window.__dshRenderer
|
||||
R.setChatView('bogus')
|
||||
assert.equal(R.getChatView(), 'list')
|
||||
})
|
||||
|
||||
test('Trace tab renders a tri-view over the active session aggregate', async () => {
|
||||
const { window, document } = await bootWithChatPane()
|
||||
const R = window.__dshRenderer
|
||||
// The trace tri-view + aggregator modules are require()'d into the harness
|
||||
// and read their globals off Node's `global.window`, while renderer.js runs
|
||||
// against the harness windowStub. In the browser these are one object; here
|
||||
// we bridge global.window to the harness window for the duration of the
|
||||
// test so tri-view.sessionTraceRecords can see __dshTraceAgg. Save/restore
|
||||
// so the mutation doesn't leak to sibling tests.
|
||||
const savedWin = global.window
|
||||
global.window = window
|
||||
try {
|
||||
// Seed a session with a step so aggregateSteps yields ≥1 record.
|
||||
const sid = 's-trace-1'
|
||||
R.ensureSession(sid)
|
||||
R.state.activeSessionId = sid
|
||||
const meta = R.getSessionMeta(sid)
|
||||
meta.cachedEvents = [
|
||||
{ type: 'step/start', seq: 1, time: 1000, data: { turn: 0, step: 0 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 1100, data: { text: 'working' } },
|
||||
{ type: 'step/end', seq: 3, time: 1500, data: {} },
|
||||
]
|
||||
R.setChatView('trace')
|
||||
const traceEl = document.getElementById('chat-session-trace')
|
||||
const triviews = traceEl.querySelectorAll('.trace-tri-view')
|
||||
assert.equal(triviews.length, 1, 'a single tri-view mounts full-pane in the Trace tab')
|
||||
} finally {
|
||||
global.window = savedWin
|
||||
}
|
||||
})
|
||||
|
||||
test('Trace tab shows the empty state when the session has no steps', async () => {
|
||||
const { window, document } = await bootWithChatPane()
|
||||
const R = window.__dshRenderer
|
||||
const sid = 's-trace-empty'
|
||||
R.ensureSession(sid)
|
||||
R.state.activeSessionId = sid
|
||||
R.getSessionMeta(sid).cachedEvents = []
|
||||
R.setChatView('trace')
|
||||
const traceEl = document.getElementById('chat-session-trace')
|
||||
assert.equal(traceEl.querySelectorAll('.chat-session-view-empty').length, 1)
|
||||
assert.equal(traceEl.querySelectorAll('.trace-tri-view').length, 0)
|
||||
})
|
||||
|
||||
// ─── behavioral: Tracing-page demotion ───────────────────────────────────────
|
||||
|
||||
test('tracing-page.openDrill navigates via __dshOpenSessionTrace', () => {
|
||||
const src = fs.readFileSync(path.join(RENDERER, 'tracing-page.js'), 'utf8')
|
||||
// openDrill prefers the Chat-pane bridge; inline tri-view is the fallback.
|
||||
assert.match(src, /window\.__dshOpenSessionTrace/, 'openDrill must call the nav bridge')
|
||||
assert.match(src, /function openDrillInline/, 'inline drill retained as a named fallback')
|
||||
// The dead inline path must not be the default drill any more: openDrill's
|
||||
// body routes through the bridge before touching the table swap.
|
||||
const openDrillBody = src.match(/function openDrill \([\s\S]*?\n \}/)
|
||||
assert.ok(openDrillBody, 'openDrill function found')
|
||||
assert.match(openDrillBody[0], /__dshOpenSessionTrace/, 'openDrill routes to the bridge first')
|
||||
})
|
||||
|
||||
test('openSessionTrace bridge switches tab, selects session, opens Trace view', async () => {
|
||||
const { window } = await bootWithChatPane()
|
||||
const R = window.__dshRenderer
|
||||
// Spy the tab switch.
|
||||
const switched = []
|
||||
window.__dshTabs = { switchTo: (name) => switched.push(name) }
|
||||
const sid = 's-nav-1'
|
||||
R.ensureSession(sid)
|
||||
R.getSessionMeta(sid).cachedEvents = [
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 0, step: 0 } },
|
||||
{ type: 'step/end', seq: 2, time: 2, data: {} },
|
||||
]
|
||||
await R.openSessionTrace(sid)
|
||||
assert.deepEqual(switched, ['chat'], 'navigates to the Chat pane')
|
||||
assert.equal(R.getActiveSessionId(), sid, 'selects the drilled session')
|
||||
assert.equal(R.getChatView(), 'trace', 'opens the Trace tab')
|
||||
})
|
||||
@@ -348,8 +348,11 @@ async function loadRenderer(customStubs = {}, options = {}) {
|
||||
const documentStub = {
|
||||
body: makeElement('body'),
|
||||
_byId: new Map(),
|
||||
createElement(tag) { return makeElement(tag) },
|
||||
createElementNS(_ns, tag) { return makeElement(tag) },
|
||||
// Real DOM nodes always expose `ownerDocument`; modules that build their
|
||||
// own subtree (e.g. session-log-view.js) resolve the document off the
|
||||
// container's ownerDocument. Stamp it so those modules find a document.
|
||||
createElement(tag) { const e = makeElement(tag); e.ownerDocument = documentStub; return e },
|
||||
createElementNS(_ns, tag) { const e = makeElement(tag); e.ownerDocument = documentStub; return e },
|
||||
createTextNode(txt) {
|
||||
// A text node is a leaf with no children — mirror the API surface
|
||||
// just enough for `append(inp, document.createTextNode(...))`.
|
||||
@@ -366,6 +369,7 @@ async function loadRenderer(customStubs = {}, options = {}) {
|
||||
// later via document.getElementById(id).
|
||||
const el = makeElement('div')
|
||||
el.setAttribute('id', id)
|
||||
el.ownerDocument = documentStub
|
||||
this._byId.set(id, el)
|
||||
documentStub.body.appendChild(el)
|
||||
return el
|
||||
@@ -414,6 +418,12 @@ async function loadRenderer(customStubs = {}, options = {}) {
|
||||
['trace-aggregator.js', '__dshTraceAgg'],
|
||||
['trace-timeline.js', '__dshTraceTimeline'],
|
||||
['trace-detail-pane.js', '__dshTraceDetailPane'],
|
||||
// lane-p1-tabs: the Chat pane's Trace/时序/Log tabs read these three.
|
||||
// trace-tri-view wraps the timeline/graph projections; session-log-view
|
||||
// owns the full-history Log tab. Both are guarded on read in renderer.js,
|
||||
// but preloading lets renderer-harness tests exercise the tab helpers.
|
||||
['trace-tri-view.js', '__dshTraceTriView'],
|
||||
['session-log-view.js', '__dshSessionLogView'],
|
||||
['edit-rerun-header.js', '__dshEditRerunHeader'],
|
||||
['panels-c.js', '__dshPanelsC'],
|
||||
['tool-cards.js', '__dshToolCards'],
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
// Tests for lane-p1-tabs — src/renderer/session-log-view.js.
|
||||
//
|
||||
// The Log tab is the Chat pane's full-history event log for the ACTIVE
|
||||
// session: a sessionEvents replay merged with live events, filtered by
|
||||
// type-chips + text search, each row expandable with a { } inspector badge.
|
||||
//
|
||||
// Pure helpers (normalizeLogEntry / mergeLiveEntry / distinctTypes /
|
||||
// summarizeEntry / pageSlice) run with no DOM. The controller
|
||||
// (renderSessionLog / ingestLiveEvent) is exercised against a hand-rolled DOM
|
||||
// shim + fake window.dsh.sessionEvents + a spy inspector — the same no-jsdom
|
||||
// approach as inspector-drawer.test.js / chat-triple-view.test.js.
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// session-log-view.js reads window.DevtoolsModel / window.__dshInspector /
|
||||
// window.dsh at call time, and attaches its API to window on load. Provide a
|
||||
// window before requiring so the load-time `window.__dshSessionLogView =`
|
||||
// assignment has a home, then read the module back off it. Also require
|
||||
// DevtoolsModel so filterEntries composes exactly like the devtools panel.
|
||||
const DevtoolsModel = require('../src/renderer/devtools-model.js')
|
||||
global.window = global.window || {}
|
||||
global.window.DevtoolsModel = DevtoolsModel
|
||||
const logView = require('../src/renderer/session-log-view.js')
|
||||
|
||||
// ─── pure: normalizeLogEntry ───────────────────────────────────────────────
|
||||
|
||||
test('normalizeLogEntry: seq becomes the id; missing type → (unknown)', () => {
|
||||
const e = logView.normalizeLogEntry({ type: 'user/message', seq: 7, time: 100, data: { text: 'hi' } }, -1)
|
||||
assert.equal(e.id, 7)
|
||||
assert.equal(e.seq, 7)
|
||||
assert.equal(e.type, 'user/message')
|
||||
assert.equal(e.time, 100)
|
||||
assert.equal(e.event.data.text, 'hi')
|
||||
})
|
||||
|
||||
test('normalizeLogEntry: seq-less event falls back to the supplied id', () => {
|
||||
const e = logView.normalizeLogEntry({ type: 'chunk/delta', data: {} }, -5)
|
||||
assert.equal(e.seq, null)
|
||||
assert.equal(e.id, -5)
|
||||
assert.equal(e.type, 'chunk/delta')
|
||||
})
|
||||
|
||||
// ─── pure: mergeLiveEntry ───────────────────────────────────────────────────
|
||||
|
||||
test('mergeLiveEntry: appends a new seq in ascending order', () => {
|
||||
const list = [
|
||||
logView.normalizeLogEntry({ type: 'a', seq: 1 }),
|
||||
logView.normalizeLogEntry({ type: 'b', seq: 3 }),
|
||||
]
|
||||
logView.mergeLiveEntry(list, logView.normalizeLogEntry({ type: 'c', seq: 2 }))
|
||||
assert.deepEqual(list.map((e) => e.seq), [1, 2, 3])
|
||||
})
|
||||
|
||||
test('mergeLiveEntry: a duplicate seq replaces in place (fuller payload)', () => {
|
||||
const list = [logView.normalizeLogEntry({ type: 'assistant/message', seq: 5, data: { text: 'par' } })]
|
||||
logView.mergeLiveEntry(list, logView.normalizeLogEntry({ type: 'assistant/message', seq: 5, data: { text: 'partial→full' } }))
|
||||
assert.equal(list.length, 1, 'no duplicate row for the same seq')
|
||||
assert.equal(list[0].event.data.text, 'partial→full')
|
||||
})
|
||||
|
||||
test('mergeLiveEntry: seq-less event always appends', () => {
|
||||
const list = [logView.normalizeLogEntry({ type: 'a', seq: 1 })]
|
||||
logView.mergeLiveEntry(list, logView.normalizeLogEntry({ type: 'delta' }, -1))
|
||||
logView.mergeLiveEntry(list, logView.normalizeLogEntry({ type: 'delta' }, -2))
|
||||
assert.equal(list.length, 3)
|
||||
})
|
||||
|
||||
// ─── pure: distinctTypes / summarizeEntry / pageSlice ───────────────────────
|
||||
|
||||
test('distinctTypes: sorted unique type list', () => {
|
||||
const list = ['turn/start', 'user/message', 'turn/start', 'tool/call'].map(
|
||||
(t, i) => logView.normalizeLogEntry({ type: t, seq: i }))
|
||||
assert.deepEqual(logView.distinctTypes(list), ['tool/call', 'turn/start', 'user/message'])
|
||||
})
|
||||
|
||||
test('summarizeEntry: prefers text, then content array, then stopReason', () => {
|
||||
assert.equal(
|
||||
logView.summarizeEntry(logView.normalizeLogEntry({ type: 'assistant/message', seq: 1, data: { text: 'hello world' } })),
|
||||
'hello world')
|
||||
assert.equal(
|
||||
logView.summarizeEntry(logView.normalizeLogEntry({
|
||||
type: 'user/message', seq: 2, data: { content: [{ type: 'text', text: 'multi' }, { type: 'text', text: 'part' }] },
|
||||
})),
|
||||
'multi part')
|
||||
assert.equal(
|
||||
logView.summarizeEntry(logView.normalizeLogEntry({ type: 'turn/end', seq: 3, data: { stopReason: 'cancelled' } })),
|
||||
'cancelled')
|
||||
})
|
||||
|
||||
test('summarizeEntry: truncates long text with an ellipsis', () => {
|
||||
const s = logView.summarizeEntry(logView.normalizeLogEntry({ type: 'assistant/message', seq: 1, data: { text: 'x'.repeat(120) } }))
|
||||
assert.ok(s.length <= 80)
|
||||
assert.ok(s.endsWith('…'))
|
||||
})
|
||||
|
||||
test('pageSlice: caps at PAGE and reports hasMore', () => {
|
||||
const big = Array.from({ length: 450 }, (_, i) => logView.normalizeLogEntry({ type: 't', seq: i }))
|
||||
const first = logView.pageSlice(big, logView.PAGE)
|
||||
assert.equal(first.rows.length, logView.PAGE)
|
||||
assert.equal(first.hasMore, true)
|
||||
assert.equal(first.total, 450)
|
||||
const second = logView.pageSlice(big, logView.PAGE * 3)
|
||||
assert.equal(second.rows.length, 450)
|
||||
assert.equal(second.hasMore, false)
|
||||
})
|
||||
|
||||
// ─── controller: seed events (in-memory cache) ──────────────────────────────
|
||||
|
||||
test('renderSessionLog: seedEvents paint immediately (live-only session)', async () => {
|
||||
const doc = makeDoc()
|
||||
const c = container(doc)
|
||||
const savedDsh = global.window.dsh
|
||||
// Bridge returns nothing — mimics a daemon that hasn't persisted this live
|
||||
// session. The seed must still render.
|
||||
global.window.dsh = { sessionEvents: async () => ({ events: [] }) }
|
||||
try {
|
||||
logView.renderSessionLog(c, {
|
||||
sessionId: 's-seed',
|
||||
seedEvents: [
|
||||
{ type: 'user/message', seq: 1, data: { text: 'live' } },
|
||||
{ type: 'turn/start', seq: 2, data: {} },
|
||||
],
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
assert.equal(c.querySelectorAll('session-log-row').length, 2, 'seed rows survive an empty wire walk')
|
||||
} finally {
|
||||
global.window.dsh = savedDsh
|
||||
}
|
||||
})
|
||||
|
||||
test('renderSessionLog: a larger wire walk supersedes the seed', async () => {
|
||||
const doc = makeDoc()
|
||||
const c = container(doc)
|
||||
const savedDsh = global.window.dsh
|
||||
const events = [
|
||||
{ type: 'user/message', seq: 1, data: {} },
|
||||
{ type: 'turn/start', seq: 2, data: {} },
|
||||
{ type: 'assistant/message', seq: 3, data: {} },
|
||||
{ type: 'turn/end', seq: 4, data: {} },
|
||||
]
|
||||
global.window.dsh = { sessionEvents: makeSessionEventsBridge(events) }
|
||||
try {
|
||||
logView.renderSessionLog(c, {
|
||||
sessionId: 's-supersede',
|
||||
seedEvents: [{ type: 'user/message', seq: 1, data: {} }], // stale 1-event cache
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
assert.equal(c.querySelectorAll('session-log-row').length, 4, 'wire walk (4) beats seed (1)')
|
||||
} finally {
|
||||
global.window.dsh = savedDsh
|
||||
}
|
||||
})
|
||||
|
||||
// ─── DOM shim ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Minimal element with the surface session-log-view.js touches: className,
|
||||
// dataset, textContent (clearing), appendChild, addEventListener + a manual
|
||||
// `_fire`, querySelectorAll by single class, hidden, type/placeholder/value.
|
||||
|
||||
function makeDoc() {
|
||||
function el(tag) {
|
||||
const node = {
|
||||
tagName: String(tag).toUpperCase(),
|
||||
className: '',
|
||||
_text: '',
|
||||
placeholder: '',
|
||||
type: '',
|
||||
value: '',
|
||||
hidden: false,
|
||||
open: false,
|
||||
dataset: {},
|
||||
_children: [],
|
||||
_listeners: {},
|
||||
// Real DOM clears children when textContent is assigned; the controller
|
||||
// relies on `el.textContent = ''` to reset a pane before repaint.
|
||||
get textContent() { return this._text || this._children.map((c) => c.textContent || '').join('') },
|
||||
set textContent(v) { this._text = String(v); this._children.length = 0 },
|
||||
classList: {
|
||||
_s: new Set(),
|
||||
add(...c) { for (const x of c) this._s.add(x) },
|
||||
remove(...c) { for (const x of c) this._s.delete(x) },
|
||||
contains(c) { return this._s.has(c) },
|
||||
toggle(c, f) { const h = this._s.has(c); const on = f === undefined ? !h : f; if (on) this._s.add(c); else this._s.delete(c); return on },
|
||||
},
|
||||
get firstChild() { return this._children[0] || null },
|
||||
appendChild(c) { this._children.push(c); c.parentNode = node; return c },
|
||||
removeChild(c) { const i = this._children.indexOf(c); if (i >= 0) this._children.splice(i, 1); return c },
|
||||
setAttribute(k, v) { this.dataset[k] = v; if (k === 'class') this.className = String(v) },
|
||||
getAttribute(k) { return this.dataset[k] },
|
||||
addEventListener(evt, fn) { (this._listeners[evt] ||= []).push(fn) },
|
||||
_fire(evt, arg) { for (const fn of (this._listeners[evt] || [])) fn(arg || { stopPropagation() {}, preventDefault() {} }) },
|
||||
querySelectorAll(sel) {
|
||||
const cls = sel.replace(/^\./, '')
|
||||
const out = []
|
||||
const walk = (n) => {
|
||||
for (const c of (n._children || [])) {
|
||||
if (typeof c.className === 'string' && c.className.split(/\s+/).includes(cls)) out.push(c)
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(node)
|
||||
return out
|
||||
},
|
||||
}
|
||||
return node
|
||||
}
|
||||
return { createElement: el, createElementNS: (_ns, t) => el(t) }
|
||||
}
|
||||
|
||||
function container(doc) {
|
||||
const c = doc.createElement('div')
|
||||
c.ownerDocument = doc
|
||||
return c
|
||||
}
|
||||
|
||||
// A fake window.dsh.sessionEvents that serves a fixed event list. First call
|
||||
// (no seq) returns a metadata listing; windowed calls return slices.
|
||||
function makeSessionEventsBridge(events) {
|
||||
const sorted = events.slice().sort((a, b) => a.seq - b.seq)
|
||||
return async function (sessionId, opts = {}) {
|
||||
if (opts.seq === undefined) {
|
||||
return { events: sorted.map((e) => ({ seq: e.seq, type: e.type })) }
|
||||
}
|
||||
const before = opts.before || 50
|
||||
const end = opts.seq
|
||||
const start = Math.max(0, end - before + 1)
|
||||
const slice = sorted.filter((e) => e.seq >= start && e.seq <= end)
|
||||
return { events: slice, startSeq: slice.length ? slice[0].seq : start }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── controller: history replay ─────────────────────────────────────────────
|
||||
|
||||
test('renderSessionLog: replays full history and paints rows + chips', async () => {
|
||||
const doc = makeDoc()
|
||||
const c = container(doc)
|
||||
const events = [
|
||||
{ type: 'user/message', seq: 1, data: { text: 'go' } },
|
||||
{ type: 'turn/start', seq: 2, data: {} },
|
||||
{ type: 'assistant/message', seq: 3, data: { text: 'ok' } },
|
||||
{ type: 'turn/end', seq: 4, data: { stopReason: 'end_turn' } },
|
||||
]
|
||||
const savedDsh = global.window.dsh
|
||||
global.window.dsh = { sessionEvents: makeSessionEventsBridge(events) }
|
||||
try {
|
||||
logView.renderSessionLog(c, { sessionId: 's1' })
|
||||
// loadHistory is async (awaits the bridge); let microtasks settle.
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
const rows = c.querySelectorAll('session-log-row')
|
||||
assert.equal(rows.length, 4, 'all four events render as rows')
|
||||
const chips = c.querySelectorAll('session-log-chip')
|
||||
assert.equal(chips.length, 4, 'one chip per distinct type')
|
||||
} finally {
|
||||
global.window.dsh = savedDsh
|
||||
}
|
||||
})
|
||||
|
||||
test('renderSessionLog: empty state when the bridge returns nothing', async () => {
|
||||
const doc = makeDoc()
|
||||
const c = container(doc)
|
||||
const savedDsh = global.window.dsh
|
||||
global.window.dsh = { sessionEvents: async () => ({ events: [] }) }
|
||||
try {
|
||||
logView.renderSessionLog(c, { sessionId: 's-empty' })
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
const empty = c.querySelectorAll('session-log-empty')
|
||||
assert.equal(empty.length, 1)
|
||||
} finally {
|
||||
global.window.dsh = savedDsh
|
||||
}
|
||||
})
|
||||
|
||||
// ─── controller: type-chip filter ───────────────────────────────────────────
|
||||
|
||||
test('type-chip toggle filters rows to the selected type', async () => {
|
||||
const doc = makeDoc()
|
||||
const c = container(doc)
|
||||
const events = [
|
||||
{ type: 'user/message', seq: 1, data: {} },
|
||||
{ type: 'tool/call', seq: 2, data: { name: 'read' } },
|
||||
{ type: 'tool/call', seq: 3, data: { name: 'write' } },
|
||||
{ type: 'turn/end', seq: 4, data: {} },
|
||||
]
|
||||
const savedDsh = global.window.dsh
|
||||
global.window.dsh = { sessionEvents: makeSessionEventsBridge(events) }
|
||||
try {
|
||||
logView.renderSessionLog(c, { sessionId: 's2' })
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
assert.equal(c.querySelectorAll('session-log-row').length, 4)
|
||||
// Click the tool/call chip.
|
||||
const chips = c.querySelectorAll('session-log-chip')
|
||||
const toolChip = chips.find((ch) => ch.dataset.type === 'tool/call')
|
||||
assert.ok(toolChip, 'tool/call chip present')
|
||||
toolChip._fire('click')
|
||||
const rows = c.querySelectorAll('session-log-row')
|
||||
assert.equal(rows.length, 2, 'only tool/call rows remain')
|
||||
for (const r of rows) assert.equal(r.dataset.type, 'tool/call')
|
||||
} finally {
|
||||
global.window.dsh = savedDsh
|
||||
}
|
||||
})
|
||||
|
||||
// ─── controller: text search ────────────────────────────────────────────────
|
||||
|
||||
test('text search narrows rows by payload substring', async () => {
|
||||
const doc = makeDoc()
|
||||
const c = container(doc)
|
||||
const events = [
|
||||
{ type: 'user/message', seq: 1, data: { text: 'deploy the thing' } },
|
||||
{ type: 'assistant/message', seq: 2, data: { text: 'rolling back' } },
|
||||
]
|
||||
const savedDsh = global.window.dsh
|
||||
global.window.dsh = { sessionEvents: makeSessionEventsBridge(events) }
|
||||
try {
|
||||
logView.renderSessionLog(c, { sessionId: 's3' })
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
const search = c.querySelectorAll('session-log-search')[0]
|
||||
assert.ok(search, 'search input present')
|
||||
search.value = 'deploy'
|
||||
search._fire('input')
|
||||
const rows = c.querySelectorAll('session-log-row')
|
||||
assert.equal(rows.length, 1)
|
||||
assert.equal(rows[0].dataset.seq, '1')
|
||||
} finally {
|
||||
global.window.dsh = savedDsh
|
||||
}
|
||||
})
|
||||
|
||||
// ─── controller: live merge ─────────────────────────────────────────────────
|
||||
|
||||
test('ingestLiveEvent: a live event for the shown session appends a row', async () => {
|
||||
const doc = makeDoc()
|
||||
const c = container(doc)
|
||||
const savedDsh = global.window.dsh
|
||||
global.window.dsh = { sessionEvents: makeSessionEventsBridge([
|
||||
{ type: 'user/message', seq: 1, data: {} },
|
||||
]) }
|
||||
try {
|
||||
logView.renderSessionLog(c, { sessionId: 's4' })
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
assert.equal(c.querySelectorAll('session-log-row').length, 1)
|
||||
logView.ingestLiveEvent(c, 's4', { type: 'turn/start', seq: 2, data: {} })
|
||||
assert.equal(c.querySelectorAll('session-log-row').length, 2)
|
||||
// An event for a DIFFERENT session is ignored.
|
||||
logView.ingestLiveEvent(c, 's-other', { type: 'turn/end', seq: 3, data: {} })
|
||||
assert.equal(c.querySelectorAll('session-log-row').length, 2)
|
||||
} finally {
|
||||
global.window.dsh = savedDsh
|
||||
}
|
||||
})
|
||||
|
||||
// ─── controller: inspector anchoring ────────────────────────────────────────
|
||||
|
||||
test('row { } badge opens the inspector anchored to that event', async () => {
|
||||
const doc = makeDoc()
|
||||
const c = container(doc)
|
||||
const opened = []
|
||||
const savedInsp = global.window.__dshInspector
|
||||
const savedDsh = global.window.dsh
|
||||
global.window.__dshInspector = {
|
||||
// Mirror the real attachInspectBadge: resolve the target at click time
|
||||
// and call open with it.
|
||||
attachInspectBadge(host, getTarget) {
|
||||
const btn = doc.createElement('button')
|
||||
btn.className = 'inspect-badge'
|
||||
btn.addEventListener('click', () => { const t = getTarget(); opened.push(t) })
|
||||
host.appendChild(btn)
|
||||
return btn
|
||||
},
|
||||
open(t) { opened.push(t) },
|
||||
}
|
||||
global.window.dsh = { sessionEvents: makeSessionEventsBridge([
|
||||
{ type: 'assistant/message', seq: 9, data: { text: 'inspect me' } },
|
||||
]) }
|
||||
try {
|
||||
logView.renderSessionLog(c, { sessionId: 's5' })
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
const badge = c.querySelectorAll('inspect-badge')[0]
|
||||
assert.ok(badge, 'inspect badge attached to the row')
|
||||
badge._fire('click')
|
||||
assert.equal(opened.length, 1)
|
||||
assert.equal(opened[0].event.seq, 9, 'inspector anchored to the row event')
|
||||
assert.equal(opened[0].event.data.text, 'inspect me')
|
||||
} finally {
|
||||
global.window.__dshInspector = savedInsp
|
||||
global.window.dsh = savedDsh
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user