From b965285f282a5c7aadde6e4de76a42d0da756b28 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Sat, 18 Jul 2026 14:04:43 +0800 Subject: [PATCH] feat(research): add trace-workbench local session-replay UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A localhost viewer over persisted session JSONL for agent developers and researchers. Three first-class views — Chat (markdown-rendered surface conversation with a second-level inspector), Trajectory (turn/step tree with scroll-spy over a step-grouped event table, inline annotations), and Waterfall (timing summary + aligned time track that deep-links into Trajectory). Subagent sessions group under their parentSession with spawn links and breadcrumbs; failed tool calls are marked in every view; ?session=&view=&sel= makes any selection a shareable deep link. Motion follows an audited restraint baseline (plans/ documents the audit): one strong ease-out token, enter-only animations, press feedback on pushbuttons, prefers-reduced-motion support. AGENTS.md gains the research/ layout entry; the AGENTS.md word ceiling rises 1370 -> 1375 to fit it (one line, relocation not applicable for a top-level layout entry). --- AGENTS.md | 1 + research/trace-workbench/.gitignore | 1 + research/trace-workbench/README.md | 39 + research/trace-workbench/app.js | 1663 +++++++++++ research/trace-workbench/index.html | 173 ++ .../plans/001-motion-foundation.md | 60 + .../plans/002-inspector-enter.md | 61 + .../plans/003-details-enter.md | 61 + .../plans/004-press-feedback.md | 56 + .../plans/005-reduced-motion.md | 70 + research/trace-workbench/plans/README.md | 15 + research/trace-workbench/server.js | 456 +++ research/trace-workbench/styles.css | 2646 +++++++++++++++++ scripts/doc-budgets.manifest.json | 2 +- 14 files changed, 5303 insertions(+), 1 deletion(-) create mode 100644 research/trace-workbench/.gitignore create mode 100644 research/trace-workbench/README.md create mode 100644 research/trace-workbench/app.js create mode 100644 research/trace-workbench/index.html create mode 100644 research/trace-workbench/plans/001-motion-foundation.md create mode 100644 research/trace-workbench/plans/002-inspector-enter.md create mode 100644 research/trace-workbench/plans/003-details-enter.md create mode 100644 research/trace-workbench/plans/004-press-feedback.md create mode 100644 research/trace-workbench/plans/005-reduced-motion.md create mode 100644 research/trace-workbench/plans/README.md create mode 100644 research/trace-workbench/server.js create mode 100644 research/trace-workbench/styles.css diff --git a/AGENTS.md b/AGENTS.md index 23d7c0263b..b7eee0a2bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ packages/ Harness packages at packages///, all named @deepseek-ai python/ Python SDK and bundled runtime (see python/README.md) examples/ Runnable cordis.yml leaves over packages/examples bundles (see examples/AGENTS.md) docs/ architecture, generated catalogs, RFCs, postmortems, cookbook (see docs/AGENTS.md) +research/ research prototypes (see research/trace-workbench/README.md) scripts/ repo gates and generators ``` diff --git a/research/trace-workbench/.gitignore b/research/trace-workbench/.gitignore new file mode 100644 index 0000000000..a4bb5320d9 --- /dev/null +++ b/research/trace-workbench/.gitignore @@ -0,0 +1 @@ +.feedback/ diff --git a/research/trace-workbench/README.md b/research/trace-workbench/README.md new file mode 100644 index 0000000000..71dea925b9 --- /dev/null +++ b/research/trace-workbench/README.md @@ -0,0 +1,39 @@ +# DeepSeek Harness Trace Workbench + +Localhost UI backed by real DeepSeek Harness persisted session JSONL files. + +```sh +node server.js +``` + +Then open . + +Defaults: + +- Reads sessions from `./.sessions` under the current working directory (set `HARNESS_SESSIONS_ROOT` to point elsewhere) +- Serves static UI and API from the same localhost origin +- Annotations are appended to `.feedback/*.jsonl` next to the server (local data, not committed) + +Useful overrides: + +```sh +HARNESS_SESSIONS_ROOT=/path/to/.sessions PORT=5174 node server.js +``` + +API: + +- `GET /api/health` +- `GET /api/sessions` +- `GET /api/sessions/:id` + +Current UI capabilities: + +- Reads real session JSONL, not mock data; replay-only (the composer is disabled until live runtime wiring lands). +- Three first-class views; the inspector is second-level and belongs to Chat only: + - **Chat** — markdown-rendered surface conversation; clicking a message opens the inspector as an inner column (paired Input/Output, metadata, feedback, Plain/JSON/JSONL/YAML formatting) that squeezes the conversation, never overlays it. + - **Trajectory** — self-contained: a structure tree (turn → step, with durations, tool summaries and error dots) navigates a step-grouped event table; lifecycle events are absorbed into the tree and sticky group headers instead of appearing as rows. Expanded rows carry Copy JSON, inline annotations (标注) and the raw event. + - **Waterfall** — hotspot finder: a summary strip (total / LLM time / tool time / errors / slowest step / tokens) plus an aligned time track; clicking any bar, label or stat jumps to the matching Trajectory row. +- Session list groups subagent sessions under their `parentSession`; a spawning tool call links to the sessions it spawned, and a child session shows a breadcrumb back to its parent. +- Failed tool calls are marked in every view (red rail + `← error` chip in Trajectory, red bar in Waterfall, `Tool failed` in Chat). +- Panes resize by dragging the dividers (clamped; widths persist), and all panes squeeze each other in one layer. +- URL carries `?session=&view=&sel=` so any selection is a shareable deep link. diff --git a/research/trace-workbench/app.js b/research/trace-workbench/app.js new file mode 100644 index 0000000000..070d1c601c --- /dev/null +++ b/research/trace-workbench/app.js @@ -0,0 +1,1663 @@ +const state = { + sessions: [], + session: null, + selected: null, // chat-side selection (drives the inspector) + activeDetailTab: 'input', + inspectorOpen: false, // chat-only second-level panel + expandedTrajectorySeqs: new Set(), + annotateOpenIds: new Set(), + trajGroups: [], // step-grouped trajectory rows (lifecycle events become group metadata) + trajectoryRows: [], // flat row list across all groups + turnMeta: new Map(), // turn -> {trigger, reason, startTime, endTime} + sessionQuery: '', + // Per-session indexes, rebuilt on every loadSession: + seqMap: new Map(), // seq -> event (tree nodes reference events by seq) + callPairs: new Map(), // callId -> { call, result } + firstChunkByStep: new Map(), // "turn:step" -> first assistant/chunk time +} + +const $ = (selector) => document.querySelector(selector) +const $$ = (selector) => [...document.querySelectorAll(selector)] + +async function api(path) { + const response = await fetch(path) + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`) + return response.json() +} + +async function postJson(path, body) { + const response = await fetch(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + return response.json() +} + +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') +} + +function ms(seconds) { + if (!Number.isFinite(seconds)) return '0ms' + return seconds >= 1 ? `${seconds.toFixed(2)}s` : `${Math.round(seconds * 1000)}ms` +} + +function fmtOffset(seconds) { + return `+${ms(Math.max(0, seconds))}` +} + +function dateTime(msValue) { + if (!Number.isFinite(msValue)) return 'unknown' + return new Date(msValue).toLocaleString() +} + +function truncate(value, limit = 180) { + const text = String(value ?? '').replace(/\s+/g, ' ').trim() + return text.length > limit ? `${text.slice(0, limit - 1)}...` : text +} + +function nodeGlyph(kind) { + return { + session: 'S', + turn: 'T', + step: 'ST', + tool: 'TL', + llm: 'AI', + event: 'EV', + }[kind] ?? 'N' +} + +function flatten(node, depth = 0, out = []) { + if (!node) return out + out.push({ ...node, depth }) + for (const child of node.children ?? []) flatten(child, depth + 1, out) + return out +} + +function currentNodes() { + return flatten(state.session?.tree) +} + +function findNode(id) { + return currentNodes().find(node => node.id === id) ?? state.session?.tree +} + +// Tree nodes carry eventSeqs (not embedded events); resolve through the seq index. +function eventsOf(node) { + if (node?.rawEvents) return node.rawEvents + return (node?.eventSeqs ?? []).map(seq => state.seqMap.get(seq)).filter(Boolean) +} + +function resolvePrompt(node) { + const seq = node?.detail?.promptSeq + if (seq !== undefined) return state.seqMap.get(seq)?.data?.header + return node?.detail?.prompt +} + +function contentToText(content) { + if (!Array.isArray(content)) return '' + return content.map((block) => { + if (block.type === 'text' || block.type === 'reasoning') return block.text ?? '' + if (block.type === 'tool-call') return `[tool-call ${block.name}] ${block.arguments ?? ''}` + if (block.type === 'tool-result') return `[tool-result] ${JSON.stringify(block.content ?? block)}` + return JSON.stringify(block) + }).filter(Boolean).join('\n') +} + +function contentBlocks(content) { + return Array.isArray(content) ? content : [] +} + +function renderValue(value, format) { + if (value === undefined || value === null || value === '') return '' + if (format === 'json') return JSON.stringify(value, null, 2) + if (format === 'jsonl') { + const rows = Array.isArray(value) ? value : [value] + return rows.map(row => typeof row === 'string' ? row : JSON.stringify(row)).join('\n') + } + if (format === 'yaml') return toYaml(value) + if (typeof value === 'string') return value + if (Array.isArray(value) && value.every(item => item?.type)) return contentToText(value) + return JSON.stringify(value, null, 2) +} + +function toYaml(value, indent = 0) { + const pad = ' '.repeat(indent) + if (value === null) return 'null' + if (typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) { + return value.map(item => { + if (item && typeof item === 'object') return `${pad}-\n${toYaml(item, indent + 2)}` + return `${pad}- ${toYaml(item)}` + }).join('\n') + } + return Object.entries(value).map(([key, item]) => { + if (item && typeof item === 'object') return `${pad}${key}:\n${toYaml(item, indent + 2)}` + return `${pad}${key}: ${toYaml(item)}` + }).join('\n') +} + +// Minimal safe markdown for the Chat surface: input is escaped FIRST, then a +// line-based pass adds structure. Headings, lists, hr, fenced code, inline +// bold/code/links (http(s) only). Everything unrecognized stays a paragraph. +function mdInline(escaped) { + return escaped + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*(\S(?:[^*\n]*\S)?)\*/g, '$1') + .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1') +} + +function mdSplitRow(line) { + return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim()) +} + +const MD_TABLE_ROW = /^\s*\|.*\|\s*$/ +// A |---|:---:| separator row (text is already HTML-escaped, pipes unaffected). +const MD_TABLE_SEP = /^\s*\|?[\s:|-]+\|[\s:|-]*$/ + +function renderMarkdown(text) { + const lines = escapeHtml(text).split('\n') + const out = [] + let inCode = false + let listType = null + const closeList = () => { + if (listType) { + out.push(``) + listType = null + } + } + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (line.trim().startsWith('```')) { + closeList() + out.push(inCode ? '' : '
')
+      inCode = !inCode
+      continue
+    }
+    if (inCode) {
+      out.push(line)
+      continue
+    }
+    // Table: a |...| row whose next line is the |---|---| separator.
+    if (MD_TABLE_ROW.test(line) && i + 1 < lines.length && MD_TABLE_SEP.test(lines[i + 1]) && lines[i + 1].includes('-')) {
+      closeList()
+      const header = mdSplitRow(line)
+      const aligns = mdSplitRow(lines[i + 1]).map(cell => {
+        if (cell.startsWith(':') && cell.endsWith(':')) return 'center'
+        if (cell.endsWith(':')) return 'right'
+        return ''
+      })
+      i += 1
+      const rows = []
+      while (i + 1 < lines.length && MD_TABLE_ROW.test(lines[i + 1])) {
+        i += 1
+        rows.push(mdSplitRow(lines[i]))
+      }
+      const cellHtml = (tag, cells) => cells.map((cell, k) =>
+        `<${tag}${aligns[k] ? ` style="text-align:${aligns[k]}"` : ''}>${mdInline(cell)}`).join('')
+      out.push('
') + out.push(`${cellHtml('th', header)}`) + out.push(`${rows.map(row => `${cellHtml('td', row)}`).join('')}`) + out.push('
') + continue + } + // Blockquote ('>' is already escaped to >). + if (/^\s*>\s?/.test(line)) { + closeList() + const quote = [] + while (i < lines.length && /^\s*>\s?/.test(lines[i])) { + quote.push(lines[i].replace(/^\s*>\s?/, '')) + i += 1 + } + i -= 1 + out.push(`
${quote.map(q => mdInline(q)).join('
')}
`) + continue + } + const heading = line.match(/^(#{1,4})\s+(.*)$/) + if (heading) { + closeList() + const level = Math.min(heading[1].length + 2, 5) + out.push(`${mdInline(heading[2])}`) + continue + } + if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { + closeList() + out.push('
') + continue + } + if (/^\s*[-*]\s+/.test(line)) { + if (listType !== 'ul') { + closeList() + out.push('
    ') + listType = 'ul' + } + out.push(`
  • ${mdInline(line.replace(/^\s*[-*]\s+/, ''))}
  • `) + continue + } + const ordered = line.match(/^\s*(\d+)[.)]\s+(.*)$/) + if (ordered) { + if (listType !== 'ol') { + closeList() + out.push('
      ') + listType = 'ol' + } + out.push(`
    1. ${mdInline(ordered[2])}
    2. `) + continue + } + if (!line.trim()) { + closeList() + continue + } + out.push(`

      ${mdInline(line)}

      `) + } + if (inCode) out.push('
') + closeList() + return out.join('\n') +} + +function toast(text) { + const el = $('#toast') + el.textContent = text + el.classList.add('show') + setTimeout(() => el.classList.remove('show'), 1500) +} + +function backendOpenHint(error) { + const currentUrl = window.location.href + const expectedUrl = 'http://127.0.0.1:5173/' + const fileHint = window.location.protocol === 'file:' + ? '

You opened the HTML file directly. The API only works through the local server.

' + : '' + return ` +
+ Backend data did not load in this browser tab. + ${fileHint} +

Current URL: ${escapeHtml(currentUrl)}

+

Open this instead: ${expectedUrl}

+

Error: ${escapeHtml(error.message)}

+
+ ` +} + +function syncUrl() { + if (!state.session) return + const params = new URLSearchParams() + params.set('session', state.session.header.id) + const activeView = document.querySelector('[data-main-view].active')?.dataset.mainView + if (activeView && activeView !== 'conversation') params.set('view', activeView) + const selId = state.selected?.id + if (selId && selId !== state.session.tree?.id) params.set('sel', selId) + history.replaceState(null, '', `?${params.toString()}`) +} + +async function loadSessions(preferredId) { + const data = await api('/api/sessions') + state.sessions = data.sessions + $('#sourceLine').textContent = `${data.sessions.length} sessions from ${data.root}` + renderSessionList(preferredId) + const id = preferredId ?? state.sessions.find(session => !session.parentSession)?.id ?? state.sessions[0]?.id + if (id) await loadSession(id) +} + +async function loadSession(id) { + document.querySelector('.main-pane')?.classList.add('loading') + try { + await loadSessionInner(id) + } finally { + document.querySelector('.main-pane')?.classList.remove('loading') + } +} + +async function loadSessionInner(id) { + state.session = await api(`/api/sessions/${encodeURIComponent(id)}`) + state.selected = state.session.tree + state.inspectorOpen = false + state.annotateOpenIds = new Set() + state.seqMap = new Map(state.session.events.map(event => [event.seq, event])) + state.callPairs = new Map() + state.firstChunkByStep = new Map() + for (const event of state.session.events) { + if (event.type === 'tool/call') { + state.callPairs.set(event.data?.callId, { call: event }) + } else if (event.type === 'tool/result') { + const pair = state.callPairs.get(event.data?.callId) + if (pair) pair.result = event + else state.callPairs.set(event.data?.callId, { result: event }) + } else if (event.type === 'assistant/chunk') { + const key = `${event.data?.turn}:${event.data?.step}` + if (!state.firstChunkByStep.has(key)) state.firstChunkByStep.set(key, event.time) + } + } + const firstAssistant = state.session.events.find(event => event.type === 'assistant/message') + state.expandedTrajectorySeqs = new Set(firstAssistant ? [`event:${firstAssistant.seq}`] : []) + renderAll() + syncUrl() +} + +function sessionRowHtml(session, isChild, kidCount) { + return ` + + ` +} + +function renderSessionList(preferredId = state.session?.header?.id) { + const query = state.sessionQuery.toLowerCase().trim() + const matches = session => !query || `${session.title} ${session.id} ${session.cwd} ${session.model}`.toLowerCase().includes(query) + const ids = new Set(state.sessions.map(session => session.id)) + const childrenByParent = new Map() + const tops = [] + for (const session of state.sessions) { + if (session.parentSession && ids.has(session.parentSession)) { + if (!childrenByParent.has(session.parentSession)) childrenByParent.set(session.parentSession, []) + childrenByParent.get(session.parentSession).push(session) + } else { + tops.push(session) + } + } + const html = tops.map(parent => { + const kids = (childrenByParent.get(parent.id) ?? []).sort((a, b) => a.createdAt - b.createdAt) + const anyKidMatches = kids.some(matches) + if (!matches(parent) && !anyKidMatches) return '' + const visibleKids = matches(parent) ? kids : kids.filter(matches) + return sessionRowHtml(parent, Boolean(parent.parentSession), kids.length) + + visibleKids.map(kid => sessionRowHtml(kid, true, 0)).join('') + }).join('') + $('#sessionList').innerHTML = html || '
No sessions match the filter.
' + if (preferredId) { + $$('#sessionList .session-row').forEach(row => row.classList.toggle('selected', row.dataset.selectSession === preferredId)) + } +} + +function renderAll() { + renderChrome() + renderSessionList() + renderConversation() + renderTrajectory() + renderTrajTree() + renderWaterfall() + // Apply the requested view BEFORE renderDetails: renderDetails syncs the URL + // from the currently-active view button, which would erase ?view= otherwise. + applyRequestedView() + renderDetails() + renderInspectorState() +} + +// One #detailDrawer element, moved into whichever split view is active (Chat or +// Waterfall). Trajectory is self-contained and never shows it. +function renderInspectorState() { + const drawer = $('#detailDrawer') + const chatSplit = $('#chatSplit') + const wfSplit = $('#wfSplit') + const view = document.querySelector('[data-main-view].active')?.dataset.mainView + const target = view === 'waterfall' ? wfSplit : view === 'conversation' ? chatSplit : null + chatSplit?.classList.remove('inspector-open') + wfSplit?.classList.remove('inspector-open') + if (drawer && target && drawer.parentElement !== target) target.appendChild(drawer) + if (target && state.inspectorOpen) target.classList.add('inspector-open') +} + +function openInspector() { + state.inspectorOpen = true + renderInspectorState() +} + +function closeInspector() { + state.inspectorOpen = false + renderInspectorState() +} + +function showMainView(viewName) { + const button = document.querySelector(`[data-main-view="${viewName}"]`) + if (!button) return + $$('[data-main-view]').forEach(item => item.classList.toggle('active', item === button)) + $$('.view').forEach(view => view.classList.remove('active')) + $(`#${viewName}View`)?.classList.add('active') + renderInspectorState() + syncUrl() +} + +function applyRequestedView() { + const view = new URLSearchParams(window.location.search).get('view') + if (['trajectory', 'conversation', 'waterfall'].includes(view)) showMainView(view) +} + +function renderChrome() { + const session = state.session + if (!session) return + const summary = state.sessions.find(item => item.id === session.header.id) + $('#conversationTitle').textContent = summary?.title || session.header.id + const crumb = $('#sessionCrumb') + if (crumb) { + crumb.innerHTML = session.parent + ? `` + : '' + } +} + +/* ── Trajectory: step-grouped rows + navigation tree ───────────────────────── + Lifecycle events (turn/step start/end) are NOT rows: they become the tree + nodes and the sticky group headers, so the tree never duplicates the table. */ + +function buildTrajectory() { + const groups = [] + const rows = [] + state.turnMeta = new Map() + let current = null + let chunkGroup = null + let rowIndex = 0 + + const pushGroup = (turn, step, startTime) => { + current = { + key: `g:${turn}:${step ?? 'pre'}`, + turn, + step, + startTime, + endTime: startTime, + rows: [], + hasError: false, + } + groups.push(current) + return current + } + + const pushRow = (event, rawEvents) => { + if (!current) pushGroup(event.data?.turn ?? 0, null, event.time) + const row = { id: event.type === 'assistant/chunks' ? `chunks:${rawEvents[0].seq}` : `event:${event.seq}`, event, rawEvents, index: ++rowIndex, group: current } + current.rows.push(row) + rows.push(row) + if (eventIsError(event)) current.hasError = true + current.endTime = Math.max(current.endTime, rawEvents.at(-1)?.time ?? event.time) + } + + const flushChunks = () => { + if (!chunkGroup) return + const first = chunkGroup.events[0] + pushRow({ + seq: first.seq, + time: first.time, + type: 'assistant/chunks', + data: { + turn: first.data?.turn, + step: first.data?.step, + count: chunkGroup.events.length, + chunks: chunkGroup.events, + }, + }, chunkGroup.events) + chunkGroup = null + } + + for (const event of state.session.events) { + switch (event.type) { + case 'turn/start': { + flushChunks() + state.turnMeta.set(event.data.turn, { trigger: event.data.trigger?.kind, startTime: event.time }) + pushGroup(event.data.turn, null, event.time) + break + } + case 'turn/end': { + flushChunks() + const meta = state.turnMeta.get(event.data.turn) + if (meta) { + meta.reason = event.data.reason?.kind + meta.endTime = event.time + } + break + } + case 'step/start': { + flushChunks() + pushGroup(event.data.turn, event.data.step, event.time) + break + } + case 'step/end': { + flushChunks() + if (current && current.step === event.data.step) current.endTime = event.time + break + } + case 'assistant/chunk': { + const key = `${event.data?.turn}:${event.data?.step}` + if (chunkGroup && chunkGroup.key !== key) flushChunks() + if (!chunkGroup) chunkGroup = { key, events: [] } + chunkGroup.events.push(event) + break + } + default: { + flushChunks() + pushRow(event, [event]) + } + } + } + flushChunks() + + state.trajGroups = groups.filter(group => group.rows.length || group.step !== null) + state.trajectoryRows = rows +} + +function groupDurationSec(group) { + return Math.max(0, (group.endTime - group.startTime) / 1000) +} + +function groupToolSummary(group) { + const names = group.rows.filter(row => row.event.type === 'tool/call').map(row => row.event.data?.name ?? 'tool') + if (!names.length) return '' + const counts = new Map() + for (const name of names) counts.set(name, (counts.get(name) ?? 0) + 1) + return [...counts.entries()].map(([name, n]) => n > 1 ? `${name} ×${n}` : name).join(' · ') +} + +function renderTrajTree() { + const root = $('#trajTree') + if (!root || !state.session) return + const turns = new Map() + for (const group of state.trajGroups) { + if (!turns.has(group.turn)) turns.set(group.turn, []) + turns.get(group.turn).push(group) + } + root.innerHTML = [...turns.entries()].map(([turn, groups]) => { + const meta = state.turnMeta.get(turn) ?? {} + const dur = meta.startTime && meta.endTime ? ms((meta.endTime - meta.startTime) / 1000) : '' + const steps = groups.map(group => { + const label = group.step === null ? `turn ${group.turn} input` : `step ${group.step}` + const sub = group.step === null + ? truncate(contentToText(group.rows.find(r => r.event.type === 'user/message')?.event.data?.content) || (meta.trigger ?? ''), 34) + : groupToolSummary(group) + return ` + + ` + }).join('') + return ` +
+ turn ${turn}${meta.reason ? ` · ${escapeHtml(meta.reason)}` : ''}${dur} +
${steps}
+
+ ` + }).join('') || '
No structure.
' +} + +function roleOfEvent(event) { + if (event.type === 'assistant/message' || event.type === 'assistant/chunks') return 'assistant' + if (event.type === 'tool/call' || event.type === 'tool/result') return 'tool' + if (event.type === 'request/header') return 'system' + if (event.type === 'context/message' || event.type === 'steering/message') return 'system' + if (event.type === 'user/message') return 'user' + return 'meta' +} + +function toolCallsFromContent(content) { + return contentBlocks(content).filter(block => block.type === 'tool-call') +} + +function usageOfEvent(event) { + const usage = event.data?.usage ?? {} + return { + input: usage.inputTokens ?? usage.input_tokens ?? '', + output: usage.outputTokens ?? usage.output_tokens ?? '', + think: usage.reasoningTokens ?? usage.reasoning_tokens ?? '', + } +} + +function eventIsError(event) { + if (event.type === 'tool/result') return Boolean(event.data?.isError) + if (event.type === 'tool/call') return Boolean(state.callPairs.get(event.data?.callId)?.result?.data?.isError) + return false +} + +function pairedDurationSec(event) { + const pair = state.callPairs.get(event.data?.callId) + if (!pair?.call || !pair?.result) return 0 + return Math.max(0, (pair.result.time - pair.call.time) / 1000) +} + +function trajectoryTitle(event) { + if (event.type === 'request/header') { + const header = event.data?.header ?? {} + return `request envelope · ${header.config?.model ?? 'unknown model'} · ${(header.tools ?? []).length} tools · system ${String(header.system ?? '').length} chars` + } + if (event.type === 'assistant/chunks') return `${event.data?.count ?? 0} streaming chunks` + if (event.type === 'tool/call') return `${event.data?.name ?? 'tool call'} · ${truncate(event.data?.arguments ?? '', 96)}` + if (event.type === 'tool/result') return truncate(contentToText(event.data?.content) || event.data?.callId || 'tool result', 96) + const toolCalls = toolCallsFromContent(event.data?.content) + if (toolCalls.length) return toolCalls.map(call => call.name).join(', ') + return truncate(contentToText(event.data?.content) || eventPreview(event), 96) +} + +function feedbackFor(targetId) { + return (state.session.feedback ?? []).filter(item => item.data?.targetId === targetId) +} + +function renderTrajectory() { + buildTrajectory() + if (!state.trajectoryRows.length) { + $('#trajectory').innerHTML = '
No trajectory events in this session.
' + return + } + const header = ` +
+ # + event + content + in + out + think + time + +
+ ` + const body = state.trajGroups.map(group => { + const label = group.step === null + ? `turn ${group.turn}${state.turnMeta.get(group.turn)?.trigger ? ` · ${state.turnMeta.get(group.turn).trigger}` : ''}` + : `step ${group.step}` + const head = ` +
+ ${escapeHtml(label)} + ${ms(groupDurationSec(group))} + ${groupToolSummary(group) ? `${escapeHtml(groupToolSummary(group))}` : ''} +
+ ` + return head + group.rows.map(row => renderTrajectoryRow(row)).join('') + }).join('') + $('#trajectory').innerHTML = header + body +} + +function renderTrajectoryRow(row) { + const { event, index, id } = row + const role = roleOfEvent(event) + const usage = usageOfEvent(event) + const toolCalls = toolCallsFromContent(event.data?.content) + const expanded = state.expandedTrajectorySeqs.has(id) + const isError = eventIsError(event) + const metaChip = trajectoryMetaChip(event, toolCalls, isError) + const offsetSec = (event.time - state.session.stats.startTime) / 1000 + const fbCount = feedbackFor(id).length + return ` +
+
+ #${index} + ${escapeHtml(event.type)} + ${metaChip}${escapeHtml(trajectoryTitle(event))}${fbCount ? `✎${fbCount}` : ''} + ${escapeHtml(String(usage.input ?? ''))} + ${escapeHtml(String(usage.output ?? ''))} + ${escapeHtml(String(usage.think ?? ''))} + ${fmtOffset(offsetSec)} + ${expanded ? '▾' : '▸'} +
+ ${expanded ? renderTrajectoryBody(row) : ''} +
+ ` +} + +function rerenderTrajectoryRow(id) { + const row = state.trajectoryRows.find(item => item.id === id) + const el = document.querySelector(`[data-select-trajectory-row="${CSS.escape(id)}"]`) + if (!row || !el) { + renderTrajectory() + return + } + el.outerHTML = renderTrajectoryRow(row) +} + +function trajectoryMetaChip(event, toolCalls, isError) { + if (toolCalls.length) return `→ ${escapeHtml(toolCalls.map(call => call.name).join(', '))}` + if (event.type === 'tool/result') return isError ? '← error' : '← result' + if (event.type === 'tool/call') return `→ ${escapeHtml(event.data?.name ?? 'tool')}` + if (event.type === 'request/header') return `${event.data?.header?.tools?.length ?? 0} tools` + if (event.type === 'assistant/chunks') return `${event.data?.count ?? 0} chunks` + return 'event' +} + +// Child sessions whose creation falls inside this tool call's lifetime are +// almost certainly the subagents it spawned (workflow / subagent tools). +function spawnedSessionsFor(event) { + const kids = state.session?.children ?? [] + if (!kids.length || event.type !== 'tool/call') return [] + const pair = state.callPairs.get(event.data?.callId) + const start = event.time - 2000 + const end = (pair?.result?.time ?? event.time + 600000) + 2000 + return kids.filter(kid => kid.createdAt >= start && kid.createdAt <= end) +} + +function spawnedSessionsHtml(event) { + const spawned = spawnedSessionsFor(event) + if (!spawned.length) return '' + return ` +
+
spawned sessions · ${spawned.length}
+ ${spawned.map(kid => ` + + `).join('')} +
+ ` +} + +/* Inline row utilities: the trajectory is self-contained — copy, the raw event + and annotations live in the expanded row instead of a side inspector. */ + +function rowPayload(row) { + const event = row.event + if (event.type === 'tool/call' || event.type === 'tool/result') { + const pair = state.callPairs.get(event.data?.callId) ?? {} + return { call: pair.call ?? null, result: pair.result ?? null } + } + if (event.type === 'assistant/chunks') return row.rawEvents + return event +} + +function rowToolsHtml(row) { + const fb = feedbackFor(row.id) + const annotateOpen = state.annotateOpenIds.has(row.id) + const author = localStorage.getItem('dh-author') || 'shentuni' + const rawDetails = row.event.type === 'assistant/chunks' ? '' : ` + + ` + const fbList = fb.length ? ` +
+ ${fb.map(item => ` + + `).join('')} +
+ ` : '' + const fbForm = annotateOpen ? ` +
+ +
+ + 针对 #${row.index} ${escapeHtml(row.event.type)} + +
+
+ ` : '' + return ` +
+ + +
+ ${fbForm}${fbList}${rawDetails} + ` +} + +const CHUNK_PREVIEW_LIMIT = 200 + +function renderTrajectoryBody(row) { + const event = row.event + const tools = rowToolsHtml(row) + if (event.type === 'request/header') { + const header = event.data?.header ?? {} + return ` +
+
+
+ System prompt + ${String(header.system ?? '').length} chars +
+
${escapeHtml(header.system || 'No system prompt recorded.')}
+
+
+
+ Tool schemas + ${(header.tools ?? []).length} tools +
+
${escapeHtml(renderValue(header.tools ?? [], 'json'))}
+
+ + ${tools} +
+ ` + } + if (event.type === 'assistant/chunks') { + const chunks = event.data?.chunks ?? [] + const preview = chunks.slice(0, CHUNK_PREVIEW_LIMIT) + return ` +
+ + ${tools} +
+ ` + } + if (event.type === 'tool/call') { + const pair = state.callPairs.get(event.data?.callId) + const duration = pairedDurationSec(event) + const failed = Boolean(pair?.result?.data?.isError) + return ` +
+
+
+ ${escapeHtml(event.data?.name ?? 'tool')} + ${escapeHtml(event.data?.callId ?? '')}${duration ? ` · ${ms(duration)}` : ''}${failed ? ' · failed' : ''} +
+
${escapeHtml(renderValue(parseMaybeJson(event.data?.arguments), 'json'))}
+
+ ${spawnedSessionsHtml(event)} + ${tools} +
+ ` + } + if (event.type === 'user/message') { + return `
${escapeHtml(contentToText(event.data?.content))}
${tools}
` + } + if (event.type === 'tool/result') { + const pair = state.callPairs.get(event.data?.callId) + const duration = pairedDurationSec(event) + const failed = Boolean(event.data?.isError) + const name = pair?.call?.data?.name + return ` +
+
+
${failed ? 'tool error' : 'tool result'}${name ? ` · ${escapeHtml(name)}` : ''} ${escapeHtml(event.data?.callId ?? '')}${duration ? ` · ${ms(duration)}` : ''}
+ ${event.data?.error ? `
${escapeHtml(renderValue(event.data.error, 'plain'))}
` : ''} +
${escapeHtml(renderValue(event.data?.content ?? event.data, 'plain'))}
+
+ ${tools} +
+ ` + } + const blocks = contentBlocks(event.data?.content) + const reasoning = blocks.filter(block => block.type === 'reasoning').map(block => block.text).filter(Boolean).join('\n\n') + const text = blocks.filter(block => block.type === 'text').map(block => block.text).filter(Boolean).join('\n\n') + const calls = toolCallsFromContent(blocks) + const usage = event.data?.usage + return ` +
+ ${reasoning ? ` +
+ Thinking +

${escapeHtml(reasoning)}

+
+ ` : ''} + ${text ? `

${escapeHtml(text)}

` : ''} + ${calls.map(call => ` +
+
+ ${escapeHtml(call.name)} + ${escapeHtml(call.id ?? '')} +
+
${escapeHtml(renderValue(parseMaybeJson(call.arguments), 'json'))}
+
+ `).join('')} + ${usage ? ` + + ` : ''} + ${tools} +
+ ` +} + +function parseMaybeJson(value) { + if (typeof value !== 'string') return value + try { + return JSON.parse(value) + } catch { + return value + } +} + +/* ── Chat ──────────────────────────────────────────────────────────────── */ + +function renderConversation() { + const messages = state.session.messages.filter(message => message.role === 'user' || message.role === 'assistant') + if (!messages.length) { + $('#conversation').innerHTML = '
No surface messages in this session.
' + return + } + $('#conversation').innerHTML = messages.map(message => ` +
+
+ ${renderChatBody(message)} +
+
+ `).join('') +} + +function renderChatBody(message) { + const text = chatText(message) + const blocks = contentBlocks(message.content) + const reasoning = blocks.filter(block => block.type === 'reasoning').map(block => block.text).filter(Boolean).join('\n\n') + const calls = toolCallsFromContent(message.content) + const activity = [ + reasoning ? ` +
+ Thinking${escapeHtml(truncate(reasoning, 112))} +
${escapeHtml(reasoning)}
+
+ ` : '', + ...calls.map(call => renderChatToolActivity(call)), + ].filter(Boolean).join('') + if (message.role === 'user') return `
${escapeHtml(text)}
` + if (text) return `${activity ? `
${activity}
` : ''}
${renderMarkdown(text)}
` + if (calls.length) { + return `
${activity}
` + } + if (reasoning) return `
${activity}
` + return '
No visible assistant text.
' +} + +// A short human-readable hint of what the call did, for the collapsed summary +// row (lay users should understand a tool line without expanding it). +function toolCallPreview(call) { + const args = parseMaybeJson(call.arguments) + if (args && typeof args === 'object') { + const preferred = args.description ?? args.command ?? args.file_path ?? args.path ?? args.name ?? args.url ?? args.query + if (typeof preferred === 'string' && preferred) return truncate(preferred, 90) + const firstString = Object.values(args).find(value => typeof value === 'string' && value) + if (firstString) return truncate(firstString, 90) + } + return '' +} + +function renderChatToolActivity(call) { + const result = state.session.events.find(event => event.type === 'tool/result' && event.data?.callId === call.id) + const failed = Boolean(result?.data?.isError) + const resultText = result ? renderValue(result.data?.content ?? result.data, 'plain') : '' + const preview = toolCallPreview(call) + // .activity-body is white-space: pre-wrap (thinking bodies rely on it), so the + // markup inside it must be emitted WITHOUT newlines/indentation between tags — + // stray template whitespace would render as literal blank space. + const inputHtml = `
Input
${escapeHtml(renderValue(parseMaybeJson(call.arguments), 'json'))}
` + const outputHtml = result + ? `
${failed ? 'Error output' : 'Output'}
${escapeHtml(resultText)}
` + : '' + const openBtn = `` + return ` +
+ ${failed ? 'Tool failed' : 'Tool use'}${escapeHtml(call.name)}${preview ? ` · ${escapeHtml(preview)}` : ''} +
${inputHtml}${outputHtml}${openBtn}
+
+ ` +} + +function chatText(message) { + if (message.role === 'user') return contentToText(message.content) + return contentBlocks(message.content) + .filter(block => block.type === 'text') + .map(block => block.text ?? '') + .filter(Boolean) + .join('\n\n') +} + +/* ── Waterfall: find the hotspot, then jump to Trajectory for detail ─────── */ + +function renderWaterfall() { + const total = Math.max(state.session?.stats.durationSec ?? 1, 0.001) + const nodes = currentNodes().filter(node => node.kind !== 'session') + const llmTime = nodes.filter(n => n.kind === 'llm').reduce((sum, n) => sum + n.durationSec, 0) + const toolTime = nodes.filter(n => n.kind === 'tool').reduce((sum, n) => sum + n.durationSec, 0) + const errors = nodes.filter(n => n.status === 'error') + const slowest = nodes.filter(n => n.kind === 'step').sort((a, b) => b.durationSec - a.durationSec)[0] + const summary = ` +
+
total${ms(state.session.stats.durationSec)}
+
llm time${ms(llmTime)}
+
tool time${ms(toolTime)}
+
errors${errors.length}
+ ${slowest ? `` : ''} +
tokens in/out${sumTokens('input')}/${sumTokens('output')}
+
+ ` + const ticks = [0, .25, .5, .75, 1].map(p => `${ms(total * p)}`).join('') + $('#waterfall').innerHTML = summary + ` +
+
+
+
${ticks}
+
+ ${nodes.map(node => { + const left = Math.max(0, (node.startSec / total) * 100) + const width = Math.max(.35, (node.durationSec / total) * 100) + const error = node.status === 'error' + return ` +
+ +
+ +
+
+ ` + }).join('')} +
+ ` +} + +function sumTokens(kind) { + let total = 0 + for (const event of state.session.events) { + if (event.type !== 'assistant/message') continue + const usage = usageOfEvent(event) + total += Number(usage[kind === 'input' ? 'input' : 'output']) || 0 + } + return total.toLocaleString() +} + +// Select a tree node (waterfall click) and open the inspector on it. +function selectNode(id) { + state.selected = findNode(id) + renderDetails() + openInspector() +} + +// Map a waterfall/tree node or a selected event to its trajectory anchor and +// navigate there. +function jumpToNode(nodeId) { + let target = null + if (nodeId.startsWith('event:') || nodeId.startsWith('chunks:')) { + target = { rowId: nodeId } + } else if (nodeId.startsWith('tool:')) { + const callId = nodeId.slice('tool:'.length) + const call = state.callPairs.get(callId)?.call + if (call) target = { rowId: `event:${call.seq}` } + } else if (nodeId.startsWith('assistant:')) { + target = { rowId: `event:${nodeId.slice('assistant:'.length)}` } + } else if (nodeId.startsWith('step:')) { + const [turn, step] = nodeId.slice('step:'.length).split(':') + target = { groupKey: `g:${turn}:${step}` } + } else if (nodeId.startsWith('turn:')) { + const turn = nodeId.slice('turn:'.length) + const group = state.trajGroups.find(g => String(g.turn) === turn) + if (group) target = { groupKey: group.key } + } + if (!target) return + showMainView('trajectory') + if (target.rowId) { + const row = state.trajectoryRows.find(item => item.id === target.rowId) + if (!row) return + if (!state.expandedTrajectorySeqs.has(target.rowId)) { + state.expandedTrajectorySeqs.add(target.rowId) + rerenderTrajectoryRow(target.rowId) + } + flashAndScroll(`[data-select-trajectory-row="${CSS.escape(target.rowId)}"]`) + } else if (target.groupKey) { + flashAndScroll(`[data-group-anchor="${CSS.escape(target.groupKey)}"]`, 'start') + } +} + +function jumpToGroup(groupKey) { + flashAndScroll(`[data-group-anchor="${CSS.escape(groupKey)}"]`, 'start') + markActiveGroup(groupKey) +} + +// Scroll-spy: as the trajectory table scrolls, highlight the step the reader is +// looking at in the tree (and keep it visible there). +function markActiveGroup(groupKey) { + let activeStep = null + $$('#trajTree .tree-step').forEach(el => { + const on = el.dataset.jumpGroup === groupKey + el.classList.toggle('active', on) + if (on) activeStep = el + }) + $$('#trajTree .tree-turn').forEach(turn => { + turn.querySelector('.tree-turn-head')?.classList.toggle('active', Boolean(turn.querySelector('.tree-step.active'))) + }) + if (activeStep) { + const tree = document.querySelector('#trajTree') + const stepRect = activeStep.getBoundingClientRect() + const treeRect = tree.getBoundingClientRect() + if (stepRect.top < treeRect.top || stepRect.bottom > treeRect.bottom) { + activeStep.scrollIntoView({ block: 'nearest' }) + } + } +} + +function updateScrollSpy() { + const main = document.querySelector('.traj-main') + if (!main) return + const anchors = [...main.querySelectorAll('[data-group-anchor]')] + if (!anchors.length) return + const topEdge = main.getBoundingClientRect().top + 70 + let current = anchors[0] + for (const anchor of anchors) { + if (anchor.getBoundingClientRect().top <= topEdge) current = anchor + else break + } + markActiveGroup(current.dataset.groupAnchor) +} + +function initScrollSpy() { + const main = document.querySelector('.traj-main') + if (!main) return + let ticking = false + main.addEventListener('scroll', () => { + if (ticking) return + ticking = true + requestAnimationFrame(() => { + ticking = false + updateScrollSpy() + }) + }) +} + +// Instant scrolling: smooth scrolling is silently dropped during boot-time +// layout. Rows center (so the sticky group head never covers them); group +// heads go to 'start' — they stick at the top, which is also exactly where the +// scroll-spy samples, so the tree highlight agrees with the jump target. +function flashAndScroll(selector, block = 'center') { + const el = document.querySelector(selector) + if (!el) return + el.scrollIntoView({ block }) + el.classList.remove('flash') + void el.offsetWidth + el.classList.add('flash') +} + +/* ── Inspector payloads (chat only) ──────────────────────────────────────── */ + +function eventPayloadText(event) { + return JSON.stringify(event?.data ?? event ?? {}, null, 0) +} + +function eventPreview(event) { + if (event.type === 'user/message' || event.type === 'assistant/message') { + return truncate(textOfEventContent(event), 220) + } + if (event.type === 'tool/call') return `${event.data?.name ?? 'tool'} ${truncate(event.data?.arguments ?? '', 180)}` + if (event.type === 'tool/result') return truncate(textOfEventContent(event) || eventPayloadText(event), 220) + if (event.type === 'request/header') { + const tools = event.data?.header?.tools?.length ?? 0 + const model = event.data?.header?.config?.model ?? 'unknown model' + return `${model}, ${tools} tools, full request envelope available` + } + return truncate(eventPayloadText(event), 220) +} + +function textOfEventContent(event) { + return contentToText(event?.data?.content) +} + +function selectedPayloads() { + const node = state.selected ?? state.session.tree + if (node.kind === 'session') { + return { + input: { + header: state.session.header, + stats: state.session.stats, + model: state.session.agent.model, + tools: (state.session.agent.tools ?? []).map(tool => tool.name), + }, + output: '', + metadata: { + id: node.id, + kind: node.kind, + title: node.title, + subtitle: node.subtitle, + durationSec: node.durationSec, + events: `${state.session.events.length} events — select a message for detail`, + children: state.session.children, + parent: state.session.parent, + }, + } + } + // Steps can own thousands of chunk events; cap what the metadata pane + // serializes so selecting a node never stalls on a multi-MB JSON string. + const nodeEvents = node.kind === 'event' ? node.rawEvents : eventsOf(node) + const events = nodeEvents.length > 50 + ? { count: nodeEvents.length, first50: nodeEvents.slice(0, 50) } + : nodeEvents + return { + input: node.detail?.input ?? nodeEvents.slice(0, 50), + output: node.detail?.output ?? '', + metadata: { + id: node.id, + kind: node.kind, + title: node.title, + subtitle: node.subtitle, + status: node.status, + durationSec: node.durationSec, + startSec: node.startSec, + events, + prompt: resolvePrompt(node), + schema: node.detail?.schema, + }, + } +} + +function renderDetails() { + const node = state.selected ?? state.session.tree + $('#detailIcon').textContent = nodeGlyph(node.kind) + $('#detailTitle').textContent = node.title + $('#detailSubtitle').textContent = `${node.kind} · ${node.subtitle ?? ''} · ${ms(node.durationSec)}` + const jumpBtn = $('#jumpFromInspector') + if (jumpBtn) jumpBtn.style.display = node.kind === 'session' ? 'none' : '' + renderFeedback() + const payloads = selectedPayloads() + for (const [id, key] of Object.entries({ + inputText: 'input', + outputText: 'output', + metadataText: 'metadata', + })) { + const select = document.querySelector(`[data-format-target="${id}"]`) + $(`#${id}`).textContent = renderValue(payloads[key], select?.value ?? 'json') + } + updateSelectionUI() + syncUrl() +} + +// Update chat selection highlighting in place — no view rebuilds. +function updateSelectionUI() { + const selectedSeq = state.selected?.kind === 'event' ? state.selected.rawEvents?.[0]?.seq : undefined + $$('#conversation .message').forEach(el => { + el.classList.toggle('selected', Number(el.dataset.selectEventSeq) === selectedSeq) + }) +} + +function currentTargetId() { + return (state.selected ?? state.session.tree)?.id +} + +function renderFeedback() { + const feedback = feedbackFor(currentTargetId()) + $('#feedbackList').innerHTML = feedback.length + ? feedback.map(item => ` + + `).join('') + : '' +} + +function selectEvent(seq) { + const event = state.seqMap.get(Number(seq)) ?? state.session.events.find(item => item.seq === Number(seq)) + if (!event) return + let input = event.data + let output = '' + let durationSec = 0 + let status = 'ok' + if (event.type === 'tool/call' || event.type === 'tool/result') { + const pair = state.callPairs.get(event.data?.callId) ?? {} + input = parseMaybeJson(pair.call?.data?.arguments ?? event.data?.arguments) ?? event.data + output = pair.result + ? { content: pair.result.data?.content, isError: pair.result.data?.isError, error: pair.result.data?.error, meta: pair.result.data?.meta } + : '' + durationSec = pairedDurationSec(pair.call ?? event) + status = pair.result?.data?.isError ? 'error' : 'ok' + } else if (event.type === 'assistant/message') { + input = { usage: event.data?.usage, model: state.session.agent.model } + output = event.data?.content + const firstChunk = state.firstChunkByStep.get(`${event.data?.turn}:${event.data?.step}`) + durationSec = firstChunk ? Math.max(0, (event.time - firstChunk) / 1000) : 0 + } else if (event.type === 'user/message') { + input = '' + output = event.data?.content + } + state.selected = { + id: `event:${event.seq}`, + kind: 'event', + title: event.type, + subtitle: `seq ${event.seq}`, + startSec: (event.time - state.session.stats.startTime) / 1000, + durationSec, + status, + rawEvents: [event], + detail: { input, output, prompt: state.session.agent.latestHeader, schema: state.session.agent.tools }, + } + renderDetails() + openInspector() +} + +function toggleTrajectoryRow(id) { + if (state.expandedTrajectorySeqs.has(id)) state.expandedTrajectorySeqs.delete(id) + else state.expandedTrajectorySeqs.add(id) + rerenderTrajectoryRow(id) +} + +function restoreSelection(id) { + const view = new URLSearchParams(window.location.search).get('view') + const row = state.trajectoryRows.find(item => item.id === id) + if (row && view === 'trajectory') { + state.expandedTrajectorySeqs.add(id) + renderTrajectory() + flashAndScroll(`[data-select-trajectory-row="${CSS.escape(id)}"]`) + return + } + if (id.startsWith('event:')) { + const seq = Number(id.slice('event:'.length)) + const message = state.session.messages.find(item => item.seq === seq) + if (message) selectEvent(seq) + } +} + +async function submitInlineFeedback(form) { + const rowId = form.dataset.fbRow + const text = form.elements.text.value.trim() + const author = form.elements.author.value.trim() || 'anonymous' + if (!text) return + localStorage.setItem('dh-author', author) + const row = state.trajectoryRows.find(item => item.id === rowId) + const result = await postJson(`/api/sessions/${encodeURIComponent(state.session.header.id)}/feedback`, { + author, + text, + targetId: rowId, + targetTitle: row ? `#${row.index} ${row.event.type}` : rowId, + targetKind: 'event', + }) + if (result.feedback) { + state.session.feedback = [...(state.session.feedback ?? []), result.feedback] + rerenderTrajectoryRow(rowId) + toast('标注已保存') + } else { + toast(result.error ?? '标注保存失败') + } +} + +/* ── Event wiring ────────────────────────────────────────────────────────── */ + +document.addEventListener('click', async (event) => { + const loadLink = event.target.closest('[data-load-session]') + if (loadLink) { + await loadSession(loadLink.dataset.loadSession) + return + } + + const sessionRow = event.target.closest('[data-select-session]') + if (sessionRow) { + await loadSession(sessionRow.dataset.selectSession) + return + } + + const copyRow = event.target.closest('[data-copy-row]') + if (copyRow) { + const row = state.trajectoryRows.find(item => item.id === copyRow.dataset.copyRow) + if (row) { + await navigator.clipboard.writeText(JSON.stringify(rowPayload(row), null, 2)) + toast('Copied') + } + return + } + + const annotateRow = event.target.closest('[data-annotate-row]') + if (annotateRow) { + const id = annotateRow.dataset.annotateRow + if (state.annotateOpenIds.has(id)) state.annotateOpenIds.delete(id) + else state.annotateOpenIds.add(id) + rerenderTrajectoryRow(id) + return + } + + const inspectNode = event.target.closest('[data-inspect-node]') + if (inspectNode) { + selectNode(inspectNode.dataset.inspectNode) + return + } + + const jumpNode = event.target.closest('[data-jump-node]') + if (jumpNode) { + jumpToNode(jumpNode.dataset.jumpNode) + return + } + + const jumpGroup = event.target.closest('[data-jump-group]') + if (jumpGroup) { + jumpToGroup(jumpGroup.dataset.jumpGroup) + return + } + + if (event.target.closest('#jumpFromInspector')) { + if (state.selected && state.selected.kind !== 'session') jumpToNode(state.selected.id) + return + } + + // One click, one behavior in chat: a summary toggles its
, a link + // navigates, and any OTHER click inside a message opens the inspector — on + // the tool call when inside its expanded body, else on the message. A click + // that ends a text selection does nothing (copying stays safe). + const inspectCall = event.target.closest('[data-inspect-call]') + if (inspectCall) { + const pair = state.callPairs.get(inspectCall.dataset.inspectCall) + if (pair?.call) selectEvent(pair.call.seq) + return + } + + if (event.target.closest('#conversation summary') || event.target.closest('#conversation a')) { + return + } + + const activityBody = event.target.closest('#conversation .activity-body') + if (activityBody) { + if (window.getSelection()?.toString()) return + const callId = activityBody.closest('[data-call-id]')?.dataset.callId + const pair = state.callPairs.get(callId) + if (pair?.call) selectEvent(pair.call.seq) + return + } + + // Expanded trajectory bodies and their
handle their own clicks; + // chat messages are NOT guarded — any click inside a message selects it. + if (event.target.closest('.traj-row') && (event.target.closest('details') || event.target.closest('.traj-body'))) { + return + } + + const trajectoryToggle = event.target.closest('[data-toggle-trajectory]') + if (trajectoryToggle) { + toggleTrajectoryRow(trajectoryToggle.dataset.toggleTrajectory) + return + } + + const eventNode = event.target.closest('[data-select-event-seq]') + if (eventNode) { + if (window.getSelection()?.toString()) return + selectEvent(eventNode.dataset.selectEventSeq) + return + } + + const mainView = event.target.closest('[data-main-view]') + if (mainView) { + showMainView(mainView.dataset.mainView) + return + } + + const tab = event.target.closest('[data-detail-tab]') + if (tab) { + state.activeDetailTab = tab.dataset.detailTab + $$('[data-detail-tab]').forEach(button => button.classList.toggle('active', button === tab)) + $$('.detail-panel').forEach(panel => panel.classList.remove('active')) + $(`#${state.activeDetailTab}Panel`).classList.add('active') + return + } + + if (event.target.closest('#expandAllTrajectory')) { + state.expandedTrajectorySeqs = new Set(state.trajectoryRows.map(item => item.id)) + renderTrajectory() + return + } + + if (event.target.closest('#collapseTrajectory')) { + state.expandedTrajectorySeqs = new Set() + renderTrajectory() + return + } + + if (event.target.closest('#inspectorClose')) { + closeInspector() + return + } + + if (event.target.closest('#newSessionButton')) { + const result = await postJson('/api/sessions', {}) + toast(result.error ?? 'New session created') + return + } +}) + +document.addEventListener('submit', (event) => { + const form = event.target.closest('.inline-fb') + if (form) { + event.preventDefault() + submitInlineFeedback(form) + } +}) + +document.addEventListener('keydown', (event) => { + if ((event.key === 'Enter' || event.key === ' ') && event.target.matches?.('.traj-summary')) { + event.preventDefault() + toggleTrajectoryRow(event.target.dataset.toggleTrajectory) + return + } + if (event.key === 'Escape' && state.inspectorOpen) { + closeInspector() + } +}) + +document.addEventListener('change', (event) => { + if (event.target.matches('[data-format-target]')) renderDetails() +}) + +$('#sessionSearch')?.addEventListener('input', (event) => { + state.sessionQuery = event.target.value + renderSessionList() +}) + +$('#feedbackForm')?.addEventListener('submit', async (event) => { + event.preventDefault() + const text = $('#feedbackText').value.trim() + if (!text) return + const target = state.selected ?? state.session.tree + const result = await postJson(`/api/sessions/${encodeURIComponent(state.session.header.id)}/feedback`, { + author: $('#feedbackAuthor').value, + text, + targetId: target.id, + targetTitle: target.title, + targetKind: target.kind, + }) + if (result.feedback) { + state.session.feedback = [...(state.session.feedback ?? []), result.feedback] + $('#feedbackText').value = '' + renderFeedback() + toast('Feedback saved') + } else { + toast(result.error ?? 'Failed to save feedback') + } +}) + +$('#feedbackText')?.addEventListener('keydown', (event) => { + if (event.key === 'Enter' && !event.altKey && !event.shiftKey && !event.metaKey) { + event.preventDefault() + $('#feedbackForm').requestSubmit() + } +}) + +$('#feedbackAuthor')?.addEventListener('input', (event) => { + localStorage.setItem('dh-author', event.target.value.trim() || 'anonymous') +}) + +/* Pane resizers: shell left divider + chat's inner inspector divider. Widths + are clamped so no pane can crush the others and persist across reloads. */ +const PANE_WIDTH_KEY = 'dh-pane-widths' + +function initPaneResizers() { + const shell = document.querySelector('.shell') + const chatSplit = document.querySelector('#chatSplit') + const wfSplit = document.querySelector('#wfSplit') + if (!shell) return + let saved = {} + try { + saved = JSON.parse(localStorage.getItem(PANE_WIDTH_KEY) ?? '{}') + } catch { + // Corrupt localStorage entry: fall back to defaults; next drag rewrites it. + } + // The inspector width is shared between the chat and waterfall splits. + const setInspectorWidth = (width) => { + chatSplit?.style.setProperty('--inspector-w', `${width}px`) + wfSplit?.style.setProperty('--inspector-w', `${width}px`) + } + if (saved.left) shell.style.setProperty('--left-w', `${saved.left}px`) + if (saved.inspector) setInspectorWidth(saved.inspector) + + const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value)) + const persist = () => { + localStorage.setItem(PANE_WIDTH_KEY, JSON.stringify({ + left: parseInt(shell.style.getPropertyValue('--left-w')) || undefined, + inspector: parseInt(chatSplit?.style.getPropertyValue('--inspector-w')) || undefined, + })) + } + + const attach = (divider, apply) => { + if (!divider) return + divider.addEventListener('pointerdown', (event) => { + event.preventDefault() + divider.classList.add('dragging') + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + const move = (ev) => apply(ev) + const up = () => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + window.removeEventListener('pointercancel', up) + divider.classList.remove('dragging') + document.body.style.cursor = '' + document.body.style.userSelect = '' + persist() + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + window.addEventListener('pointercancel', up) + }) + } + + attach($('#dividerLeft'), (ev) => { + const rect = shell.getBoundingClientRect() + const width = clamp(ev.clientX - rect.left - 12, 220, Math.min(480, rect.width * 0.4)) + shell.style.setProperty('--left-w', `${width}px`) + }) + attach($('#dividerInspector'), (ev) => { + if (!chatSplit) return + const rect = chatSplit.getBoundingClientRect() + setInspectorWidth(clamp(rect.right - ev.clientX - 4, 300, Math.min(640, rect.width * 0.6))) + }) + attach($('#dividerWfInspector'), (ev) => { + if (!wfSplit) return + const rect = wfSplit.getBoundingClientRect() + setInspectorWidth(clamp(rect.right - ev.clientX - 4, 300, Math.min(640, rect.width * 0.6))) + }) +} + +initPaneResizers() +initScrollSpy() +const bootParams = new URLSearchParams(window.location.search) +loadSessions(bootParams.get('session') || undefined) + .then(() => { + const sel = bootParams.get('sel') + if (sel && state.session) restoreSelection(sel) + }) + .catch((error) => { + const hint = backendOpenHint(error) + $('#sourceLine').textContent = `Backend failed from ${window.location.href}` + $('#sessionList').innerHTML = hint + $('.main-pane').innerHTML = hint + }) diff --git a/research/trace-workbench/index.html b/research/trace-workbench/index.html new file mode 100644 index 0000000000..f71086c5fd --- /dev/null +++ b/research/trace-workbench/index.html @@ -0,0 +1,173 @@ + + + + + + DeepSeek Harness Workbench + + + +
+ + + + +
+
+
+ replay + Loading session... + +
+
+ + + +
+
+ +
+
+
+
+
+ + +
+
+ +
+
+ +
+
+ Agent trajectory +
+ + +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+ +
+ + +
read-only
+ +
+
+
+ +
+ + + diff --git a/research/trace-workbench/plans/001-motion-foundation.md b/research/trace-workbench/plans/001-motion-foundation.md new file mode 100644 index 0000000000..66782e210f --- /dev/null +++ b/research/trace-workbench/plans/001-motion-foundation.md @@ -0,0 +1,60 @@ +# 001 — Add easing token and upgrade existing transitions + +- **Status**: DONE +- **Commit**: (repo has no commits yet — uncommitted working tree, 2026-07-18) +- **Severity**: LOW (foundation for 002–005) +- **Category**: Easing & duration / Cohesion & tokens +- **Estimated scope**: 1 file (styles.css), ~4 small edits + +## Problem + +No shared easing tokens exist; entrances use weak built-in `ease`: + +```css +/* styles.css — current */ +.toast { + transition: opacity .18s ease, transform .18s ease; +} +.main-pane { + transition: opacity .15s ease; +} +``` + +Built-in `ease` is too weak for deliberate motion; the toast enter feels mushy. + +## Target + +```css +:root { + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* strong ease-out for UI */ +} +.toast { + transition: opacity 200ms var(--ease-out), transform 200ms var(--ease-out); +} +.main-pane { + transition: opacity 150ms var(--ease-out); +} +``` + +Keep `.pane-divider::after { transition: background .12s ease }` unchanged — hover/color change correctly uses `ease`. + +## Repo conventions to follow + +- All custom properties live in the single `:root` block at the top of styles.css (e.g. `--blue: #0a64ff;`); add `--ease-out` there. + +## Steps + +1. styles.css `:root`: add `--ease-out: cubic-bezier(0.23, 1, 0.32, 1);`. +2. styles.css `.toast`: replace transition with `opacity 200ms var(--ease-out), transform 200ms var(--ease-out)`. +3. styles.css `.main-pane`: replace transition with `opacity 150ms var(--ease-out)`. + +## Boundaries + +- Do NOT touch app.js or index.html. +- Do NOT change the `.pane-divider` or `.tree-turn-head::before` transitions. + +## Verification + +- **Mechanical**: reload http://127.0.0.1:5173/ — no console errors. +- **Feel check**: trigger a toast (submit an annotation); it should decelerate crisply into place instead of easing symmetrically. Switch sessions; the loading fade should feel unchanged or slightly snappier. +- **Done when**: token exists and both rules reference it. diff --git a/research/trace-workbench/plans/002-inspector-enter.md b/research/trace-workbench/plans/002-inspector-enter.md new file mode 100644 index 0000000000..db260ff96e --- /dev/null +++ b/research/trace-workbench/plans/002-inspector-enter.md @@ -0,0 +1,61 @@ +# 002 — Slide the inspector's content in on open + +- **Status**: DONE +- **Commit**: (repo has no commits yet — uncommitted working tree, 2026-07-18) +- **Severity**: MEDIUM +- **Category**: Missed opportunities +- **Estimated scope**: 1 file (styles.css), 1 rule + @starting-style + +## Problem + +The chat/waterfall inspector is a grid column toggled by `.inspector-open`; it appears via `display: none → flex` with zero motion — the panel teleports in with nothing explaining where it came from. + +```css +/* styles.css — current */ +.inspector { + display: none; + ... +} +.chat-split.inspector-open .inspector, +.wf-split.inspector-open .inspector { + display: flex; +} +``` + +## Target + +Animate the CONTENT entering (transform+opacity only — never animate the grid track, that's layout). Close stays instant (asymmetric timing: the system's response snaps). + +```css +.chat-split.inspector-open .inspector, +.wf-split.inspector-open .inspector { + display: flex; + transition: transform 200ms var(--ease-out), opacity 200ms var(--ease-out); + transform: translateX(0); + opacity: 1; + @starting-style { + transform: translateX(16px); + opacity: 0; + } +} +``` + +(Nested `@starting-style` is supported in Chrome 117+; this app targets local Chrome.) + +## Repo conventions to follow + +- `--ease-out` token from plan 001 (depends on it). + +## Steps + +1. styles.css: extend the `.chat-split.inspector-open .inspector, .wf-split.inspector-open .inspector` rule with the transition, resting transform/opacity, and the nested `@starting-style` block exactly as in Target. + +## Boundaries + +- Do NOT animate `grid-template-columns`, width, or the divider. +- Do NOT add a close animation. + +## Verification + +- **Feel check**: click a chat message — the panel's content slides ~16px leftward while fading in, settling fast. Click Close — it disappears instantly. Rapidly open/close: no restart-from-zero flicker (transitions retarget). +- **Done when**: open animates, close is instant, no layout properties in the transition list. diff --git a/research/trace-workbench/plans/003-details-enter.md b/research/trace-workbench/plans/003-details-enter.md new file mode 100644 index 0000000000..d7d350d89b --- /dev/null +++ b/research/trace-workbench/plans/003-details-enter.md @@ -0,0 +1,61 @@ +# 003 — Fade-slide expanded content into place + +- **Status**: DONE +- **Commit**: (repo has no commits yet — uncommitted working tree, 2026-07-18) +- **Severity**: MEDIUM +- **Category**: Missed opportunities +- **Estimated scope**: 1 file (styles.css), 1 keyframe + 3 selectors + +## Problem + +Expanding a trajectory row, a chat activity block, or a tree turn teleports its content into the layout — a jarring change on the most-used disclosure surfaces: + +- `.traj-body` (trajectory expanded row) — inserted by re-render on toggle +- `.activity-body` (chat Thinking / Tool use `
`) +- `.tree-steps` (tree turn `
`) + +## Target + +One shared enter animation; exit stays instant (collapse must snap): + +```css +@keyframes content-enter { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.traj-body, +.chat-activity[open] .activity-body, +.tree-turn[open] .tree-steps { + animation: content-enter 160ms var(--ease-out); +} +``` + +Keyframes (not transitions) are correct here: each open is a fresh one-shot enter; re-opening restarting from zero is the intended semantic. + +## Repo conventions to follow + +- `--ease-out` token from plan 001 (depends on it). +- Place the keyframe near the existing `@keyframes traj-flash` block. + +## Steps + +1. styles.css: add the `content-enter` keyframe next to `traj-flash`. +2. styles.css: add the three-selector rule exactly as in Target. + +## Boundaries + +- Do NOT animate height (no interpolate-size tricks) — opacity+transform only. +- Do NOT animate collapse. +- Do NOT touch `.md-*`, `.spawn-list`, or inline feedback form styles. + +## Verification + +- **Feel check**: expand a trajectory row — the body settles downward-into-place in ~160ms; collapse is instant. Open a chat Tool use block — same. Expand-all in trajectory: bodies animate once, scrolling stays smooth (animation is per-element, one-shot). +- **Done when**: all three surfaces animate on open, none on close. diff --git a/research/trace-workbench/plans/004-press-feedback.md b/research/trace-workbench/plans/004-press-feedback.md new file mode 100644 index 0000000000..0dcc2b1184 --- /dev/null +++ b/research/trace-workbench/plans/004-press-feedback.md @@ -0,0 +1,56 @@ +# 004 — Press feedback on pushbuttons + +- **Status**: DONE +- **Commit**: (repo has no commits yet — uncommitted working tree, 2026-07-18) +- **Severity**: LOW +- **Category**: Physicality & origin +- **Estimated scope**: 1 file (styles.css), 1 rule + +## Problem + +No pressable element gives press feedback; clicks feel dead. Applies to true pushbuttons only — rows and list items must NOT scale (they're selection surfaces, not buttons). + +## Target + +```css +.segmented button:active, +.trajectory-toolbar button:active, +.drawer-actions button:active, +.row-tools button:active, +.fb-send:active, +.feedback-submit:active, +.new-session-button:active, +.activity-open-inspector:active { + transform: scale(0.97); +} + +.segmented button, +.trajectory-toolbar button, +.drawer-actions button, +.row-tools button, +.fb-send, +.feedback-submit, +.new-session-button { + transition: transform 160ms var(--ease-out); +} +``` + +Subtle (0.97), transform-only, 160ms — inside the 100–160ms press-feedback budget on release. + +## Repo conventions to follow + +- `--ease-out` token from plan 001 (depends on it). + +## Steps + +1. styles.css: add both rules near the global `button` styles at the top of the file. + +## Boundaries + +- Do NOT add `:active` scaling to `.session-row`, `.traj-summary`, `.tree-step`, `.spawn-link`, `.wf-label-col`, `.wf-bar`, or chat summaries. +- Do NOT scale below 0.95. + +## Verification + +- **Feel check**: hold the Trajectory segmented button down — it compresses slightly; release — it springs back over ~160ms. Click a session row — no scaling. +- **Done when**: pushbuttons compress on press, selection surfaces don't. diff --git a/research/trace-workbench/plans/005-reduced-motion.md b/research/trace-workbench/plans/005-reduced-motion.md new file mode 100644 index 0000000000..155ad23f1e --- /dev/null +++ b/research/trace-workbench/plans/005-reduced-motion.md @@ -0,0 +1,70 @@ +# 005 — prefers-reduced-motion support + +- **Status**: DONE +- **Commit**: (repo has no commits yet — uncommitted working tree, 2026-07-18) +- **Severity**: MEDIUM +- **Category**: Accessibility +- **Estimated scope**: 1 file (styles.css), 1 media query block + +## Problem + +styles.css has zero `prefers-reduced-motion` handling. Movement-based motion (toast slide, inspector slide-in from 002, content slide from 003, press scale from 004) plays regardless of the OS setting. + +## Target + +Reduced motion = drop position/scale changes, KEEP opacity feedback (comprehension aids stay): + +```css +@media (prefers-reduced-motion: reduce) { + .toast { + transform: none; + transition: opacity 200ms var(--ease-out); + } + .chat-split.inspector-open .inspector, + .wf-split.inspector-open .inspector { + transition: opacity 200ms var(--ease-out); + @starting-style { + transform: translateX(0); + } + } + .traj-body, + .chat-activity[open] .activity-body, + .tree-turn[open] .tree-steps { + animation: none; + } + .segmented button:active, + .trajectory-toolbar button:active, + .drawer-actions button:active, + .row-tools button:active, + .fb-send:active, + .feedback-submit:active, + .new-session-button:active, + .activity-open-inspector:active { + transform: none; + } + .flash { + animation: none; + box-shadow: 0 0 0 3px rgba(10, 100, 255, .45); + } +} +``` + +Note `.flash` keeps a static highlight ring (the jump-target indicator is comprehension, not decoration) — it just stops pulsing. `.toast` keeps its opacity fade. + +## Repo conventions to follow + +- Depends on plans 001–004 (targets their rules). Place the block at the end of styles.css, before the responsive media queries. + +## Steps + +1. styles.css: add the media query block exactly as in Target. + +## Boundaries + +- Do NOT remove opacity transitions — reduced motion is fewer/gentler, not zero. +- Do NOT gate hover color changes (no movement involved). + +## Verification + +- **Feel check**: DevTools → Rendering → emulate `prefers-reduced-motion: reduce`. Toast fades without sliding; inspector fades in place; expanding rows appears instantly; jump-to still shows a static ring. +- **Done when**: with reduction on, nothing on screen translates or scales, but state feedback remains visible. diff --git a/research/trace-workbench/plans/README.md b/research/trace-workbench/plans/README.md new file mode 100644 index 0000000000..113ef2e9ad --- /dev/null +++ b/research/trace-workbench/plans/README.md @@ -0,0 +1,15 @@ +# Animation plans + +Written by the `improve-animations` audit (2026-07-18) against the uncommitted working tree. + +| # | Plan | Severity | Status | +| --- | --- | --- | --- | +| 001 | [Add easing token, upgrade transitions](001-motion-foundation.md) | LOW (foundation) | DONE | +| 002 | [Inspector content slide-in](002-inspector-enter.md) | MEDIUM | DONE | +| 003 | [Fade-slide expanded content](003-details-enter.md) | MEDIUM | DONE | +| 004 | [Press feedback on pushbuttons](004-press-feedback.md) | LOW | DONE | +| 005 | [prefers-reduced-motion support](005-reduced-motion.md) | MEDIUM | DONE | + +Execution order: 001 first (defines `--ease-out` used by all others), then 002–004 in any order, 005 last (its selectors target rules created by 002–004). + +Explicit non-findings from the audit (do not "fix" these): instant view switching, instant hover states, the 1.4s flash pulse, instant collapse/close animations — all deliberate restraint for a crisp dashboard. diff --git a/research/trace-workbench/server.js b/research/trace-workbench/server.js new file mode 100644 index 0000000000..f8ce6a29b1 --- /dev/null +++ b/research/trace-workbench/server.js @@ -0,0 +1,456 @@ +#!/usr/bin/env node +const fs = require('node:fs') +const http = require('node:http') +const path = require('node:path') +const { URL } = require('node:url') + +const PORT = Number(process.env.PORT || 5173) +const HOST = process.env.HOST || '127.0.0.1' +const DEFAULT_ROOT = path.join(process.cwd(), '.sessions') +const SESSIONS_ROOT = process.env.HARNESS_SESSIONS_ROOT || DEFAULT_ROOT +const STATIC_ROOT = __dirname +const FEEDBACK_ROOT = process.env.HARNESS_FEEDBACK_ROOT || path.join(STATIC_ROOT, '.feedback') + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', +} + +function send(res, status, body, type = 'application/json; charset=utf-8') { + const payload = type.startsWith('application/json') ? JSON.stringify(body, null, 2) : body + res.writeHead(status, { + 'content-type': type, + 'cache-control': 'no-store', + 'content-length': Buffer.byteLength(payload), + }) + res.end(payload) +} + +function sendFile(res, full, type) { + const body = fs.readFileSync(full) + res.writeHead(200, { + 'content-type': type, + 'cache-control': 'no-store', + 'content-length': body.length, + }) + res.end(body) +} + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let body = '' + req.setEncoding('utf8') + req.on('data', chunk => { + body += chunk + if (body.length > 1024 * 1024) { + req.destroy() + reject(new Error('request body too large')) + } + }) + req.on('end', () => { + if (!body.trim()) return resolve({}) + try { + resolve(JSON.parse(body)) + } catch (error) { + reject(error) + } + }) + req.on('error', reject) + }) +} + +function walkJsonl(dir, out = []) { + if (!fs.existsSync(dir)) return out + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) walkJsonl(full, out) + else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full) + } + return out +} + +function readJsonl(file) { + const text = fs.readFileSync(file, 'utf8') + const rows = [] + for (const [index, line] of text.split(/\r?\n/).entries()) { + if (!line.trim()) continue + try { + rows.push(JSON.parse(line)) + } catch (error) { + rows.push({ type: 'parse/error', seq: index, time: 0, data: { line, error: String(error) } }) + } + } + const first = rows[0] + const meta = first?.type === 'session' + ? { ...first, path: file } + : { type: 'session', version: 0, id: path.basename(file, '.jsonl'), createdAt: 0, path: file } + const events = first?.type === 'session' ? rows.slice(1) : rows + return { meta, events, rawText: text } +} + +function feedbackFile(sessionId) { + return path.join(FEEDBACK_ROOT, `${encodeURIComponent(sessionId)}.feedback.jsonl`) +} + +function readFeedback(sessionId) { + const file = feedbackFile(sessionId) + if (!fs.existsSync(file)) return [] + return fs.readFileSync(file, 'utf8') + .split(/\r?\n/) + .filter(Boolean) + .map((line, index) => { + try { + return JSON.parse(line) + } catch (error) { + return { type: 'feedback/parse-error', seq: index, time: 0, data: { line, error: String(error) } } + } + }) +} + +function appendFeedback(sessionId, data) { + fs.mkdirSync(FEEDBACK_ROOT, { recursive: true }) + const rows = readFeedback(sessionId) + const record = { + type: 'feedback/add', + seq: rows.length, + time: Date.now(), + data: { + sessionId, + targetId: String(data.targetId || `session:${sessionId}`), + targetTitle: String(data.targetTitle || sessionId), + targetKind: String(data.targetKind || 'session'), + author: String(data.author || 'anonymous').trim() || 'anonymous', + text: String(data.text || '').trim(), + }, + } + if (!record.data.text) throw new Error('feedback text is required') + fs.appendFileSync(feedbackFile(sessionId), `${JSON.stringify(record)}\n`) + return record +} + +function textOfContent(content) { + if (!Array.isArray(content)) return '' + return content.map((block) => { + if (block.type === 'text' || block.type === 'reasoning') return block.text || '' + if (block.type === 'tool-call') return `[tool-call ${block.name}] ${block.arguments || ''}` + if (block.type === 'tool-result') return `[tool-result] ${JSON.stringify(block.content ?? block)}` + return JSON.stringify(block) + }).filter(Boolean).join('\n') +} + +function latestHeader(events) { + const headers = events.filter(event => event.type === 'request/header' && event.data?.header) + return headers.at(-1)?.data?.header ?? {} +} + +function summarize(file) { + const { meta, events } = readJsonl(file) + const header = latestHeader(events) + const last = events.at(-1) + const firstUser = events.find(event => event.type === 'user/message') + return { + id: String(meta.id), + parentSession: meta.parentSession ? String(meta.parentSession) : undefined, + cwd: meta.cwd, + path: file, + createdAt: meta.createdAt || events[0]?.time || 0, + lastActivity: last?.time || meta.createdAt || 0, + eventCount: events.length, + turnCount: events.filter(event => event.type === 'turn/start').length, + stepCount: events.filter(event => event.type === 'step/start').length, + toolCallCount: events.filter(event => event.type === 'tool/call').length, + model: header.config?.model, + title: textOfContent(firstUser?.data?.content).slice(0, 120) || String(meta.id), + } +} + +function findSessionFile(id) { + const files = walkJsonl(SESSIONS_ROOT) + const byName = files.find(file => path.basename(file, '.jsonl') === id) + if (byName) return byName + return files + .map(file => ({ file, summary: summarize(file) })) + .find(item => item.summary.id === id)?.file +} + +function parseArguments(text) { + if (typeof text !== 'string') return text + try { + return JSON.parse(text) + } catch { + return text + } +} + +function makeNode({ id, kind, title, subtitle, startTime, status = 'ok' }) { + return { + id, kind, title, subtitle, status, + startTime, endTime: startTime, startSec: 0, durationSec: 0, + children: [], rawEvents: [], detail: {}, + } +} + +function assignTiming(node, zero, fallbackEnd) { + const start = Number.isFinite(node.startTime) ? node.startTime : zero + const end = Math.max(start, Number.isFinite(node.endTime) ? node.endTime : fallbackEnd) + node.startSec = Math.max(0, (start - zero) / 1000) + node.durationSec = Math.max(0, (end - start) / 1000) + for (const child of node.children) assignTiming(child, zero, fallbackEnd) +} + +function buildSession(file) { + const { meta, events } = readJsonl(file) + const firstTime = events[0]?.time || meta.createdAt || Date.now() + const lastTime = events.at(-1)?.time || firstTime + const headerEvents = events.filter(event => event.type === 'request/header' && event.data?.header) + const header = latestHeader(events) + const tools = Array.isArray(header.tools) ? header.tools : [] + const toolsByName = new Map(tools.map(tool => [tool.name, tool])) + + const root = makeNode({ + id: `session:${meta.id}`, + kind: 'session', + title: String(meta.id), + subtitle: meta.cwd || path.dirname(file), + startTime: firstTime, + }) + root.endTime = lastTime + root.rawEvents = events + + const turnMap = new Map() + const stepMap = new Map() + const toolMap = new Map() + const firstChunkByStep = new Map() + + for (const event of events) { + if (event.type === 'assistant/chunk') { + const key = `${event.data?.turn}:${event.data?.step}` + if (!firstChunkByStep.has(key)) firstChunkByStep.set(key, event.time) + } + } + + let currentTurn + let currentStep + for (const event of events) { + if (event.type === 'turn/start') { + const turn = event.data.turn + const node = makeNode({ + id: `turn:${turn}`, + kind: 'turn', + title: `turn ${turn}`, + subtitle: event.data.trigger?.kind || 'turn/start', + startTime: event.time, + }) + node.rawEvents.push(event) + root.children.push(node) + turnMap.set(turn, node) + currentTurn = node + currentStep = undefined + } else if (event.type === 'turn/end') { + const node = turnMap.get(event.data.turn) + if (node) { + node.endTime = event.time + node.status = event.data.reason?.kind === 'completed' ? 'ok' : 'error' + node.rawEvents.push(event) + } + } else if (event.type === 'step/start') { + const turn = turnMap.get(event.data.turn) || currentTurn || root + const key = `${event.data.turn}:${event.data.step}` + const node = makeNode({ + id: `step:${key}`, + kind: 'step', + title: `step ${event.data.step}`, + subtitle: `turn ${event.data.turn}`, + startTime: event.time, + }) + node.rawEvents.push(event) + turn.children.push(node) + stepMap.set(key, node) + currentStep = node + } else if (event.type === 'step/end') { + const node = stepMap.get(`${event.data.turn}:${event.data.step}`) + if (node) { + node.endTime = event.time + node.rawEvents.push(event) + } + } else if (event.type === 'request/header') { + const node = currentStep || currentTurn || root + node.rawEvents.push(event) + node.detail.promptSeq = event.seq + } else if (event.type === 'assistant/message') { + const key = `${event.data.turn}:${event.data.step}` + const step = stepMap.get(key) || currentStep || root + const startTime = firstChunkByStep.get(key) ?? event.time + const text = textOfContent(event.data.content) + const node = makeNode({ + id: `assistant:${event.seq}`, + kind: 'llm', + title: 'assistant/message', + subtitle: text.slice(0, 96) || 'assembled assistant message', + startTime, + }) + node.endTime = event.time + node.rawEvents.push(event) + node.detail = { + input: event.data.usage ? { usage: event.data.usage } : '', + output: event.data.content, + promptSeq: step.detail.promptSeq, + } + step.children.push(node) + step.rawEvents.push(event) + } else if (event.type === 'tool/call') { + const key = `${event.data.turn}:${event.data.step}` + const step = stepMap.get(key) || currentStep || root + const node = makeNode({ + id: `tool:${event.data.callId}`, + kind: 'tool', + title: event.data.name, + subtitle: event.data.callId, + startTime: event.time, + }) + node.rawEvents.push(event) + node.detail = { + input: parseArguments(event.data.arguments), + output: '', + promptSeq: step.detail.promptSeq, + schema: toolsByName.get(event.data.name), + } + step.children.push(node) + step.rawEvents.push(event) + toolMap.set(event.data.callId, node) + } else if (event.type === 'tool/result') { + const node = toolMap.get(event.data.callId) + if (node) { + node.endTime = event.time + node.status = event.data.isError ? 'error' : 'ok' + node.rawEvents.push(event) + node.detail.output = { + content: event.data.content, + isError: event.data.isError, + error: event.data.error, + meta: event.data.meta, + } + } + } else { + const target = currentStep || currentTurn || root + target.rawEvents.push(event) + } + } + + assignTiming(root, firstTime, lastTime) + + const messages = events + .filter(event => ['user/message', 'assistant/message', 'tool/result', 'context/message', 'steering/message'].includes(event.type)) + .map(event => ({ + seq: event.seq, + time: event.time, + type: event.type, + role: event.type === 'assistant/message' ? 'assistant' : event.type === 'tool/result' ? 'tool' : 'user', + content: event.data.content, + text: textOfContent(event.data.content), + })) + + const stats = { + startTime: firstTime, + durationSec: Math.max(0, (lastTime - firstTime) / 1000), + turns: events.filter(event => event.type === 'turn/start').length, + steps: events.filter(event => event.type === 'step/start').length, + toolCalls: events.filter(event => event.type === 'tool/call').length, + llmMessages: events.filter(event => event.type === 'assistant/message').length, + } + + // Serialize the tree with event seq references instead of embedded event + // copies: every event is already shipped once in `events`, so nodes carry + // `eventSeqs` and the client resolves them through its seq index. + const packNode = (node) => { + const { rawEvents, children, ...rest } = node + return { ...rest, eventSeqs: rawEvents.map(event => event.seq), children: children.map(packNode) } + } + + const siblings = listSessions() + const sessionId = String(meta.id) + const children = siblings.filter(summary => summary.parentSession === sessionId) + const parent = meta.parentSession + ? siblings.find(summary => summary.id === String(meta.parentSession)) ?? { id: String(meta.parentSession) } + : undefined + + return { + header: { ...meta, path: file }, + stats, + agent: { + id: 'A_main', + model: header.config?.model, + systemPrompt: header.system || '', + messagePrefix: header.messagePrefix, + tools, + headerEvents: headerEvents.length, + latestHeader: header, + }, + tree: packNode(root), + messages, + events, + children, + parent, + feedback: readFeedback(sessionId), + } +} + +function listSessions() { + return walkJsonl(SESSIONS_ROOT) + .filter(file => !path.basename(file).startsWith('stdout.')) + .map(summarize) + .sort((a, b) => b.lastActivity - a.lastActivity) +} + +function serveStatic(req, res, pathname) { + const target = pathname === '/' ? '/index.html' : pathname + const decoded = decodeURIComponent(target) + const full = path.normalize(path.join(STATIC_ROOT, decoded)) + if (full !== STATIC_ROOT && !full.startsWith(STATIC_ROOT + path.sep)) return send(res, 403, 'forbidden', 'text/plain; charset=utf-8') + if (!fs.existsSync(full) || fs.statSync(full).isDirectory()) return send(res, 404, 'not found', 'text/plain; charset=utf-8') + const ext = path.extname(full) + sendFile(res, full, MIME[ext] || 'application/octet-stream') +} + +const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://${req.headers.host}`) + if (url.pathname === '/api/health') return send(res, 200, { ok: true, root: SESSIONS_ROOT }) + if (url.pathname === '/api/sessions' && req.method === 'POST') { + return send(res, 501, { + error: 'New live sessions are not connected yet. This prototype currently replays persisted JSONL sessions.', + next: 'Wire this endpoint to the Harness ACP/stdio runtime to create a live session.', + }) + } + if (url.pathname === '/api/sessions') return send(res, 200, { root: SESSIONS_ROOT, sessions: listSessions() }) + if (url.pathname.startsWith('/api/sessions/')) { + const suffix = decodeURIComponent(url.pathname.slice('/api/sessions/'.length)) + if (suffix.endsWith('/feedback')) { + const id = suffix.slice(0, -'/feedback'.length) + if (req.method === 'GET') return send(res, 200, { feedback: readFeedback(id) }) + if (req.method === 'POST') return send(res, 200, { feedback: appendFeedback(id, await readJsonBody(req)) }) + } + if (req.method === 'POST' && suffix.endsWith('/messages')) { + return send(res, 501, { + error: 'Live interaction is not connected yet. This prototype is reading persisted JSONL replay data.', + next: 'Wire this endpoint to the Harness ACP/stdio runtime to continue a session.', + }) + } + const id = suffix + const file = findSessionFile(id) + if (!file) return send(res, 404, { error: `session not found: ${id}` }) + return send(res, 200, buildSession(file)) + } + serveStatic(req, res, url.pathname) + } catch (error) { + send(res, 500, { error: String(error?.stack || error) }) + } +}) + +server.listen(PORT, HOST, () => { + console.log(`Harness Local Workbench listening on http://${HOST}:${PORT}`) + console.log(`Reading sessions from ${SESSIONS_ROOT}`) +}) diff --git a/research/trace-workbench/styles.css b/research/trace-workbench/styles.css new file mode 100644 index 0000000000..932bf17f1f --- /dev/null +++ b/research/trace-workbench/styles.css @@ -0,0 +1,2646 @@ +:root { + color-scheme: light; + --bg: #f5f5f7; + --chrome: rgba(255, 255, 255, .74); + --sidebar: rgba(244, 246, 249, .82); + --card: #ffffff; + --card-soft: #fbfbfd; + --ink: #1d1d1f; + --muted: #6e6e73; + --muted-2: #98a1b2; + --line: #dfe3ea; + --line-strong: #c7ceda; + --blue: #0a64ff; + --blue-soft: #e8f1ff; + --green: #087f5b; + --green-soft: #dcf7eb; + --amber: #995200; + --amber-soft: #fff3cf; + --purple: #8b5cf6; + --purple-soft: #f5efff; + --red: #c9342c; + --code-bg: #f6f7f9; + --code-ink: #2f343d; + --shadow: 0 18px 50px rgba(0, 0, 0, .10); + --shadow-soft: 0 8px 24px rgba(0, 0, 0, .06); + --radius-lg: 22px; + --radius-md: 15px; + --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif; + --mono: "SF Mono", "SFMono-Regular", ui-monospace, monospace; + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + overflow: hidden; + color: var(--ink); + background: + radial-gradient(circle at 16% -8%, rgba(10, 100, 255, .12), transparent 30%), + radial-gradient(circle at 92% 4%, rgba(120, 120, 128, .14), transparent 24%), + var(--bg); + font-family: var(--font); + font-size: 13px; + -webkit-font-smoothing: antialiased; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +select, +input, +textarea { + border: 1px solid var(--line); + border-radius: 11px; + background: rgba(255, 255, 255, .86); + color: var(--ink); +} + +button { + padding: 8px 12px; + cursor: pointer; +} + +button:hover { + border-color: var(--line-strong); + background: #fff; +} + +button.active { + border-color: var(--blue); + background: var(--blue); + color: #fff; +} + +/* Press feedback on true pushbuttons only — rows and list items are selection + surfaces and must not scale. */ +.segmented button, +.trajectory-toolbar button, +.drawer-actions button, +.row-tools button, +.fb-send, +.feedback-submit, +.new-session-button { + transition: transform 160ms var(--ease-out); +} + +.segmented button:active, +.trajectory-toolbar button:active, +.drawer-actions button:active, +.row-tools button:active, +.fb-send:active, +.feedback-submit:active, +.new-session-button:active, +.activity-open-inspector:active { + transform: scale(0.97); +} + +select, +input, +textarea { + width: 100%; + padding: 9px 11px; + outline: none; +} + +textarea { + resize: none; +} + +input:focus, +select:focus, +textarea:focus { + border-color: rgba(10, 100, 255, .72); + box-shadow: 0 0 0 4px rgba(10, 100, 255, .12); +} + +/* Top level is two panes (sessions | main). The inspector is second-level: it + lives inside the Chat view (see .chat-split), so Trajectory and Waterfall + never show it. Pane widths are CSS vars driven by drag handles (clamped in + JS); panes share one layer and squeeze each other, never overlay. */ +.shell { + --left-w: 300px; + display: grid; + grid-template-columns: var(--left-w) 9px minmax(480px, 1fr); + gap: 0; + height: 100vh; + padding: 12px; +} + +.pane-divider { + position: relative; + align-self: stretch; + cursor: col-resize; + touch-action: none; +} + +.pane-divider::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 4px; + width: 1px; + background: transparent; + transition: background .12s ease; +} + +.pane-divider:hover::after, +.pane-divider.dragging::after { + width: 3px; + left: 3px; + border-radius: 2px; + background: rgba(10, 100, 255, .45); +} + +.left-pane, +.main-pane { + min-height: 0; + overflow: hidden; + border: 1px solid rgba(198, 205, 218, .72); + box-shadow: var(--shadow); + backdrop-filter: blur(24px) saturate(1.2); +} + +.left-pane { + display: flex; + flex-direction: column; + gap: 12px; + border-radius: var(--radius-lg); + padding: 14px; + background: var(--sidebar); +} + +.main-pane { + display: flex; + flex-direction: column; + border-radius: 26px; + background: rgba(255, 255, 255, .82); + transition: opacity 150ms var(--ease-out); +} + +.main-pane.loading { + opacity: .55; + pointer-events: none; +} + +/* Split views (Chat, Trajectory) manage their own inner scrollers, so the + .view itself must not scroll or pad. Three classes to outrank .view.active. */ +.view.split-view.active { + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; +} + +/* Second-level inspector: a grid column inside the Chat and Waterfall views + (the single #detailDrawer element is moved into the active split). Opening it + squeezes the content column; it never overlays. */ +.chat-split, +.wf-split { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr); +} + +.chat-split.inspector-open, +.wf-split.inspector-open { + grid-template-columns: minmax(0, 1fr) 9px var(--inspector-w, 400px); +} + +.chat-split > .pane-divider, +.wf-split > .pane-divider { + display: none; +} + +.chat-split.inspector-open > .pane-divider, +.wf-split.inspector-open > .pane-divider { + display: block; +} + +.conversation-scroll, +.waterfall-scroll { + min-width: 0; + overflow: auto; + padding: 18px 20px; +} + +.inspector { + display: none; + min-width: 0; + flex-direction: column; + overflow: auto; + padding: 14px; + background: #fbfcfe; +} + +/* Enter only: content slides in from the right; Close stays instant + (asymmetric timing — the system's response snaps). */ +@keyframes inspector-enter { + from { + transform: translateX(16px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.chat-split.inspector-open .inspector, +.wf-split.inspector-open .inspector { + display: flex; + animation: inspector-enter 200ms var(--ease-out); +} + +/* Trajectory: structure tree (navigation) + event table (content). */ +.traj-split { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 250px minmax(0, 1fr); +} + +.traj-tree { + overflow: auto; + border-right: 1px solid var(--line); + padding: 14px 10px 14px 14px; + background: #fafbfd; +} + +.traj-main { + min-width: 0; + overflow: auto; + padding: 14px 20px 18px; +} + +.brand, +.main-switcher, +.details-head, +.section-title, +.format-row, +.top-actions, +.toolbar, +.event-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.brand { + justify-content: flex-start; + padding: 2px 2px 4px; +} + +.mark { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 12px; + background: linear-gradient(180deg, #2f6fff, #0a58d8); + color: #fff; + font-weight: 750; + letter-spacing: -.02em; + box-shadow: 0 8px 18px rgba(10, 100, 255, .24); +} + +h1, +h2, +h3, +h4 { + margin: 0; + letter-spacing: -.03em; +} + +h1 { + font-size: 18px; + font-weight: 700; +} + +h2 { + font-size: 25px; + font-weight: 720; +} + +h3 { + font-size: 17px; + font-weight: 700; +} + +h4 { + font-size: 16px; +} + +.overline { + margin: 0 0 4px; + color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} + +.subtitle, +.source-line { + margin: 6px 0 0; + color: var(--muted); + font-size: 12px; +} + +.source-line { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.control-card { + border: 1px solid rgba(210, 216, 226, .88); + border-radius: 17px; + padding: 12px; + background: rgba(255, 255, 255, .64); + box-shadow: var(--shadow-soft); +} + +.new-session-button { + width: 100%; + justify-content: center; + border-radius: 14px; + padding: 10px 12px; + background: #fff; + font-weight: 650; + box-shadow: var(--shadow-soft); +} + +.session-search-card { + border: 1px solid rgba(210, 216, 226, .88); + border-radius: 17px; + padding: 12px; + background: rgba(255, 255, 255, .64); + box-shadow: var(--shadow-soft); +} + +.session-list { + flex: 1; + min-height: 0; + overflow: auto; + display: grid; + align-content: start; + gap: 7px; +} + +.session-row { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 3px; + align-items: center; + border: 1px solid transparent; + border-radius: 14px; + padding: 10px; + background: transparent; + text-align: left; +} + +.session-row:hover, +.session-row.selected { + border-color: #c7dcff; + background: var(--blue-soft); +} + +.session-row.child { + margin-left: 14px; + width: calc(100% - 14px); + padding: 6px 10px; +} + +.session-row.child .session-title { + color: #5b6472; + font-weight: 550; + font-size: 12px; +} + +.session-row.child .session-time { + display: none; +} + +.child-mark { + color: #9aa5b8; + font-family: var(--mono); +} + +.kid-count { + color: #2456b3; + font-weight: 650; +} + +.session-title, +.session-meta, +.session-time { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-title { + color: #2f3440; + font-weight: 650; +} + +.session-meta, +.session-time { + color: var(--muted); + font-size: 12px; +} + +.control-card.compact { + display: grid; + gap: 9px; +} + +.field-label { + display: block; + margin-bottom: 7px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.session-stats { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} + +.session-stats strong, +.session-stats span { + border: 1px solid var(--line); + border-radius: 999px; + padding: 4px 8px; + background: rgba(247, 248, 250, .86); + color: #3f4857; +} + +.segmented { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.segmented button, +.detail-tabs button { + border-radius: 999px; + padding: 7px 12px; + background: rgba(255, 255, 255, .72); +} + +.segmented.large { + padding: 4px; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(242, 244, 248, .86); +} + +.segmented.large button { + border-color: transparent; + padding: 8px 13px; +} + +.segmented button.active, +.detail-tabs button.active { + border-color: rgba(198, 205, 218, .72); + background: #fff; + color: var(--ink); + box-shadow: 0 5px 14px rgba(0, 0, 0, .08); +} + +.status-pill { + border: 1px solid rgba(8, 127, 91, .18); + border-radius: 999px; + padding: 7px 10px; + background: var(--green-soft); + color: var(--green); + font-weight: 700; + white-space: nowrap; +} + +.trace-tree { + flex: 1; + min-height: 0; + overflow: auto; + padding-right: 2px; +} + +/* Waterfall: fixed label column + uniform track column. Every bar's left/width + percentage resolves against the SAME track width regardless of tree depth, so + time positions align vertically; depth is shown by indenting the label only. */ +.waterfall { + width: 100%; + margin: 0; + border: 1px solid var(--line); + border-radius: 14px; + background: #fff; + padding: 12px 16px 16px; + overflow: auto; +} + +.wf { + min-width: 640px; +} + +.wf-row { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 12px; + align-items: center; +} + +.wf-head-row { + position: sticky; + top: 0; + z-index: 1; + background: #fff; + border-bottom: 1px solid var(--line); + padding-bottom: 6px; + margin-bottom: 6px; +} + +.wf-axis { + display: flex; + justify-content: space-between; + color: var(--muted); + font-family: var(--mono); + font-size: 11px; +} + +.wf-label-col { + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + gap: 7px; + align-items: center; + border: 0; + border-radius: 7px; + padding: 3px 6px 3px calc(6px + var(--depth, 0) * 14px); + background: transparent; + text-align: left; + cursor: pointer; +} + +.wf-label-col:hover, +.wf-label-col.selected { + background: var(--blue-soft); +} + +.wf-glyph { + display: grid; + place-items: center; + width: 20px; + height: 16px; + border-radius: 5px; + background: #eef1f6; + color: #4b5565; + font-size: 8.5px; + font-weight: 750; + font-family: var(--mono); +} + +.wf-glyph.turn { background: var(--blue-soft); color: #2456b3; } +.wf-glyph.step { background: #eceff4; color: #4c586c; } +.wf-glyph.tool { background: var(--green-soft); color: var(--green); } +.wf-glyph.llm { background: var(--amber-soft); color: var(--amber); } +.wf-glyph.error { background: #fde8e6; color: var(--red); } + +.wf-name { + overflow: hidden; + color: #3a4356; + font-size: 11.5px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wf-dur { + color: #9aa5b8; + font-family: var(--mono); + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} + +.wf-track { + position: relative; + height: 20px; + border-radius: 4px; + background-image: + linear-gradient(90deg, rgba(135, 145, 160, .18) 1px, transparent 1px); + background-size: 25% 100%; + background-repeat: repeat-x; +} + +.wf-bar { + position: absolute; + top: 3px; + height: 14px; + min-width: 2px; + border: 0; + border-radius: 4px; + padding: 0; + background: var(--green); + cursor: pointer; +} + +.wf-bar.turn { background: var(--blue); } +.wf-bar.step { background: #657084; } +.wf-bar.llm { background: var(--amber); } +.wf-bar.error { background: var(--red); } + +.wf-bar:hover, +.wf-bar.selected { + outline: 2px solid rgba(10, 100, 255, .45); + outline-offset: 1px; +} + +.tree-node { + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 9px; + align-items: center; + margin: 5px 0; + padding: 8px; + border: 1px solid transparent; + border-radius: 12px; + cursor: pointer; +} + +.tree-node:hover, +.tree-node.selected { + border-color: #c7dcff; + background: var(--blue-soft); +} + +.tree-node[data-depth="1"] { + margin-left: 12px; +} + +.tree-node[data-depth="2"] { + margin-left: 28px; +} + +.tree-node[data-depth="3"] { + margin-left: 44px; +} + +.node-glyph { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px; + background: #eef1f6; + color: #4b5565; + font-size: 10px; + font-weight: 750; +} + +.node-title { + min-width: 0; +} + +.node-title strong, +.node-title span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.node-title span, +.node-time { + color: var(--muted); + font-size: 12px; +} + +.main-switcher { + padding: 14px 18px; + border-bottom: 1px solid var(--line); + background: rgba(255, 255, 255, .46); +} + +.conversation-title { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; + color: #2c3038; +} + +.conversation-title strong { + overflow: hidden; + max-width: 560px; + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.replay-pill { + flex: 0 0 auto; + border: 1px solid var(--line); + border-radius: 999px; + padding: 3px 8px; + background: #f5f6f8; + color: #7b8495; + font-size: 10px; + font-weight: 750; + letter-spacing: .08em; + text-transform: uppercase; +} + +.crumb { + flex: 0 0 auto; + max-width: 300px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 999px; + padding: 3px 10px; + background: #fff; + color: #4c586c; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.crumb:hover { + border-color: #b7d5fb; + background: var(--blue-soft); + color: #2456b3; +} + +.metrics { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 8px; + padding: 12px 20px 0; +} + +.metric { + border: 1px solid var(--line); + border-radius: 14px; + padding: 10px 11px; + background: rgba(255, 255, 255, .78); +} + +.metric strong, +.metric span { + display: block; +} + +.metric strong { + font-family: var(--mono); + font-size: 16px; +} + +.metric span { + margin-top: 3px; + color: var(--muted); + font-size: 11px; + font-weight: 650; +} + +.view { + display: none; + flex: 1; + min-height: 0; + overflow: auto; + padding: 24px 20px 18px; +} + +.view.active { + display: block; +} + +.section-title { + margin-bottom: 14px; +} + +.event-controls { + min-width: min(520px, 52%); +} + +.event-controls.narrow { + min-width: min(320px, 38%); +} + +.conversation { + display: grid; + gap: 22px; + width: min(920px, 100%); + margin: 0 auto; + padding-bottom: 18px; +} + +.message { + display: grid; + grid-template-columns: 36px minmax(0, 1fr); + gap: 11px; +} + +.avatar { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 11px; + background: #eef1f6; + color: #455064; + font-weight: 760; +} + +.message.tool .avatar { + background: var(--green-soft); + color: var(--green); +} + +.message.assistant .avatar { + background: var(--amber-soft); + color: var(--amber); +} + +.message-card { + border: 0; + border-radius: 0; + padding: 0; + background: transparent; + box-shadow: none; +} + +.message-card header { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + color: var(--muted); +} + +.message-card header strong { + color: var(--ink); +} + +.message-card p { + margin: 0; + color: #2f3137; + font-size: 15px; + line-height: 1.64; + white-space: pre-wrap; +} + +.activity-list { + display: grid; + gap: 8px; + margin: 2px 0 14px; +} + +.chat-activity { + width: 100%; + max-width: 760px; + color: #8d9199; +} + +.chat-activity summary { + display: grid; + grid-template-columns: 78px minmax(0, 1fr) 18px; + align-items: center; + gap: 10px; + cursor: pointer; + list-style: none; + font-size: 13px; + line-height: 1.35; +} + +.chat-activity summary::-webkit-details-marker { + display: none; +} + +.chat-activity summary::after { + content: "⌄"; + color: #9a9ca1; + text-align: right; +} + +.chat-activity[open] summary::after { + content: "⌃"; +} + +.chat-activity strong { + overflow: hidden; + color: #7d8189; + font-family: var(--font); + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-activity p, +.chat-activity pre { + margin: 7px 0 2px 88px; + border-left: 2px solid #d8dde6; + padding: 7px 10px; + background: rgba(246, 247, 249, .86); + color: #6a6d73; + font-family: var(--font); + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; +} + +.chat-activity pre { + font-family: var(--mono); +} + +.muted-text { + color: var(--muted); +} + +.message.user { + grid-template-columns: minmax(0, 1fr); + justify-items: stretch; +} + +.message.user .avatar, +.message.user header { + display: none; +} + +.message.selected .message-card { + outline: 2px solid rgba(10, 100, 255, .22); + outline-offset: 4px; +} + +.message.assistant { + grid-template-columns: minmax(0, 1fr); +} + +.message.assistant .avatar, +.message.assistant header { + display: none; +} + +.trajectory { + display: grid; + gap: 9px; + width: 100%; + padding-bottom: 12px; +} + +.trajectory-toolbar { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + margin: 0 0 14px; + color: var(--muted); +} + +.trajectory-toolbar span { + font-weight: 650; +} + +.traj-head-row { + display: grid; + grid-template-columns: 48px 150px minmax(0, 1fr) 58px 58px 58px 76px 18px; + gap: 10px; + padding: 4px 14px 8px; + color: #9aa5b8; + font-size: 10px; + font-weight: 750; + letter-spacing: .08em; + text-transform: uppercase; +} + +.traj-head-row .num { + text-align: right; +} + +.traj-row { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(255, 255, 255, .88); + box-shadow: var(--shadow-soft); +} + +.traj-row.expanded.assistant { + border-left: 4px solid #66c28c; +} + +.traj-row.error { + border-left: 4px solid var(--red); +} + +.traj-row.selected { + border-color: #b8cff8; + box-shadow: 0 0 0 2px rgba(10, 100, 255, .14); +} + +.traj-summary { + width: 100%; + display: grid; + grid-template-columns: 48px 150px minmax(0, 1fr) 58px 58px 58px 76px 18px; + gap: 10px; + align-items: center; + border: 0; + border-radius: 0; + padding: 10px 14px; + background: transparent; + color: #465268; + text-align: left; + cursor: pointer; + user-select: none; +} + +.traj-summary:hover { + background: #f7f9fc; +} + +.traj-content { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + overflow: hidden; +} + +.traj-index, +.token-cell, +.chevron { + color: #9aa5b8; + font-family: var(--mono); + font-weight: 650; +} + +.token-cell { + text-align: right; + font-variant-numeric: tabular-nums; + font-size: 11px; +} + +.token-cell.offset { + color: #b3bcca; +} + + +.role-chip, +.tool-chip, +.result-chip, +.event-chip { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + width: fit-content; + border-radius: 8px; + padding: 4px 9px; + font-weight: 760; + letter-spacing: .06em; + white-space: nowrap; +} + +.role-chip.user { + background: #eef2ff; + color: #3151b7; +} + +.role-chip.assistant, +.tool-chip { + background: #dff8ec; + color: #236d50; +} + +.role-chip.tool, +.result-chip { + background: #fff0c2; + color: #87400c; +} + +.event-chip { + background: #f5f6f8; + color: #7b8495; + letter-spacing: 0; + text-transform: none; +} + +.role-chip.system { + background: #edf2f7; + color: #334155; +} + +.role-chip.turn { + background: #f1f5f9; + color: #475569; +} + +.role-chip.step { + background: #eef6ff; + color: #245b9e; +} + +.role-chip.meta { + background: #f5f6f8; + color: #7b8495; +} + +.result-chip.error { + background: #fde8e6; + color: var(--red); +} + +.traj-title { + overflow: hidden; + color: #445168; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.traj-body { + margin: 0 14px 14px 116px; + padding-top: 6px; + color: #555; +} + +.user-body { + white-space: pre-wrap; +} + +.thinking-card { + margin-bottom: 14px; + border-left: 4px solid #c084fc; + border-radius: 12px; + padding: 14px 16px; + background: var(--purple-soft); + color: #9860d8; +} + +.thinking-card strong { + display: block; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: .04em; +} + +.thinking-card p, +.assistant-text { + margin: 0; + line-height: 1.7; + white-space: pre-wrap; +} + +.assistant-text { + margin-bottom: 14px; + color: #5c5c60; +} + +.tool-call-card, +.tool-output-card { + overflow: hidden; + margin: 12px 0; + border: 1px solid var(--line); + border-radius: 12px; + background: #fff; +} + +.tool-call-head, +.tool-output-head { + display: flex; + justify-content: space-between; + gap: 10px; + padding: 9px 12px; + background: #7d8694; + color: #eff6ff; +} + +.tool-call-head span, +.tool-output-head span { + overflow: hidden; + color: #cad2df; + font-family: var(--mono); + text-overflow: ellipsis; + white-space: nowrap; +} + +.tool-call-card pre, +.tool-output-card pre, +.metadata-line pre { + margin: 0; + max-height: 360px; + padding: 12px 14px; + overflow: auto; + background: var(--code-bg); + color: #666; + font-family: var(--mono); + font-size: 12px; + line-height: 1.48; + white-space: pre-wrap; +} + +.metadata-line { + margin-top: 8px; + color: var(--muted-2); +} + +.tool-call-card.error, +.tool-output-card.error { + border-color: #f3c2bd; +} + +.tool-call-card.error .tool-call-head, +.tool-output-card.error .tool-output-head { + background: #b2453d; +} + +.error-text { + color: var(--red) !important; + background: #fdf3f2 !important; +} + +.more-note { + padding: 8px 14px; + border-top: 1px dashed var(--line); + background: var(--code-bg); + color: #9aa5b8; + font-size: 11px; +} + +.spawn-list { + margin: 12px 0; + border: 1px solid #d9e6d9; + border-radius: 12px; + background: #f7fbf7; + overflow: hidden; +} + +.spawn-list-head { + padding: 8px 12px; + border-bottom: 1px solid #e3eee3; + color: #3d7a52; + font-size: 11px; + font-weight: 750; + letter-spacing: .06em; + text-transform: uppercase; +} + +.spawn-link { + width: 100%; + display: grid; + grid-template-columns: 16px minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + border: 0; + border-radius: 0; + border-bottom: 1px solid #edf4ed; + padding: 8px 12px; + background: transparent; + text-align: left; +} + +.spawn-link:last-child { + border-bottom: 0; +} + +.spawn-link:hover { + background: #ecf6ec; +} + +.spawn-mark { + color: #6aa981; + font-family: var(--mono); +} + +.spawn-title { + overflow: hidden; + color: #2f4a3a; + font-size: 12px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.spawn-meta { + color: #86a292; + font-family: var(--mono); + font-size: 10.5px; + white-space: nowrap; +} + +.event-table { + overflow: auto; + border: 1px solid var(--line); + border-radius: 16px; + background: #fff; +} + +.event-row { + width: 100%; + display: grid; + grid-template-columns: 72px 178px 90px minmax(320px, 1fr); + gap: 12px; + align-items: center; + border: 0; + border-bottom: 1px solid var(--line); + border-radius: 0; + padding: 10px 12px; + background: #fff; + color: var(--ink); + text-align: left; +} + +.event-row:last-child { + border-bottom: 0; +} + +.event-row:not(.event-head):hover, +.event-row.selected { + background: var(--blue-soft); +} + +.event-head { + position: sticky; + top: 0; + z-index: 1; + background: #f6f7f9; + color: var(--muted); + font-size: 11px; + font-weight: 750; + text-transform: uppercase; +} + +.event-row .seq { + font-family: var(--mono); + color: var(--muted); +} + +.agent-overview { + display: grid; + grid-template-columns: minmax(340px, .92fr) minmax(420px, 1.08fr); + gap: 14px; +} + +.agent-card, +.tool-card { + border: 1px solid var(--line); + border-radius: 18px; + padding: 16px; + background: rgba(255, 255, 255, .86); + box-shadow: var(--shadow-soft); +} + +.agent-card p, +.tool-card p { + color: var(--muted); +} + +.agent-card pre, +.tool-card pre { + max-height: 420px; + overflow: auto; + margin: 12px 0 0; + padding: 12px; + border: 1px solid var(--line); + border-radius: 13px; + background: var(--code-bg); + font-family: var(--mono); + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; +} + +.tool-list { + display: grid; + gap: 9px; +} + +.tool-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + align-items: center; + border: 1px solid var(--line); + border-radius: 13px; + padding: 10px 12px; + background: #fff; + cursor: pointer; +} + +.tool-row:hover { + border-color: #b7d5fb; + background: var(--blue-soft); +} + +.tool-row span { + border-radius: 999px; + padding: 4px 8px; + background: #eef2f7; + color: var(--muted); + font-family: var(--mono); +} + +.composer { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto 72px; + gap: 9px; + align-items: end; + width: calc(100% - 40px); + margin: 0 auto 18px; + border: 1px solid var(--line); + border-radius: 18px; + padding: 10px; + background: rgba(255, 255, 255, .90); + box-shadow: var(--shadow-soft); +} + +.composer textarea { + min-height: 38px; + max-height: 120px; + border: 0; + background: transparent; + box-shadow: none; +} + +.composer-plus, +.send-button { + height: 34px; + border-radius: 11px; +} + +.composer-meta { + align-self: center; + color: var(--muted-2); + font-size: 12px; + white-space: nowrap; +} + +.send-button { + border-color: var(--blue); + background: var(--blue); + color: #fff; + font-weight: 700; +} + +.composer.disabled { + background: #fafbfc; + border-style: dashed; +} + +.composer.disabled textarea, +.composer.disabled .composer-plus, +.composer.disabled .send-button { + opacity: .55; + cursor: not-allowed; +} + +.composer.disabled .send-button { + border-color: var(--line-strong); + background: #d6dbe4; +} + +.details-head { + align-items: flex-start; + margin-bottom: 22px; +} + +.drawer-actions { + display: flex; + flex: 0 0 auto; + gap: 8px; +} + +#inspectorClose { + background: #f7f8fa; + color: #5f6675; +} + +.details-head h2 { + overflow: hidden; + max-width: 220px; + font-size: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detail-object { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.detail-icon { + width: 36px; + height: 36px; + display: grid; + place-items: center; + flex: 0 0 auto; + border-radius: 10px; + background: var(--blue); + color: #fff; + font-size: 11px; + font-weight: 750; +} + +#detailSubtitle { + overflow: hidden; + max-width: 260px; + margin: 4px 0 0; + color: var(--muted); + text-overflow: ellipsis; + white-space: nowrap; +} + +.detail-tabs { + display: flex; + flex-wrap: nowrap; + gap: 8px; + padding-bottom: 22px; + border-bottom: 0; +} + +.detail-tabs button { + border: 0; + border-radius: 8px; + padding: 8px 9px; + background: transparent; + color: #657089; + font-size: 14px; + white-space: nowrap; +} + +.detail-tabs button.active { + background: #eef2f8; + color: var(--ink); + box-shadow: none; +} + +.panel-section { + display: grid; + gap: 20px; +} + +.feedback-button { + width: fit-content; + border-style: dashed; + padding: 10px 16px; + color: #35445d; + font-size: 16px; +} + +.feedback-form { + display: grid; + gap: 10px; +} + +.feedback-compose-card { + border: 1px solid var(--line); + border-radius: 12px; + padding: 10px; + background: #fff; +} + +.feedback-author { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: center; + color: #74819a; + font-weight: 600; +} + +.feedback-author input { + border: 0; + border-radius: 0; + padding: 3px 0; + background: transparent; + color: #111827; + font-weight: 600; +} + +.feedback-history { + margin-top: 18px; +} + +.feedback-history header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; + color: #2f3645; +} + +.feedback-history header span { + color: #98a1b2; + font-size: 11px; +} + +.feedback-list { + display: grid; + gap: 8px; +} + +.feedback-empty { + border: 1px dashed var(--line); + border-radius: 10px; + padding: 10px; + color: #9aa5b8; + background: #fafbfc; + font-size: 12px; +} + +.feedback-item { + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px; + background: #fff; +} + +.feedback-item header { + display: flex; + justify-content: space-between; + gap: 12px; + color: #7c879a; + font-size: 12px; +} + +.feedback-item p { + margin: 8px 0 0; + color: #242832; + line-height: 1.5; + white-space: pre-wrap; +} + +.feedback-form textarea { + min-height: 118px; + border-radius: 10px; + background: #fbfcfe; + line-height: 1.5; +} + +.feedback-submit { + justify-self: end; + border-color: var(--blue); + background: var(--blue); + color: #fff; + font-weight: 700; +} + +/* Linear-inspired density pass */ +:root { + --shadow: none; + --shadow-soft: none; +} + +body { + font-size: 12px; +} + +.shell { + padding: 10px; +} + +.left-pane, +.main-pane { + border-color: #e4e7ee; + box-shadow: none; +} + +.left-pane, +.main-pane, +.session-search-card, +.control-card { + border-radius: 14px; +} + +.left-pane { + padding: 12px; + background: #f7f8fb; +} + +.main-pane { + background: #fff; +} + +h1 { + font-size: 16px; +} + +h2 { + font-size: 18px; +} + +h3 { + font-size: 14px; +} + +.new-session-button, +.session-search-card, +.session-row { + box-shadow: none; +} + +.new-session-button { + padding: 8px 10px; +} + +.session-search-card { + padding: 10px; +} + +.session-row { + border-radius: 10px; + padding: 8px; +} + +.main-switcher { + padding: 10px 14px; +} + +.conversation-title strong { + font-size: 13px; +} + +.segmented.large { + flex-wrap: nowrap; + padding: 3px; +} + +.segmented.large button { + padding: 6px 10px; + white-space: nowrap; +} + +.view { + padding: 18px 20px; +} + +.conversation { + width: min(860px, 100%); + gap: 18px; +} + +.message-card p { + font-size: 13px; +} + +/* Codex-like conversation surface */ +.conversation { + width: 100%; + max-width: 980px; + gap: 20px; + padding: 2px 0 58px; +} + +.message { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0; +} + +/* The whole message row opens the inspector — make that visible: pointer + cursor + a faint tint on hover (negative margin keeps layout unchanged). */ +#conversation .message { + cursor: pointer; + border-radius: 12px; + margin: -6px -12px; + padding: 6px 12px; + transition: background 120ms ease; +} + +#conversation .message:hover { + background: #f6f8fb; +} + +.message-card { + min-width: 0; +} + +.message.user { + justify-items: stretch; +} + +.message.user .message-card { + width: 100%; + max-width: none; +} + +.message.assistant { + justify-items: stretch; +} + +.message.selected .message-card { + outline: none; +} + +.user-bubble { + width: fit-content; + margin-left: auto; + max-width: min(650px, 76%); + border-radius: 18px; + padding: 13px 16px; + background: #f0f1f3; + color: #272a31; + font-size: 13px; + line-height: 1.62; + white-space: pre-wrap; +} + +.assistant-prose { + color: #24272f; + font-size: 13px; + line-height: 1.68; +} + +.assistant-prose p { + margin: 0 0 9px; + font-size: 13px; + line-height: 1.68; + white-space: pre-wrap; +} + +.assistant-prose .md-h { + margin: 16px 0 8px; + font-size: 13.5px; + font-weight: 700; + letter-spacing: -.01em; +} + +.assistant-prose h3.md-h { + font-size: 15px; +} + +.assistant-prose ul, +.assistant-prose ol { + margin: 4px 0 10px; + padding-left: 22px; +} + +.assistant-prose li { + margin: 3px 0; + line-height: 1.6; +} + +.assistant-prose hr { + margin: 14px 0; + border: 0; + border-top: 1px solid var(--line); +} + +.assistant-prose code { + border-radius: 4px; + padding: 1px 5px; + background: #f0f1f4; + color: #45516a; + font-family: var(--mono); + font-size: 11.5px; +} + +.assistant-prose .md-code { + margin: 6px 0 12px; + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px 12px; + overflow: auto; + background: var(--code-bg); + color: #3c4453; + font-family: var(--mono); + font-size: 12px; + line-height: 1.55; + white-space: pre; +} + +.assistant-prose .md-code code { + padding: 0; + background: transparent; +} + +.assistant-prose a { + color: var(--blue); + text-decoration: none; +} + +.assistant-prose a:hover { + text-decoration: underline; +} + +.assistant-prose .md-table-wrap { + overflow-x: auto; + margin: 8px 0 12px; +} + +.assistant-prose .md-table { + border-collapse: collapse; + font-size: 12.5px; +} + +.assistant-prose .md-table th, +.assistant-prose .md-table td { + border: 1px solid var(--line); + padding: 6px 10px; + text-align: left; + vertical-align: top; + line-height: 1.5; +} + +.assistant-prose .md-table th { + background: #f6f7f9; + font-weight: 650; + white-space: nowrap; +} + +.assistant-prose .md-table tr:nth-child(even) td { + background: #fafbfc; +} + +.assistant-prose blockquote { + margin: 6px 0 10px; + border-left: 3px solid var(--line-strong); + padding: 4px 12px; + color: #5f6571; +} + +.activity-list { + display: grid; + gap: 7px; + margin: 0 0 16px; +} + +.chat-activity { + max-width: 100%; + color: #858b98; +} + +.chat-activity summary { + display: grid; + grid-template-columns: 80px minmax(0, 1fr) 18px; + align-items: center; + gap: 8px; + min-height: 24px; + border-radius: 7px; + padding: 2px 0; + cursor: pointer; + list-style: none; + user-select: none; +} + +.activity-open-inspector { + display: inline-block; + margin-top: 6px; + border: 0; + border-radius: 0; + padding: 2px 0; + background: transparent; + color: var(--blue); + font-size: 12px; + cursor: pointer; +} + +.activity-open-inspector:hover { + background: transparent; + border: 0; + text-decoration: underline; +} + +.chat-activity summary:hover { + background: #f7f8fa; +} + +.chat-activity summary::-webkit-details-marker { + display: none; +} + +.chat-activity summary::after { + content: "⌄"; + color: #a0a6b2; + text-align: center; +} + +.chat-activity[open] summary::after { + content: "⌃"; +} + +.chat-activity summary span { + color: #8e95a3; + font-size: 13px; +} + +.chat-activity summary strong { + overflow: hidden; + color: #747b88; + font-size: 13px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-preview { + color: #a6acb8; + font-weight: 450; +} + +.chat-activity.failed summary span { + color: var(--red); + font-weight: 700; +} + +.chat-activity.failed .activity-body { + border-left-color: #eab8b3; + background: #fdf7f6; +} + +.activity-body { + margin: 7px 0 4px 80px; + border-left: 2px solid #dde2ea; + padding: 8px 12px; + background: #fafbfc; + color: #5f6571; + font-size: 12px; + line-height: 1.58; + white-space: pre-wrap; +} + +.activity-body pre { + max-height: 320px; + margin: 4px 0 10px; + overflow: auto; + color: #4f5663; + font-family: var(--mono); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; +} + +.activity-section-title { + margin: 4px 0 5px; + color: #9aa2b1; + font-size: 11px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; +} + +.detail-panel { + display: none; + min-height: 0; + overflow: auto; + padding-top: 14px; +} + +.detail-panel.active { + display: block; +} + +.detail-metrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin-bottom: 14px; +} + +.detail-metrics div { + border: 1px solid var(--line); + border-radius: 13px; + padding: 10px; + background: #fff; +} + +.detail-metrics strong, +.detail-metrics span { + display: block; +} + +.detail-metrics strong { + font-family: var(--mono); +} + +.detail-metrics span { + color: var(--muted); + font-size: 11px; +} + +.prose { + line-height: 1.65; +} + +.prose p { + margin: 0 0 12px; +} + +.format-row { + margin-bottom: 10px; +} + +.format-row select { + width: 128px; +} + +.code-block { + margin: 0; + min-height: 260px; + max-height: 58vh; + overflow: auto; + border: 1px solid var(--line); + border-radius: 15px; + padding: 14px; + background: var(--code-bg); + color: var(--code-ink); + font-family: var(--mono); + font-size: 12px; + line-height: 1.62; + white-space: pre-wrap; +} + +.code-block.tall { + min-height: 520px; +} + +.toast { + position: fixed; + right: 22px; + bottom: 22px; + transform: translateY(18px); + opacity: 0; + pointer-events: none; + border-radius: 999px; + padding: 10px 14px; + background: rgba(29, 29, 31, .94); + color: #fff; + transition: opacity 200ms var(--ease-out), transform 200ms var(--ease-out); +} + +.toast.show { + opacity: 1; + transform: translateY(0); +} + +.empty-state, +.error-state { + border: 1px dashed var(--line); + border-radius: 16px; + padding: 16px; + color: var(--muted); + background: rgba(255, 255, 255, .72); +} + +/* Trajectory structure tree: turns are collapsible parents, steps branch off + them behind a guide line. Collapse every turn to see just the turn list. */ +.tree-turn { + margin-bottom: 8px; +} + +.tree-turn-head { + display: grid; + grid-template-columns: 12px minmax(0, 1fr) auto; + align-items: baseline; + gap: 6px; + padding: 5px 8px 5px 6px; + border-radius: 8px; + color: #4c586c; + font-size: 11px; + font-weight: 750; + letter-spacing: .06em; + text-transform: uppercase; + cursor: pointer; + list-style: none; + user-select: none; +} + +.tree-turn-head::-webkit-details-marker { + display: none; +} + +.tree-turn-head::before { + content: '▸'; + color: #9aa5b8; + font-size: 9px; + transition: transform .12s ease; +} + +.tree-turn[open] > .tree-turn-head::before { + transform: rotate(90deg); +} + +.tree-turn-head:hover { + background: #eef1f6; +} + +.tree-turn-head.active .tt-label { + color: #2456b3; +} + +.tree-turn-head .tt-label { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.tree-turn-head .t-dur { + color: #9aa5b8; + font-family: var(--mono); + font-weight: 650; + letter-spacing: 0; + text-transform: none; +} + +.tree-steps { + display: grid; + gap: 1px; + margin: 2px 0 0 11px; + border-left: 1px solid var(--line); + padding-left: 6px; +} + +.tree-step { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px 8px; + align-items: baseline; + border: 0; + border-radius: 8px; + padding: 5px 8px; + background: transparent; + text-align: left; + cursor: pointer; +} + +.tree-step:hover, +.tree-step.active { + background: var(--blue-soft); +} + +.tree-step .t-name { + color: #3a4356; + font-size: 12px; + font-weight: 650; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tree-step.error .t-name::after { + content: '●'; + margin-left: 6px; + color: var(--red); + font-size: 8px; + vertical-align: 2px; +} + +.tree-step .t-dur { + color: #9aa5b8; + font-family: var(--mono); + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} + +.tree-step .t-sub { + grid-column: 1 / -1; + color: #8e95a3; + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Step group headers inside the trajectory table */ +.traj-group-head { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: baseline; + gap: 10px; + margin-top: 8px; + padding: 8px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: #f4f6fa; + color: #4c586c; + font-size: 11px; + font-weight: 750; +} + +.traj-group-head.error { + border-left: 3px solid var(--red); +} + +.traj-group-head .g-dur { + color: #9aa5b8; + font-family: var(--mono); + font-weight: 650; + font-variant-numeric: tabular-nums; +} + +.traj-group-head .g-meta { + color: #8e95a3; + font-weight: 550; +} + +@keyframes traj-flash { + 0% { box-shadow: 0 0 0 3px rgba(10, 100, 255, .55); } + 100% { box-shadow: 0 0 0 3px rgba(10, 100, 255, 0); } +} + +.flash { + animation: traj-flash 1.4s ease-out; +} + +/* One-shot enter for disclosed content; collapse stays instant. */ +@keyframes content-enter { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.traj-body, +.chat-activity[open] .activity-body, +.tree-turn[open] .tree-steps { + animation: content-enter 160ms var(--ease-out); +} + +/* Inline row tools + annotation (Trajectory is self-contained: copy, raw event + and annotations live in the expanded row, no inspector needed) */ +.row-tools { + display: flex; + align-items: center; + gap: 8px; + margin: 10px 0 2px; +} + +.row-tools button { + border-color: transparent; + border-radius: 7px; + padding: 4px 10px; + background: #f4f5f8; + color: #5f6675; + font-size: 11px; +} + +.row-tools button:hover { + border-color: var(--line-strong); + background: #eef0f4; +} + +.fb-chip { + color: #8b5cf6; + font-family: var(--mono); + font-size: 10.5px; +} + +.inline-fb { + display: grid; + gap: 8px; + margin: 10px 0; + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px; + background: #fff; +} + +.inline-fb textarea { + min-height: 60px; + border-radius: 8px; + background: #fbfcfe; +} + +.inline-fb .inline-fb-row { + display: grid; + grid-template-columns: 110px minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} + +.inline-fb .inline-fb-row input { + padding: 6px 9px; +} + +.inline-fb .fb-hint { + color: #9aa5b8; + font-size: 11px; +} + +.inline-fb .fb-send { + border-color: var(--blue); + background: var(--blue); + color: #fff; + font-weight: 650; +} + +.inline-fb-list { + display: grid; + gap: 6px; + margin: 8px 0 2px; +} + +/* Waterfall summary strip: where did time go, what failed, what was slowest */ +.wf-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; +} + +.wf-stat { + border: 1px solid var(--line); + border-radius: 10px; + padding: 8px 12px; + background: #fff; +} + +.wf-stat strong { + display: block; + font-family: var(--mono); + font-size: 14px; + font-variant-numeric: tabular-nums; +} + +.wf-stat span { + color: var(--muted); + font-size: 10px; + font-weight: 750; + letter-spacing: .06em; + text-transform: uppercase; +} + +.wf-stat.error strong { + color: var(--red); +} + +.wf-stat.link { + cursor: pointer; +} + +.wf-stat.link:hover { + border-color: #b7d5fb; + background: var(--blue-soft); +} + +/* Opacity-only variant of inspector-enter for reduced motion. */ +@keyframes inspector-enter-fade { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Reduced motion: drop position/scale changes, keep opacity feedback — + fewer and gentler animations, not zero. */ +@media (prefers-reduced-motion: reduce) { + .toast { + transform: none; + transition: opacity 200ms var(--ease-out); + } + + .chat-split.inspector-open .inspector, + .wf-split.inspector-open .inspector { + animation: inspector-enter-fade 200ms var(--ease-out); + } + + .traj-body, + .chat-activity[open] .activity-body, + .tree-turn[open] .tree-steps { + animation: none; + } + + .segmented button:active, + .trajectory-toolbar button:active, + .drawer-actions button:active, + .row-tools button:active, + .fb-send:active, + .feedback-submit:active, + .new-session-button:active, + .activity-open-inspector:active { + transform: none; + } + + /* The jump-target indicator is comprehension, not decoration: keep a static + ring, just stop the pulse. */ + .flash { + animation: none; + box-shadow: 0 0 0 3px rgba(10, 100, 255, .45); + } +} + +@media (max-width: 900px) { + body { + overflow: auto; + } + + .shell { + display: block; + height: auto; + padding: 10px; + } + + .pane-divider { + display: none !important; + } + + .left-pane, + .main-pane { + margin-bottom: 12px; + min-height: 520px; + } + + /* On small screens the inspector falls back to a full-screen overlay + and the trajectory tree hides. */ + .chat-split.inspector-open, + .wf-split.inspector-open { + grid-template-columns: minmax(0, 1fr); + } + + .chat-split.inspector-open .inspector, + .wf-split.inspector-open .inspector { + position: fixed; + inset: 8px; + z-index: 40; + border-radius: 14px; + border: 1px solid var(--line); + box-shadow: 0 18px 48px rgba(15, 23, 42, .2); + } + + .traj-split { + grid-template-columns: minmax(0, 1fr); + } + + .traj-tree { + display: none; + } + + .topbar, + .section-title, + .details-head, + .event-controls, + .top-actions { + align-items: stretch; + flex-direction: column; + } + + .metrics, + .agent-overview, + .detail-metrics, + .composer { + grid-template-columns: 1fr; + } + + .traj-head-row { + display: none; + } + + .traj-summary { + grid-template-columns: 48px minmax(0, 1fr) 18px; + row-gap: 6px; + } + + .traj-summary .token-cell { + display: none; + } + + .traj-summary .traj-content { + grid-column: 1 / -1; + } + + .traj-summary .chevron { + grid-row: 1; + grid-column: 3; + } + + .event-row { + grid-template-columns: 58px 1fr; + } + + .event-row span:nth-child(n+3) { + grid-column: 1 / -1; + } + + .wf-row { + grid-template-columns: 170px minmax(0, 1fr); + } + + .traj-body { + margin-left: 14px; + } +} diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 72d2d74533..64491d2eb5 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1370, + "AGENTS.md": 1375, "docs/AGENTS.md": 1100, "docs/architecture.md": 1790, "docs/cordis-primer.md": 600,