feat(desktop): five-way chat view strip (List|Graph|时序|Trace|Log) + Tracing page demotes to nav
This commit is contained in:
14 files changed
+1718
-24
No files matched your search
@@ -393,15 +393,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- feat/chat-triple-view: view switcher tabs (List | Graph).
|
||||
The `data-chat-view` attribute on the parent .pane[data-pane="chat"]
|
||||
swaps which child (stream vs graph container) is visible. Default
|
||||
is "list" so first-paint stays identical to previous versions. -->
|
||||
<!-- feat/chat-triple-view + lane-p1-tabs: view switcher tabs
|
||||
(List | Graph | 时序 | Trace | Log). The `data-chat-view`
|
||||
attribute on the parent .pane[data-pane="chat"] swaps which child
|
||||
(stream / graph / timeline / trace / log container) is visible.
|
||||
Default is "list" so first-paint stays identical to before.
|
||||
时序 / Trace reuse the trace tri-view modules (trace-timeline.js,
|
||||
trace-tri-view.js) full-pane over the ACTIVE session's events; Log
|
||||
is a full-history replay (session-log-view.js) — see renderer.js
|
||||
setChatView + the refreshSession{Timeline,Trace,Log} helpers. -->
|
||||
<div class="chat-view-tabs" role="tablist" aria-label="Chat view">
|
||||
<button class="chat-view-tab active" data-chat-view-tab="list"
|
||||
role="tab" aria-selected="true" type="button">List</button>
|
||||
<button class="chat-view-tab" data-chat-view-tab="graph"
|
||||
role="tab" aria-selected="false" type="button">Graph</button>
|
||||
<button class="chat-view-tab" data-chat-view-tab="timeline"
|
||||
role="tab" aria-selected="false" type="button">时序</button>
|
||||
<button class="chat-view-tab" data-chat-view-tab="trace"
|
||||
role="tab" aria-selected="false" type="button">Trace</button>
|
||||
<button class="chat-view-tab" data-chat-view-tab="log"
|
||||
role="tab" aria-selected="false" type="button">Log</button>
|
||||
</div>
|
||||
<section id="stream" class="stream" aria-live="polite">
|
||||
<!-- Fresh-eyes P0 (2026-07-18): the empty-welcome block used to
|
||||
@@ -618,6 +629,31 @@
|
||||
aria-label="Session graph">
|
||||
<div class="chat-session-graph-empty">Switch to Graph to see this session's turn DAG.</div>
|
||||
</div>
|
||||
<!-- lane-p1-tabs: full-pane 时序 (Timeline) mount. Painted by
|
||||
renderer.js refreshSessionTimeline() via trace-timeline.js over
|
||||
the active session's aggregate step records. Shown only for
|
||||
[data-chat-view="timeline"]. -->
|
||||
<div class="chat-session-timeline" id="chat-session-timeline" role="region"
|
||||
aria-label="Session timeline">
|
||||
<div class="chat-session-view-empty">Switch to 时序 to see this session's step timeline.</div>
|
||||
</div>
|
||||
<!-- lane-p1-tabs: full-pane Trace mount. Painted by renderer.js
|
||||
refreshSessionTrace() via the trace tri-view module (Tree / Graph
|
||||
sub-chips) over the active session's aggregate. Shown only for
|
||||
[data-chat-view="trace"]. -->
|
||||
<div class="chat-session-trace" id="chat-session-trace" role="region"
|
||||
aria-label="Session trace">
|
||||
<div class="chat-session-view-empty">Switch to Trace to see this session's step tree.</div>
|
||||
</div>
|
||||
<!-- lane-p1-tabs: full-pane Log mount. Owned by session-log-view.js —
|
||||
a full-history replay (window.dsh.sessionEvents) of the active
|
||||
session merged with live events, with type-filter chips + text
|
||||
search + per-row { } inspector badge. Shown only for
|
||||
[data-chat-view="log"]. -->
|
||||
<div class="chat-session-log" id="chat-session-log" role="region"
|
||||
aria-label="Session log">
|
||||
<div class="chat-session-view-empty">Switch to Log to replay this session's full event history.</div>
|
||||
</div>
|
||||
<!-- feat/chat-triple-view: right-side detail drawer. Rendered by
|
||||
chat-side-drawer.js on toggle. `.hidden` class collapses; the
|
||||
#chat-side-drawer-btn button in the header flips it. -->
|
||||
@@ -1541,6 +1577,7 @@
|
||||
<script src="./assistant-turn.js"></script><!-- task #162 rec 22-bis: assistant-turn container (consumes the three above) -->
|
||||
<script src="./chat-side-drawer.js"></script><!-- feat/chat-triple-view: right-side turn/session detail drawer -->
|
||||
<script src="./chat-session-graph.js"></script><!-- feat/chat-triple-view: session DAG (turn nodes + fork/interrupt edges) -->
|
||||
<script src="./session-log-view.js"></script><!-- lane-p1-tabs: full-history Log view (sessionEvents replay + live merge + filter/inspector) -->
|
||||
<script src="./chat-refresh-throttle.js"></script><!-- fix/code-bugs-batch P1-3: rAF-coalesced throttle for drawer/graph refresh -->
|
||||
|
||||
<script src="./event-filter.js"></script>
|
||||
|
||||
@@ -602,6 +602,14 @@ async function selectSession(id) {
|
||||
// Per-session queue: repaint the strip for the session we just switched to.
|
||||
// Strict isolation — the strip only ever shows the active session's queue.
|
||||
renderMsgQueueStrip()
|
||||
// lane-p1-tabs: re-point whichever alternate Chat view is on-screen at the
|
||||
// session we just switched to. The graph path already refreshed via
|
||||
// refreshSessionGraphIfActive during replay ticks, but timeline/trace/log
|
||||
// key off the active session and must rebuild on switch even when no live
|
||||
// event follows. setChatView is cheap and idempotent for the current view.
|
||||
if (chatPaneEl && chatPaneEl.dataset.chatView && chatPaneEl.dataset.chatView !== 'list') {
|
||||
setChatView(chatPaneEl.dataset.chatView)
|
||||
}
|
||||
}
|
||||
|
||||
async function replayHistory(id) {
|
||||
@@ -2605,6 +2613,24 @@ function deepLinkToSeq(seq) {
|
||||
}
|
||||
if (typeof window !== 'undefined') window.__dshDeepLinkToSeq = deepLinkToSeq
|
||||
|
||||
// lane-p1-tabs: cross-page bridge for the Tracing-page demotion. Drilling a
|
||||
// row on the Tracing page no longer swaps the table for inline tri-view
|
||||
// panels — it navigates to the Chat pane, selects that session, and opens the
|
||||
// session-scoped Trace tab (one call). This is the single navigation seam
|
||||
// tracing-page.openDrill calls; it keeps all session-switch bookkeeping
|
||||
// (replay, meter, queue) in renderer.js rather than duplicating it there.
|
||||
async function openSessionTrace(sessionId) {
|
||||
if (!sessionId) return
|
||||
try {
|
||||
if (window.__dshTabs && typeof window.__dshTabs.switchTo === 'function') {
|
||||
window.__dshTabs.switchTo('chat')
|
||||
}
|
||||
await selectSession(sessionId)
|
||||
setChatView('trace')
|
||||
} catch (_) { /* stale session id / offline — swallow, nav is best-effort */ }
|
||||
}
|
||||
if (typeof window !== 'undefined') window.__dshOpenSessionTrace = openSessionTrace
|
||||
|
||||
// expose a direct-dispatch seam so the tri-view CDP shoot driver
|
||||
// can play fixtures without booting a daemon (offline env where tsx
|
||||
// resolution fails at daemon spawn). Sets state.activeSessionId + streamEl
|
||||
@@ -4615,6 +4641,12 @@ const chatSideDrawerEl = document.getElementById('chat-side-drawer')
|
||||
const chatSideDrawerBodyEl = document.getElementById('chat-side-drawer-body')
|
||||
const chatSideDrawerCloseBtn = document.getElementById('chat-side-drawer-close')
|
||||
const chatSessionGraphEl = document.getElementById('chat-session-graph')
|
||||
// lane-p1-tabs: full-pane 时序 / Trace / Log mounts. Timeline + Trace reuse
|
||||
// the trace tri-view modules over the active session's aggregate; Log is
|
||||
// owned by session-log-view.js (full-history replay + live merge).
|
||||
const chatSessionTimelineEl = document.getElementById('chat-session-timeline')
|
||||
const chatSessionTraceEl = document.getElementById('chat-session-trace')
|
||||
const chatSessionLogEl = document.getElementById('chat-session-log')
|
||||
const chatViewTabEls = document.querySelectorAll('.chat-view-tab')
|
||||
|
||||
// Default the pane to List. The absence of the attribute would leave the
|
||||
@@ -4691,9 +4723,14 @@ if (chatSideDrawerCloseBtn) {
|
||||
chatSideDrawerCloseBtn.addEventListener('click', () => setChatDrawerOpen(false))
|
||||
}
|
||||
|
||||
// lane-p1-tabs: the Chat pane view strip is now five-way —
|
||||
// list | graph | timeline | trace | log — all scoped to the ACTIVE session.
|
||||
// list/graph keep their prior behavior; timeline/trace mount the trace
|
||||
// tri-view modules full-pane; log mounts session-log-view.js.
|
||||
const CHAT_VIEWS = ['list', 'graph', 'timeline', 'trace', 'log']
|
||||
function setChatView(view) {
|
||||
if (!chatPaneEl) return
|
||||
const v = view === 'graph' ? 'graph' : 'list'
|
||||
const v = CHAT_VIEWS.includes(view) ? view : 'list'
|
||||
chatPaneEl.dataset.chatView = v
|
||||
for (const btn of chatViewTabEls) {
|
||||
const active = btn.dataset.chatViewTab === v
|
||||
@@ -4701,6 +4738,91 @@ function setChatView(view) {
|
||||
btn.setAttribute('aria-selected', active ? 'true' : 'false')
|
||||
}
|
||||
if (v === 'graph') refreshSessionGraph()
|
||||
else if (v === 'timeline') refreshSessionTimeline()
|
||||
else if (v === 'trace') refreshSessionTrace()
|
||||
else if (v === 'log') refreshSessionLog()
|
||||
}
|
||||
// Aggregate the active session's cached events into trace step-records — the
|
||||
// same derivation the per-turn footer tri-view and the Tracing-page drill use
|
||||
// (trace-tri-view.sessionTraceRecords → trace-aggregator.aggregateSteps).
|
||||
function activeSessionTraceRecords() {
|
||||
const Tri = window.__dshTraceTriView
|
||||
if (!Tri || typeof Tri.sessionTraceRecords !== 'function') return []
|
||||
const meta = state.activeSessionId ? state.sessions.get(state.activeSessionId) : null
|
||||
const events = (meta && Array.isArray(meta.cachedEvents)) ? meta.cachedEvents : []
|
||||
return Tri.sessionTraceRecords(events)
|
||||
}
|
||||
function activeSessionHeader() {
|
||||
const meta = state.activeSessionId ? state.sessions.get(state.activeSessionId) : null
|
||||
return (meta && meta.header) ? meta.header : null
|
||||
}
|
||||
// 时序 tab: mount the tri-view's Timeline projection full-pane for the active
|
||||
// session. Rebuilt on every entry/session-switch/tick so a live turn extends
|
||||
// the Gantt as steps close. Reuses trace-timeline.js via renderTimeline.
|
||||
function refreshSessionTimeline() {
|
||||
if (!chatSessionTimelineEl) return
|
||||
const T = window.__dshTraceTimeline
|
||||
chatSessionTimelineEl.textContent = ''
|
||||
const records = activeSessionTraceRecords()
|
||||
if (!T || typeof T.renderTimeline !== 'function' || records.length === 0) {
|
||||
const empty = document.createElement('div')
|
||||
empty.className = 'chat-session-view-empty'
|
||||
empty.textContent = records.length === 0
|
||||
? 'No steps to plot yet. Send a message on this session.'
|
||||
: 'trace-timeline.js not loaded.'
|
||||
chatSessionTimelineEl.appendChild(empty)
|
||||
return
|
||||
}
|
||||
const el = T.renderTimeline(document, records, {
|
||||
width: 860,
|
||||
onSeqClick: (seq) => deepLinkToSeq(seq),
|
||||
})
|
||||
chatSessionTimelineEl.appendChild(el)
|
||||
}
|
||||
// Trace tab: mount the tri-view full-pane (Tree | Timeline | Graph sub-chips)
|
||||
// for the active session, defaulting to the Tree projection. Reuses
|
||||
// trace-tri-view.buildTriView — no duplicated view code. The session-scope
|
||||
// tri-view omits a pre-rendered tree card (that belongs to a single turn's
|
||||
// footer), so Tree falls through to its session-scope stub while Timeline /
|
||||
// Graph render from the aggregate; this is the same shape the Tracing-page
|
||||
// drill used before its demotion.
|
||||
function refreshSessionTrace() {
|
||||
if (!chatSessionTraceEl) return
|
||||
const Tri = window.__dshTraceTriView
|
||||
chatSessionTraceEl.textContent = ''
|
||||
const records = activeSessionTraceRecords()
|
||||
if (!Tri || typeof Tri.buildTriView !== 'function' || records.length === 0) {
|
||||
const empty = document.createElement('div')
|
||||
empty.className = 'chat-session-view-empty'
|
||||
empty.textContent = records.length === 0
|
||||
? 'No trace steps yet. Send a message on this session.'
|
||||
: 'trace-tri-view.js not loaded.'
|
||||
chatSessionTraceEl.appendChild(empty)
|
||||
return
|
||||
}
|
||||
const tri = Tri.buildTriView(document, {
|
||||
records,
|
||||
scope: 'session',
|
||||
defaultView: 'graph',
|
||||
sessionId: state.activeSessionId || null,
|
||||
sessionHeader: activeSessionHeader(),
|
||||
onSeqClick: (seq) => deepLinkToSeq(seq),
|
||||
})
|
||||
chatSessionTraceEl.appendChild(tri)
|
||||
}
|
||||
// Log tab: full-history replay of the active session, owned by
|
||||
// session-log-view.js. Points the log at the active session (re-runs the
|
||||
// sessionEvents window walk); live events merge in via refreshSessionLogLive.
|
||||
function refreshSessionLog() {
|
||||
if (!chatSessionLogEl) return
|
||||
const L = window.__dshSessionLogView
|
||||
if (!L || typeof L.renderSessionLog !== 'function') return
|
||||
// Seed from the in-memory cache so the log paints immediately even for a
|
||||
// live-only session the daemon hasn't persisted; the sessionEvents walk
|
||||
// supersedes it when the wire has more.
|
||||
const meta = state.activeSessionId ? state.sessions.get(state.activeSessionId) : null
|
||||
const seedEvents = (meta && Array.isArray(meta.cachedEvents)) ? meta.cachedEvents : []
|
||||
L.renderSessionLog(chatSessionLogEl, { sessionId: state.activeSessionId || null, seedEvents })
|
||||
}
|
||||
function refreshSessionGraph() {
|
||||
if (!chatSessionGraphEl) return
|
||||
@@ -4742,7 +4864,22 @@ function refreshSessionGraph() {
|
||||
})
|
||||
}
|
||||
function refreshSessionGraphIfActive() {
|
||||
if (chatPaneEl && chatPaneEl.dataset.chatView === 'graph') refreshSessionGraph()
|
||||
// lane-p1-tabs: keep whichever alternate view is on-screen live. Timeline
|
||||
// and Trace re-derive from the aggregate on each tick; Log merges the one
|
||||
// live event that just arrived (cheaper than a full history re-walk).
|
||||
const view = chatPaneEl && chatPaneEl.dataset.chatView
|
||||
if (view === 'graph') refreshSessionGraph()
|
||||
else if (view === 'timeline') refreshSessionTimeline()
|
||||
else if (view === 'trace') refreshSessionTrace()
|
||||
}
|
||||
// Merge one just-arrived live event into an open Log view. Called from
|
||||
// onSessionEvent's coalesced surface refresh with the raw event so the log
|
||||
// tails without re-walking history. No-op unless the Log tab is on-screen.
|
||||
function refreshSessionLogLive(sessionId, event) {
|
||||
if (!chatPaneEl || chatPaneEl.dataset.chatView !== 'log') return
|
||||
const L = window.__dshSessionLogView
|
||||
if (!L || typeof L.ingestLiveEvent !== 'function' || !chatSessionLogEl) return
|
||||
L.ingestLiveEvent(chatSessionLogEl, sessionId, event)
|
||||
}
|
||||
for (const btn of chatViewTabEls) {
|
||||
btn.addEventListener('click', () => setChatView(btn.dataset.chatViewTab))
|
||||
@@ -5107,6 +5244,10 @@ function onSessionEvent(sessionId, event) {
|
||||
// switched to Graph yet. Coalesced via rAF so long sessions don't take
|
||||
// an O(N²) hit from the O(N) derives.
|
||||
refreshChatSurfacesCoalesced()
|
||||
// lane-p1-tabs: the Log view needs the specific event (the coalesced rAF
|
||||
// refresh above re-derives from cache and can't carry it), so merge it
|
||||
// directly here. No-op unless the Log tab is on-screen for this session.
|
||||
refreshSessionLogLive(sessionId, event)
|
||||
|
||||
// §2.3 (batch 6) template triggers: pure module decides whether the event
|
||||
// qualifies for a template card (T2 error recovery / T4 artifact preview /
|
||||
@@ -8597,6 +8738,12 @@ window.__dshRenderer = {
|
||||
compactNow,
|
||||
confirmDialog,
|
||||
notifyDialog,
|
||||
// lane-p1-tabs: expose the Chat-pane view switcher + the session-scoped
|
||||
// Trace navigation bridge so unit tests + QA can drive the five-way strip
|
||||
// (list | graph | timeline | trace | log) without synthetic click events.
|
||||
setChatView,
|
||||
openSessionTrace,
|
||||
getChatView: () => (chatPaneEl ? chatPaneEl.dataset.chatView : null),
|
||||
// Batch 6 (§2.2): expose steer-card injector so demo drivers can drop a
|
||||
// non-blocking steer card into the active session without a real
|
||||
// session/interrupt round-trip.
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
// session-log-view.js — lane-p1-tabs.
|
||||
//
|
||||
// The Chat pane's Log tab: a full-HISTORY event log for the ACTIVE session,
|
||||
// distinct from the global devtools ring buffer (500-entry, cross-session).
|
||||
// It replays the session's complete event stream through
|
||||
// `window.dsh.sessionEvents(sessionId)` — the same paginated window walk
|
||||
// renderer.js uses for chat replay — then merges live events arriving while
|
||||
// the tab is open so the log tails in real time.
|
||||
//
|
||||
// UI grammar is borrowed from devtools-panel.js so the two logs feel like
|
||||
// one family:
|
||||
// - type-filter chips (one per distinct event type in the log)
|
||||
// - a text search box (matches type / seq / pretty JSON)
|
||||
// - each row is `seq · type · summary`, expandable to a payload preview,
|
||||
// with a `{ }` badge that opens window.__dshInspector anchored to that
|
||||
// event.
|
||||
//
|
||||
// Filtering reuses DevtoolsModel.filterEntries (pure, unit-tested) so the
|
||||
// AND-composition of chips + search matches the devtools panel exactly. The
|
||||
// entry shape ({ id, seq, type, time, event }) mirrors DevtoolsModel's
|
||||
// normalizeEntry so the shared filter works verbatim.
|
||||
//
|
||||
// Large sessions: the log renders lazily. It holds the full entry list in
|
||||
// memory (bounded by whatever the daemon window-walk returned) but only
|
||||
// paints `PAGE` rows at a time, with a "Load more" affordance that reveals
|
||||
// the next page by seq. This keeps first paint cheap on a 5k-event session
|
||||
// without a virtual scroller.
|
||||
//
|
||||
// Pure helpers (normalizeLogEntry / mergeLiveEntry / distinctTypes /
|
||||
// pageSlice) run under node --test with no DOM; the controller (mount /
|
||||
// renderInto / open) needs a document.
|
||||
|
||||
'use strict'
|
||||
|
||||
;(function () {
|
||||
// Rows painted per page. A session with thousands of events still first-
|
||||
// paints one page; "Load more" reveals the next PAGE by ascending seq.
|
||||
const PAGE = 200
|
||||
|
||||
// ─── pure helpers ──────────────────────────────────────────────────────
|
||||
|
||||
// Normalize a raw wire event into the entry shape the filter + row
|
||||
// renderer consume. Mirrors DevtoolsModel.normalizeEntry's field set
|
||||
// ({ id, time, sessionId, type, seq, event }) so DevtoolsModel.filterEntries
|
||||
// works on our entries unchanged. `id` here is the seq when present (stable
|
||||
// across re-render and dedup) falling back to a monotonic counter the
|
||||
// caller supplies.
|
||||
function normalizeLogEntry(event, fallbackId) {
|
||||
const ev = (event && typeof event === 'object') ? event : {}
|
||||
const type = (typeof ev.type === 'string' && ev.type) ? ev.type : '(unknown)'
|
||||
const seq = Number.isFinite(ev.seq) ? ev.seq : null
|
||||
const time = Number.isFinite(ev.time) ? ev.time : null
|
||||
const id = seq !== null ? seq : fallbackId
|
||||
return { id, seq, type, time, sessionId: '', event: ev }
|
||||
}
|
||||
|
||||
// Merge a live entry into an existing (seq-sorted) list, deduping by seq.
|
||||
// An event with no seq always appends (can't dedup a seq-less event). An
|
||||
// event whose seq already exists replaces the prior copy in place (the
|
||||
// daemon may re-emit a fuller payload for the same seq during a live turn).
|
||||
// Returns the same array reference for caller convenience.
|
||||
function mergeLiveEntry(entries, entry) {
|
||||
if (!Array.isArray(entries) || !entry) return entries || []
|
||||
if (entry.seq === null || entry.seq === undefined) {
|
||||
entries.push(entry)
|
||||
return entries
|
||||
}
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if (entries[i] && entries[i].seq === entry.seq) {
|
||||
entries[i] = entry
|
||||
return entries
|
||||
}
|
||||
}
|
||||
// Insert keeping ascending-seq order. Most live events land at the tail,
|
||||
// so scan from the end.
|
||||
let i = entries.length - 1
|
||||
while (i >= 0 && entries[i] && Number.isFinite(entries[i].seq) && entries[i].seq > entry.seq) i--
|
||||
entries.splice(i + 1, 0, entry)
|
||||
return entries
|
||||
}
|
||||
|
||||
// Distinct event types across the entry list, sorted, for the chip row.
|
||||
// Same contract as DevtoolsModel.collectTypes.
|
||||
function distinctTypes(entries) {
|
||||
const s = new Set()
|
||||
for (const e of entries) if (e && e.type) s.add(e.type)
|
||||
return Array.from(s).sort()
|
||||
}
|
||||
|
||||
// One-line summary for a row. Prefers a human field on the payload
|
||||
// (text / content / summary / name / stopReason), falling back to the
|
||||
// trace aggregator's trimSummary when loaded, then a bare type echo.
|
||||
function summarizeEntry(entry) {
|
||||
const ev = entry && entry.event ? entry.event : {}
|
||||
const data = (ev.data && typeof ev.data === 'object') ? ev.data : ev
|
||||
let raw = ''
|
||||
if (typeof data.text === 'string') raw = data.text
|
||||
else if (typeof data.content === 'string') raw = data.content
|
||||
else if (Array.isArray(data.content)) {
|
||||
raw = data.content.map((c) => (c && typeof c.text === 'string') ? c.text : '').join(' ')
|
||||
} else if (typeof data.summary === 'string') raw = data.summary
|
||||
else if (typeof data.name === 'string') raw = data.name
|
||||
else if (typeof data.stopReason === 'string' || typeof data.stop_reason === 'string') {
|
||||
raw = data.stopReason || data.stop_reason
|
||||
} else if (typeof data.delta === 'string') raw = data.delta
|
||||
raw = String(raw || '').replace(/\s+/g, ' ').trim()
|
||||
if (!raw) return ''
|
||||
return raw.length > 80 ? raw.slice(0, 79) + '…' : raw
|
||||
}
|
||||
|
||||
// Slice the filtered list to the first `count` rows (lazy paging). Returns
|
||||
// { rows, hasMore, total }. `count` is clamped to at least PAGE.
|
||||
function pageSlice(filtered, count) {
|
||||
const total = filtered.length
|
||||
const shown = Math.min(total, Math.max(PAGE, count || PAGE))
|
||||
return { rows: filtered.slice(0, shown), hasMore: shown < total, total, shown }
|
||||
}
|
||||
|
||||
// ─── controller (DOM) ────────────────────────────────────────────────────
|
||||
|
||||
// Per-container controller state, keyed off the container element so a
|
||||
// remount reuses the same instance.
|
||||
const controllers = new WeakMap()
|
||||
|
||||
function makeController(container) {
|
||||
const doc = container.ownerDocument
|
||||
|| (typeof window !== 'undefined' && window.document)
|
||||
|| (typeof document !== 'undefined' ? document : null)
|
||||
const state = {
|
||||
sessionId: null,
|
||||
entries: [], // full seq-sorted entry list
|
||||
fallbackId: -1, // decreasing counter for seq-less events
|
||||
typeFilter: new Set(),// active chip types; empty = all
|
||||
text: '',
|
||||
pageCount: PAGE,
|
||||
// element handles, built once
|
||||
chipsEl: null,
|
||||
searchEl: null,
|
||||
listEl: null,
|
||||
countEl: null,
|
||||
moreBtn: null,
|
||||
}
|
||||
|
||||
function nextFallbackId() { state.fallbackId -= 1; return state.fallbackId }
|
||||
|
||||
// Build the static shell (search row + chips row + list + footer). Called
|
||||
// once; subsequent renders only repaint chips/list.
|
||||
function buildShell() {
|
||||
container.textContent = ''
|
||||
const head = doc.createElement('div')
|
||||
head.className = 'session-log-head'
|
||||
|
||||
const search = doc.createElement('input')
|
||||
search.type = 'search'
|
||||
search.className = 'session-log-search'
|
||||
search.placeholder = 'Search type / seq / payload…'
|
||||
search.setAttribute('aria-label', 'Search session log')
|
||||
search.addEventListener('input', function () {
|
||||
state.text = search.value || ''
|
||||
state.pageCount = PAGE
|
||||
renderList()
|
||||
})
|
||||
state.searchEl = search
|
||||
|
||||
const count = doc.createElement('span')
|
||||
count.className = 'session-log-count muted'
|
||||
state.countEl = count
|
||||
|
||||
head.appendChild(search)
|
||||
head.appendChild(count)
|
||||
|
||||
const chips = doc.createElement('div')
|
||||
chips.className = 'session-log-chips'
|
||||
chips.setAttribute('role', 'group')
|
||||
chips.setAttribute('aria-label', 'Filter by event type')
|
||||
state.chipsEl = chips
|
||||
|
||||
const list = doc.createElement('div')
|
||||
list.className = 'session-log-list'
|
||||
list.setAttribute('role', 'log')
|
||||
state.listEl = list
|
||||
|
||||
const more = doc.createElement('button')
|
||||
more.type = 'button'
|
||||
more.className = 'session-log-more ghost small'
|
||||
more.textContent = 'Load more'
|
||||
more.hidden = true
|
||||
more.addEventListener('click', function () {
|
||||
state.pageCount += PAGE
|
||||
renderList()
|
||||
})
|
||||
state.moreBtn = more
|
||||
|
||||
container.appendChild(head)
|
||||
container.appendChild(chips)
|
||||
container.appendChild(list)
|
||||
container.appendChild(more)
|
||||
}
|
||||
|
||||
function toggleType(t) {
|
||||
if (state.typeFilter.has(t)) state.typeFilter.delete(t)
|
||||
else state.typeFilter.add(t)
|
||||
state.pageCount = PAGE
|
||||
renderChips()
|
||||
renderList()
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
if (!state.chipsEl) return
|
||||
state.chipsEl.textContent = ''
|
||||
const types = distinctTypes(state.entries)
|
||||
if (types.length === 0) {
|
||||
const empty = doc.createElement('span')
|
||||
empty.className = 'session-log-chips-empty muted'
|
||||
empty.textContent = 'no events yet'
|
||||
state.chipsEl.appendChild(empty)
|
||||
return
|
||||
}
|
||||
for (const t of types) {
|
||||
const chip = doc.createElement('button')
|
||||
chip.type = 'button'
|
||||
chip.className = 'session-log-chip' + (state.typeFilter.has(t) ? ' active' : '')
|
||||
chip.dataset.type = t
|
||||
chip.textContent = t
|
||||
chip.addEventListener('click', function () { toggleType(t) })
|
||||
state.chipsEl.appendChild(chip)
|
||||
}
|
||||
}
|
||||
|
||||
function filtered() {
|
||||
const M = (typeof window !== 'undefined' && window.DevtoolsModel) || null
|
||||
if (M && typeof M.filterEntries === 'function') {
|
||||
return M.filterEntries(state.entries, { types: state.typeFilter, text: state.text })
|
||||
}
|
||||
// Fallback (module not loaded — lean test env): type set + substring.
|
||||
const q = String(state.text || '').trim().toLowerCase()
|
||||
const typeSet = state.typeFilter.size > 0 ? state.typeFilter : null
|
||||
return state.entries.filter(function (e) {
|
||||
if (typeSet && !typeSet.has(e.type)) return false
|
||||
if (q) {
|
||||
const hay = (String(e.type) + ' ' + String(e.seq) + ' ' + JSON.stringify(e.event || {})).toLowerCase()
|
||||
if (!hay.includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
if (!state.listEl) return
|
||||
state.listEl.textContent = ''
|
||||
const rows = filtered()
|
||||
const { rows: page, hasMore, total, shown } = pageSlice(rows, state.pageCount)
|
||||
for (const entry of page) {
|
||||
state.listEl.appendChild(buildRow(entry))
|
||||
}
|
||||
if (state.countEl) {
|
||||
state.countEl.textContent = total === state.entries.length
|
||||
? `${total} events`
|
||||
: `${total} / ${state.entries.length} events`
|
||||
}
|
||||
if (state.moreBtn) {
|
||||
state.moreBtn.hidden = !hasMore
|
||||
state.moreBtn.textContent = hasMore ? `Load more (${total - shown} hidden)` : 'Load more'
|
||||
}
|
||||
if (page.length === 0) {
|
||||
const empty = doc.createElement('div')
|
||||
empty.className = 'session-log-empty muted'
|
||||
empty.textContent = state.entries.length === 0
|
||||
? 'No events in this session yet.'
|
||||
: 'No events match the current filter.'
|
||||
state.listEl.appendChild(empty)
|
||||
}
|
||||
}
|
||||
|
||||
function buildRow(entry) {
|
||||
const row = doc.createElement('details')
|
||||
row.className = 'session-log-row'
|
||||
if (entry.seq !== null && entry.seq !== undefined) row.dataset.seq = String(entry.seq)
|
||||
row.dataset.type = entry.type
|
||||
|
||||
const summary = doc.createElement('summary')
|
||||
summary.className = 'session-log-row-summary'
|
||||
|
||||
const seqEl = doc.createElement('span')
|
||||
seqEl.className = 'session-log-seq mono'
|
||||
seqEl.textContent = entry.seq !== null && entry.seq !== undefined ? String(entry.seq) : '—'
|
||||
|
||||
const typeEl = doc.createElement('span')
|
||||
typeEl.className = 'session-log-type mono'
|
||||
typeEl.textContent = entry.type
|
||||
|
||||
const sumEl = doc.createElement('span')
|
||||
sumEl.className = 'session-log-summary'
|
||||
sumEl.textContent = summarizeEntry(entry)
|
||||
|
||||
summary.appendChild(seqEl)
|
||||
summary.appendChild(typeEl)
|
||||
summary.appendChild(sumEl)
|
||||
|
||||
// { } inspector badge — opens the unified inspector anchored to this
|
||||
// event. attachInspectBadge resolves the target at click time, so we
|
||||
// hand it a closure returning { event }. Falls back to a bare button
|
||||
// wired to open() when attachInspectBadge is unavailable.
|
||||
const insp = (typeof window !== 'undefined' && window.__dshInspector) || null
|
||||
if (insp && typeof insp.attachInspectBadge === 'function') {
|
||||
insp.attachInspectBadge(summary, function () {
|
||||
return { event: entry.event, tab: 'pretty', title: `seq ${entry.seq} · ${entry.type}` }
|
||||
})
|
||||
} else if (insp && typeof insp.open === 'function') {
|
||||
const badge = doc.createElement('button')
|
||||
badge.type = 'button'
|
||||
badge.className = 'inspect-badge'
|
||||
badge.textContent = '{ }'
|
||||
badge.title = 'Inspect · Pretty / Raw / JSON'
|
||||
badge.addEventListener('click', function (e) {
|
||||
if (e && e.stopPropagation) e.stopPropagation()
|
||||
if (e && e.preventDefault) e.preventDefault()
|
||||
insp.open({ event: entry.event, tab: 'pretty', title: `seq ${entry.seq} · ${entry.type}` })
|
||||
})
|
||||
summary.appendChild(badge)
|
||||
}
|
||||
|
||||
row.appendChild(summary)
|
||||
|
||||
// Expanded body: a pretty-printed payload preview. Built lazily on
|
||||
// first toggle so a filter over thousands of rows doesn't pay the
|
||||
// JSON.stringify cost up front.
|
||||
const body = doc.createElement('div')
|
||||
body.className = 'session-log-row-body'
|
||||
let filled = false
|
||||
row.addEventListener('toggle', function () {
|
||||
if (row.open && !filled) {
|
||||
filled = true
|
||||
const pre = doc.createElement('pre')
|
||||
pre.className = 'session-log-payload mono'
|
||||
pre.textContent = formatPayload(entry.event)
|
||||
body.appendChild(pre)
|
||||
}
|
||||
})
|
||||
row.appendChild(body)
|
||||
return row
|
||||
}
|
||||
|
||||
// ─── data lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
// Replay the session's full history through the sessionEvents window
|
||||
// walk, then paint. Bounded by the daemon's window cap (same walk as
|
||||
// renderer.js replayHistory). `seedEvents` is the in-memory cache the
|
||||
// caller already holds (state.sessions[sid].cachedEvents): we paint it
|
||||
// immediately so the log isn't blank while the walk runs, and keep
|
||||
// whichever source ends up with more entries — mirroring replayHistory's
|
||||
// "more events wins" rule so a live-only session (daemon hasn't persisted
|
||||
// it yet) still shows its full history.
|
||||
async function loadHistory(sessionId, seedEvents) {
|
||||
state.fallbackId = -1
|
||||
const seed = Array.isArray(seedEvents) ? seedEvents : []
|
||||
state.entries = seed.map((ev) => normalizeLogEntry(ev, nextFallbackId()))
|
||||
renderChips()
|
||||
renderList()
|
||||
const bridge = (typeof window !== 'undefined' && window.dsh && window.dsh.sessionEvents)
|
||||
? window.dsh.sessionEvents
|
||||
: null
|
||||
if (!bridge) return
|
||||
let listing
|
||||
try { listing = await bridge(sessionId, {}) }
|
||||
catch (_) { return }
|
||||
if (state.sessionId !== sessionId) return // switched away mid-fetch
|
||||
if (!listing || !Array.isArray(listing.events) || listing.events.length === 0) return
|
||||
const WINDOW = 50
|
||||
const total = listing.events.length
|
||||
const maxRounds = Math.ceil(total / WINDOW) + 2
|
||||
const collected = []
|
||||
const seen = new Set()
|
||||
let cursor = listing.events[total - 1].seq
|
||||
let rounds = 0
|
||||
let progressed = true
|
||||
while (cursor >= 0 && rounds < maxRounds && progressed) {
|
||||
rounds++
|
||||
progressed = false
|
||||
let chunk
|
||||
try { chunk = await bridge(sessionId, { seq: cursor, before: WINDOW, after: 0 }) }
|
||||
catch (_) { break }
|
||||
if (state.sessionId !== sessionId) return
|
||||
if (!chunk || !Array.isArray(chunk.events) || chunk.events.length === 0) break
|
||||
const beforeSize = collected.length
|
||||
for (const ev of chunk.events) {
|
||||
if (typeof ev.seq !== 'number' || seen.has(ev.seq)) continue
|
||||
seen.add(ev.seq)
|
||||
collected.push(ev)
|
||||
}
|
||||
if (collected.length > beforeSize) progressed = true
|
||||
if (collected.length >= total) break
|
||||
const nextStart = typeof chunk.startSeq === 'number' ? chunk.startSeq : chunk.events[0].seq
|
||||
if (nextStart <= 0) break
|
||||
const nextCursor = nextStart - 1
|
||||
if (nextCursor >= cursor) break
|
||||
cursor = nextCursor
|
||||
}
|
||||
collected.sort((a, b) => (a.seq || 0) - (b.seq || 0))
|
||||
// "More events wins" (replayHistory parity): keep the seed when it has
|
||||
// at least as many entries as the wire walk, so a live-only session
|
||||
// isn't blanked by a daemon that returns nothing. Preserve any live
|
||||
// events that merged into the seed while the walk was in flight.
|
||||
if (collected.length > state.entries.length) {
|
||||
state.entries = collected.map((ev) => normalizeLogEntry(ev, nextFallbackId()))
|
||||
renderChips()
|
||||
renderList()
|
||||
}
|
||||
}
|
||||
|
||||
// Point the log at a session: rebuild shell if needed, kick history load.
|
||||
// `seedEvents` is the caller's in-memory cache for immediate paint.
|
||||
function setSession(sessionId, seedEvents) {
|
||||
if (!state.chipsEl) buildShell()
|
||||
state.sessionId = sessionId || null
|
||||
state.pageCount = PAGE
|
||||
if (!sessionId) {
|
||||
state.entries = []
|
||||
renderChips(); renderList()
|
||||
return
|
||||
}
|
||||
void loadHistory(sessionId, seedEvents)
|
||||
}
|
||||
|
||||
// A live event landed for a session. Merge it if it belongs to the
|
||||
// session we're showing; ignore otherwise. Repaints coalesced by the
|
||||
// caller (renderer.js already rAF-throttles chat surface refreshes).
|
||||
function onLiveEvent(sessionId, event) {
|
||||
if (!sessionId || sessionId !== state.sessionId) return
|
||||
const entry = normalizeLogEntry(event, nextFallbackId())
|
||||
mergeLiveEntry(state.entries, entry)
|
||||
// A brand-new type means the chip row grew.
|
||||
renderChips()
|
||||
renderList()
|
||||
}
|
||||
|
||||
return { setSession, onLiveEvent, _state: state }
|
||||
}
|
||||
|
||||
function formatPayload(event) {
|
||||
// Reuse the inspector/devtools JSON formatter when present so the payload
|
||||
// preview matches the { } drawer; fall back to a guarded stringify.
|
||||
const M = (typeof window !== 'undefined' && window.DevtoolsModel) || null
|
||||
if (M && typeof M.formatJSON === 'function') return M.formatJSON(event)
|
||||
try { return JSON.stringify(event, null, 2) }
|
||||
catch (_) { return String(event) }
|
||||
}
|
||||
|
||||
// Get (or lazily create) the controller bound to a container element.
|
||||
function controllerFor(container) {
|
||||
if (!container) return null
|
||||
let c = controllers.get(container)
|
||||
if (!c) { c = makeController(container); controllers.set(container, c) }
|
||||
return c
|
||||
}
|
||||
|
||||
// ─── public API ──────────────────────────────────────────────────────
|
||||
|
||||
// renderSessionLog(container, { sessionId, seedEvents }) — (re)point the
|
||||
// Log view at a session. Idempotent per container; a session switch re-runs
|
||||
// the history replay. `seedEvents` (the caller's in-memory cache) paints
|
||||
// immediately so the log isn't blank while the wire walk runs. Safe to call
|
||||
// when sessionId is falsy (renders the empty state).
|
||||
function renderSessionLog(container, opts) {
|
||||
const c = controllerFor(container)
|
||||
if (!c) return
|
||||
const o = opts || {}
|
||||
c.setSession(o.sessionId || null, o.seedEvents || null)
|
||||
}
|
||||
|
||||
// ingestLiveEvent(container, sessionId, event) — merge a live event into an
|
||||
// open Log view. No-op when the event is for another session.
|
||||
function ingestLiveEvent(container, sessionId, event) {
|
||||
const c = controllers.get(container)
|
||||
if (!c) return
|
||||
c.onLiveEvent(sessionId, event)
|
||||
}
|
||||
|
||||
const api = {
|
||||
// pure
|
||||
normalizeLogEntry, mergeLiveEntry, distinctTypes, summarizeEntry, pageSlice,
|
||||
// controller
|
||||
renderSessionLog, ingestLiveEvent,
|
||||
PAGE,
|
||||
}
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api
|
||||
if (typeof window !== 'undefined') window.__dshSessionLogView = api
|
||||
})()
|
||||
@@ -12533,11 +12533,154 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
/* Stream shows only for [data-chat-view="list"], graph only for
|
||||
* [data-chat-view="graph"]. The pane is the parent that carries the
|
||||
* data attribute so a single toggle switches both children. */
|
||||
.pane[data-pane="chat"][data-chat-view="graph"] .stream { display: none; }
|
||||
.pane[data-pane="chat"][data-chat-view="list"] .chat-session-graph { display: none; }
|
||||
/* Stream shows only for [data-chat-view="list"]; each alternate view shows
|
||||
* only when its own data-chat-view value is active. The pane is the parent
|
||||
* that carries the data attribute so a single toggle switches all children.
|
||||
* lane-p1-tabs expanded this from a 2-way (list/graph) toggle to 5 views. */
|
||||
.chat-session-timeline,
|
||||
.chat-session-trace,
|
||||
.chat-session-log { display: none; }
|
||||
.pane[data-pane="chat"][data-chat-view="graph"] .stream { display: none; }
|
||||
.pane[data-pane="chat"][data-chat-view="timeline"] .stream { display: none; }
|
||||
.pane[data-pane="chat"][data-chat-view="trace"] .stream { display: none; }
|
||||
.pane[data-pane="chat"][data-chat-view="log"] .stream { display: none; }
|
||||
.pane[data-pane="chat"]:not([data-chat-view="graph"]) .chat-session-graph { display: none; }
|
||||
.pane[data-pane="chat"][data-chat-view="timeline"] .chat-session-timeline { display: block; }
|
||||
.pane[data-pane="chat"][data-chat-view="trace"] .chat-session-trace { display: flex; }
|
||||
.pane[data-pane="chat"][data-chat-view="log"] .chat-session-log { display: flex; }
|
||||
|
||||
/* lane-p1-tabs: 时序 / Trace / Log full-pane containers share the graph's
|
||||
* scroll+padding shell. Trace + Log are flex-column so their inner toolbar
|
||||
* pins while the body scrolls. */
|
||||
.chat-session-timeline {
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
.chat-session-trace,
|
||||
.chat-session-log {
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.chat-session-view-empty {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.chat-session-trace .trace-tri-view { padding: 12px 20px; overflow: auto; min-height: 0; }
|
||||
|
||||
/* lane-p1-tabs: Log view (session-log-view.js). Header (search + count),
|
||||
* chip row, scrolling list of expandable rows, footer "Load more". Grammar
|
||||
* mirrors the devtools panel so the two logs read as one family. */
|
||||
.session-log-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.session-log-search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
padding: 5px 9px;
|
||||
}
|
||||
.session-log-count { font-size: 12px; white-space: nowrap; }
|
||||
.session-log-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
padding: 8px 20px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
flex: 0 0 auto;
|
||||
max-height: 92px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.session-log-chips-empty { font-size: 12px; }
|
||||
.session-log-chip {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
padding: 2px 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.session-log-chip:hover { color: var(--text); }
|
||||
.session-log-chip.active {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.session-log-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
padding: 6px 12px 20px;
|
||||
}
|
||||
.session-log-row {
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
.session-log-row-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.session-log-row-summary::-webkit-details-marker { display: none; }
|
||||
.session-log-seq {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
min-width: 44px;
|
||||
text-align: right;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.session-log-type {
|
||||
color: var(--accent);
|
||||
font-size: 11.5px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.session-log-summary {
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.session-log-row-body { padding: 0 8px 8px 62px; }
|
||||
.session-log-payload {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
color: var(--text);
|
||||
font-size: 11.5px;
|
||||
margin: 0;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
padding: 8px 10px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.session-log-empty { font-size: 12.5px; padding: 24px 8px; text-align: center; }
|
||||
.session-log-more {
|
||||
align-self: center;
|
||||
margin: 8px auto 16px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Give the pane a positioning context so the absolute drawer anchors
|
||||
* inside it, not against the viewport root. */
|
||||
|
||||
@@ -17,10 +17,14 @@
|
||||
// - Columns menu: checkbox per column, persisted to localStorage under
|
||||
// `dsh.tracing.columns.v1`. New columns land visible by default so a
|
||||
// later release doesn't come up mysteriously narrow for old users.
|
||||
// - Row click: pulls the session's cachedEvents through
|
||||
// __dshTraceTriView.sessionTraceRecords(), swaps the table for the
|
||||
// tri-view panels (Timeline / Graph default; Tree stub notes per-turn
|
||||
// scope), and shows a breadcrumb Back to the table.
|
||||
// - Row click: navigates to the Chat pane and opens that session's Trace
|
||||
// tab (lane-p1-tabs demotion — window.__dshOpenSessionTrace). The
|
||||
// eight-column cross-session table is the Tracing page's whole job; the
|
||||
// per-session tri-view now lives on the Chat pane's Trace tab, so a drill
|
||||
// is a one-call navigation rather than an inline table swap. The former
|
||||
// inline drill (breadcrumb + #tracing-page-detail tri-view) is retained
|
||||
// only as a fallback for when the Chat-pane bridge is unavailable (lean
|
||||
// test env); see openDrill.
|
||||
//
|
||||
// Layer contract per docs/design-refs/density-layering-spec.md §7:
|
||||
// - Numeric columns right-align with `tabular-nums` (see style.css).
|
||||
@@ -330,7 +334,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Row click / rubric-cell-jump entry point. lane-p1-tabs demotion: prefer
|
||||
// the Chat-pane Trace tab (window.__dshOpenSessionTrace) so a drill is a
|
||||
// single navigation and the per-session tri-view has one home. Falls back
|
||||
// to the legacy inline tri-view (openDrillInline) only when the bridge is
|
||||
// absent — e.g. a lean unit-test env that mounts tracing-page.js without
|
||||
// the full renderer. `name` is unused on the nav path (the Chat pane owns
|
||||
// its own title) but kept for the inline fallback signature.
|
||||
function openDrill (sessionId, name) {
|
||||
if (!sessionId) return
|
||||
if (typeof window.__dshOpenSessionTrace === 'function') {
|
||||
try { void window.__dshOpenSessionTrace(sessionId) } catch (_) { /* nav best-effort */ }
|
||||
return
|
||||
}
|
||||
openDrillInline(sessionId, name)
|
||||
}
|
||||
|
||||
// Legacy inline drill — swaps the table for a session-scoped tri-view with
|
||||
// a Back breadcrumb. Retained as the no-bridge fallback (see openDrill).
|
||||
// Fully reachable only when window.__dshOpenSessionTrace is undefined; in
|
||||
// the shipped app the Chat-pane Trace tab supersedes it.
|
||||
function openDrillInline (sessionId, name) {
|
||||
if (!els || !sessionId) return
|
||||
const Chat = window.__dshChat
|
||||
const Tri = window.__dshTraceTriView
|
||||
|
||||
Reference in New Issue
Block a user