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 files changed
+1718 -24

No files matched your search

+22 -7
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
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)
#### 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) })
+41 -4
View File
@@ -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>
+149 -2
View File
@@ -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
})()
+148 -5
View File
@@ -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. */
+28 -4
View File
@@ -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')
})
+12 -2
View File
@@ -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
}
})