feat(desktop): five-way chat view strip (List|Graph|时序|Trace|Log) + Tracing page demotes to nav

This commit is contained in:
ZiyaZhang
2026-07-20 02:57:57 -07:00
parent d5e2115aba
commit fa640ec9fe
14 changed files with 1718 additions and 24 deletions

View File

@@ -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 cancelled turn, and is cleared with a notice if the runtime restarts or you
switch profiles. 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.
![](docs/readme-shots/03-chat.png) ![](docs/readme-shots/03-chat.png)
#### Session Tree #### Session Tree
@@ -146,11 +159,12 @@ outcomes) and honestly labels the rest.
The project-wide runs table — every session across every profile in a The project-wide runs table — every session across every profile in a
single eight-column aggregate: **Name / Most Recent Run / Trace Count / single eight-column aggregate: **Name / Most Recent Run / Trace Count /
Error Rate / P50 / P99 / Total Tokens / Total Cost**. Clicking a row Error Rate / P50 / P99 / Total Tokens / Total Cost**. Clicking a row
opens a tri-view drawer (Tree / Timeline / Graph — see Feature navigates to the Chat pane and opens that session's **Trace** tab (the
highlights) that recursively unfolds a single session's event tree, Tree / Timeline / Graph tri-view — see Feature highlights), so the table
LLM/tool timing spans, and callgraph. Meant for the "which of my 200 stays a pure scanning surface and the per-session drill has one home. Meant
sessions actually cost me tokens this afternoon" question, backed by for the "which of my 200 sessions actually cost me tokens this afternoon"
the same `session/list` projection the Chat sidebar reads. question, backed by the same `session/list` projection the Chat sidebar
reads.
### Iteration ### 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 reprojections of the same event stream. Tree is the recursive event
hierarchy (turn → tool call → sub-events). Timeline is a Gantt-style hierarchy (turn → tool call → sub-events). Timeline is a Gantt-style
span chart with LLM latency and tool latency on separate lanes. span chart with LLM latency and tool latency on separate lanes.
Graph is a callgraph over `parentSession` + subagent edges. Wired Graph is a callgraph over `parentSession` + subagent edges. Reachable
from the Tracing table and from any assistant bubble's ⋯ menu. 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 - **Recursive collapsible Fields tree.** Every event payload — no
matter how deep — renders as a folder-tree of `field: value` rows 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 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

View File

@@ -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) })

View File

