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

+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