diff --git a/examples/desktop/README.md b/examples/desktop/README.md index 85f1215222..ed57191b02 100644 --- a/examples/desktop/README.md +++ b/examples/desktop/README.md @@ -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 diff --git a/examples/desktop/docs/qa-p1-tabs/p1-01-timeline.png b/examples/desktop/docs/qa-p1-tabs/p1-01-timeline.png new file mode 100644 index 0000000000..23ee56208b Binary files /dev/null and b/examples/desktop/docs/qa-p1-tabs/p1-01-timeline.png differ diff --git a/examples/desktop/docs/qa-p1-tabs/p1-02-trace.png b/examples/desktop/docs/qa-p1-tabs/p1-02-trace.png new file mode 100644 index 0000000000..46f74c1f17 Binary files /dev/null and b/examples/desktop/docs/qa-p1-tabs/p1-02-trace.png differ diff --git a/examples/desktop/docs/qa-p1-tabs/p1-03-log.png b/examples/desktop/docs/qa-p1-tabs/p1-03-log.png new file mode 100644 index 0000000000..73f78749fe Binary files /dev/null and b/examples/desktop/docs/qa-p1-tabs/p1-03-log.png differ diff --git a/examples/desktop/docs/qa-p1-tabs/p1-04-tracing-nav.png b/examples/desktop/docs/qa-p1-tabs/p1-04-tracing-nav.png new file mode 100644 index 0000000000..b4ed735713 Binary files /dev/null and b/examples/desktop/docs/qa-p1-tabs/p1-04-tracing-nav.png differ diff --git a/examples/desktop/scripts/qa-cdp-shoot-p1-tabs.mjs b/examples/desktop/scripts/qa-cdp-shoot-p1-tabs.mjs new file mode 100644 index 0000000000..5b99481db1 --- /dev/null +++ b/examples/desktop/scripts/qa-cdp-shoot-p1-tabs.mjs @@ -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= isolates Chromium userdata +// DSH_DESKTOP_HOME= 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) }) diff --git a/examples/desktop/src/renderer/index.html b/examples/desktop/src/renderer/index.html index 8918f36c44..f9a4eb0bae 100644 --- a/examples/desktop/src/renderer/index.html +++ b/examples/desktop/src/renderer/index.html @@ -393,15 +393,26 @@ - +
+ + +
+
+
Switch to 时序 to see this session's step timeline.
+
+ +
+
Switch to Trace to see this session's step tree.
+
+ +
+
Switch to Log to replay this session's full event history.
+
@@ -1541,6 +1577,7 @@ + diff --git a/examples/desktop/src/renderer/renderer.js b/examples/desktop/src/renderer/renderer.js index 3c1aab32e2..0c081a99a4 100644 --- a/examples/desktop/src/renderer/renderer.js +++ b/examples/desktop/src/renderer/renderer.js @@ -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. diff --git a/examples/desktop/src/renderer/session-log-view.js b/examples/desktop/src/renderer/session-log-view.js new file mode 100644 index 0000000000..bf913445a1 --- /dev/null +++ b/examples/desktop/src/renderer/session-log-view.js @@ -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 +})() diff --git a/examples/desktop/src/renderer/style.css b/examples/desktop/src/renderer/style.css index 67a0c8ed29..2db77ec9e6 100644 --- a/examples/desktop/src/renderer/style.css +++ b/examples/desktop/src/renderer/style.css @@ -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. */ diff --git a/examples/desktop/src/renderer/tracing-page.js b/examples/desktop/src/renderer/tracing-page.js index e07edf8bd2..c64e2241b7 100644 --- a/examples/desktop/src/renderer/tracing-page.js +++ b/examples/desktop/src/renderer/tracing-page.js @@ -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 diff --git a/examples/desktop/test/chat-view-tabs.test.js b/examples/desktop/test/chat-view-tabs.test.js new file mode 100644 index 0000000000..6565db7687 --- /dev/null +++ b/examples/desktop/test/chat-view-tabs.test.js @@ -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"[^>]*>时序 { + 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') +}) diff --git a/examples/desktop/test/renderer-harness.js b/examples/desktop/test/renderer-harness.js index eb1998a7ae..53671331e8 100644 --- a/examples/desktop/test/renderer-harness.js +++ b/examples/desktop/test/renderer-harness.js @@ -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'], diff --git a/examples/desktop/test/session-log-view.test.js b/examples/desktop/test/session-log-view.test.js new file mode 100644 index 0000000000..a2ae9d0481 --- /dev/null +++ b/examples/desktop/test/session-log-view.test.js @@ -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 + } +})