@@ -393,15 +393,26 @@
</div> </div>
</div> </div>
</header> </header>
<!-- feat/chat-triple-view: view switcher tabs (List | Graph). <!-- feat/chat-triple-view + lane-p1-tabs: view switcher tabs
The `data-chat-view` attribute on the parent .pane[data-pane="chat"] (List | Graph | 时序 | Trace | Log). The `data-chat-view`
swaps which child (stream vs graph container) is visible. Default attribute on the parent .pane[data-pane="chat"] swaps which child
is "list" so first-paint stays identical to previous versions. --> (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"> <div class="chat-view-tabs" role="tablist" aria-label="Chat view">
<button class="chat-view-tab active" data-chat-view-tab="list" <button class="chat-view-tab active" data-chat-view-tab="list"
role="tab" aria-selected="true" type="button">List</button> role="tab" aria-selected="true" type="button">List</button>
<button class="chat-view-tab" data-chat-view-tab="graph" <button class="chat-view-tab" data-chat-view-tab="graph"
role="tab" aria-selected="false" type="button">Graph</button> 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> </div>
<section id="stream" class="stream" aria-live="polite"> <section id="stream" class="stream" aria-live="polite">
<!-- Fresh-eyes P0 (2026-07-18): the empty-welcome block used to <!-- Fresh-eyes P0 (2026-07-18): the empty-welcome block used to
@@ -618,6 +629,31 @@
aria-label="Session graph"> aria-label="Session graph">
<div class="chat-session-graph-empty">Switch to Graph to see this session's turn DAG.</div> <div class="chat-session-graph-empty">Switch to Graph to see this session's turn DAG.</div>
</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 <!-- feat/chat-triple-view: right-side detail drawer. Rendered by
chat-side-drawer.js on toggle. `.hidden` class collapses; the chat-side-drawer.js on toggle. `.hidden` class collapses; the
#chat-side-drawer-btn button in the header flips it. --> #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="./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-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="./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="./chat-refresh-throttle.js"></script><!-- fix/code-bugs-batch P1-3: rAF-coalesced throttle for drawer/graph refresh -->
<script src="./event-filter.js"></script> <script src="./event-filter.js"></script>

View File

@@ -602,6 +602,14 @@ async function selectSession(id) {
// Per-session queue: repaint the strip for the session we just switched to. // 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. // Strict isolation — the strip only ever shows the active session's queue.
renderMsgQueueStrip() 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) { async function replayHistory(id) {
@@ -2605,6 +2613,24 @@ function deepLinkToSeq(seq) {
} }
if (typeof window !== 'undefined') window.__dshDeepLinkToSeq = deepLinkToSeq 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 // expose a direct-dispatch seam so the tri-view CDP shoot driver
// can play fixtures without booting a daemon (offline env where tsx // can play fixtures without booting a daemon (offline env where tsx
// resolution fails at daemon spawn). Sets state.activeSessionId + streamEl // 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 chatSideDrawerBodyEl = document.getElementById('chat-side-drawer-body')
const chatSideDrawerCloseBtn = document.getElementById('chat-side-drawer-close') const chatSideDrawerCloseBtn = document.getElementById('chat-side-drawer-close')
const chatSessionGraphEl = document.getElementById('chat-session-graph') 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') const chatViewTabEls = document.querySelectorAll('.chat-view-tab')
// Default the pane to List. The absence of the attribute would leave the // Default the pane to List. The absence of the attribute would leave the
@@ -4691,9 +4723,14 @@ if (chatSideDrawerCloseBtn) {
chatSideDrawerCloseBtn.addEventListener('click', () => setChatDrawerOpen(false)) 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) { function setChatView(view) {
if (!chatPaneEl) return if (!chatPaneEl) return
const v = view === 'graph' ? 'graph' : 'list' const v = CHAT_VIEWS.includes(view) ? view : 'list'
chatPaneEl.dataset.chatView = v chatPaneEl.dataset.chatView = v
for (const btn of chatViewTabEls) { for (const btn of chatViewTabEls) {
const active = btn.dataset.chatViewTab === v const active = btn.dataset.chatViewTab === v
@@ -4701,6 +4738,91 @@ function setChatView(view) {
btn.setAttribute('aria-selected', active ? 'true' : 'false') btn.setAttribute('aria-selected', active ? 'true' : 'false')
} }
if (v === 'graph') refreshSessionGraph() 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() { function refreshSessionGraph() {
if (!chatSessionGraphEl) return if (!chatSessionGraphEl) return
@@ -4742,7 +4864,22 @@ function refreshSessionGraph() {
}) })
} }
function refreshSessionGraphIfActive() { 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) { for (const btn of chatViewTabEls) {
btn.addEventListener('click', () => setChatView(btn.dataset.chatViewTab)) 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 // switched to Graph yet. Coalesced via rAF so long sessions don't take
// an O(N²) hit from the O(N) derives. // an O(N²) hit from the O(N) derives.
refreshChatSurfacesCoalesced() 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 // §2.3 (batch 6) template triggers: pure module decides whether the event
// qualifies for a template card (T2 error recovery / T4 artifact preview / // qualifies for a template card (T2 error recovery / T4 artifact preview /
@@ -8597,6 +8738,12 @@ window.__dshRenderer = {
compactNow, compactNow,
confirmDialog, confirmDialog,
notifyDialog, 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 // 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 // non-blocking steer card into the active session without a real
// session/interrupt round-trip. // session/interrupt round-trip.

View File

@@ -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
})()

View File

@@ -12533,11 +12533,154 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9
stroke-width: 2; stroke-width: 2;
} }
/* Stream shows only for [data-chat-view="list"], graph only for /* Stream shows only for [data-chat-view="list"]; each alternate view shows
* [data-chat-view="graph"]. The pane is the parent that carries the * only when its own data-chat-view value is active. The pane is the parent
* data attribute so a single toggle switches both children. */ * that carries the data attribute so a single toggle switches all children.
.pane[data-pane="chat"][data-chat-view="graph"] .stream { display: none; } * lane-p1-tabs expanded this from a 2-way (list/graph) toggle to 5 views. */
.pane[data-pane="chat"][data-chat-view="list"] .chat-session-graph { display: none; } .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 /* Give the pane a positioning context so the absolute drawer anchors
* inside it, not against the viewport root. */ * inside it, not against the viewport root. */

View File

@@ -17,10 +17,14 @@
// - Columns menu: checkbox per column, persisted to localStorage under // - Columns menu: checkbox per column, persisted to localStorage under
// `dsh.tracing.columns.v1`. New columns land visible by default so a // `dsh.tracing.columns.v1`. New columns land visible by default so a
// later release doesn't come up mysteriously narrow for old users. // later release doesn't come up mysteriously narrow for old users.
// - Row click: pulls the session's cachedEvents through // - Row click: navigates to the Chat pane and opens that session's Trace
// __dshTraceTriView.sessionTraceRecords(), swaps the table for the // tab (lane-p1-tabs demotion — window.__dshOpenSessionTrace). The
// tri-view panels (Timeline / Graph default; Tree stub notes per-turn // eight-column cross-session table is the Tracing page's whole job; the
// scope), and shows a breadcrumb Back to the table. // 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: // Layer contract per docs/design-refs/density-layering-spec.md §7:
// - Numeric columns right-align with `tabular-nums` (see style.css). // - 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) { 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 if (!els || !sessionId) return
const Chat = window.__dshChat const Chat = window.__dshChat
const Tri = window.__dshTraceTriView const Tri = window.__dshTraceTriView

View File

@@ -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')
})

