feat(desktop): trace signal detection + tri-view badges

Adds a small signal-detection layer over trace payloads and surfaces
three kinds of semantic signals inline in the trace tri-view (Timeline
/ Graph / turn footer chips) so a researcher can spot repetitive
tool-call loops, redundant identical calls, and plan-completion beats
without hand-scanning the raw log:

- loop-detected  — three or more repeats of the same tool.method
                   signature inside a window, badge in Timeline gutter
                   + colored ring on the Graph node
- redundant-call — identical (tool, arg-hash) call twice without any
                   state change in between, badge + subdued styling
                   so it stays advisory not alarmist
- plan-* chips   — plan/subplan step-complete beats surfaced above the
                   assistant body in the turn footer, wired through
                   the finishTurnContainer tail

Pure rendering; no wire-format changes needed on the runtime side —
signal detection is fixture-driven off the same payload the tri-view
already consumes. Companion RFC L-2 in docs/upstream-ledger.md asks
the runtime to emit these signals natively rather than deriving them
in the shell, so once L-2 lands the shell will consume upstream signals
and this detector becomes a fallback.

Files:
  src/renderer/trace-signal-detect.js       new  (loop/redundant/plan)
  src/renderer/trace-tri-view.js            +24  (wire detector output)
  src/renderer/trace-timeline.js            +39  (badge in gutter)
  src/renderer/trace-graph.js               +30  (colored ring)
  src/renderer/renderer.js                  +81  (finishTurnContainer chips)
  src/renderer/index.html                    +1  (one script tag)
  src/renderer/style.css                    +58  (badge/ring/chip rules)
  docs/upstream-ledger.md                  +121  (append L-2 RFC)
  scripts/qa-trace-signals-fixture.mjs     new  (headless SVG proof)
  scripts/qa-trace-signals-shoot.mjs       new  (CDP live shoot)
  docs/trace-signals-shoot/*.html          new  (3 fixture-driven shots)
  test/trace-signal-detect.test.js         new  (detector unit tests)
  test/trace-signal-overlay.test.js        new  (renderer overlay tests)

Test suite: 1668/1668 pass (17 new). Fixture-driven signals-01-timeline-
loop.html regenerated byte-identical from the merged HEAD.
This commit is contained in:
ZiyaZhang
2026-07-19 00:55:55 -07:00
parent c05f1b6dd2
commit 47949244c8
15 changed files with 1639 additions and 1 deletions

View File

@@ -1323,6 +1323,7 @@
<script src="./inject-family.js"></script>
<script src="./raw-inject.js"></script><!-- ticket #15 B: envelope:'raw' classifier -->
<script src="./trace-aggregator.js"></script>
<script src="./trace-signal-detect.js"></script><!-- lane-trace-signals: heuristic signal detector (see docs/upstream-ledger.md L-2) -->
<script src="./trace-timeline.js"></script><!-- task #203: view B Gantt/waterfall -->
<script src="./trace-graph.js"></script><!-- task #203: view C agent node graph -->
<script src="./trace-detail-pane.js"></script><!-- task #205: right-side detail pane (4 tabs) -->

View File

@@ -1553,9 +1553,90 @@ function finishTurnContainer(sessionId, { footerSpec, traceCard, traceSummaryTex
}
ct.section.appendChild(footer)
ct.section.dataset.turnStatus = 'sealed'
// Signal marker chips: overlay any loop/redundant/plan/error signals
// detected in this turn's cached events onto the top of the turn
// section. The chip row sits above the assistant body so a reader
// scanning the stream sees "this turn had a loop" before deciding
// whether to open the trace drawer. See trace-signal-detect.js and
// docs/upstream-ledger.md L-2.
applyTurnSignalChips(sessionId, ct.section)
state.currentTurn = null
}
// Compute+attach the signal chip row for a just-sealed turn. Reads
// meta.cachedEvents (already populated) and detects signals whose seq
// falls inside this turn's range. When no signals fire, no chip row is
// added.
function applyTurnSignalChips(sessionId, section) {
try {
const SD = window.__dshTraceSignalDetect
if (!SD || typeof SD.detectSignals !== 'function') return
const meta = state.sessions.get(sessionId)
if (!meta || !Array.isArray(meta.cachedEvents) || !meta.cachedEvents.length) return
// Restrict to events whose seq falls inside this turn's range so the
// chip row reflects THIS turn, not the whole session. We use the last
// `turn/start`→`turn/end` bracket in the cache. When no bracket is
// findable, fall back to detecting on the whole cache (which will still
// produce meaningful chips at the session scope).
const range = _lastTurnSeqRange(meta.cachedEvents)
const scope = range
? meta.cachedEvents.filter(ev => typeof ev.seq === 'number'
&& ev.seq >= range.start && ev.seq <= range.end)
: meta.cachedEvents
const { all } = SD.detectSignals(scope)
if (!all.length) return
// Dedup by signal kind for the chip row: the row is a "kinds seen"
// summary; the badges in the drawer show the specific seqs.
const seen = new Map()
for (const sig of all) {
const key = sig.signal
if (!seen.has(key)) seen.set(key, { signal: sig.signal, count: 1, first: sig })
else seen.get(key).count++
}
const row = document.createElement('div')
row.className = 'turn-signal-chip-row'
for (const entry of seen.values()) {
const chip = document.createElement('button')
chip.type = 'button'
chip.className = `turn-signal-chip ${SD.classFor(entry.signal)}`
chip.dataset.signal = entry.signal
chip.textContent = entry.count > 1
? `${SD.labelFor(entry.signal)} × ${entry.count}`
: SD.labelFor(entry.signal)
chip.title = SD.tooltipFor(entry.first)
// Clicking a chip opens the trace drawer so the reader can drill in.
chip.addEventListener('click', function () {
const drawer = section.querySelector('.turn-trace-drawer')
if (drawer) {
drawer.open = true
if (typeof drawer.scrollIntoView === 'function') {
try { drawer.scrollIntoView({ block: 'nearest' }) } catch (_) {}
}
}
})
row.appendChild(chip)
}
// Insert as the first body-child so it sits above assistant text/tool
// rows without breaking the turn-rule up top.
const body = section.querySelector('.turn-body')
if (body && body.firstChild) body.insertBefore(row, body.firstChild)
else if (body) body.appendChild(row)
else section.appendChild(row)
} catch (_) { /* chip row is a visual enhancement — never crash the stream */ }
}
function _lastTurnSeqRange(events) {
let start = null, end = null
for (let i = events.length - 1; i >= 0; i--) {
const ev = events[i]
if (!ev || typeof ev.seq !== 'number') continue
if (end === null && ev.type === 'turn/end') end = ev.seq
if (ev.type === 'turn/start') { start = ev.seq; break }
}
if (start === null || end === null) return null
return { start, end }
}
function ensureStreamingBubble(sessionId) {
if (state.streaming && state.streaming.sessionId === sessionId) return state.streaming.el
// Ensure the turn container is open before the bubble drops in so

View File

@@ -11510,6 +11510,64 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9
.trace-detail-field-block[open] > .trace-detail-field-block-head::before { transform: rotate(90deg); }
.trace-detail-field-block > .trace-detail-field-block-head:hover { background: var(--surface-hover); }
/* --- trace signal badges (lane-trace-signals; see docs/upstream-ledger.md L-2) --
* The detector runs renderer-side until upstream emits `trace/signal` events.
* Colors follow the palette: red=error/loop, amber=redundant, blue=plan.
* Multiple signals stack horizontally on the Timeline; the Graph uses a single
* outer ring per node (highest-priority signal wins the color).
*/
.trace-timeline-signal-badge { stroke: rgba(0,0,0,0.15); stroke-width: 0.75; }
.trace-timeline-signal-badge.sig-error { fill: #dc2626; }
.trace-timeline-signal-badge.sig-loop { fill: #ef4444; }
.trace-timeline-signal-badge.sig-redundant{ fill: #f59e0b; }
.trace-timeline-signal-badge.sig-plan { fill: #2563eb; }
.trace-timeline-signal-badge.sig-plan-restart { fill: #1d4ed8; }
.trace-timeline-signal-badge.sig-generic { fill: #6b7280; }
.trace-graph-signal-ring { stroke-width: 2.5; }
.trace-graph-signal-ring.sig-error { stroke: #dc2626; }
.trace-graph-signal-ring.sig-loop { stroke: #ef4444; }
.trace-graph-signal-ring.sig-redundant{ stroke: #f59e0b; }
.trace-graph-signal-ring.sig-plan { stroke: #2563eb; }
.trace-graph-signal-ring.sig-plan-restart { stroke: #1d4ed8; }
.trace-graph-signal-ring.sig-generic { stroke: #6b7280; }
/* Main-flow marker chips — small pill row above the assistant turn body */
.turn-signal-chip-row {
display: flex; flex-wrap: wrap; gap: 6px;
padding: 4px 0 6px 0;
align-items: center;
}
.turn-signal-chip {
font-size: 11px; line-height: 1;
padding: 3px 8px; border-radius: 10px;
border: 1px solid transparent;
background: rgba(0,0,0,0.03);
color: #1d1d1f;
cursor: pointer;
font-family: inherit;
}
.turn-signal-chip:hover { filter: brightness(0.96); }
.turn-signal-chip::before {
content: ''; display: inline-block; width: 6px; height: 6px;
border-radius: 50%; margin-right: 6px; vertical-align: 1px;
background: currentColor;
}
.turn-signal-chip.sig-error { color: #b91c1c; border-color: rgba(220,38,38,0.35); background: rgba(220,38,38,0.06); }
.turn-signal-chip.sig-loop { color: #b91c1c; border-color: rgba(239,68,68,0.35); background: rgba(239,68,68,0.06); }
.turn-signal-chip.sig-redundant { color: #92400e; border-color: rgba(245,158,11,0.4); background: rgba(245,158,11,0.08); }
.turn-signal-chip.sig-plan { color: #1d4ed8; border-color: rgba(37,99,235,0.35); background: rgba(37,99,235,0.06); }
.turn-signal-chip.sig-plan-restart{ color: #1e3a8a; border-color: rgba(29,78,216,0.4); background: rgba(29,78,216,0.08); }
/* Dark theme reads the badges/chips against the dark surface, so bump the
* saturation a touch — the palette is designed for both. */
@media (prefers-color-scheme: dark) {
.turn-signal-chip { background: rgba(255,255,255,0.05); color: #f3f4f6; }
.turn-signal-chip.sig-error, .turn-signal-chip.sig-loop { color: #fca5a5; }
.turn-signal-chip.sig-redundant { color: #fcd34d; }
.turn-signal-chip.sig-plan, .turn-signal-chip.sig-plan-restart { color: #93c5fd; }
}
/* --- onboarding overlay: first-frame FOUC guard ------------------------ */
/* index.html declares <div id="onboarding" class="onboarding" hidden> and */
/* onboarding-ui.js clears the [hidden] attribute only after a first-run */

View File

@@ -372,6 +372,36 @@ function renderGraph(doc, input, opts) {
class: `trace-graph-node-body family-${node.family}`,
})
nodeG.appendChild(circle)
// Signal ring — a colored outer stroke when this node's seq shows up
// in the passed signals map. Multiple signals share one ring but the
// tooltip enumerates them. See trace-signal-detect.js + L-2 RFC.
if (options.signals && typeof options.signals.get === 'function' && node.seq !== null) {
const sigs = options.signals.get(node.seq)
if (sigs && sigs.length) {
// Highest-priority signal wins the ring class (error > loop > redundant > plan).
const priority = ['tool-error', 'loop-detected', 'plan-restart', 'redundant-call', 'plan-update']
let winner = sigs[0]
let winnerRank = 999
for (const s of sigs) {
const r = priority.indexOf(s.signal)
if (r >= 0 && r < winnerRank) { winner = s; winnerRank = r }
}
const SD = (typeof window !== 'undefined' && window.__dshTraceSignalDetect)
|| (typeof require !== 'undefined' ? require('./trace-signal-detect.js') : null)
const cls = SD ? SD.classFor(winner.signal) : 'sig-generic'
const ring = svgEl(doc, 'circle', {
cx: 0, cy: 0, r: NODE_R + 4,
class: `trace-graph-signal-ring ${cls}`,
fill: 'none',
})
const title = svgEl(doc, 'title', {})
title.textContent = SD ? sigs.map(s => SD.tooltipFor(s)).join('\n') : sigs.map(s => s.signal).join(', ')
ring.appendChild(title)
nodeG.appendChild(ring)
}
}
const glyph = svgEl(doc, 'text', {
x: 0, y: 4, class: 'trace-graph-node-glyph',
'text-anchor': 'middle',

View File

@@ -0,0 +1,381 @@
// trace-signal-detect.js — heuristic detector for "trace signals" that a
// researcher wants highlighted in the trace tri-view + the main assistant
// stream. This is a renderer-side workaround for a gap in the runtime wire:
// the SDK does not (yet) emit `type: 'trace/signal'` events, so we scan the
// event stream ourselves and mark four kinds of interesting nodes.
//
// See docs/upstream-ledger.md L-2 for the RFC that would remove this file
// from the critical path — once upstream emits real signal events, this
// module becomes dead code (the DOM overlay path already checks `_wireSignal`
// first and only falls back to detected signals when the wire is silent).
//
// The four signal kinds:
//
// loop-detected — >= N consecutive tool/call events with the same tool
// name AND the same args-prefix. Default N = 3.
// Reported on the SECOND repeat's seq (so a reader sees
// the badge as soon as the loop becomes suspicious).
// redundant-call — a tool/call whose (name, args-prefix) exactly matches
// a call within the last WINDOW seqs. Default WINDOW = 8.
// A true duplicate is often benign (retries, polling),
// but a researcher wants it flagged because it's the
// shape of accidental re-work. Not reported when the
// loop-detected badge already covers the same seq.
// plan-update — assistant/message text mentions a new plan (keywords:
// "new plan", "revised plan", "updated plan", "here's the
// plan", "plan:", numbered "1." at line start). Heuristic
// — never claimed to be authoritative. The DOM overlay
// surfaces the badge with an "heuristic" tooltip.
// plan-restart — the same tool retried after a tool/result error
// (ok:false). Signals "the plan is being restarted after
// a failure" — often the first place a reader wants to
// look when debugging a stuck agent.
// tool-error — tool/result with ok:false. Already visible via the ✗
// glyph, but we surface a red badge on Timeline/Graph so
// a full-session view has the error nodes stand out.
//
// The detector is pure: it takes `events` (a flat array from
// session.cachedEvents), returns `{ bySeq: Map<seq, Signal[]>, all: Signal[] }`.
// Each Signal is { signal, seq, meta } where `meta` names the offending
// tools / prior seqs / snippet so the tooltip can be informative.
//
// Deliberately lightweight — no dep on trace-aggregator so the detector
// can run on raw wire without step boundaries.
'use strict'
;(function () {
const DEFAULT_LOOP_N = 3
const DEFAULT_REDUNDANT_WINDOW = 8
const PLAN_KEYWORDS = [
'new plan',
'revised plan',
'updated plan',
'here\'s the plan',
'here is the plan',
'the plan is now',
'let me revise',
'let me update the plan',
]
function detectSignals(events, opts) {
const options = opts || {}
const loopN = Number.isFinite(options.loopN) && options.loopN >= 2 ? options.loopN : DEFAULT_LOOP_N
const window = Number.isFinite(options.window) && options.window >= 2 ? options.window : DEFAULT_REDUNDANT_WINDOW
const list = Array.isArray(events) ? events : []
const all = []
const bySeq = new Map()
// Wire-pass first: if any event is already a signal from the runtime
// (post-RFC L-2), consume it verbatim and skip heuristic detection for
// that seq. Keeps this module a no-op once upstream lands the fix.
for (const ev of list) {
if (ev && ev.type === 'trace/signal' && ev.data && typeof ev.data.signal === 'string') {
const sig = {
signal: ev.data.signal,
seq: typeof ev.seq === 'number' ? ev.seq : null,
meta: Object.assign({ source: 'wire' }, ev.data),
}
emit(all, bySeq, sig)
}
}
// Rolling window over tool/call events for loop + redundant detection.
const recentCalls = [] // { seq, name, argsKey }
let lastToolError = null // { seq, name, callId }
for (let i = 0; i < list.length; i++) {
const ev = list[i]
if (!ev || typeof ev !== 'object') continue
// Loop / redundant — tool/call ordering
if (ev.type === 'tool/call') {
const name = ev.data && typeof ev.data.name === 'string' ? ev.data.name : ''
const argsKey = _argsKey(ev.data)
const seq = typeof ev.seq === 'number' ? ev.seq : null
const cur = { seq, name, argsKey, callId: ev.data && ev.data.callId }
// loop-detected: look back at recentCalls tail for a run of
// (name, argsKey) matches. When length >= loopN including cur,
// flag cur's seq. Use `argsPrefix` (first 80 chars) so a call that
// varies only in a trailing timestamp still matches.
let run = 1
for (let j = recentCalls.length - 1; j >= 0; j--) {
const prev = recentCalls[j]
if (prev.name === name && prev.argsKey === argsKey) run++
else break
}
if (run >= loopN && seq !== null) {
emit(all, bySeq, {
signal: 'loop-detected',
seq,
meta: { source: 'heuristic', name, argsKey, run, priorSeqs: _lastNPriorSeqs(recentCalls, run - 1) },
})
}
// redundant-call: exact (name, argsKey) match within the last N calls,
// NOT counting the immediate consecutive run (that's loop-detected).
// Only flag when there is at least one intervening different call, so
// we don't double-flag a pure loop.
if (seq !== null && run < loopN) {
for (let j = recentCalls.length - 1; j >= 0 && recentCalls.length - j <= window; j--) {
const prev = recentCalls[j]
if (prev.name === name && prev.argsKey === argsKey && (recentCalls.length - 1 - j) >= 1) {
// Ensure at least one call between prev and cur has a different key.
let interleaved = false
for (let k = j + 1; k < recentCalls.length; k++) {
if (recentCalls[k].name !== name || recentCalls[k].argsKey !== argsKey) { interleaved = true; break }
}
if (interleaved) {
emit(all, bySeq, {
signal: 'redundant-call',
seq,
meta: { source: 'heuristic', name, argsKey, priorSeq: prev.seq },
})
break
}
}
}
}
// plan-restart: same tool re-invoked after a recent tool/result error.
// Guard: current callId must differ from the errored one (a wire replay
// of the same call shouldn't count as a restart).
if (lastToolError && lastToolError.name === name && seq !== null
&& cur.callId !== lastToolError.callId) {
emit(all, bySeq, {
signal: 'plan-restart',
seq,
meta: { source: 'heuristic', name, priorErrorSeq: lastToolError.seq },
})
lastToolError = null
}
recentCalls.push(cur)
// Cap window to keep the scan O(N) — recentCalls only needs to hold
// the last `window` entries.
if (recentCalls.length > window * 2) recentCalls.splice(0, recentCalls.length - window * 2)
continue
}
// Tool errors
if (ev.type === 'tool/result' && ev.data && ev.data.ok === false) {
const seq = typeof ev.seq === 'number' ? ev.seq : null
const name = ev.data && typeof ev.data.name === 'string' ? ev.data.name
: _findCallName(list, ev.data && ev.data.callId)
// Emit on the result seq AND the matching call seq. Timeline pairs
// call+result into one bar keyed by the call's seq, and the Graph
// absorbs the result into the call node — so the call seq is the
// seq a reader actually sees. Result seq is kept for tree/detail
// rendering that lists events individually.
const callSeq = _findCallSeq(list, ev.data && ev.data.callId)
const errorMeta = { source: 'wire', name, callId: ev.data.callId, error: ev.data.error || null }
if (seq !== null) {
emit(all, bySeq, { signal: 'tool-error', seq, meta: errorMeta })
}
if (callSeq !== null && callSeq !== seq) {
emit(all, bySeq, { signal: 'tool-error', seq: callSeq, meta: errorMeta })
}
lastToolError = { seq, name, callId: ev.data && ev.data.callId }
continue
}
// plan-update: assistant text mentioning "new plan" / "revised plan".
if (ev.type === 'assistant/message') {
const seq = typeof ev.seq === 'number' ? ev.seq : null
const text = _assistantText(ev.data)
if (seq !== null && _looksLikePlanUpdate(text)) {
emit(all, bySeq, {
signal: 'plan-update',
seq,
meta: { source: 'heuristic', snippet: _trim(text, 80) },
})
}
continue
}
}
return { bySeq, all }
}
function emit(all, bySeq, sig) {
// Dedup: don't emit the same signal kind on the same seq twice.
const list = bySeq.get(sig.seq) || []
for (const prev of list) if (prev.signal === sig.signal) return
list.push(sig)
bySeq.set(sig.seq, list)
all.push(sig)
}
function _argsKey(data) {
if (!data) return ''
// Prefer a stable JSON of the arguments; fall back to string coercion.
// Truncate to 80 chars so we don't burn memory on huge blobs, and so
// small trailing-timestamp diffs still coalesce.
try {
const raw = data.arguments != null ? data.arguments
: data.args != null ? data.args : ''
const s = typeof raw === 'string' ? raw : JSON.stringify(raw)
return _trim(String(s || ''), 80)
} catch (_) {
return _trim(String(data.arguments || data.args || ''), 80)
}
}
function _assistantText(data) {
if (!data) return ''
if (Array.isArray(data.content)) {
const parts = []
for (const block of data.content) {
if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
parts.push(block.text)
}
}
return parts.join('\n')
}
if (typeof data.text === 'string') return data.text
return ''
}
function _looksLikePlanUpdate(text) {
if (typeof text !== 'string' || !text) return false
const lower = text.toLowerCase()
for (const kw of PLAN_KEYWORDS) if (lower.includes(kw)) return true
// Numbered plan intro: "1. …\n2. …" occurring in the first 200 chars.
// Requires two adjacent numbered lines to reduce false positives from
// enumerations inside prose.
const head = text.slice(0, 400)
const m = head.match(/(^|\n)\s*1\.\s.+\n\s*2\.\s/)
if (m) return true
return false
}
function _findCallName(events, callId) {
if (!callId) return ''
for (let i = events.length - 1; i >= 0; i--) {
const ev = events[i]
if (ev && ev.type === 'tool/call' && ev.data && ev.data.callId === callId) {
return typeof ev.data.name === 'string' ? ev.data.name : ''
}
}
return ''
}
function _findCallSeq(events, callId) {
if (!callId) return null
for (let i = events.length - 1; i >= 0; i--) {
const ev = events[i]
if (ev && ev.type === 'tool/call' && ev.data && ev.data.callId === callId
&& typeof ev.seq === 'number') return ev.seq
}
return null
}
function _lastNPriorSeqs(list, n) {
const out = []
for (let i = list.length - 1; i >= 0 && out.length < n; i--) {
if (typeof list[i].seq === 'number') out.unshift(list[i].seq)
}
return out
}
function _trim(s, n) {
if (typeof s !== 'string') return ''
if (s.length <= n) return s
return s.slice(0, n - 1) + '…'
}
// Given a step-record (or array of them from the aggregator), flatten
// the outputs+inputs+events into one seq-ordered event list and detect.
// Consumers on the tri-view side prefer this shape because they hold
// records, not raw events.
function detectSignalsFromRecords(records, opts) {
const list = Array.isArray(records) ? records : (records ? [records] : [])
const flat = []
const seen = new Set()
for (const rec of list) {
if (!rec) continue
for (const bucket of ['inputs', 'outputs', 'events']) {
const arr = rec[bucket]
if (!Array.isArray(arr)) continue
for (const ev of arr) {
if (!ev || typeof ev !== 'object') continue
const key = typeof ev.seq === 'number' ? `s${ev.seq}` : `t${ev.type}|${flat.length}`
if (seen.has(key)) continue
seen.add(key)
flat.push(ev)
}
}
}
flat.sort(function (a, b) {
const sa = typeof a.seq === 'number' ? a.seq : Infinity
const sb = typeof b.seq === 'number' ? b.seq : Infinity
return sa - sb
})
return detectSignals(flat, opts)
}
// Human-readable label for a signal — used by tooltip helpers on both
// Timeline/Graph badges and the main-flow chip.
function labelFor(signal) {
switch (signal) {
case 'loop-detected': return 'Loop detected'
case 'redundant-call': return 'Redundant call'
case 'plan-update': return 'Plan update'
case 'plan-restart': return 'Plan restart'
case 'tool-error': return 'Tool error'
default: return signal || 'Signal'
}
}
// CSS class-name suffix — kept aligned with the badge styles in style.css.
function classFor(signal) {
switch (signal) {
case 'loop-detected': return 'sig-loop'
case 'redundant-call': return 'sig-redundant'
case 'plan-update': return 'sig-plan'
case 'plan-restart': return 'sig-plan-restart'
case 'tool-error': return 'sig-error'
default: return 'sig-generic'
}
}
function tooltipFor(sig) {
if (!sig) return ''
const parts = [labelFor(sig.signal)]
const m = sig.meta || {}
if (sig.signal === 'loop-detected' && m.name) {
parts.push(`${m.run || '?'} × ${m.name}`)
if (Array.isArray(m.priorSeqs) && m.priorSeqs.length) {
parts.push(`prior seq ${m.priorSeqs.join(', ')}`)
}
} else if (sig.signal === 'redundant-call' && m.name) {
parts.push(`${m.name}, matches seq ${m.priorSeq}`)
} else if (sig.signal === 'plan-update' && m.snippet) {
parts.push(`${m.snippet}`)
} else if (sig.signal === 'plan-restart' && m.name) {
parts.push(`retry ${m.name} after seq ${m.priorErrorSeq}`)
} else if (sig.signal === 'tool-error' && m.name) {
parts.push(m.name + (m.error ? `: ${_trim(String(m.error), 60)}` : ''))
}
if (m.source === 'heuristic') parts.push('(heuristic)')
return parts.join(' · ')
}
const api = {
detectSignals,
detectSignalsFromRecords,
labelFor,
classFor,
tooltipFor,
// Exposed for tests / diagnostics only.
_internals: { _argsKey, _looksLikePlanUpdate },
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = api
}
if (typeof window !== 'undefined') {
window.__dshTraceSignalDetect = api
}
})()

View File

@@ -338,6 +338,45 @@ function renderTimeline(doc, input, opts) {
svg.appendChild(g)
}
// Signal badges — small filled circles hanging off the right edge of the
// label column for any row whose seq matches a detected/emitted signal.
// The `signals` option is a bySeq Map from trace-signal-detect.js. We keep
// this render entirely optional so tests + non-signal callers stay
// untouched; when no map is supplied nothing is drawn. See L-2 in
// docs/upstream-ledger.md for the RFC that would move detection upstream.
if (options.signals && typeof options.signals.get === 'function') {
const badgesG = svgGroup(doc, 'trace-timeline-badges')
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
if (row.seq === null) continue
const sigs = options.signals.get(row.seq)
if (!sigs || !sigs.length) continue
const y = topPad + i * rowH + rowH * 0.5
// Stack badges horizontally so multiple signals on one seq stay
// readable. The seed x sits just inside the label column so the
// badge visually "labels" the row without eating bar space.
let bx = labelW - 10
for (const sig of sigs) {
const cls = (typeof window !== 'undefined' && window.__dshTraceSignalDetect)
? window.__dshTraceSignalDetect.classFor(sig.signal)
: (typeof require !== 'undefined' ? require('./trace-signal-detect.js').classFor(sig.signal) : 'sig-generic')
const tip = (typeof window !== 'undefined' && window.__dshTraceSignalDetect)
? window.__dshTraceSignalDetect.tooltipFor(sig)
: (typeof require !== 'undefined' ? require('./trace-signal-detect.js').tooltipFor(sig) : sig.signal)
const badge = svgEl(doc, 'circle', {
cx: bx, cy: y, r: 4,
class: `trace-timeline-signal-badge ${cls}`,
})
const title = svgEl(doc, 'title', {})
title.textContent = tip
badge.appendChild(title)
badgesG.appendChild(badge)
bx -= 10
}
}
svg.appendChild(badgesG)
}
// Live cursor: hairline at nowMs during streaming.
if (typeof options.nowMs === 'number' && Number.isFinite(options.nowMs)) {
const nx = labelW + xScale(options.nowMs)

View File

@@ -117,6 +117,12 @@
}
openDetailForSeq(seq, meta)
}
// Signals map (bySeq) is derived once from the tri-view's records and
// passed to both Timeline + Graph so badge/ring placement stays
// consistent across view switches. See trace-signal-detect.js and
// docs/upstream-ledger.md L-2 for the RFC that would replace this
// renderer-side detection with wire-emitted `trace/signal` events.
const signalMap = _computeSignals(spec.records)
function ensureTimeline() {
if (builtTimeline) return
builtTimeline = true
@@ -126,6 +132,7 @@
onSeqClick: handleNodeClick,
nowMs: typeof spec.nowMs === 'number' ? spec.nowMs : undefined,
width: spec.scope === 'session' ? 860 : 720,
signals: signalMap,
})
panelEls.timeline.appendChild(el)
}
@@ -134,7 +141,10 @@
builtGraph = true
const G = (typeof window !== 'undefined' && window.__dshTraceGraph) || null
if (!G) { panelEls.graph.textContent = 'trace-graph.js not loaded'; return }
const el = G.renderGraph(doc, spec.records || [], { onSeqClick: handleNodeClick })
const el = G.renderGraph(doc, spec.records || [], {
onSeqClick: handleNodeClick,
signals: signalMap,
})
panelEls.graph.appendChild(el)
}
@@ -254,6 +264,18 @@
return agg.aggregateSteps(Array.isArray(events) ? events : [])
}
// Derive the signals bySeq map from a set of step-records. Falls back to
// an empty Map when the detector module isn't loaded, so a lean test env
// that only pulls tri-view keeps working.
function _computeSignals(records) {
const SD = (typeof window !== 'undefined' && window.__dshTraceSignalDetect) || null
if (!SD || typeof SD.detectSignalsFromRecords !== 'function') return new Map()
try {
const res = SD.detectSignalsFromRecords(records)
return (res && res.bySeq) || new Map()
} catch (_) { return new Map() }
}
// Pick the step-record whose seq range contains `seq`, preferring an
// exact startSeq match. Falls back to the closest containing range, then
// to the last record before `seq`. Returns null when nothing matches.