From 4c839890ed175d6ccff8617d22556052a17056b5 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Thu, 13 Aug 2026 15:20:29 +0800 Subject: [PATCH 1/6] feat(inspector): link the activity timeline into Session View The wall-clock activity chart lived only in the workbench lane behind a collapsed disclosure, while Session View was a vertical Turn list with no time axis. The two representations never shared a screen, so a reviewer could not jump from a busy stretch to the calls under it, and a session with no dialogue Turn showed "0 tool calls" on every Turn while its whole trace sat in an unordered page-tail bucket. Move the same chart into a sticky, collapsible strip at the top of Session View (reusing activityChartMarkup and the [data-activity-chart] wiring, so it shares the per-session zoom state) and link it to the list: - click a bar to scroll the list to those calls; multi-call bars also zoom - order the untied-calls bucket by observed startedAt and relabel it - open every Turn's tool calls by default on short sessions (<=12 Turns); keep identical-row runs collapsed so the default stays concise - hide run bands with their tool filter and recompute the sidebar tool total to what survives the current filters, regardless of disclosure - clarify the idle legend: no observed call, not a user wait No new data is projected; reuses startedAt and Turn startMs/endMs. Row virtualization and a node-link canvas remain non-goals. npm test 1305/1305, pack:verify 508/530. Co-Authored-By: Claude Opus 4.8 --- docs/specs/2026-08-13-inspector-trace-view.md | 41 ++++++++++ scripts/harness-inspector/ui/workbench.css | 9 +++ scripts/harness-inspector/ui/workbench.js | 78 ++++++++++++++++--- 3 files changed, 119 insertions(+), 9 deletions(-) diff --git a/docs/specs/2026-08-13-inspector-trace-view.md b/docs/specs/2026-08-13-inspector-trace-view.md index 81afff9..fd68060 100644 --- a/docs/specs/2026-08-13-inspector-trace-view.md +++ b/docs/specs/2026-08-13-inspector-trace-view.md @@ -123,3 +123,44 @@ claim the evidence vocabulary forbids. inactivity, and the outside-the-window commit track states its own limit. - Scale: binning is bounded by plot width, not by call count, and the removal of per-session SVG templates reduced report size at the same evidence coverage. + +## Amendment (2026-08-13): the timeline is inside Session View and linked + +The first slice kept the wall-clock activity chart in the workbench lane and left +Session View as a vertical Turn list. In use that split the one time-axis +visualization from the one place calls can be read line by line: they never +shared a screen, so a reviewer could not go from a busy stretch on the chart to +the calls under it, and a session whose calls carried no dialogue Turn showed +"0 tool calls" on every Turn while its whole trace sat in a page-tail bucket. + +This amendment moves the same chart (`activityChartMarkup`, re-rendered by +`renderActivityChart` through the existing `[data-activity-chart]` wiring, so it +shares the per-session zoom state and needs no second model) into a sticky, +collapsible strip at the top of Session View, and links it to the list. No new +data is projected; it reuses `startedAt` and Turn `startMs/endMs` already +present. + +- AC-10: Session View opens with the wall-clock strip visible above the list. + A session with no dialogue Turn still renders the strip from observed call + times, and its untied calls are held in one bucket ordered by observed time + rather than as an unordered pile. +- AC-11: The strip is a minimap. Clicking a bar scrolls the list to the calls + under it (expanding whatever disclosure hides them); a multi-call bar also + zooms. Zooming in the strip and in the workbench chart share one state. +- AC-12: A short session opens every Turn's tool calls; a long session (> 12 + Turns) stays collapsed and is navigated from the strip. Identical consecutive + rows stay collapsed as a run so the default view is concise, not a wall of + duplicates. Row virtualization and a node-link canvas remain non-goals. +- AC-13: Filtering a tool type hides both its rows and the run bands that stand + in for it, and the sidebar tool total recomputes to the count that survives + the current filters (a run counted once per grouped call), independent of + which disclosures are open. +- AC-14: The idle legend states the shading is a window with no observed call, + not a user wait; a session with no observed timing keeps the sequence-axis + label rather than implying a time. + +Verified on a real local report: on a dialogue-less 235-call session the strip +renders on open and clicking a bar zooms to ~18 calls in view and scrolls the +list to the matching rows (timestamps ascending); toggling the Bash tool moves +the sidebar total 235 → 150 → 235 while its run bands hide and restore; a +10-Turn session opens all five tool blocks by default; no console errors. diff --git a/scripts/harness-inspector/ui/workbench.css b/scripts/harness-inspector/ui/workbench.css index 71e7b14..7852b76 100644 --- a/scripts/harness-inspector/ui/workbench.css +++ b/scripts/harness-inspector/ui/workbench.css @@ -257,6 +257,15 @@ .session-titlebar h2 { margin:0; font-size:23px; letter-spacing:-.025em; } .session-meta { margin-top:7px; display:flex; flex-wrap:wrap; gap:6px 10px; align-items:center; color:#747b85; font-size:10px; } .session-platform { border-radius:6px; padding:3px 6px; color:#9a4f0e; background:#fff0d9; font:700 9px ui-monospace,SFMono-Regular,Menlo,monospace; text-transform:uppercase; } + .session-axis-panel { position:sticky; top:46px; z-index:3; margin:0 0 18px; border:1px solid #dfe3e8; border-radius:10px; background:rgba(255,255,255,.98); box-shadow:0 6px 18px rgba(15,23,42,.06); backdrop-filter:blur(8px); } + .session-axis-panel > summary { padding:8px 12px; display:flex; align-items:baseline; justify-content:space-between; gap:10px; list-style:none; cursor:pointer; color:#4a5565; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; } + .session-axis-panel > summary::-webkit-details-marker { display:none; } + .session-axis-panel > summary small { color:#8a929d; font-weight:600; text-transform:none; letter-spacing:0; } + .session-axis-panel > summary::after { content:"⌄"; margin-left:6px; color:#7b8490; transition:transform .12s ease; } + .session-axis-panel[open] > summary::after { transform:rotate(180deg); } + .session-axis { padding:0 12px 8px; } + .session-axis .chart-inspector { display:none; } + .session-axis .chart-card { max-height:320px; overflow-y:auto; } .session-layout { display:grid; grid-template-columns:minmax(0,1fr) 250px; gap:24px; align-items:start; } .session-timeline { position:relative; display:grid; gap:12px; padding-left:28px; } .session-timeline::before { content:""; position:absolute; left:8px; top:6px; bottom:6px; width:1px; background:#dde2e8; } diff --git a/scripts/harness-inspector/ui/workbench.js b/scripts/harness-inspector/ui/workbench.js index 5fde614..074de90 100644 --- a/scripts/harness-inspector/ui/workbench.js +++ b/scripts/harness-inspector/ui/workbench.js @@ -525,7 +525,7 @@ + '
' + inDomain.length + ' of ' + activity.totalCalls + ' calls in view' + (longestGap ? 'longest idle ' + escape(formatSpan(longestGap.gap)) + ' at ' + escape(formatShortClock(longestGap.at)) + ' UTC' : '') + (untimed ? '' + untimed + ' without observed timing' : '') - + 'failedidle window' + + 'failedidle window (no observed call, not user wait)' + (domain.timeBasis ? 'directly linked commit' : '') + '' + escape(basisNote) + '
'; } @@ -616,7 +616,7 @@ const first = run.calls[0]; const last = run.calls.at(-1); const total = run.calls.reduce((sum,call) => sum + (call.durationStatus === 'observed' ? call.durationMs : 0),0); - return '
' + escape(first.id) + '–' + escape(last.id) + '' + return '
' + escape(first.id) + '–' + escape(last.id) + '' + '' + escape(first.actionLabel) + ' ×' + run.calls.length + '' + escape(first.toolName) + '' + '' + escape(formatStamp(first.startedAt) ?? '—') + '' + escape(total ? formatLatency(total) + ' total' : '—') + '' + (first.detail ? '' + escape(first.detail) + '' : '') @@ -649,13 +649,17 @@ }); const turns = session.dialogue?.turns?.length ? session.dialogue.turns : fallbackTurns; const placement = placeCommits(commits,turns,session); + // Short sessions open every Turn's tool calls by default so the trace reads + // top to bottom without a click per Turn; long sessions stay collapsed and + // lean on the linked timeline strip to jump into a dense stretch instead. + const denseTurns = turns.length > 12; const turnEvents = turns.map(turn => { const anchor = turn.anchorId ?? ('turn-' + turn.index); const prompt = ''; const notes = turn.steps.filter(step => step.kind === 'note').map((step,noteIndex) => '
Intermediate response ' + (noteIndex + 1) + '

' + escape(step.text) + '

').join(''); const calls = turn.steps.filter(step => step.kind === 'tool').map(step => callsById.get(step.callId)).filter(Boolean); - const toolEvent = calls.length ? '
' + calls.length + ' tool call' + (calls.length === 1 ? '' : 's') + '' + turn.toolCallCount + ' observed in turn' + toolListMarkup(session,calls) + '
' : ''; + const toolEvent = calls.length ? '
' + calls.length + ' tool call' + (calls.length === 1 ? '' : 's') + '' + turn.toolCallCount + ' observed in turn' + toolListMarkup(session,calls) + '
' : ''; const response = '
Assistant response' + (turn.response ? 'retained' : 'unavailable') + '

' + escape(turn.response ?? 'Response body was unavailable or removed by privacy filtering.') + '

'; const clock = Number.isFinite(turn.startMs) ? formatShortClock(turn.startMs) + (Number.isFinite(turn.endMs) ? '–' + formatShortClock(turn.endMs) : '') + ' UTC · ' : ''; const summary = clock + turn.messageCount + ' intermediate events · ' + turn.toolCallCount + ' tool calls' + (Number.isFinite(turn.durationMs) ? ' · ' + formatDuration(turn.durationMs) : ''); @@ -664,13 +668,23 @@ }).join(''); const placedCallIds = new Set(turns.flatMap(turn => turn.steps.filter(step => step.kind === 'tool').map(step => step.callId))); - const unplacedCalls = session.toolActivity.calls.filter(call => !placedCallIds.has(call.id)); + // Order the page-tail bucket by observed start time so a session with no + // dialogue Turns still reads as a trace. This reuses the same startedAt the + // timeline strip plots; it never infers a time the host did not observe. + const unplacedCalls = session.toolActivity.calls + .filter(call => !placedCallIds.has(call.id)) + .slice() + .sort((left,right) => { + const leftTime = Number.isFinite(left.startedAt) ? left.startedAt : Infinity; + const rightTime = Number.isFinite(right.startedAt) ? right.startedAt : Infinity; + return leftTime - rightTime || String(left.id).localeCompare(String(right.id)); + }); const unplacedFiles = unplacedCalls.length || turns.length === 0 ? session.toolActivity.files : []; const unplacedFileEvent = unplacedFiles.length ? '
' + unplacedFiles.length + ' attributed file path' + (unplacedFiles.length === 1 ? '' : 's') + 'observed tool evidence
' + unplacedFiles.map(file => '').join('') + '
' : ''; const unplacedToolEvent = unplacedCalls.length - ? '
' + unplacedCalls.length + ' unplaced tool call' + (unplacedCalls.length === 1 ? '' : 's') + 'retained without a dialogue Turn' + toolListMarkup(session,unplacedCalls) + '
' + ? '
' + unplacedCalls.length + ' tool call' + (unplacedCalls.length === 1 ? '' : 's') + ' not tied to a Turnordered by observed time' + toolListMarkup(session,unplacedCalls) + '
' : ''; const unplacedMarkup = unplacedToolEvent || unplacedFileEvent ? '
Unplaced evidenceobserved evidence retained outside dialogue
' + unplacedToolEvent + unplacedFileEvent + '
' @@ -689,7 +703,7 @@ + (unplacedMarkup ? '' : '') + (outsideMarkup ? '' : ''); const timeline = turnEvents + unplacedMarkup + outsideMarkup || '
No retained dialogue or observed evidence exists for this session.
'; - return { title, html:'

' + escape(title) + '

' + escape(session.platform) + '' + escape(session.models.join(', ') || 'model unavailable') + '' + formatDuration(session.durationMs) + '' + coverage.turnCount + ' turns' + session.toolActivity.totalCalls + ' tool calls' + session.fileEditCount + ' file edits' + escape(formatTokens(session.tokenUsage)) + '' + truncatedNote + '
' + timeline + '
' }; + return { title, html:'

' + escape(title) + '

' + escape(session.platform) + '' + escape(session.models.join(', ') || 'model unavailable') + '' + formatDuration(session.durationMs) + '' + coverage.turnCount + ' turns' + session.toolActivity.totalCalls + ' tool calls' + session.fileEditCount + ' file edits' + escape(formatTokens(session.tokenUsage)) + '' + truncatedNote + '
' + (session.toolActivity.totalCalls ? '
Activity timelineclick a bar to jump to those calls · drag to zoom
' : '') + '
' + timeline + '
' }; } function applySessionFilters() { @@ -697,10 +711,30 @@ document.querySelectorAll('[data-session-event="' + CSS.escape(input.dataset.sessionKindFilter) + '"]').forEach(event => event.classList.toggle('session-hidden',!input.checked)); }); document.querySelectorAll('[data-session-tool-filter]').forEach(input => { - document.querySelectorAll('[data-session-tool-row][data-tool="' + CSS.escape(input.dataset.sessionToolFilter) + '"]').forEach(row => row.classList.toggle('session-hidden',!input.checked)); + const selector = '[data-tool="' + CSS.escape(input.dataset.sessionToolFilter) + '"]'; + // A collapsed run stands in for its own tool, so hide the run summary with + // the rows it represents; otherwise unchecking a tool leaves the run band + // visible and the filter contradicts what is on screen. + document.querySelectorAll('.session-tool-row' + selector + ', details.session-tool-run' + selector).forEach(row => row.classList.toggle('session-hidden',!input.checked)); }); const fileFilter = document.querySelector('[data-session-file-filter]'); document.querySelectorAll('.session-tool-file').forEach(file => file.classList.toggle('session-hidden',fileFilter && !fileFilter.checked)); + // Report how many calls survive the current filters, counting a collapsed + // run once per grouped call, so the sidebar total tracks what is shown. + const toolsEm = document.querySelector('[data-session-kind-filter="tools"]')?.closest('.session-filter')?.querySelector('em'); + if (toolsEm) { + let visible = 0; + // Standalone rows count once; a run's inner rows are represented by the + // run's own callCount, so they are excluded here to avoid double counting. + // Disclosure state (closed details, "show more" overflow) is deliberately + // ignored — the total tracks the filters, not what is expanded. + document.querySelectorAll('#session-view .session-tool-row').forEach(row => { + if (row.closest('.session-tool-run')) return; + if (!row.closest('.session-hidden')) visible += 1; + }); + document.querySelectorAll('#session-view details.session-tool-run').forEach(run => { if (!run.closest('.session-hidden')) visible += Number(run.dataset.callCount) || 0; }); + toolsEm.textContent = String(visible); + } } function sessionSelectionTarget(selection,item) { @@ -767,6 +801,11 @@ } renderEvidenceDrawer(); requestAnimationFrame(() => { + const axis = document.querySelector('[data-session-axis] [data-activity-chart]'); + if (axis && !axis.childElementCount) { + renderActivityChart(axis); + chartObserver?.observe(axis); + } revealSelectionTarget(sessionSelectionTarget(selection,item)); observeTurnsForJump(); applySelectionPresentation(); @@ -1254,7 +1293,11 @@ const bin = event.target.closest('[data-chart-bin]'); if (bin) { const sessionId = bin.dataset.sessionId; - if (Number(bin.dataset.binCount) === 1) { setSelection({ type:'tool-call', sessionId, callId:bin.dataset.callId }); return; } + // Inside Session View the strip is a minimap: a bar always scrolls the + // list to the calls under it, and a multi-call bar zooms in as well. + const inSession = event.target.closest('#session-view'); + const locate = () => inSession && revealSelectionTarget(sessionSelectionTarget({ type:'tool-call', sessionId, callId:bin.dataset.callId },state.sessionItem)); + if (Number(bin.dataset.binCount) === 1) { setSelection({ type:'tool-call', sessionId, callId:bin.dataset.callId }); locate(); return; } const surface = bin.ownerSVGElement?.querySelector('[data-chart-surface]'); const fullMin = Number(surface?.dataset.fullMin); const fullWidth = Math.max(1,Number(surface?.dataset.fullMax) - fullMin); @@ -1262,10 +1305,17 @@ const to = (Number(bin.dataset.binTo) - fullMin) / fullWidth; const padding = Math.max(0.004,(to - from) * 0.3); setZoom(sessionId,Math.max(0,from - padding),Math.min(1,to + padding)); + locate(); return; } const mark = event.target.closest('.chart-mark'); - if (mark) { showChartDetail(mark); setSelection(descriptorFromElement(mark)); return; } + if (mark) { + showChartDetail(mark); + const descriptor = descriptorFromElement(mark); + setSelection(descriptor); + if (event.target.closest('#session-view')) revealSelectionTarget(sessionSelectionTarget(descriptor,state.sessionItem)); + return; + } const mode = event.target.closest('[data-mode]'); if (mode) { setMode(mode.dataset.mode); return; } const treeToggle = event.target.closest('[data-tree-toggle]'); @@ -1376,6 +1426,16 @@ }); document.addEventListener('toggle', event => { + const axisPanel = event.target.closest?.('[data-session-axis]'); + if (axisPanel) { + if (!axisPanel.open) return; + const axis = axisPanel.querySelector('[data-activity-chart]'); + if (axis && !axis.childElementCount) { + renderActivityChart(axis); + chartObserver?.observe(axis); + } + return; + } const details = event.target.closest?.('[data-activity-session]'); if (!details) return; // Expanding the trace no longer collapses the delivery lane: reading what From 98c6d1d3e056ee02bd43c9acc34068e3e402541c Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Thu, 13 Aug 2026 15:28:49 +0800 Subject: [PATCH 2/6] docs: add project logo and refresh favicon Add a gradient Better Harness logo (assets/logo.svg) and surface it in the README header. Update the docs favicon to the same gradient mark so branding stays consistent across the repo and documentation site. Co-authored-by: QoderAI (Qwen 3.8 Max) --- README.md | 4 ++++ assets/logo.svg | 13 +++++++++++++ docs/static/img/favicon.svg | 15 ++++++++++----- 3 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 assets/logo.svg diff --git a/README.md b/README.md index 4025eff..5c0676e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ Better Harness logo +

+

Better Harness

diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..83ee4b9 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,13 @@ + + diff --git a/docs/static/img/favicon.svg b/docs/static/img/favicon.svg index abc5b38..a4b6351 100644 --- a/docs/static/img/favicon.svg +++ b/docs/static/img/favicon.svg @@ -1,6 +1,11 @@ - - - - - + + + + + + + + + From 463645c990b2fbcf517cafd95706282833e4d88e Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Thu, 13 Aug 2026 16:42:51 +0800 Subject: [PATCH 3/6] feat(inspector): fill the activity chart with a trace ribbon The lanes plot the instant a call ran, so the chart only ever drew the moments a tool was executing. Measured on a real local report, the 1178 gaps between consecutive calls have a median of 6.6s and a p90 of 29.7s, while the idle threshold that produces any shading is 45s -- 1113 of those gaps (94.5%) had no visual representation at all. On one 2.2h session the observed tool time totals 14m, so the chart was drawing 21% of the trace and leaving 79% as blank canvas. Add a ribbon above the action lanes that fills the whole domain: observed calls are painted over a continuous band, so time not spent inside a tool becomes a visible share of the trace. Blocks reuse familyColor and carry the same selection attributes as lane marks, so hover, focus, click and the Session View list linking all work without a second code path. The bare band is labelled unattributed -- model work or waiting -- never a model turn: projectDialogue keeps note text but drops the note timestamp, so the host observed when tools ran, not when the model worked. The ribbon is omitted on the call-order fallback, where spacing would be an artefact of ordinal position rather than elapsed time. npm test 1305/1305. Verified in a browser on a real report: 19 of 19 charts render the ribbon, clicking a block selects the call and scrolls the Session View list to it, no console errors. Co-Authored-By: Claude Opus 4.8 --- docs/specs/2026-08-13-inspector-trace-view.md | 16 ++++++ scripts/harness-inspector/ui/workbench.css | 5 ++ scripts/harness-inspector/ui/workbench.js | 54 +++++++++++++++++-- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/docs/specs/2026-08-13-inspector-trace-view.md b/docs/specs/2026-08-13-inspector-trace-view.md index fd68060..ee763e7 100644 --- a/docs/specs/2026-08-13-inspector-trace-view.md +++ b/docs/specs/2026-08-13-inspector-trace-view.md @@ -158,6 +158,22 @@ present. - AC-14: The idle legend states the shading is a window with no observed call, not a user wait; a session with no observed timing keeps the sequence-axis label rather than implying a time. +- AC-15: Above the action lanes the chart carries a ribbon that fills the whole + domain: observed calls are painted over a continuous band, so the time not + spent inside a tool is a visible share of the trace rather than blank canvas. + The bare band is labelled unattributed — model work or waiting — and never as + a model turn. The ribbon is omitted on the call-order fallback, where spacing + would be an artefact of ordinal position rather than elapsed time. + +**Why the ribbon.** The lanes plot the instant a call ran, so a reader saw only +the moments a tool was executing. Measured on a real local report, 1 178 gaps +between consecutive calls had a median of 6.6 s and a p90 of 29.7 s, while the +idle threshold that produced any shading was 45 s: 1 113 of those gaps (94.5 %) +had no visual representation at all. On one 2.2 h session the observed tool time +totals 14 m — the chart was drawing 21 % of the trace and leaving 79 % blank. +The ribbon makes that residue a first-class part of the picture without claiming +to know what happened inside it: `projectDialogue` keeps note text but no note +timestamp, so the host observed when tools ran, not when the model worked. Verified on a real local report: on a dialogue-less 235-call session the strip renders on open and clicking a bar zooms to ~18 calls in view and scrolls the diff --git a/scripts/harness-inspector/ui/workbench.css b/scripts/harness-inspector/ui/workbench.css index 7852b76..cef759c 100644 --- a/scripts/harness-inspector/ui/workbench.css +++ b/scripts/harness-inspector/ui/workbench.css @@ -152,6 +152,10 @@ .chart-lane-label { fill:#526072; font-size:10px; font-weight:700; } .chart-tick, .chart-axis-label { fill:#8a96a6; font-size:9px; } .chart-axis-label { font-weight:700; } + .chart-ribbon-base { fill:#e9edf3; } + .chart-ribbon-block { cursor:pointer; } + .chart-ribbon-block:hover, .chart-ribbon-block:focus { outline:none; stroke:#163f77; stroke-width:2; } + .chart-ribbon-label { fill:#41506a; } .chart-gap rect { fill:rgba(148,163,184,.13); } .chart-gap-label { fill:#8a96a6; font-size:8px; font-style:italic; } .chart-lane, .chart-gap, .chart-grid-line, .chart-tick, .chart-axis-line, .chart-axis-label { pointer-events:none; } @@ -172,6 +176,7 @@ .legend-dot { width:8px; height:8px; display:inline-block; border-radius:2px; background:var(--blue); } .legend-dot.failed { background:var(--red); } .legend-dot.gap { background:rgba(148,163,184,.45); } + .legend-dot.between { background:#e9edf3; box-shadow:inset 0 0 0 1px #cbd3de; } .legend-dot.commit { border-radius:1px; background:#16805b; transform:rotate(45deg); } .chart-empty { padding:18px; color:var(--muted); text-align:center; font-size:9px; } .commit-card { margin-bottom:8px; border:1px solid var(--line); border-radius:8px; overflow:hidden; } diff --git a/scripts/harness-inspector/ui/workbench.js b/scripts/harness-inspector/ui/workbench.js index 074de90..1366632 100644 --- a/scripts/harness-inspector/ui/workbench.js +++ b/scripts/harness-inspector/ui/workbench.js @@ -384,7 +384,14 @@ const labelWidth = 132; const rowHeight = 26; - const topPad = 8; + // The ribbon fills the whole observed span with no holes, so the time + // between calls stays visible instead of reading as blank canvas. It only + // makes sense against real clock time; on the call-order fallback the + // spacing would be an artefact of ordinal position, so it is omitted. + const showRibbon = domain.timeBasis; + const ribbonTop = 8; + const ribbonHeight = 22; + const topPad = showRibbon ? ribbonTop + ribbonHeight + 14 : 8; const width = Math.max(300,Math.floor(availableWidth || 640)); const plotLeft = labelWidth + 12; const plotRight = width - 14; @@ -447,6 +454,42 @@ } } + // The base band is the span itself; every observed call is painted over it, + // so whatever stays bare is time this session did not spend inside a tool. + // That residue is labelled as unattributed, never as a model turn: the host + // timestamps calls, not the model's own work, and the projection drops the + // note stamps that would be needed to claim otherwise. + let ribbonMarkup = ''; + if (showRibbon) { + const ribbonMid = ribbonTop + ribbonHeight / 2; + const blocks = inDomain.map(call => { + const position = callPosition(call,true); + const timed = call.durationStatus === 'observed' && Number.isFinite(call.durationMs); + const left = xFor(position); + const blockWidth = timed + ? Math.max(1.5,Math.min(plotLeft + plotWidth - left,(call.durationMs / domainWidth) * plotWidth)) + : 1.5; + const failed = call.status === 'failed'; + const stamp = formatStamp(call.startedAt); + const label = call.id + ' · ' + (call.actionLabel ?? call.toolName) + ' · ' + call.toolName + + (stamp ? ' · ' + stamp + ' UTC' : '') + + ' · ' + (timed ? formatLatency(call.durationMs) : 'timing unavailable') + + (failed ? ' · failed' : ''); + return '' + escape(label) + ''; + }).join(''); + const toolMs = inDomain.reduce((sum,call) => sum + (call.durationStatus === 'observed' && Number.isFinite(call.durationMs) ? call.durationMs : 0),0); + const baseLabel = 'Full observed span. Coloured blocks are observed tool calls; bare band is time not attributed to any tool call — model work or waiting the host did not separately observe.'; + ribbonMarkup = '' + + '' + escape(baseLabel) + '' + + blocks + + '' + escape('Observed tool time ' + formatSpan(toolMs) + ' of ' + formatSpan(domain.max - domain.min) + ' in view') + 'All activity' + + ''; + } + let marksMarkup = ''; if (detailMode) { marksMarkup = inDomain.map(call => { @@ -516,7 +559,7 @@ + '' + '' + escape(aria) + '' + escape(basisNote + ' Shaded columns are windows with no observed call. Red marks are failed calls. Green diamonds mark directly linked commit times. Drag across the plot to zoom.') + '' + '' - + laneMarkup + gapMarkup + tickMarkup + marksMarkup + commitMarkup + + laneMarkup + gapMarkup + tickMarkup + ribbonMarkup + marksMarkup + commitMarkup + '' + '' + '' + escape(domain.timeBasis ? 'UTC' : 'Call') + '' @@ -525,6 +568,7 @@ + '
' + inDomain.length + ' of ' + activity.totalCalls + ' calls in view' + (longestGap ? 'longest idle ' + escape(formatSpan(longestGap.gap)) + ' at ' + escape(formatShortClock(longestGap.at)) + ' UTC' : '') + (untimed ? '' + untimed + ' without observed timing' : '') + + (showRibbon ? 'between calls (unattributed — model work or wait)' : '') + 'failedidle window (no observed call, not user wait)' + (domain.timeBasis ? 'directly linked commit' : '') + '' + escape(basisNote) + ''; @@ -1308,7 +1352,7 @@ locate(); return; } - const mark = event.target.closest('.chart-mark'); + const mark = event.target.closest('.chart-mark, .chart-ribbon-block'); if (mark) { showChartDetail(mark); const descriptor = descriptorFromElement(mark); @@ -1416,12 +1460,12 @@ }); document.addEventListener('mouseover', event => { - const mark = event.target.closest?.('.chart-mark, .chart-commit, [data-chart-bin]'); + const mark = event.target.closest?.('.chart-mark, .chart-ribbon-block, .chart-commit, [data-chart-bin]'); if (mark) showChartDetail(mark); }); document.addEventListener('focusin', event => { - const mark = event.target.closest?.('.chart-mark, .chart-commit, [data-chart-bin]'); + const mark = event.target.closest?.('.chart-mark, .chart-ribbon-block, .chart-commit, [data-chart-bin]'); if (mark) showChartDetail(mark); }); From 873cc422a05d4ddaae5a680345222321c98732bf Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Thu, 13 Aug 2026 17:07:46 +0800 Subject: [PATCH 4/6] fix(inspector): expand capability tree, calm selection, fix matching Address three reviewer-reported workbench issues: - The capability tree now opens fully expanded so every declared node is visible on load instead of collapsing to the scoped path. - Clicking a capability node only navigates scope; it no longer sets the evidence selection, so the detail header stays unhighlighted until the reviewer clicks a real object in the workbench. - Add "Open detail" and "Open session" affordances inside the expanded normalized-actions view. Also fix a matchTokens operator-precedence bug where `.match(...) ?? []` made the stop-word .filter() run on the empty fallback array. Generic terms like harness/project/session leaked into the story-to-session overlap score, so candidateSessionForStory frequently linked the wrong session. Wrapping the match/fallback in parentheses restores the filter. Validated with the inspector report tests and a browser check of the rendered workbench. Co-authored-by: QoderAI (Qwen 3.8 Max) --- scripts/harness-inspector/report-model.mjs | 4 ++-- scripts/harness-inspector/ui/workbench.css | 5 +++++ scripts/harness-inspector/ui/workbench.js | 15 +++++++-------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/scripts/harness-inspector/report-model.mjs b/scripts/harness-inspector/report-model.mjs index 4f29408..42651b9 100644 --- a/scripts/harness-inspector/report-model.mjs +++ b/scripts/harness-inspector/report-model.mjs @@ -395,10 +395,10 @@ const STORY_MATCH_STOP_WORDS = new Set([ ]); function matchTokens(value) { - return new Set(String(value ?? "") + return new Set((String(value ?? "") .normalize("NFKC") .toLowerCase() - .match(/[\p{L}\p{N}_-]{4,}/gu) ?? [] + .match(/[\p{L}\p{N}_-]{4,}/gu) ?? []) .filter((token) => !STORY_MATCH_STOP_WORDS.has(token))); } diff --git a/scripts/harness-inspector/ui/workbench.css b/scripts/harness-inspector/ui/workbench.css index cef759c..c833f34 100644 --- a/scripts/harness-inspector/ui/workbench.css +++ b/scripts/harness-inspector/ui/workbench.css @@ -134,6 +134,11 @@ .commit-bridge.linked { border-left-color:#16805b; color:#155f48; background:#edf8f3; } .activity-details > summary { min-height:34px; padding:8px 2px; display:flex; align-items:center; justify-content:space-between; gap:12px; cursor:pointer; color:#35445a; font-size:10px; font-weight:700; } .activity-details > summary small { color:var(--muted); font-size:8px; font-weight:500; } + .activity-actions { display:flex; flex-wrap:wrap; gap:7px; padding:2px 2px 9px; } + .activity-action { min-height:28px; border:1px solid #cad6e6; border-radius:7px; padding:5px 9px; color:#244f86; background:#f5f9ff; cursor:pointer; font-size:9px; font-weight:700; } + .activity-action:hover { background:#eaf2ff; } + .activity-action.primary { color:#fff; border-color:#2f5fa8; background:#376fcb; } + .activity-action.primary:hover { background:#2f5fa8; } .trace-target { min-width:0; border-top:1px solid var(--line); } .chart-card { min-width:0; color:#526072; } .chart-toolbar { padding:7px 2px 5px; display:flex; flex-wrap:wrap; align-items:center; gap:8px; font-size:9px; } diff --git a/scripts/harness-inspector/ui/workbench.js b/scripts/harness-inspector/ui/workbench.js index 1366632..3bb2e8d 100644 --- a/scripts/harness-inspector/ui/workbench.js +++ b/scripts/harness-inspector/ui/workbench.js @@ -248,7 +248,7 @@ : directCommits.length ? '

' + directCommits.length + ' commit' + (directCommits.length === 1 ? ' was' : 's were') + ' created in this session, but no Edit/Write path was observed before the commit. The files may have entered the session as existing workspace changes.

' : ''; - return '
Checkpoint activity' + pathSummary + '
' + activity.totalCalls + 'calls · ' + activity.failedCalls + ' failed' + escape(spanCopy) + '
' + bars + '
' + commitBridge + '
Expand ' + activity.totalCalls + ' normalized actionsfocus view
'; + return '
Checkpoint activity' + pathSummary + '
' + activity.totalCalls + 'calls · ' + activity.failedCalls + ' failed' + escape(spanCopy) + '
' + bars + '
' + commitBridge + '
Expand ' + activity.totalCalls + ' normalized actionsfocus view
'; } function fileTree(commit, link) { @@ -1321,12 +1321,9 @@ } function initializeTree() { - document.querySelectorAll('[data-tree-item][aria-expanded]').forEach(item => setTreeItemExpanded(item,false)); - let item = document.querySelector('[data-feature-id="' + CSS.escape(state.scope ?? '') + '"]')?.closest('[data-tree-item]') ?? null; - while (item) { - setTreeItemExpanded(item,true); - item = item.parentElement?.closest('[data-tree-item]') ?? null; - } + // The capability tree opens fully expanded so every declared node is + // visible without hunting; the picker is short and reviewers scan it whole. + document.querySelectorAll('[data-tree-item][aria-expanded]').forEach(item => setTreeItemExpanded(item,true)); } document.addEventListener('click', event => { @@ -1370,10 +1367,12 @@ } const feature = event.target.closest('[data-feature-id]'); if (feature) { + // Selecting a capability node only navigates scope. It must not set the + // evidence selection, so the detail header stays unhighlighted until the + // reviewer clicks an actual object inside the workbench. state.scope = feature.dataset.featureId; setTreeItemExpanded(feature.closest('[data-tree-item]'),true); setMode('feature'); - if (feature.dataset.selectionType === 'story') setSelection(descriptorFromElement(feature)); return; } const pickerToggle = event.target.closest('[data-toggle-picker]'); From afed21df11a0e4c2035cbe82803cf95705d18e23 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Thu, 13 Aug 2026 18:49:22 +0800 Subject: [PATCH 5/6] feat(inspector): add session replay with legible playback UI Project each retained session into a read-only SessionReplay model (prompts, intermediate responses, tool calls, and directly linked commits) with explicit observed/turn-boundary/sequence-only timing bases, and render Trace/Replay tabs, an event/file index, a compact timeline, and playback controls in Session View. Also fixes six legibility defects found by deep-linking a mid-session event: keep the current event row in view inside the index's own scroller, use a stronger non-color selection cue, track the index column to layout width so it never stacks under the sticky transport, flag a clipped projection body as an Excerpt, add a timeline type legend, collapse duplicated position and timing text to one Event N / total label, and lock the root scroller while the overlay is open. Validated with npx vitest run test/reporting/harness-inspector.test.mjs (19) and full npm test (1307), plus in-browser replay against a real multi-hour local session with no console or page errors. Spec: docs/specs/2026-08-13-inspector-session-replay.md Co-authored-by: QoderAI (Qwen 3.8 Max) --- .../2026-08-13-inspector-session-replay.md | 125 +++++++ scripts/harness-inspector/report-model.mjs | 179 +++++++++ scripts/harness-inspector/ui/workbench.css | 112 +++++- scripts/harness-inspector/ui/workbench.js | 340 +++++++++++++++++- test/reporting/harness-inspector.test.mjs | 75 ++++ 5 files changed, 824 insertions(+), 7 deletions(-) create mode 100644 docs/specs/2026-08-13-inspector-session-replay.md diff --git a/docs/specs/2026-08-13-inspector-session-replay.md b/docs/specs/2026-08-13-inspector-session-replay.md new file mode 100644 index 0000000..3f583af --- /dev/null +++ b/docs/specs/2026-08-13-inspector-session-replay.md @@ -0,0 +1,125 @@ +# Replay retained session evidence + +## Traceability + +- Spec ID: inspector-session-replay +- Status: Draft + +## Intent + +Let a reviewer play through one retained coding-agent session without leaving +the Inspector's existing evidence model. Session View should keep its current +chronological Trace and add a Replay mode that synchronizes a current-event +stage, an event/file index, a compact timeline, and playback controls. + +Replay is a read-only presentation of the sanitized report projection. It does +not rerun tools, restore a worktree, resume a native host session, or invent +timestamps for content whose time was not observed. + +## Acceptance Scenarios + +- AC-1: `Open session` exposes semantic `Trace` and `Replay` tabs. Trace remains + the default, and a URL with `view=session&session-mode=replay` restores Replay + for the named session. +- AC-2: Each projected session owns a `SessionReplay` model, without a version + suffix in its name. It deterministically projects retained prompts, + intermediate responses, tool calls, final responses, and directly linked + commits from the already-sanitized report model. +- AC-3: Every replay event declares its timing basis. Observed prompt, tool, and + commit timestamps may appear on the wall-clock rail; response boundaries may + be labelled as Turn-bound; intermediate responses and other untimed content + remain `sequence-only` and are never assigned an invented clock time. +- AC-4: Selecting an event updates the current-event stage, the event index, the + compact timeline cursor when timing exists, and the Inspector selection when + the event has a Story, Session, Turn, Tool Call, File, or Commit descriptor. + Existing Evidence Drawer relationships and limitations remain authoritative. +- AC-5: Replay provides previous, play/pause, and next controls plus 1x, 2x, 4x, + and 8x event pacing. Playback advances by retained event order, compresses + long unobserved gaps instead of making multi-hour sessions wait in real time, + stops at the end, and never invokes a host tool. +- AC-6: Replay exposes `Events` and `Files` index tabs. Choosing a file selects + that repository-relative path and moves to the first retained replay event + that names it, without claiming the event authored a commit. +- AC-7: The mode tabs, index tabs, event rows, file rows, playback buttons, and + speed controls are keyboard operable; the current event uses non-color state + cues, playback respects reduced-motion preference, and controls do not steal + shortcuts from form inputs. +- AC-8: The self-contained HTML retains no raw tool input/output, hidden + reasoning, absolute home path, or credential. Missing content and unavailable + timing stay explicit in the model and UI. +- AC-9: Focused behavior tests cover `SessionReplay` ordering and timing bases, + direct-commit inclusion, untimed fallbacks, mode restoration, playback + controls, and final HTML privacy. A real multi-hour local session is verified + in desktop and narrow browser layouts with no page or console errors. + +## Non-goals + +- Adding Replay as a top-level Inspector scope beside Delivery Tree and Date. +- Executing, retrying, resuming, or mutating a tool call, Git state, worktree, + feature-tree mapping, checkpoint, or native host session. +- Human annotations, pinned notes, author identity, or report persistence. +- Fabricating cost, token, test-result, response, or timing evidence that the + source projection did not retain. +- Replacing Trace, the Evidence Drawer, or the existing wall-clock activity + chart with a second relationship model. + +## Plan and Tasks + +1. Extend `scripts/harness-inspector/report-model.mjs` with a bounded + `SessionReplay` projection built only after Session/Commit links exist. + Preserve explicit timing bases and direct-commit limitations. +2. Extend `scripts/harness-inspector/ui/workbench.js` with a session-local + playback owner separate from evidence selection and chart zoom. Render + Trace/Replay tabs, the event stage, Events/Files index, compact rail, and + playback controls from the projected model. +3. Extend `scripts/harness-inspector/ui/workbench.css` with responsive Replay + layout and accessible selected/focus treatments that reuse current Inspector + tokens and typography. +4. Extend focused Inspector tests with behavior assertions over the model and + rendered interactions. Preserve unrelated working-tree changes in the same + files and keep the report self-contained. +5. Render a real report, exercise mode switching, event/file selection, + playback, speed, URL restoration, Trace return, keyboard behavior, desktop + and narrow layouts, then inspect console/page errors. + +## Test and Review Evidence + +- AC-2/AC-3/AC-8: `npx vitest run test/reporting/harness-inspector.test.mjs` + asserts the parsed model shape, event ordering, timing bases, direct commits, + and sanitized projection rather than matching implementation source text. +- AC-1/AC-4..AC-7: browser behavior against a real self-contained report, + including a copied Replay deep link and an event with no observed timestamp. +- AC-9 regression: `npm test`, `npm run pack:verify`, documentation link graph, + and a Review Readiness Check over the final local/staged split. +- Privacy risk: Replay consumes only projected safe text and repository-relative + paths; final HTML is checked for private-path and credential leakage. +- Correlation risk: only explicit or observed-commit relationships enter the + replay stream. Same-path and contextual commits stay in Trace/Evidence. +- Scale risk: the index is event-driven and the compact rail renders timed + events only; playback advances by event pace so long idle windows do not block + review. +- Interaction risk: Replay state is session-local and URL-addressable, while + Evidence selection and activity zoom remain separate owners. + +## Replay Legibility Follow-up + +Deep-linking to a mid-session event (`replay-event=call:A179`) exposed six +legibility defects, since fixed and verified in-browser against a real +multi-hour local session with no console or page errors: + +- AC-4 follow-up: `updateReplayPresentation` now keeps the current event row in + view inside the index's own scroller only, so following playback never scrolls + Session View or the Workbench underneath it, and a tab return re-reveals it. +- AC-7 follow-up: the current row uses a stronger non-color cue (heavier bar, + bold title) in addition to background, and the mode tabs (filled) read + distinctly from the index tabs (underlined). +- Layout: the index column tracks layout width instead of the viewport and keeps + its own readable height, so a narrow window or open Drawer narrows the column + rather than stacking it where the sticky transport would cover the list. The + event card no longer reserves a tall fixed height that dwarfed short evidence. +- AC-8 follow-up: a bounded (clipped) projection body is flagged `bodyExcerpt` + and shown as an `Excerpt` badge instead of a silently truncated command. +- Orientation: the timeline rail gains a type legend, and duplicated position and + timing text collapses to one `Event N / total` label plus the card's Turn line. +- Overlay: the root scroller locks while Session View is open so wheel gestures + cannot scroll the hidden Workbench. diff --git a/scripts/harness-inspector/report-model.mjs b/scripts/harness-inspector/report-model.mjs index 42651b9..a904216 100644 --- a/scripts/harness-inspector/report-model.mjs +++ b/scripts/harness-inspector/report-model.mjs @@ -623,6 +623,184 @@ function buildStoryLinks(tree, sessions, commits, diagnostics) { return stories; } +function replayClock(value) { + if (Number.isFinite(value)) return Math.round(value); + const parsed = Date.parse(value ?? ""); + return Number.isFinite(parsed) ? parsed : null; +} + +function buildSessionReplay(session, commits) { + const callsById = new Map(session.toolActivity.calls.map((call) => [call.id, call])); + const events = []; + const placedCallIds = new Set(); + let sequence = 0; + const append = (event) => { + sequence += 1; + // Bounded projection text keeps its ellipsis, so Replay can say the body is an + // excerpt instead of showing a silently clipped command. + const bodyExcerpt = typeof event.body === "string" && event.body.endsWith("…"); + events.push({ ...event, ...(bodyExcerpt ? { bodyExcerpt: true } : {}), order: sequence }); + }; + + for (const turn of session.dialogue.turns) { + const promptAt = replayClock(turn.prompt?.timestamp); + const turnStart = replayClock(turn.startMs); + append({ + id: `turn:${turn.index}:prompt`, + type: "prompt", + turnIndex: turn.index, + label: "Prompt", + title: `User prompt ${turn.index}`, + body: safeText(turn.prompt?.text, 1_500, "Prompt unavailable after privacy filtering"), + timeBasis: promptAt !== null ? "observed" : turnStart !== null ? "turn-boundary" : "sequence-only", + ...(promptAt !== null || turnStart !== null ? { atMs: promptAt ?? turnStart } : {}), + selection: { type: "turn", sessionId: session.sessionId, turnIndex: turn.index }, + files: [], + }); + + let noteIndex = 0; + for (const step of turn.steps) { + if (step.kind === "note") { + noteIndex += 1; + append({ + id: `turn:${turn.index}:note:${noteIndex}`, + type: "intermediate", + turnIndex: turn.index, + label: "Intermediate", + title: `Intermediate response ${noteIndex}`, + body: safeText(step.text, 400, "Intermediate response unavailable"), + timeBasis: "sequence-only", + selection: { type: "turn", sessionId: session.sessionId, turnIndex: turn.index }, + files: [], + }); + continue; + } + if (step.kind !== "tool") continue; + const call = callsById.get(step.callId); + if (!call) continue; + placedCallIds.add(call.id); + const atMs = replayClock(call.startedAt); + const files = [...(call.filePaths ?? (call.filePath ? [call.filePath] : []))]; + append({ + id: `call:${call.id}`, + type: "tool-call", + turnIndex: turn.index, + label: call.actionLabel, + title: `${call.id} · ${call.actionLabel}`, + body: safeText(call.detail, 240, `${call.toolName} input was not retained`), + meta: safeText(call.toolName, 64, "Unknown tool"), + status: call.status, + timeBasis: atMs !== null ? "observed" : "sequence-only", + ...(atMs !== null ? { atMs } : {}), + ...(call.durationStatus === "observed" && Number.isFinite(call.durationMs) ? { durationMs: call.durationMs } : {}), + selection: { type: "tool-call", sessionId: session.sessionId, callId: call.id }, + files, + }); + } + + const responseAt = replayClock(turn.endMs); + append({ + id: `turn:${turn.index}:response`, + type: "response", + turnIndex: turn.index, + label: "Response", + title: "Assistant response", + body: safeText(turn.response, 6_000, "Response body was unavailable or removed by privacy filtering."), + availability: turn.response ? "retained" : "unavailable", + timeBasis: responseAt !== null ? "turn-boundary" : "sequence-only", + ...(responseAt !== null ? { atMs: responseAt } : {}), + selection: { type: "turn", sessionId: session.sessionId, turnIndex: turn.index }, + files: [], + }); + } + + const insertByObservedTime = (event) => { + const nextIndex = events.findIndex((candidate) => Number.isFinite(candidate.atMs) && candidate.atMs > event.atMs); + if (nextIndex === -1) events.push(event); + else events.splice(nextIndex, 0, event); + }; + + const unplacedCalls = session.toolActivity.calls + .filter((call) => !placedCallIds.has(call.id)) + .slice() + .sort((left, right) => (replayClock(left.startedAt) ?? Number.POSITIVE_INFINITY) + - (replayClock(right.startedAt) ?? Number.POSITIVE_INFINITY) || left.step - right.step); + for (const call of unplacedCalls) { + const atMs = replayClock(call.startedAt); + const files = [...(call.filePaths ?? (call.filePath ? [call.filePath] : []))]; + const event = { + id: `call:${call.id}`, + type: "tool-call", + turnIndex: null, + label: call.actionLabel, + title: `${call.id} · ${call.actionLabel}`, + body: safeText(call.detail, 240, `${call.toolName} input was not retained`), + meta: safeText(call.toolName, 64, "Unknown tool"), + status: call.status, + timeBasis: atMs !== null ? "observed" : "sequence-only", + ...(atMs !== null ? { atMs } : {}), + ...(call.durationStatus === "observed" && Number.isFinite(call.durationMs) ? { durationMs: call.durationMs } : {}), + selection: { type: "tool-call", sessionId: session.sessionId, callId: call.id }, + files, + }; + if (atMs === null) events.push(event); + else insertByObservedTime(event); + } + + const directCommitLinks = session.commitLinks.filter((link) => ["explicit", "observed-commit"].includes(link.evidenceKind)); + for (const link of directCommitLinks) { + const commit = commits.find((candidate) => candidate.hash === link.hash); + const atMs = commit ? commitTimeMs(commit) : null; + if (!commit || atMs === null) continue; + insertByObservedTime({ + id: `commit:${commit.hash}`, + type: "commit", + turnIndex: null, + label: "Commit", + title: `${commit.shortHash} · ${commit.subject}`, + body: safeText(`${commit.fileCount} files · +${commit.linesAdded} / -${commit.linesRemoved}. ${link.limitations[0] ?? ""}`, 600), + meta: link.evidenceKind, + timeBasis: "observed", + atMs, + selection: { type: "commit", hash: commit.hash, contextSessionId: session.sessionId }, + files: commit.files.map((file) => file.path), + }); + } + + events.forEach((event, index) => { event.order = index + 1; }); + const eventTimes = events.map((event) => event.atMs).filter(Number.isFinite); + const observedStart = replayClock(session.firstSeen); + const observedEnd = replayClock(session.lastSeen); + const startCandidates = [...eventTimes, observedStart].filter(Number.isFinite); + const endCandidates = [...events.map((event) => Number.isFinite(event.atMs) + ? event.atMs + (Number.isFinite(event.durationMs) ? event.durationMs : 0) + : null), observedEnd].filter(Number.isFinite); + const files = new Map(); + for (const event of events) { + for (const filePath of event.files) { + const file = files.get(filePath) ?? { path: filePath, eventIds: [] }; + file.eventIds.push(event.id); + files.set(filePath, file); + } + } + for (const file of session.toolActivity.files) { + if (!files.has(file.path)) files.set(file.path, { path: file.path, eventIds: [] }); + } + + return { + kind: "SessionReplay", + schemaVersion: 1, + timeBasis: eventTimes.length ? "observed-time" : "call-sequence", + startMs: startCandidates.length ? Math.min(...startCandidates) : null, + endMs: endCandidates.length ? Math.max(...endCandidates) : null, + eventCount: events.length, + timedEventCount: eventTimes.length, + sequenceOnlyCount: events.filter((event) => event.timeBasis === "sequence-only").length, + files: [...files.values()].sort((left, right) => left.path.localeCompare(right.path)), + events, + }; +} + function buildDays(sessions, commits) { const byDay = new Map(); const ensure = (day) => { @@ -691,6 +869,7 @@ export function buildHarnessInspectorReport({ const commits = (correlation?.commits ?? []).map(projectCommit); const reportDiagnostics = diagnostics.map((item) => safeText(item, 240)).filter(Boolean); const stories = buildStoryLinks(tree, projectedSessions, commits, reportDiagnostics); + for (const session of projectedSessions) session.replay = buildSessionReplay(session, commits); const stages = [...new Set(tree.nodes.map((node) => node.stage).filter(Boolean))].sort(); const unmappedSessionIds = projectedSessions.filter((session) => session.storyLinks.length === 0).map((session) => session.sessionId); diff --git a/scripts/harness-inspector/ui/workbench.css b/scripts/harness-inspector/ui/workbench.css index c833f34..ad58c9b 100644 --- a/scripts/harness-inspector/ui/workbench.css +++ b/scripts/harness-inspector/ui/workbench.css @@ -7,6 +7,11 @@ a grid column, so opening it cannot reflow the lanes underneath it. */ body { --drawer-inset:0px; } body.drawer-open { --drawer-inset:var(--drawer-width); } + /* Session View is a fixed overlay with its own scroller. Without locking the + root scroller, a wheel gesture near the overlay edge scrolls a Workbench the + reviewer cannot see. Programmatic reveal still works, and the Workbench keeps + its offset for when the overlay closes. */ + html:has(body.session-open) { overflow:hidden; } .visually-hidden { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; } .app { min-height:100vh; display:grid; grid-template-columns:270px minmax(0,1fr); margin-right:var(--drawer-inset); } .app.picker-collapsed { grid-template-columns:46px minmax(0,1fr); } @@ -256,7 +261,7 @@ .modal-body { padding:15px 16px 17px; } .modal-note { padding:9px 10px; border-left:3px solid var(--amber); color:#714317; background:var(--amber-soft); font-size:10px; } .context-block { margin:12px 0 0; padding:11px; overflow:auto; border:1px solid var(--line); border-radius:8px; color:#334155; background:#f8fafc; white-space:pre-wrap; font:10px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace; } - .session-view { position:fixed; inset:0 var(--drawer-inset) 0 0; z-index:20; overflow:auto; color:#20242b; background:#fff; } + .session-view { position:fixed; inset:0 var(--drawer-inset) 0 0; z-index:20; overflow:auto; overscroll-behavior:contain; color:#20242b; background:#fff; } .session-view[hidden] { display:none; } .session-nav { min-height:46px; position:sticky; top:0; z-index:2; padding:8px 18px; display:flex; align-items:center; justify-content:space-between; gap:16px; border-bottom:1px solid #e4e7eb; background:rgba(255,255,255,.97); backdrop-filter:blur(10px); } .session-crumbs { min-width:0; display:flex; align-items:center; gap:8px; color:#747b85; font-size:11px; } @@ -345,6 +350,108 @@ .session-unplaced { padding-bottom:14px; } .session-file-list { display:grid; gap:5px; padding:9px 11px; } .session-file-list button { padding:5px 7px; overflow:hidden; border:1px solid #e3e8ee; border-radius:6px; color:#376fcb; background:#fbfcfe; text-align:left; text-overflow:ellipsis; white-space:nowrap; cursor:pointer; font:9px ui-monospace,SFMono-Regular,Menlo,monospace; } + .session-mode-tabs { width:max-content; margin:0 0 18px; padding:3px; display:flex; gap:3px; border:1px solid #dfe3e8; border-radius:9px; background:#f5f7fa; } + .session-mode-tabs button { min-width:72px; min-height:30px; border:0; border-radius:6px; color:#687383; background:transparent; cursor:pointer; font-size:10px; font-weight:800; } + .session-mode-tabs button[aria-selected="true"] { color:#fff; background:#263244; box-shadow:0 3px 8px rgba(15,23,42,.16); } + .session-mode-tabs button:focus-visible, .replay-index-tabs button:focus-visible, .replay-event-list button:focus-visible, .replay-file-list button:focus-visible, .replay-controls button:focus-visible, .replay-stage-files button:focus-visible, .replay-explain:focus-visible, .replay-rail-mark:focus-visible { outline:2px solid #376fcb; outline-offset:2px; } + .session-mode-panel[hidden] { display:none; } + .replay-shell { min-width:0; } + .replay-boundary { margin-bottom:14px; padding:10px 12px; display:flex; align-items:baseline; gap:10px 16px; border:1px solid #dbe6f5; border-left:3px solid #376fcb; border-radius:8px; color:#526072; background:#f7faff; font-size:10px; line-height:1.5; } + .replay-boundary strong { flex:none; color:#244f86; } + /* The index column tracks the real layout width instead of the viewport, so a + narrow window or an open Drawer narrows the column rather than stacking it + under the stage where the sticky transport would cover it. */ + .replay-layout { display:grid; grid-template-columns:minmax(0,1fr) clamp(220px,26%,320px); align-items:start; gap:16px; } + .replay-stage { min-width:0; display:grid; align-content:stretch; } + .replay-event-card { min-height:280px; overflow:hidden; display:flex; flex-direction:column; border:1px solid #dfe3e8; border-top:3px solid #7a8797; border-radius:12px; background:#fff; box-shadow:0 10px 28px rgba(15,23,42,.07); } + .replay-event-card.prompt { border-top-color:#4f73b6; } + .replay-event-card.intermediate { border-top-color:#b27627; } + .replay-event-card.response { border-top-color:#258675; } + .replay-event-card.tool-call { border-top-color:#7658b5; } + .replay-event-card.commit { border-top-color:#6c9f25; } + .replay-event-card > header { min-height:70px; padding:15px 17px 12px; display:flex; align-items:flex-start; justify-content:space-between; gap:16px; border-bottom:1px solid #edf0f3; } + .replay-event-card > header small { color:#687383; font-size:9px; font-weight:800; text-transform:uppercase; letter-spacing:.08em; } + .replay-event-card > header h3 { margin:4px 0 0; color:#252a31; font-size:17px; letter-spacing:-.015em; overflow-wrap:anywhere; } + .replay-event-badges { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:5px; } + .replay-status, .replay-availability, .replay-excerpt { border-radius:5px; padding:3px 6px; font-size:8px; font-weight:800; text-transform:uppercase; letter-spacing:.05em; } + .replay-status.failed { color:#8f2f2f; background:var(--red-soft); } + .replay-availability { color:#714317; background:var(--amber-soft); } + .replay-excerpt { color:#5a6675; background:#eef1f4; } + .replay-event-meta { min-height:34px; padding:8px 17px; display:flex; flex-wrap:wrap; align-items:center; gap:7px 12px; color:#7a8492; background:#fbfcfd; font-size:9px; } + .replay-event-meta code { color:#596579; font:9px ui-monospace,SFMono-Regular,Menlo,monospace; } + .replay-event-body { flex:1; padding:18px 17px; overflow:auto; color:#344155; font-size:12px; line-height:1.65; } + .replay-event-body p { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; } + .replay-stage-files { padding:0 17px 16px; display:flex; flex-wrap:wrap; align-items:center; gap:6px; } + .replay-stage-files strong { margin-right:2px; color:#687383; font-size:9px; text-transform:uppercase; letter-spacing:.06em; } + .replay-stage-files button { max-width:100%; padding:4px 7px; overflow:hidden; border:1px solid #dbe6f5; border-radius:6px; color:#376fcb; background:#f7faff; cursor:pointer; text-overflow:ellipsis; white-space:nowrap; } + .replay-stage-files code { font:9px ui-monospace,SFMono-Regular,Menlo,monospace; } + .replay-event-card > footer { min-height:48px; padding:9px 17px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-top:1px solid #edf0f3; color:#7a8492; background:#fbfcfd; font-size:9px; } + .replay-explain { border:1px solid #cad6e6; border-radius:7px; padding:6px 9px; color:#244f86; background:#f5f9ff; cursor:pointer; font-size:9px; font-weight:800; } + /* The index keeps its own readable height instead of stretching to match the + stage, so a short evidence body cannot shrink the event list. */ + .replay-index { min-width:0; height:430px; overflow:hidden; display:flex; flex-direction:column; border:1px solid #dfe3e8; border-radius:10px; background:#fff; } + /* Underline tabs, unlike the pill mode tabs, so panel switching does not read + as a second Trace/Replay control. */ + .replay-index-tabs { padding:0 6px; display:grid; grid-template-columns:1fr 1fr; gap:0; border-bottom:1px solid #e6e9ed; background:#fff; } + .replay-index-tabs button { min-height:34px; border:0; border-bottom:2px solid transparent; border-radius:0; color:#7a8492; background:transparent; cursor:pointer; font-size:10px; font-weight:700; } + .replay-index-tabs button span { margin-left:4px; color:#929aa5; font-size:8px; } + .replay-index-tabs button[aria-selected="true"] { color:#244f86; border-bottom-color:#376fcb; font-weight:800; } + .replay-index-body { min-height:0; flex:1; overflow:auto; } + .replay-event-list, .replay-file-list { display:grid; } + .replay-event-list button, .replay-file-list button { width:100%; min-width:0; border:0; border-bottom:1px solid #edf0f3; color:#3f4752; background:#fff; text-align:left; cursor:pointer; } + .replay-event-list button { min-height:50px; padding:7px 9px; display:grid; grid-template-columns:22px minmax(0,1fr) auto; gap:8px; align-items:center; } + .replay-event-list button:hover, .replay-file-list button:hover { background:#f7faff; } + .replay-event-list button.replay-current { box-shadow:inset 4px 0 0 #244f86; background:#e2ecfb; } + .replay-event-list button.replay-current .replay-event-copy strong { color:#1d3f6d; font-weight:800; } + .replay-event-list button.replay-current .replay-event-kind { color:#3a5f92; font-weight:700; } + .replay-event-order { width:20px; height:20px; display:grid; place-items:center; border-radius:50%; color:#687383; background:#edf0f3; font:8px ui-monospace,SFMono-Regular,Menlo,monospace; } + .replay-current .replay-event-order { color:#fff; background:#244f86; } + .replay-event-copy { min-width:0; display:grid; gap:3px; } + .replay-event-copy strong, .replay-event-copy small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .replay-event-copy strong { font-size:9px; } + .replay-event-copy small { color:#858e9a; font-size:8px; font-weight:500; } + .replay-event-kind { color:#858e9a; font-size:8px; text-transform:capitalize; } + .replay-file-list button { min-height:48px; padding:8px 10px; display:grid; gap:4px; } + .replay-file-list code { overflow:hidden; color:#376fcb; text-overflow:ellipsis; white-space:nowrap; font:9px ui-monospace,SFMono-Regular,Menlo,monospace; } + .replay-file-list span { color:#858e9a; font-size:8px; } + .replay-transport { position:sticky; bottom:0; z-index:2; margin-top:16px; padding:12px 14px; border:1px solid #d9dfe6; border-radius:11px; background:rgba(255,255,255,.98); box-shadow:0 -8px 24px rgba(15,23,42,.08); backdrop-filter:blur(10px); } + .replay-rail-head { margin-bottom:8px; display:flex; align-items:baseline; justify-content:space-between; gap:12px; } + .replay-rail-head strong { color:#4a5565; font-size:9px; text-transform:uppercase; letter-spacing:.06em; } + .replay-rail-head span { color:#7a8492; font-size:8px; } + .replay-rail { min-height:30px; } + .replay-rail-track { height:12px; position:relative; overflow:visible; border-radius:6px; background:#edf0f3; } + .replay-rail-fill { position:absolute; inset:0; border-radius:inherit; background:linear-gradient(90deg,#eef3fa,#f1f4f8); } + .replay-rail-mark { min-width:2px; height:12px; position:absolute; top:0; z-index:1; margin:0; padding:0; border:0; border-radius:1px; background:#7a8797; cursor:pointer; } + .replay-rail-mark.prompt { background:#4f73b6; } + .replay-rail-mark.intermediate { background:#b27627; } + .replay-rail-mark.response { background:#258675; } + .replay-rail-mark.tool-call { background:#7658b5; } + .replay-rail-mark.commit { background:#6c9f25; } + .replay-rail-mark.failed { box-shadow:0 0 0 2px #b94a48; } + .replay-rail-mark.replay-current { z-index:2; box-shadow:0 0 0 2px #fff,0 0 0 4px #244f86; } + .replay-rail-cursor { width:2px; height:20px; position:absolute; z-index:3; top:-4px; background:#20242b; transform:translateX(-1px); pointer-events:none; } + .replay-rail-labels { margin-top:5px; display:flex; justify-content:space-between; color:#929aa5; font:8px ui-monospace,SFMono-Regular,Menlo,monospace; } + /* The rail encodes event type as colour, so the colours need a named legend. */ + .replay-rail-legend { margin-top:7px; display:flex; flex-wrap:wrap; gap:4px 12px; color:#7a8492; font-size:8px; } + .replay-rail-legend span { display:inline-flex; align-items:center; gap:4px; } + .replay-rail-legend span::before { content:""; width:8px; height:8px; border-radius:2px; background:#7a8797; } + .replay-rail-legend .prompt::before { background:#4f73b6; } + .replay-rail-legend .intermediate::before { background:#b27627; } + .replay-rail-legend .response::before { background:#258675; } + .replay-rail-legend .tool-call::before { background:#7658b5; } + .replay-rail-legend .commit::before { background:#6c9f25; } + .replay-rail-legend .failed::before { border:2px solid #b94a48; background:#fff; } + .replay-sequence-rail { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:8px; color:#858e9a; font:8px ui-monospace,SFMono-Regular,Menlo,monospace; } + .replay-sequence-rail div { height:8px; border-radius:4px; background:repeating-linear-gradient(90deg,#aeb8c5 0,#aeb8c5 3px,#edf0f3 3px,#edf0f3 7px); } + .replay-controls { margin-top:10px; display:flex; align-items:center; gap:7px; } + .replay-controls > button, .replay-speeds button { min-height:30px; border:1px solid #d7dce2; border-radius:7px; color:#455163; background:#fff; cursor:pointer; font-size:9px; font-weight:800; } + .replay-controls > button { padding:5px 9px; } + .replay-controls .replay-play { color:#fff; border-color:#2f5fa8; background:#376fcb; } + .replay-controls kbd { margin-left:4px; border:1px solid currentColor; border-radius:3px; padding:1px 3px; opacity:.62; font:7px ui-monospace,SFMono-Regular,Menlo,monospace; } + .replay-position { min-width:0; margin-left:4px; overflow:hidden; color:#687383; text-overflow:ellipsis; white-space:nowrap; font-size:8px; } + .replay-speeds { margin-left:auto; display:flex; gap:3px; } + .replay-speeds button { min-width:30px; padding:4px 6px; } + .replay-speeds button[aria-pressed="true"] { color:#244f86; border-color:#9eb8dc; background:#eef5ff; } @media (max-width:1080px) { .window-badge { display:none; } } /* Below three-lane width the lanes stack instead of forcing a 730px page to scroll sideways, which is what starved the activity chart of room. */ @@ -372,4 +479,5 @@ body.drawer-open .workbench.delivery-collapsed .delivery-title-copy { display:flex; } body.drawer-open .workbench.delivery-collapsed .delivery-toggle { width:auto; min-height:0; margin:0; padding:4px 6px; writing-mode:horizontal-tb; } } - @media (max-width:760px) { body.drawer-open { --drawer-inset:0px; } .app { display:block; margin-right:0; } .scope-picker { position:relative; width:100%; height:auto; max-height:340px; border-right:0; border-bottom:1px solid var(--line); } .picker-footer { display:none; } .workspace-header { position:relative; padding:5px 9px; } .workspace-breadcrumb span:first-child, .workspace-breadcrumb i:first-of-type, .window-badge { display:none; } .workspace-header-meta { gap:0; } .metric { padding:0 4px; } .metric .metric-label { display:none; } .metric .metric-short { display:inline; } .workspace-scroll { padding:8px 7px 36px; } .workbench { overflow-x:auto; } .evidence-drawer { position:fixed; inset:auto 0 0; top:auto; width:100%; height:min(72vh,620px); z-index:41; border-top:1px solid var(--line); border-left:0; border-radius:16px 16px 0 0; box-shadow:0 -18px 50px rgba(15,23,42,.18); } .evidence-drawer-head { min-height:62px; padding:11px 14px; } .session-shell { padding:16px 14px 38px; } .session-layout { grid-template-columns:1fr; } .session-sidebar { position:static; grid-row:1; } } + @media (max-width:760px) { body.drawer-open { --drawer-inset:0px; } .app { display:block; margin-right:0; } .scope-picker { position:relative; width:100%; height:auto; max-height:340px; border-right:0; border-bottom:1px solid var(--line); } .picker-footer { display:none; } .workspace-header { position:relative; padding:5px 9px; } .workspace-breadcrumb span:first-child, .workspace-breadcrumb i:first-of-type, .window-badge { display:none; } .workspace-header-meta { gap:0; } .metric { padding:0 4px; } .metric .metric-label { display:none; } .metric .metric-short { display:inline; } .workspace-scroll { padding:8px 7px 36px; } .workbench { overflow-x:auto; } .evidence-drawer { position:fixed; inset:auto 0 0; top:auto; width:100%; height:min(72vh,620px); z-index:41; border-top:1px solid var(--line); border-left:0; border-radius:16px 16px 0 0; box-shadow:0 -18px 50px rgba(15,23,42,.18); } .evidence-drawer-head { min-height:62px; padding:11px 14px; } .session-shell { padding:16px 14px 38px; } .session-titlebar, .replay-boundary { align-items:flex-start; flex-direction:column; } .session-layout { grid-template-columns:1fr; } .session-sidebar { position:static; grid-row:1; } .replay-layout { grid-template-columns:1fr; } .replay-index { height:min(46vh,320px); } .replay-event-card { min-height:260px; } .replay-controls { flex-wrap:wrap; } .replay-position { width:100%; order:4; } .replay-speeds { margin-left:0; } } + @media (prefers-reduced-motion:reduce) { .session-view *, .session-view *::before, .session-view *::after { scroll-behavior:auto !important; transition-duration:.001ms !important; animation-duration:.001ms !important; animation-iteration-count:1 !important; } } diff --git a/scripts/harness-inspector/ui/workbench.js b/scripts/harness-inspector/ui/workbench.js index 3bb2e8d..14970af 100644 --- a/scripts/harness-inspector/ui/workbench.js +++ b/scripts/harness-inspector/ui/workbench.js @@ -37,11 +37,18 @@ // One focused object drives explanation. A future CompareSet must remain a // separate state owner and full-workspace mode rather than widening Drawer. selection:null, + evidenceDrawerSuppressed:false, sessionTrigger:null, sessionItem:null, sessionOpen:false, sessionPushed:false, syncingHistory:false, + sessionMode:initialParams.get('session-mode') === 'replay' ? 'replay' : 'trace', + replayEventId:initialParams.get('replay-event'), + replayIndexTab:'events', + replayPlaying:false, + replaySpeed:2, + replayTimer:null, // Chart zoom is a per-session view concern, never part of the evidence // model, so it stays out of the selection and out of the deep link. zoom:new Map(), @@ -747,7 +754,241 @@ + (unplacedMarkup ? '' : '') + (outsideMarkup ? '' : ''); const timeline = turnEvents + unplacedMarkup + outsideMarkup || '
No retained dialogue or observed evidence exists for this session.
'; - return { title, html:'

' + escape(title) + '

' + escape(session.platform) + '' + escape(session.models.join(', ') || 'model unavailable') + '' + formatDuration(session.durationMs) + '' + coverage.turnCount + ' turns' + session.toolActivity.totalCalls + ' tool calls' + session.fileEditCount + ' file edits' + escape(formatTokens(session.tokenUsage)) + '' + truncatedNote + '
' + (session.toolActivity.totalCalls ? '
Activity timelineclick a bar to jump to those calls · drag to zoom
' : '') + '
' + timeline + '
' }; + const tracePanel = '
' + + (session.toolActivity.totalCalls ? '
Activity timelineclick a bar to jump to those calls · drag to zoom
' : '') + + '
' + timeline + '
'; + const replayPanel = ''; + const modeTabs = '
'; + return { title, html:'

' + escape(title) + '

' + escape(session.platform) + '' + escape(session.models.join(', ') || 'model unavailable') + '' + formatDuration(session.durationMs) + '' + coverage.turnCount + ' turns' + session.toolActivity.totalCalls + ' tool calls' + session.fileEditCount + ' file edits' + escape(formatTokens(session.tokenUsage)) + '' + truncatedNote + '
' + modeTabs + tracePanel + replayPanel + '
' }; + } + + function replayModel() { + return state.sessionItem?.session?.replay ?? null; + } + + function replayCurrentEvent() { + const replay = replayModel(); + return replay?.events.find(event => event.id === state.replayEventId) ?? replay?.events[0] ?? null; + } + + function replayTiming(event) { + if (!event) return 'No event selected'; + if (event.timeBasis === 'observed') return formatStamp(event.atMs) + ' UTC · observed time'; + if (event.timeBasis === 'turn-boundary') return formatStamp(event.atMs) + ' UTC · Turn boundary, not exact event time'; + return 'Sequence only · timestamp unavailable'; + } + + /* The rail paints one colour per event type, so name only the types this + session actually retained. */ + function replayLegendMarkup(replay) { + const present = new Set(replay.events.map(event => event.type)); + const entries = [['prompt','Prompt'],['intermediate','Intermediate'],['response','Response'],['tool-call','Tool call'],['commit','Commit']] + .filter(([type]) => present.has(type)); + if (replay.events.some(event => event.status === 'failed')) entries.push(['failed','Failed']); + return entries.map(([type,label]) => '' + label + '').join(''); + } + + function replayStageMarkup(event) { + if (!event) return '
No retained replay event exists for this session.
'; + const files = event.files?.length + ? '
Files' + event.files.map(file => '').join('') + '
' + : ''; + const unavailable = event.availability === 'unavailable' ? 'Content unavailable' : ''; + const excerpt = event.bodyExcerpt ? 'Excerpt' : ''; + const status = event.status === 'failed' ? 'Failed' : ''; + const explain = event.selection ? '' : ''; + return '
' + escape(event.label) + '

' + escape(event.title) + '

' + status + unavailable + excerpt + '
' + + '
' + escape(replayTiming(event)) + '' + (event.meta ? '' + escape(event.meta) + '' : '') + (Number.isFinite(event.durationMs) ? '' + escape(formatLatency(event.durationMs)) + '' : '') + '
' + + '

' + escape(event.body) + '

' + files + + '
' + (event.turnIndex ? 'Turn ' + event.turnIndex : 'Outside any observed Turn') + '' + explain + '
'; + } + + function renderReplayIndex() { + const replay = replayModel(); + const body = document.querySelector('[data-replay-index-body]'); + if (!replay || !body) return; + document.querySelectorAll('[data-replay-index-tab]').forEach(tab => { + const selected = tab.dataset.replayIndexTab === state.replayIndexTab; + tab.setAttribute('aria-selected',String(selected)); + tab.tabIndex = selected ? 0 : -1; + }); + body.setAttribute('aria-labelledby','replay-index-tab-' + state.replayIndexTab); + if (state.replayIndexTab === 'files') { + body.innerHTML = replay.files.length + ? '
' + replay.files.map(file => '').join('') + '
' + : '
No repository-relative file was retained for Replay.
'; + return; + } + body.innerHTML = '
' + replay.events.map(event => { const current = event.id === state.replayEventId; return ''; }).join('') + '
'; + keepReplayIndexRowVisible(); + } + + function renderReplayRail() { + const replay = replayModel(); + const rail = document.querySelector('[data-replay-rail]'); + const range = document.querySelector('[data-replay-range]'); + if (!replay || !rail || !range) return; + const timed = replay.events.filter(event => Number.isFinite(event.atMs)); + const start = replay.startMs; + const end = replay.endMs; + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start || timed.length === 0) { + range.textContent = 'Sequence axis · no observed event timing'; + rail.innerHTML = '
1
' + replay.eventCount + '
'; + return; + } + range.textContent = formatShortClock(start) + ' → ' + formatShortClock(end) + ' UTC · ' + formatSpan(end - start); + const span = Math.max(1,end - start); + const bins = new Map(); + const binCount = 120; + for (const event of timed) { + const index = Math.min(binCount - 1,Math.max(0,Math.floor(((event.atMs - start) / span) * binCount))); + const bin = bins.get(index) ?? { index, events:[], failed:false }; + bin.events.push(event); + if (event.status === 'failed') bin.failed = true; + bins.set(index,bin); + } + const marks = [...bins.values()].map(bin => { + const first = bin.events[0]; + const label = bin.events.length + ' event' + (bin.events.length === 1 ? '' : 's') + ' near ' + formatStamp(first.atMs) + ' UTC'; + return ''; + }).join(''); + rail.innerHTML = '
' + marks + '
' + escape(formatShortClock(start)) + '' + escape(formatShortClock(end)) + '
'; + rail.dataset.startMs = String(start); + rail.dataset.endMs = String(end); + } + + /* Only the index's own scroller moves, so following the current event can never + scroll Session View or the page underneath it. */ + function keepReplayIndexRowVisible() { + const body = document.querySelector('[data-replay-index-body]'); + const row = body?.querySelector('.replay-event-list .replay-current'); + if (!body || !row) return; + const view = body.getBoundingClientRect(); + const target = row.getBoundingClientRect(); + if (target.top >= view.top && target.bottom <= view.bottom) return; + body.scrollTop += target.top - view.top - Math.max(0,(view.height - target.height) / 2); + } + + function updateReplayPresentation() { + const replay = replayModel(); + const event = replayCurrentEvent(); + if (!replay) return; + const stage = document.querySelector('[data-replay-stage]'); + if (stage) stage.innerHTML = replayStageMarkup(event); + document.querySelectorAll('[data-replay-event]').forEach(row => { + const current = row.dataset.replayEvent === event?.id; + row.classList.toggle('replay-current',current); + if (row.closest('.replay-event-list')) row.setAttribute('aria-current',current ? 'step' : 'false'); + }); + keepReplayIndexRowVisible(); + const cursor = document.querySelector('[data-replay-cursor]'); + if (cursor) { + const start = Number(cursor.closest('[data-replay-rail]')?.dataset.startMs); + const end = Number(cursor.closest('[data-replay-rail]')?.dataset.endMs); + if (Number.isFinite(event?.atMs) && end > start) { + cursor.style.left = Math.min(100,Math.max(0,((event.atMs - start) / (end - start)) * 100)) + '%'; + cursor.hidden = false; + } else cursor.hidden = true; + } + const position = document.querySelector('[data-replay-position]'); + if (position) position.textContent = 'Event ' + (event ? event.order : 0) + ' / ' + replay.eventCount; + const play = document.querySelector('[data-replay-play]'); + if (play) { + play.innerHTML = (state.replayPlaying ? 'Pause' : 'Play') + ' Space'; + play.setAttribute('aria-pressed',String(state.replayPlaying)); + } + document.querySelectorAll('[data-replay-speed]').forEach(button => button.setAttribute('aria-pressed',String(Number(button.dataset.replaySpeed) === state.replaySpeed))); + } + + function renderReplay() { + const replay = replayModel(); + if (!replay) return; + if (!replay.events.some(event => event.id === state.replayEventId)) { + const selectedKey = selectionKey(state.selection); + state.replayEventId = replay.events.find(event => selectionKey(event.selection) === selectedKey)?.id ?? replay.events[0]?.id ?? null; + } + renderReplayIndex(); + renderReplayRail(); + updateReplayPresentation(); + } + + function stopReplay() { + if (state.replayTimer) clearTimeout(state.replayTimer); + state.replayTimer = null; + state.replayPlaying = false; + updateReplayPresentation(); + } + + function scheduleReplay() { + if (!state.replayPlaying) return; + if (state.replayTimer) clearTimeout(state.replayTimer); + const reducedMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches === true; + const delay = Math.max(reducedMotion ? 220 : 90,900 / state.replaySpeed); + state.replayTimer = setTimeout(() => { + state.replayTimer = null; + const replay = replayModel(); + const current = replay?.events.findIndex(event => event.id === state.replayEventId) ?? -1; + if (!replay || current >= replay.events.length - 1) { + stopReplay(); + return; + } + setReplayEvent(replay.events[current + 1].id,{ updateHistory:true }); + scheduleReplay(); + },delay); + } + + function setReplayPlaying(playing) { + const replay = replayModel(); + if (!replay?.events.length) return; + if (!playing) { + stopReplay(); + return; + } + if (replay.events.at(-1)?.id === state.replayEventId) state.replayEventId = replay.events[0].id; + state.replayPlaying = true; + updateReplayPresentation(); + updateUrl(); + scheduleReplay(); + } + + function setReplayEvent(eventId,{ syncSelection = true, updateHistory = true } = {}) { + const replay = replayModel(); + const event = replay?.events.find(candidate => candidate.id === eventId); + if (!event) return; + state.replayEventId = event.id; + if (syncSelection && event.selection) setSelection(event.selection,{ updateHistory:false, reveal:false }); + updateReplayPresentation(); + if (updateHistory) updateUrl(); + } + + function stepReplay(delta) { + const replay = replayModel(); + if (!replay?.events.length) return; + const current = Math.max(0,replay.events.findIndex(event => event.id === state.replayEventId)); + const next = Math.max(0,Math.min(replay.events.length - 1,current + delta)); + setReplayEvent(replay.events[next].id); + if (state.replayPlaying && next === replay.events.length - 1) stopReplay(); + } + + function setSessionMode(mode,{ updateHistory = true } = {}) { + const next = mode === 'replay' ? 'replay' : 'trace'; + state.sessionMode = next; + if (next !== 'replay') stopReplay(); + else { + state.evidenceDrawerSuppressed = true; + renderEvidenceDrawer(); + } + document.querySelectorAll('[data-session-mode]').forEach(tab => { + const selected = tab.dataset.sessionMode === next; + tab.setAttribute('aria-selected',String(selected)); + tab.tabIndex = selected ? 0 : -1; + }); + document.querySelectorAll('[data-session-mode-panel]').forEach(panel => { panel.hidden = panel.dataset.sessionModePanel !== next; }); + if (next === 'replay') renderReplay(); + if (updateHistory) updateUrl(); } function applySessionFilters() { @@ -831,6 +1072,14 @@ function openSessionView(item,selection = state.selection,trigger = document.activeElement,{ updateHistory = true } = {}) { const view = sessionViewMarkup(item); + const params = new URLSearchParams(location.search); + const restoringThisSession = params.get('session') === item.session.sessionId; + const changingSession = state.sessionItem?.session?.sessionId !== item.session.sessionId; + if (changingSession) { + state.replayEventId = restoringThisSession ? params.get('replay-event') : null; + state.sessionMode = restoringThisSession && params.get('session-mode') === 'replay' ? 'replay' : 'trace'; + state.replayIndexTab = 'events'; + } state.sessionItem = item; state.sessionTrigger = trigger; state.sessionOpen = true; @@ -839,13 +1088,14 @@ document.getElementById('session-view').hidden = false; document.body.classList.add('session-open'); document.getElementById('session-view-close').focus(); + setSessionMode(state.sessionMode,{ updateHistory:false }); if (updateHistory) { state.sessionPushed = true; updateUrl({ push:true }); } renderEvidenceDrawer(); requestAnimationFrame(() => { - const axis = document.querySelector('[data-session-axis] [data-activity-chart]'); + const axis = document.querySelector('[data-session-mode-panel="trace"] [data-session-axis] [data-activity-chart]'); if (axis && !axis.childElementCount) { renderActivityChart(axis); chartObserver?.observe(axis); @@ -857,10 +1107,15 @@ } function teardownSessionView() { + stopReplay(); jumpObserver?.disconnect(); jumpObserver = null; state.sessionOpen = false; state.sessionItem = null; + state.sessionMode = 'trace'; + state.replayEventId = null; + state.replayIndexTab = 'events'; + state.evidenceDrawerSuppressed = false; document.getElementById('session-view').hidden = true; document.getElementById('session-view-body').innerHTML = ''; document.body.classList.remove('session-open'); @@ -1141,7 +1396,7 @@ function urlForState() { const url = new URL(location.href); - ['feature','date','story','session','context-session','turn','call','file','commit','view'].forEach(key => url.searchParams.delete(key)); + ['feature','date','story','session','context-session','turn','call','file','commit','view','session-mode','replay-event'].forEach(key => url.searchParams.delete(key)); url.searchParams.set('mode',state.mode); if (state.mode === 'feature' && state.scope) url.searchParams.set('feature',state.scope); if (state.mode === 'date' && state.scope) url.searchParams.set('date',state.scope); @@ -1159,6 +1414,10 @@ url.searchParams.set('view','session'); const sessionId = state.sessionItem?.session?.sessionId; if (sessionId) url.searchParams.set('session',sessionId); + if (state.sessionMode === 'replay') { + url.searchParams.set('session-mode','replay'); + if (state.replayEventId) url.searchParams.set('replay-event',state.replayEventId); + } } return url; } @@ -1197,7 +1456,7 @@ function renderEvidenceDrawer() { const drawer = document.getElementById('evidence-drawer'); const app = document.querySelector('.app'); - if (!state.selection) { + if (!state.selection || state.evidenceDrawerSuppressed) { drawer.hidden = true; document.body.classList.remove('drawer-open'); if (app.dataset.drawerCollapsedPicker === 'true') setPickerCollapsed(false); @@ -1225,8 +1484,9 @@ if (!state.sessionOpen && innerWidth > 760 && innerWidth < 1200 && !app.classList.contains('picker-collapsed')) setPickerCollapsed(true,{ automatic:true }); } - function setSelection(selection,{ updateHistory = true } = {}) { + function setSelection(selection,{ updateHistory = true, reveal = true } = {}) { state.selection = selection; + state.evidenceDrawerSuppressed = !reveal; renderEvidenceDrawer(); applySelectionPresentation(); if (updateHistory) updateUrl(); @@ -1234,6 +1494,7 @@ function clearSelection({ updateHistory = true } = {}) { state.selection = null; + state.evidenceDrawerSuppressed = false; renderEvidenceDrawer(); applySelectionPresentation(); if (updateHistory) updateUrl(); @@ -1425,6 +1686,39 @@ if (!open) document.querySelectorAll('#session-view details.session-tool-run').forEach(details => { details.open = false; }); return; } + const sessionMode = event.target.closest('[data-session-mode]'); + if (sessionMode) { setSessionMode(sessionMode.dataset.sessionMode); return; } + const replayIndexTab = event.target.closest('[data-replay-index-tab]'); + if (replayIndexTab) { + state.replayIndexTab = replayIndexTab.dataset.replayIndexTab === 'files' ? 'files' : 'events'; + renderReplayIndex(); + return; + } + const replayEvent = event.target.closest('[data-replay-event]'); + if (replayEvent) { setReplayEvent(replayEvent.dataset.replayEvent); return; } + const replayFile = event.target.closest('[data-replay-file]'); + if (replayFile) { + const filePath = replayFile.dataset.replayFile; + const file = replayModel()?.files.find(candidate => candidate.path === filePath); + if (file?.eventIds[0]) setReplayEvent(file.eventIds[0],{ syncSelection:false, updateHistory:false }); + setSelection({ type:'file', path:filePath, contextSessionId:state.sessionItem?.session?.sessionId ?? null },{ reveal:false }); + return; + } + const replayStep = event.target.closest('[data-replay-step]'); + if (replayStep) { stepReplay(Number(replayStep.dataset.replayStep)); return; } + if (event.target.closest('[data-replay-play]')) { setReplayPlaying(!state.replayPlaying); return; } + const replaySpeed = event.target.closest('[data-replay-speed]'); + if (replaySpeed) { + state.replaySpeed = Number(replaySpeed.dataset.replaySpeed) || 1; + updateReplayPresentation(); + if (state.replayPlaying) scheduleReplay(); + return; + } + if (event.target.closest('[data-replay-explain]')) { + const current = replayCurrentEvent(); + if (current?.selection) setSelection(current.selection); + return; + } const selectable = event.target.closest('[data-selectable]'); if (selectable) { setSelection(descriptorFromElement(selectable)); return; } if (event.target.closest('[data-close-session]')) { closeSessionView(); return; } @@ -1582,6 +1876,40 @@ }); document.addEventListener('keydown', event => { + const sessionModeTab = event.target.closest?.('[data-session-mode]'); + if (sessionModeTab && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { + const nextMode = sessionModeTab.dataset.sessionMode === 'trace' ? 'replay' : 'trace'; + setSessionMode(nextMode); + document.querySelector('[data-session-mode="' + nextMode + '"]')?.focus(); + event.preventDefault(); + return; + } + const replayIndexTab = event.target.closest?.('[data-replay-index-tab]'); + if (replayIndexTab && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { + state.replayIndexTab = replayIndexTab.dataset.replayIndexTab === 'events' ? 'files' : 'events'; + renderReplayIndex(); + document.querySelector('[data-replay-index-tab="' + state.replayIndexTab + '"]')?.focus(); + event.preventDefault(); + return; + } + const shortcutTarget = event.target.closest?.('input, select, textarea, button, [contenteditable="true"]'); + if (state.sessionOpen && state.sessionMode === 'replay' && !shortcutTarget) { + const key = event.key.toLowerCase(); + if (event.key === ' ') setReplayPlaying(!state.replayPlaying); + else if (key === 'j' || event.key === 'ArrowLeft') stepReplay(-1); + else if (key === 'l' || event.key === 'ArrowRight') stepReplay(1); + else if ([1,2,4,8].includes(Number(event.key))) { + state.replaySpeed = Number(event.key); + updateReplayPresentation(); + if (state.replayPlaying) scheduleReplay(); + } else if (key === 'p') stepReplay(-1); + else if (key === 'n') stepReplay(1); + else if (event.key !== 'Escape') return; + if (event.key !== 'Escape') { + event.preventDefault(); + return; + } + } const bin = event.target.closest?.('[data-chart-bin]'); if (bin && (event.key === 'Enter' || event.key === ' ')) { bin.dispatchEvent(new MouseEvent('click',{ bubbles:true })); @@ -1637,6 +1965,8 @@ setMode(mode,{ preserveSelection:true, updateHistory:false }); const session = bySession.get(params.get('session')); if (params.get('view') === 'session' && session) { + state.sessionMode = params.get('session-mode') === 'replay' ? 'replay' : 'trace'; + state.replayEventId = params.get('replay-event'); const item = itemForSession(session); if (item) openSessionView(item,state.selection,state.sessionTrigger,{ updateHistory:false }); } else if (state.sessionOpen) { diff --git a/test/reporting/harness-inspector.test.mjs b/test/reporting/harness-inspector.test.mjs index d3d4a45..330cf0f 100644 --- a/test/reporting/harness-inspector.test.mjs +++ b/test/reporting/harness-inspector.test.mjs @@ -574,6 +574,81 @@ test("activity projection carries a wall-clock timeline and falls back to call o assert.equal(Object.hasOwn(untimed.calls[0], "startedAt"), false); }); +test("SessionReplay preserves retained order and labels observed versus sequence-only time", () => { + const report = buildHarnessInspectorReport({ + repoRoot: "/workspace/repo", + featureTree: parseFeatureTreeMarkdown(FEATURE_TREE), + sessions: [fixtureSession()], + correlation: fixtureCorrelation(), + }); + const replay = report.sessions[0].replay; + assert.equal(replay.kind, "SessionReplay"); + assert.equal(replay.schemaVersion, 1); + assert.equal(replay.timeBasis, "observed-time"); + assert.deepEqual(replay.events.map((event) => event.type), [ + "prompt", + "tool-call", + "intermediate", + "commit", + "tool-call", + "response", + ]); + assert.deepEqual(replay.events.map((event) => event.order), [1, 2, 3, 4, 5, 6]); + assert.equal(replay.events.find((event) => event.type === "intermediate").timeBasis, "sequence-only"); + assert.equal(replay.events.find((event) => event.type === "response").timeBasis, "turn-boundary"); + assert.equal(replay.events.find((event) => event.type === "commit").title.startsWith("aaaaaaa"), true); + assert.equal(replay.events.some((event) => event.id === `commit:${"b".repeat(40)}`), false); + assert.deepEqual( + replay.files.find((file) => file.path === "scripts/harness-inspector/render-html.mjs").eventIds, + ["call:A1", `commit:${"a".repeat(40)}`], + ); + + const base = fixtureSession(); + const sequenceReplay = buildHarnessInspectorReport({ + repoRoot: "/workspace/repo", + featureTree: parseFeatureTreeMarkdown(FEATURE_TREE), + sessions: [{ + ...base, + dialogue: { + ...base.dialogue, + turns: base.dialogue.turns.map((turn) => ({ + ...turn, + prompt: { ...turn.prompt, timestamp: null }, + startMs: null, + endMs: null, + })), + }, + toolActivity: { ...base.toolActivity, calls: base.toolActivity.calls.map(({ startedAt, ...call }) => call) }, + }], + correlation: { commits: [] }, + }).sessions[0].replay; + assert.equal(sequenceReplay.timeBasis, "call-sequence"); + assert.equal(sequenceReplay.timedEventCount, 0); + assert.equal(sequenceReplay.events.every((event) => event.timeBasis === "sequence-only"), true); +}); + +test("SessionReplay flags a clipped body as an excerpt and leaves whole bodies unmarked", () => { + const base = fixtureSession(); + const longDetail = `rg -n ${"pattern ".repeat(80)}`; + const replay = buildHarnessInspectorReport({ + repoRoot: "/workspace/repo", + featureTree: parseFeatureTreeMarkdown(FEATURE_TREE), + sessions: [{ + ...base, + toolActivity: { + ...base.toolActivity, + calls: base.toolActivity.calls.map((call, index) => (index === 0 ? { ...call, detail: longDetail } : call)), + }, + }], + correlation: fixtureCorrelation(), + }).sessions[0].replay; + const clipped = replay.events.find((event) => event.id === "call:A1"); + const prompt = replay.events.find((event) => event.type === "prompt"); + assert.equal(clipped.body.endsWith("\u2026"), true); + assert.equal(clipped.bodyExcerpt, true); + assert.equal(Object.hasOwn(prompt, "bodyExcerpt"), false); +}); + test("one turn vocabulary is projected and retained prompts resolve to their real Turn", () => { const base = fixtureSession(); const report = buildHarnessInspectorReport({ From 6d68cb508e10d2c7fcf6be994cc35992ab695a27 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Thu, 13 Aug 2026 19:18:41 +0800 Subject: [PATCH 6/6] feat(inspector): publish interactive docs demo Add a first-class Inspector tab with a green New badge, a deterministic English Workbench sample, evidence-bounded product guidance, and bilingual wrapper documentation. Implements docs/specs/2026-08-13-public-inspector-demo.md. Validated with 21 focused Inspector tests, the 1,309-test full suite, package verification, bilingual Docusaurus builds, and browser checks of Session View, Replay, console errors, and page overflow. Co-authored-by: Codex (GPT 5.6 Sol) --- .github/workflows/pages.yml | 3 + docs/docusaurus.config.js | 7 + docs/i18n/zh-Hans/code.json | 120 +++++ .../docusaurus-theme-classic/navbar.json | 4 + docs/scripts/sync-assets.mjs | 10 +- .../specs/2026-08-13-public-inspector-demo.md | 124 +++++ docs/src/css/custom.css | 56 +++ docs/src/pages/inspector/index.js | 337 +++++++++++++ docs/src/pages/inspector/inspector.module.css | 449 ++++++++++++++++++ scripts/harness-inspector/demo-report.mjs | 437 +++++++++++++++++ scripts/harness-inspector/index.mjs | 5 + scripts/harness-inspector/render-html.mjs | 12 +- scripts/harness-inspector/ui/workbench.html | 5 +- scripts/harness-inspector/ui/workbench.js | 6 +- .../reporting/harness-inspector-demo.test.mjs | 47 ++ 15 files changed, 1614 insertions(+), 8 deletions(-) create mode 100644 docs/specs/2026-08-13-public-inspector-demo.md create mode 100644 docs/src/pages/inspector/index.js create mode 100644 docs/src/pages/inspector/inspector.module.css create mode 100644 scripts/harness-inspector/demo-report.mjs create mode 100644 test/reporting/harness-inspector-demo.test.mjs diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 560403d..26693e6 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -6,6 +6,9 @@ on: paths: - "docs/**" - "assets/**" + - "scripts/commit-session-link/**" + - "scripts/harness-inspector/**" + - "scripts/session-analysis/**" - ".github/workflows/pages.yml" workflow_dispatch: diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 3dfa087..36022a8 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -109,6 +109,13 @@ const config = { position: "left", label: "Docs", }, + { + to: "/inspector/", + label: "Inspector", + position: "left", + className: "navbar__link--inspector-new", + "aria-label": "Inspector", + }, { to: "/blog", label: "Blog", diff --git a/docs/i18n/zh-Hans/code.json b/docs/i18n/zh-Hans/code.json index 3027a47..4d29614 100644 --- a/docs/i18n/zh-Hans/code.json +++ b/docs/i18n/zh-Hans/code.json @@ -1,4 +1,124 @@ { + "inspector.meta.description": { + "message": "探索 Better Harness 如何以明确的证据边界连接产品意图、Coding Agent 活动、会话、文件与提交。" + }, + "inspector.hero.lead": { + "message": "从产品意图追踪到 Agent 活动、会话、文件与提交,同时让证据强度及其限制始终可见。" + }, + "inspector.boundaries.label": { + "message": "Demo 边界" + }, + "inspector.boundaries.interactive": { + "message": "可交互样例" + }, + "inspector.boundaries.readOnly": { + "message": "只读" + }, + "inspector.actions.fullscreen": { + "message": "全屏打开" + }, + "inspector.demo.title": { + "message": "可交互 Harness Inspector 样例" + }, + "inspector.demo.frameTitle": { + "message": "可交互 Harness Inspector 样例" + }, + "inspector.demo.loading": { + "message": "正在加载可交互样例…" + }, + "inspector.demo.note": { + "message": "此确定性样例使用虚构的英文数据,不会读取你的工作区、Git 历史或 Coding Agent 会话。" + }, + "inspector.features.eyebrow": { + "message": "它展示什么" + }, + "inspector.features.title": { + "message": "沿着交付链路追踪,同时不越过证据边界" + }, + "inspector.features.intro": { + "message": "Inspector 把产品结构、Agent 活动与 Git 结果放进同一个只读工作区,并明确区分强关联与有用但有限的上下文。" + }, + "inspector.features.intent.title": { + "message": "从意图到交付" + }, + "inspector.features.intent.body": { + "message": "沿 Delivery Tree 从能力和 Story 意图,查看与之关联的会话、文件与提交。" + }, + "inspector.features.session.title": { + "message": "把会话放回上下文" + }, + "inspector.features.session.body": { + "message": "检查保留的提示词、归一化工具活动、文件路径、提交事件,以及只读会话回放。" + }, + "inspector.features.evidence.title": { + "message": "可解释的证据" + }, + "inspector.features.evidence.body": { + "message": "同步选择与 Evidence Drawer 会说明关联为何存在、可信度如何,以及它不能证明什么。" + }, + "inspector.how.eyebrow": { + "message": "如何使用" + }, + "inspector.how.title": { + "message": "先看全局,再沿着证据深入" + }, + "inspector.how.scope.title": { + "message": "选择范围" + }, + "inspector.how.scope.body": { + "message": "按产品意图查看时使用 Delivery Tree;按时间查看会话与提交时使用 Date。" + }, + "inspector.how.lanes.title": { + "message": "阅读三条信息轨" + }, + "inspector.how.lanes.body": { + "message": "在不离开当前 Story 或日期的情况下,对照用户提示、检查点活动以及提交或文件。" + }, + "inspector.how.evidence.title": { + "message": "检查关联关系" + }, + "inspector.how.evidence.body": { + "message": "选择提示、操作、提交或路径。相关项目会保持高亮,Evidence Drawer 则解释关联及其限制。" + }, + "inspector.how.session.title": { + "message": "打开 Session View 或 Replay" + }, + "inspector.how.session.body": { + "message": "深入查看保留的轮次和归一化工具调用,或回放已观察到的序列。Replay 不会重新运行工具,也不会恢复 Coding Agent 会话。" + }, + "inspector.evidence.eyebrow": { + "message": "理解证据标签" + }, + "inspector.evidence.title": { + "message": "关联会明确展示,但不会擅自推断作者身份" + }, + "inspector.evidence.explicit": { + "message": "保留的引用直接连接 Story、会话或提交。" + }, + "inspector.evidence.observed": { + "message": "会话与提交共享完全相同的仓库路径。这支持相关性判断,但不能证明作者身份。" + }, + "inspector.evidence.candidate": { + "message": "结构或时间信息提示了有用的关联,但仍需人工确认。" + }, + "inspector.evidence.contextual": { + "message": "附近的历史有助于解释交付过程,但不会声称存在直接关联。" + }, + "inspector.local.eyebrow": { + "message": "使用你自己的证据" + }, + "inspector.local.title": { + "message": "生成私有的自包含 Inspector" + }, + "inspector.local.body": { + "message": "安装 Better Harness 后,在仓库中运行高级 Inspector 命令。采集留在本地,生成的报告保持只读。" + }, + "inspector.local.installation": { + "message": "查看安装方式" + }, + "inspector.local.output": { + "message": "默认输出:.qoder/better-harness-runs/harness-inspector/inspector.html" + }, "homepage.dimensions.taskUnderstanding.title": { "message": "任务理解" }, diff --git a/docs/i18n/zh-Hans/docusaurus-theme-classic/navbar.json b/docs/i18n/zh-Hans/docusaurus-theme-classic/navbar.json index e5c4bce..51cf7cb 100644 --- a/docs/i18n/zh-Hans/docusaurus-theme-classic/navbar.json +++ b/docs/i18n/zh-Hans/docusaurus-theme-classic/navbar.json @@ -7,6 +7,10 @@ "message": "文档", "description": "Navbar item with label Docs" }, + "item.label.Inspector": { + "message": "Inspector", + "description": "Navbar item with label Inspector" + }, "item.label.Blog": { "message": "博客", "description": "Navbar item with label Blog" diff --git a/docs/scripts/sync-assets.mjs b/docs/scripts/sync-assets.mjs index b4deda9..f5e7b32 100644 --- a/docs/scripts/sync-assets.mjs +++ b/docs/scripts/sync-assets.mjs @@ -1,9 +1,11 @@ // Sync published assets from the repository root into docs/static/. // assets/ stays the single source of truth; synced targets are gitignored. -import { cpSync, mkdirSync, rmSync } from "node:fs"; +import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { renderHarnessInspectorDemoHtml } from "../../scripts/harness-inspector/demo-report.mjs"; + export function syncAssets({ repoRoot, siteRoot }) { const generatedDemoRoot = join(siteRoot, "static", "demo"); @@ -42,7 +44,11 @@ export function syncAssets({ repoRoot, siteRoot }) { mkdirSync(dirname(to), { recursive: true }); cpSync(from, to, { recursive: true }); } - return copies.length; + const inspectorDemo = join(generatedDemoRoot, "harness-inspector", "index.html"); + mkdirSync(dirname(inspectorDemo), { recursive: true }); + writeFileSync(inspectorDemo, renderHarnessInspectorDemoHtml(), "utf8"); + + return copies.length + 1; } const currentFile = fileURLToPath(import.meta.url); diff --git a/docs/specs/2026-08-13-public-inspector-demo.md b/docs/specs/2026-08-13-public-inspector-demo.md new file mode 100644 index 0000000..deb2e0f --- /dev/null +++ b/docs/specs/2026-08-13-public-inspector-demo.md @@ -0,0 +1,124 @@ +# Public Harness Inspector demo + +## Traceability + +- Spec ID: `public-inspector-demo` +- Status: Implemented + +## Intent + +Add a first-class Harness Inspector entry to the Better Harness GitHub Pages +site. Readers should understand the Inspector's evidence-bounded purpose and +explore the current Workbench interactions without installing the project or +exposing any maintainer's local sessions, prompts, paths, or repository state. + +## Acceptance Scenarios + +- AC-1: The website navbar contains an internal `Inspector` link immediately + after Docs. A compact green `New` badge sits at the link's upper-right on + desktop and beside the link on the mobile menu; `New` is not part of the + product name, route, page title, or accessible link name. +- AC-2: `/inspector/` is a full-width Docusaurus page with a concise product + introduction, explicit `Interactive sample`, `Read-only`, and `English sample + data` boundaries, an `Open full screen` action, and an embedded current + Harness Inspector Workbench. +- AC-3: The embedded Workbench is generated from a deterministic in-memory + fixture. Every user-facing fixture value is English, no local session or Git + discovery runs during the docs build, and the sample contains enough Stories, + Sessions, Tool Calls, Files, Commits, dates, and evidence kinds to exercise + Delivery Tree, Date, Evidence Drawer, Session View, and Replay interactions. +- AC-4: The embedded page identifies itself as sample data rather than `real + local evidence`, carries `noindex, follow`, and preserves the standalone + Workbench's escaping and privacy guarantees. The indexable Docusaurus wrapper + owns the public description. +- AC-5: English and Simplified Chinese website builds expose the same English + Workbench sample. Only the wrapper introduction and actions are localized. + Desktop and narrow viewports retain usable navigation, iframe focus, a + full-screen fallback, and no page-level horizontal overflow. +- AC-6: Changes to the Inspector renderer, UI, fixture, docs page, or publishing + workflow trigger or participate in the GitHub Pages build so the published + demo cannot silently drift behind the current Workbench. +- AC-7: The page continues below the interactive sample with concise, + indexable documentation that explains the Inspector's core jobs, the + difference between direct, observed, candidate, and contextual evidence, and + the limits of what those relationships prove. +- AC-8: A numbered usage guide walks readers through choosing a scope, + inspecting the three Workbench lanes, opening evidence details, and using + Session View or Replay. A separate local-project callout provides the exact + advanced CLI command, default output location, and a link to installation + documentation without implying that the hosted sample reads local data. + +## Non-goals + +- Publishing a maintainer's `.qoder/` report, native session identifiers, real + prompts, commit authors, or current repository activity. +- Reading a visitor's local workspace or offering a server-backed live report. +- Rewriting the self-contained Workbench as Docusaurus React components. +- Synchronizing the wrapper URL with every iframe selection in this slice; the + standalone Workbench remains the owner of evidence deep links. +- Adding Compare, editing mappings, recovery, or workspace mutation controls. + +## Plan and Tasks + +1. Add a pure Inspector demo builder under `scripts/harness-inspector/` with a + fixed English fixture and generated timestamp. +2. Add a bounded rendering presentation option for the sample context label, + robots metadata, and optional public-demo marker without changing local + report defaults. +3. Generate `docs/static/demo/harness-inspector/index.html` during the existing + docs asset-sync step instead of committing a second Workbench copy. +4. Add a custom full-width `/inspector/` page, localized wrapper copy, full-screen + action, iframe title, clipboard permission, and responsive frame layout. +5. Add the `Inspector` navbar item and CSS-only green `New` badge while keeping + the DOM link text and accessible name equal to `Inspector`. +6. Extend Pages path triggers to include the Inspector owners and add focused + behavior tests for fixture language, privacy, presentation metadata, and + generated output. +7. Add localized feature, usage, evidence-label, and local-project sections + below the sample, with responsive cards and semantic headings/lists. + +Decision rationale: the Inspector already emits a portable self-contained HTML +Workbench. Embedding that artifact keeps one UI owner and makes the public sample +track current interactions. A deterministic fixture prevents CI from depending +on developer homes or native host state, while the Docusaurus wrapper provides +the indexable explanation and locale-specific framing. + +## Test and Review Evidence + +- AC-3/AC-4: focused Vitest coverage builds the demo twice, compares the stable + report projection, validates English fixture strings, asserts sample/noindex + metadata, and rejects absolute-home or credential-shaped output. +- AC-1/AC-2/AC-5: `cd docs && npm run build`, followed by Playwright checks of + English and `zh-Hans` `/inspector/` routes, navbar/badge placement, iframe + interaction, full-screen target, console/page errors, and desktop/narrow + screenshots. +- AC-6: inspect the Pages workflow path filter and run the docs prebuild from a + clean generated-static target. +- AC-7/AC-8: inspect the English and Simplified Chinese page structure in the + production build, verify the installation link and literal CLI command, and + review the lower-page layout at desktop and narrow breakpoints. +- Documentation integrity: `node scripts/doc-link-graph/cli.mjs + skills/better-harness` and `npx vitest run + test/skills-docs/doc-link-graph.test.mjs` after adding this spec. +- Regression and package boundary: focused Inspector tests, `npm test`, + `npm run pack:verify`, and `git diff --check`. +- Privacy risk: fixture generation must remain pure and fixed; the docs build + must never call session discovery or Git history collection. +- UI risk: nested browser scrolling can obscure the Workbench. Keep the wrapper + as a viewport-height flex surface and provide a visible full-screen escape. + +Observed on 2026-08-13: + +- Focused Inspector coverage: 21 tests passed. +- Documentation link graph: 6 tests passed after regeneration. +- Full regression suite: 92 files and 1,309 tests passed. +- Package verification: npm package and runtime zip contents passed. +- Production documentation build: English and Simplified Chinese builds passed. +- Browser verification: the English and Simplified Chinese wrappers loaded with + no console errors or page-level horizontal overflow; the embedded sample used + English-only fixture data and realistic short commit hashes. The standalone + sample opened Session View and switched to Replay successfully. +- Documentation extension: the English and Simplified Chinese production pages + exposed all feature, usage, evidence-label, and local-project sections; the + advanced CLI command and installation link were present, with no console + errors or page-level horizontal overflow. diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css index 50db69e..0e5bf58 100644 --- a/docs/src/css/custom.css +++ b/docs/src/css/custom.css @@ -69,3 +69,59 @@ html[data-theme="dark"] { --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); } + +/* `New` is presentation metadata, not part of the Inspector product name or + accessible link label. The compact corner tag becomes inline on mobile. */ +.navbar__link--inspector-new { + position: relative; + margin-right: 10px; + padding-right: 12px; +} + +.navbar__link--inspector-new::after { + content: "New"; + position: absolute; + top: 2px; + right: 0; + padding: 2px 5px; + border-radius: 999px; + color: #fff; + background: #218739; + box-shadow: 0 0 0 2px var(--ifm-navbar-background-color); + font-size: 8px; + font-weight: 800; + line-height: 1.25; + letter-spacing: 0.02em; + transform: translate(20%, -20%); + pointer-events: none; +} + +@media (max-width: 996px) { + .navbar__link--inspector-new { + display: flex; + margin-right: 0; + padding-right: var(--ifm-navbar-item-padding-horizontal); + align-items: center; + gap: 8px; + } + + .navbar__link--inspector-new::after { + position: static; + box-shadow: none; + font-size: 9px; + transform: none; + } +} + +@media (min-width: 997px) and (max-width: 1250px) { + .navbar__item { + padding-right: 0.55rem; + padding-left: 0.55rem; + font-size: 0.88rem; + white-space: nowrap; + } + + .navbar__search-input { + width: 9.5rem; + } +} diff --git a/docs/src/pages/inspector/index.js b/docs/src/pages/inspector/index.js new file mode 100644 index 0000000..50eb4b5 --- /dev/null +++ b/docs/src/pages/inspector/index.js @@ -0,0 +1,337 @@ +import BrowserOnly from "@docusaurus/BrowserOnly"; +import Link from "@docusaurus/Link"; +import Translate, { translate } from "@docusaurus/Translate"; +import useBaseUrl from "@docusaurus/useBaseUrl"; +import Layout from "@theme/Layout"; + +import styles from "./inspector.module.css"; + +export default function InspectorPage() { + const demoUrl = useBaseUrl("/demo/harness-inspector/"); + const installationUrl = useBaseUrl("/docs/installation"); + + return ( + +
+
+
+

Better Harness

+

Harness Inspector

+

+ + Trace product intent through agent activity, sessions, files, + and commits—while keeping evidence strength and limitations + visible. + +

+
+
+
    +
  • + + Interactive sample + +
  • +
  • + Read-only +
  • +
  • English sample data
  • +
+ + + Open full screen + + + +
+
+ +
+

+ + Interactive Harness Inspector sample + +

+ + + Loading the interactive sample… + + + )} + > + {() => ( +