View File

@@ -348,8 +348,11 @@ async function loadRenderer(customStubs = {}, options = {}) {
const documentStub = { const documentStub = {
body: makeElement('body'), body: makeElement('body'),
_byId: new Map(), _byId: new Map(),
createElement(tag) { return makeElement(tag) }, // Real DOM nodes always expose `ownerDocument`; modules that build their
createElementNS(_ns, tag) { return makeElement(tag) }, // 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) { createTextNode(txt) {
// A text node is a leaf with no children — mirror the API surface // A text node is a leaf with no children — mirror the API surface
// just enough for `append(inp, document.createTextNode(...))`. // just enough for `append(inp, document.createTextNode(...))`.
@@ -366,6 +369,7 @@ async function loadRenderer(customStubs = {}, options = {}) {
// later via document.getElementById(id). // later via document.getElementById(id).
const el = makeElement('div') const el = makeElement('div')
el.setAttribute('id', id) el.setAttribute('id', id)
el.ownerDocument = documentStub
this._byId.set(id, el) this._byId.set(id, el)
documentStub.body.appendChild(el) documentStub.body.appendChild(el)
return el return el
@@ -414,6 +418,12 @@ async function loadRenderer(customStubs = {}, options = {}) {
['trace-aggregator.js', '__dshTraceAgg'], ['trace-aggregator.js', '__dshTraceAgg'],
['trace-timeline.js', '__dshTraceTimeline'], ['trace-timeline.js', '__dshTraceTimeline'],
['trace-detail-pane.js', '__dshTraceDetailPane'], ['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'], ['edit-rerun-header.js', '__dshEditRerunHeader'],
['panels-c.js', '__dshPanelsC'], ['panels-c.js', '__dshPanelsC'],
['tool-cards.js', '__dshToolCards'], ['tool-cards.js', '__dshToolCards'],

View File

@@ -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
}
})