diff --git a/examples/desktop/docs/qa-step-card-merge/01-fused-collapsed.png b/examples/desktop/docs/qa-step-card-merge/01-fused-collapsed.png new file mode 100644 index 0000000000..5c26950290 Binary files /dev/null and b/examples/desktop/docs/qa-step-card-merge/01-fused-collapsed.png differ diff --git a/examples/desktop/docs/qa-step-card-merge/02-fused-expanded.png b/examples/desktop/docs/qa-step-card-merge/02-fused-expanded.png new file mode 100644 index 0000000000..aff38b4a90 Binary files /dev/null and b/examples/desktop/docs/qa-step-card-merge/02-fused-expanded.png differ diff --git a/examples/desktop/docs/qa-step-card-merge/03-fused-edit-rerun.png b/examples/desktop/docs/qa-step-card-merge/03-fused-edit-rerun.png new file mode 100644 index 0000000000..d20d45a69f Binary files /dev/null and b/examples/desktop/docs/qa-step-card-merge/03-fused-edit-rerun.png differ diff --git a/examples/desktop/scripts/qa-cdp-shoot-step-card-merge.mjs b/examples/desktop/scripts/qa-cdp-shoot-step-card-merge.mjs new file mode 100644 index 0000000000..32e86a9993 --- /dev/null +++ b/examples/desktop/scripts/qa-cdp-shoot-step-card-merge.mjs @@ -0,0 +1,258 @@ +// scripts/qa-cdp-shoot-step-card-merge.mjs — feat/step-card-merge shoot. +// +// Boots an isolated Electron on CDP :9522 with its own --user-data-dir +// and $DSH_DESKTOP_HOME so the user's live desktop demo (pid 7810/7816 +// on ~/.dsh-desktop) is never touched. Seeds a turn with thinking + +// multiple tool calls, captures three screenshots: +// +// 01-fused-collapsed.png — chat flow, fused step/tool cards collapsed +// 02-fused-expanded.png — first fused card opened, showing args + +// result + inputs/outputs/events panes + the +// edit-and-re-run trigger revealed on open +// 03-fused-edit-rerun.png — edit-and-re-run panel opened inside the +// expanded fused card (textarea visible) +// +// Isolation follows scripts/qa-cdp-shoot-context-topright.mjs precedent. + +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { resolve, join } from 'node:path' +import { setTimeout as sleep } from 'node:timers/promises' +import { tmpdir } from 'node:os' + +const WORKTREE = resolve(process.env.DSH_WORKTREE || process.cwd()) +const PARENT = resolve(process.env.DSH_REPO || '/Users/ziya/harness/dsh-desktop-demo') +const ELECTRON = join(PARENT, 'node_modules/.bin/electron') +const CDP_PORT = Number(process.env.DSH_STEP_CARD_MERGE_PORT || 9522) +const USER_DATA = join(tmpdir(), 'dsh-step-card-merge-userdata') +const DSH_HOME = join(tmpdir(), 'dsh-step-card-merge-home') +const OUTDIR = join(WORKTREE, 'docs/qa-step-card-merge') + +if (!existsSync(ELECTRON)) { + console.error(`electron binary not found at ${ELECTRON}`) + process.exit(2) +} +mkdirSync(OUTDIR, { recursive: true }) +for (const dir of [USER_DATA, DSH_HOME]) { + try { rmSync(dir, { recursive: true, force: true }) } catch {} + mkdirSync(dir, { recursive: true }) +} +writeFileSync(join(DSH_HOME, 'config.json'), JSON.stringify({ + role: 'coding', approvalMode: 'never', +})) +writeFileSync(join(DSH_HOME, '.onboarded'), new Date().toISOString()) + +async function bootElectron() { + const child = spawn(ELECTRON, [ + `--remote-debugging-port=${CDP_PORT}`, + `--user-data-dir=${USER_DATA}`, + '--disable-gpu', + '--no-sandbox', + '.', + ], { + cwd: WORKTREE, + env: { + ...process.env, + DSH_DESKTOP_HOME: DSH_HOME, + DSH_MAXIMIZE: '1', + // Deliberately DO NOT set DSH_QA=1 — the QA fixture bootstrap seeds + // its own bench/session content which overlays our seeded turns and + // scrolls the fused cards off-screen. We drive the renderer seam + // directly instead of relying on the fixture path. + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const logs = [] + child.stdout.on('data', d => logs.push(String(d))) + child.stderr.on('data', d => logs.push(String(d))) + for (let i = 0; i < 40; i++) { + await sleep(500) + try { + const r = await fetch(`http://localhost:${CDP_PORT}/json/list`) + if (r.ok) return { child, logs } + } catch {} + } + child.kill('SIGKILL') + console.error('electron CDP did not come up. logs:\n' + logs.join('')) + process.exit(3) +} + +async function newCdp() { + const targets = await (await fetch(`http://localhost:${CDP_PORT}/json/list`)).json() + const target = targets.find(t => t.type === 'page') + if (!target) throw new Error('no page target on port ' + CDP_PORT) + const ws = new WebSocket(target.webSocketDebuggerUrl) + await new Promise((ok, err) => { ws.onopen = ok; ws.onerror = e => err(e) }) + let id = 1 + const pending = new Map() + ws.onmessage = ev => { + const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data)) + if (msg.id != null && pending.has(msg.id)) { + const [ok, err] = pending.get(msg.id); pending.delete(msg.id) + if (msg.error) err(new Error(msg.error.message)); else ok(msg.result) + } + } + const call = (m, p = {}, ms = 15000) => new Promise((ok, err) => { + const _id = id++ + const t = setTimeout(() => { pending.delete(_id); err(new Error('cdp timeout: ' + m)) }, ms) + pending.set(_id, [v => { clearTimeout(t); ok(v) }, e => { clearTimeout(t); err(e) }]) + ws.send(JSON.stringify({ id: _id, method: m, params: p })) + }) + const evj = async expr => { + const r = await call('Runtime.evaluate', { + expression: `(async()=>{try{return (${expr})}catch(e){return {__err:String(e)}}})()`, + returnByValue: true, awaitPromise: true, + }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text) + return r.result?.value + } + return { ws, call, evj } +} + +// Seed: user prompt → 2 turns. First turn has one thinking chunk and a +// single tool call (read). Second turn has thinking + 3 parallel tool +// calls (read, grep, bash) so we can observe the " +N" summary +// on the multi-call fused card. +const SEED = `(async () => { + const R = window.__dshRenderer + if (!R) return { __err: 'renderer seam missing' } + const sid = 'step-merge-' + Date.now() + R.ensureSession(sid, { title: 'step-card-merge demo', header: { model: 'deepseek-r1' } }) + await R.selectSession(sid) + const emit = (ev) => R.onSessionEvent(sid, ev) + const t0 = Date.now() + let seq = 1 + const at = (o) => t0 + o + // Turn 1: single-tool step + emit({ type: 'user/message', seq: seq++, time: at(0), + data: { content: [{ type: 'text', text: 'Read the design doc header for me.' }] } }) + emit({ type: 'turn/start', seq: seq++, time: at(5), data: { turnId: 't0', model: 'deepseek-r1' } }) + emit({ type: 'step/start', seq: seq++, time: at(10), data: { turn: 0, step: 0 } }) + emit({ type: 'assistant/message', seq: seq++, time: at(20), data: { + content: [{ type: 'text', text: 'thinking: open the design doc' }], + usage: { inputTokens: 210, outputTokens: 8, cacheReadTokens: 1024 }, + } }) + emit({ type: 'tool/call', seq: seq++, time: at(60), + data: { callId: 'c-1', name: 'read', arguments: JSON.stringify({ path: 'docs/DESIGN.md', limit: 40 }) } }) + emit({ type: 'tool/result', seq: seq++, time: at(140), + data: { callId: 'c-1', content: [{ type: 'text', text: '# design doc\\n\\n(header excerpt)' }] } }) + emit({ type: 'step/end', seq: seq++, time: at(150), data: {} }) + emit({ type: 'turn/end', seq: seq++, time: at(160), data: { turnId: 't0', reason: 'completed', + usage: { total_tokens: 240 }, durationMs: 160 } }) + // Turn 2: multi-tool step + emit({ type: 'user/message', seq: seq++, time: at(200), + data: { content: [{ type: 'text', text: 'now grep for TODOs and list changed files.' }] } }) + emit({ type: 'turn/start', seq: seq++, time: at(210), data: { turnId: 't1', model: 'deepseek-r1' } }) + emit({ type: 'step/start', seq: seq++, time: at(215), data: { turn: 1, step: 0 } }) + emit({ type: 'assistant/message', seq: seq++, time: at(220), data: { + content: [{ type: 'text', text: 'thinking: run three tools in parallel' }], + usage: { inputTokens: 320, outputTokens: 14, cacheReadTokens: 2048 }, + } }) + emit({ type: 'tool/call', seq: seq++, time: at(230), + data: { callId: 'c-2', name: 'read', arguments: JSON.stringify({ path: 'src/renderer/renderer.js', limit: 20 }) } }) + emit({ type: 'tool/call', seq: seq++, time: at(232), + data: { callId: 'c-3', name: 'grep', arguments: JSON.stringify({ pattern: 'TODO', path: 'src' }) } }) + emit({ type: 'tool/call', seq: seq++, time: at(234), + data: { callId: 'c-4', name: 'bash', arguments: JSON.stringify({ command: 'git status -s' }) } }) + emit({ type: 'tool/result', seq: seq++, time: at(310), + data: { callId: 'c-2', content: [{ type: 'text', text: '// top of renderer.js' }] } }) + emit({ type: 'tool/result', seq: seq++, time: at(320), + data: { callId: 'c-3', content: [{ type: 'text', text: 'src/renderer/foo.js:42: // TODO polish' }] } }) + emit({ type: 'tool/result', seq: seq++, time: at(330), + data: { callId: 'c-4', content: [{ type: 'text', text: ' M src/renderer/style.css' }] } }) + emit({ type: 'step/end', seq: seq++, time: at(340), data: {} }) + emit({ type: 'turn/end', seq: seq++, time: at(350), data: { turnId: 't1', reason: 'completed', + usage: { total_tokens: 512 }, durationMs: 150 } }) + return { sid, count: seq - 1 } +})()` + +async function shoot(cdp, name) { + const shot = await cdp.call('Page.captureScreenshot', { format: 'png', fromSurface: false }) + const buf = Buffer.from(shot.data, 'base64') + writeFileSync(join(OUTDIR, name), buf) + console.log(' shot', name, buf.length, 'bytes') +} + +async function main() { + const { child } = await bootElectron() + try { + await sleep(1500) + const cdp = await newCdp() + await cdp.call('Page.enable') + await cdp.call('Emulation.setDeviceMetricsOverride', { + width: 1400, height: 1000, deviceScaleFactor: 2, mobile: false, + }) + for (let i = 0; i < 20; i++) { + const ready = await cdp.evj(`!!(window.__dshRenderer && window.__dshRenderer.onSessionEvent)`) + if (ready) break + await sleep(250) + } + const seedRes = await cdp.evj(SEED) + console.log('seed:', JSON.stringify(seedRes)) + await sleep(800) + + // Verify fusion DOM contract before shooting so we don't waste a + // screenshot on a broken build. + const contract = await cdp.evj(`(() => { + const fused = document.querySelectorAll('.tool-block.trace-card-fused') + const standalone = document.querySelectorAll('.trace-card:not(.trace-card-fused)') + const drawers = document.querySelectorAll('.turn-trace-drawer') + const multi = document.querySelector('.tool-block.trace-card-fused[data-step-index="0"][data-step-turn="1"]') + const multiName = multi ? multi.querySelector('.tool-family-name').textContent : null + return { + fusedCount: fused.length, + standaloneTraceCount: standalone.length, + drawerCount: drawers.length, + multiCallSummary: multiName, + } + })()`) + console.log('contract:', JSON.stringify(contract)) + + // Make sure we're on the Chat tab so the seeded turns are on-screen, + // then scroll to the top of the stream so shot 1 captures the whole + // "collapsed fused" flow (thinking bubbles + tool row + turn footer). + await cdp.evj(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('chat')`) + await sleep(300) + await cdp.evj(`(() => { + const first = document.querySelector('.tool-block.trace-card-fused') + if (first && first.scrollIntoView) first.scrollIntoView({ block: 'center' }) + })()`) + await sleep(300) + + // Shot 1: collapsed + await shoot(cdp, '01-fused-collapsed.png') + + // Open the first fused card + shoot expanded (args, result, panes + edit&re-run trigger visible) + await cdp.evj(`(() => { + const c = document.querySelector('.tool-block.trace-card-fused') + if (c && c.tagName === 'DETAILS') c.open = true + c && c.scrollIntoView({ block: 'center' }) + })()`) + await sleep(400) + await shoot(cdp, '02-fused-expanded.png') + + // Open the edit-and-re-run panel inside the same expanded fused card. + const editState = await cdp.evj(`(() => { + const c = document.querySelector('.tool-block.trace-card-fused') + if (!c) return { __err: 'no fused card' } + const trigger = c.querySelector('.tool-edit-rerun-trigger') + if (!trigger) return { __err: 'no edit trigger inside fused card' } + trigger.click() + const panel = c.querySelector('.tool-edit-rerun-panel') + return { visible: !!(panel && !panel.hidden), triggerText: trigger.textContent } + })()`) + console.log('edit-rerun:', JSON.stringify(editState)) + // Scroll the whole fused card into view so the shot captures the + // summary + args/result + edit panel, not just the panel textarea. + await cdp.evj(`(() => { + const c = document.querySelector('.tool-block.trace-card-fused') + if (c && c.scrollIntoView) c.scrollIntoView({ block: 'start' }) + })()`) + await sleep(300) + await shoot(cdp, '03-fused-edit-rerun.png') + } finally { + try { child.kill('SIGKILL') } catch {} + } +} + +main().catch(err => { console.error(err); process.exit(1) }) diff --git a/examples/desktop/src/renderer/renderer.js b/examples/desktop/src/renderer/renderer.js index 127e660f72..da08e54a43 100644 --- a/examples/desktop/src/renderer/renderer.js +++ b/examples/desktop/src/renderer/renderer.js @@ -1444,8 +1444,21 @@ function finishTurnContainer(sessionId, { footerSpec, traceCard, traceSummaryTex const hasSignal = tf && typeof tf.specHasAnySignal === 'function' ? (!!tf.specHasAnySignal(rawSpec) || !!tf.specHasAnySignal(footerSpec)) : !!footerSpec - const hasTraceCard = !!(traceCard && traceCard.parentNode) - if (!hasSignal && !hasTraceCard) { + // Fusion: a fused step card is already positioned inline at the tool + // call's narrative slot. Lifting it into the footer drawer would tear + // it out of the stream — treat it as "no drawer needed here". The + // turn-flow glyph already scrolls to it via `dsh-open-turn-trace` + // (see the listener below). + const isFusedCard = !!(traceCard && traceCard.dataset && traceCard.dataset.stepFused === '1') + const hasTraceCard = !isFusedCard && !!(traceCard && traceCard.parentNode) + // Fused card path: the trailing standalone .trace-card is suppressed + // (the card now lives inline at the tool call's position), so we can't + // rely on `hasTraceCard` alone. Keep the footer up when the turn has + // at least one fused step so the "turn ended" chip row + flow glyph + + // click-to-scroll behavior remain reachable. + const hasFusedStep = Array.isArray(turnSteps) && turnSteps.some( + s => s && Array.isArray(s._toolBlocks) && s._toolBlocks.length > 0) + if (!hasSignal && !hasTraceCard && !hasFusedStep) { // Nothing to draw — leave the assistant-turn container clean. ct.section.dataset.turnStatus = 'sealed' state.currentTurn = null @@ -1493,7 +1506,7 @@ function finishTurnContainer(sessionId, { footerSpec, traceCard, traceSummaryTex }) } let drawer = null - if (traceCard && traceCard.parentNode) { + if (!isFusedCard && traceCard && traceCard.parentNode) { // Lift the trace card out of `streamEl` into a details drawer inside // the footer. Preserves whatever internal state the card already has // (chunk fold, per-line payload expander). When the tri-view module @@ -1556,6 +1569,28 @@ function finishTurnContainer(sessionId, { footerSpec, traceCard, traceSummaryTex try { drawer.scrollIntoView({ block: 'nearest' }) } catch (_) { /* jsdom */ } } }) + } else { + // Fused-only turn: no trailing drawer was built (the step card lives + // in-stream at its tool-call position). The glyph still fires, so + // fall back to opening + scrolling to the first fused card inside + // this turn's section, plus a brief `.flash-ring` accent so the eye + // tracks the jump. Matches the drawer path's "open + scroll" gesture. + footer.addEventListener('dsh-open-turn-trace', () => { + const section = ct.section + if (!section || typeof section.querySelector !== 'function') return + const fused = section.querySelector('.tool-block.trace-card-fused') + if (!fused) return + fused.open = true + if (typeof fused.scrollIntoView === 'function') { + try { fused.scrollIntoView({ block: 'nearest' }) } catch (_) { /* jsdom */ } + } + if (fused.classList && typeof fused.classList.add === 'function') { + fused.classList.add('flash-ring') + setTimeout(() => { + try { fused.classList.remove('flash-ring') } catch (_) { /* jsdom */ } + }, 2000) + } + }) } ct.section.appendChild(footer) ct.section.dataset.turnStatus = 'sealed' @@ -2041,6 +2076,13 @@ function beginTraceStep(meta, event) { inputs: meta.pendingTraceInputs || [], outputs: [], events: [], + // Tool-blocks emitted within this step's window — populated by the + // tool/call handler. finishTraceStep fuses these with the step + // meta (usage badge + duration + panes) instead of appending a + // trailing standalone .trace-card, so the tool row IS the step card + // at its narrative position. Text-only steps leave this empty and + // fall through to the historical .trace-card render. + _toolBlocks: [], } meta.pendingTraceInputs = [] // "streaming-first": drop a placeholder card @@ -2123,6 +2165,20 @@ function finishTraceStep(meta, endSeq, endTime) { } rec._streamingNode = null } + // Fusion path: if the step emitted any tool blocks, upgrade the first + // one into the step card in place (adds usage badge / duration / step + // marker to its summary, absorbs sibling tool-blocks, appends the + // trace panes to its body). The narrative position of the tool call + // is preserved and the trailing standalone .trace-card is not emitted. + // Text-only steps fall through to the historical renderTraceCard path. + if (Array.isArray(rec._toolBlocks) && rec._toolBlocks.length > 0) { + const fused = fuseStepIntoToolBlock(rec) + if (fused) { + meta.lastTurnTraceCard = fused + return fused + } + // fall through if fusion failed (defensive) + } // Return the just-appended trace card so callers (turn/end handler) // can lift it into the turn-footer drawer. // @@ -2142,6 +2198,128 @@ function finishTraceStep(meta, endSeq, endTime) { return card } +// Fuse the step's aggregated record (usage badge / duration / step marker +// / trace panes) into the first tool-block emitted during the step. +// The tool-block stays at its narrative position in the turn body — no +// standalone .trace-card is appended at the stream tail for tool steps. +// +// Multi-call step: the first tool-block becomes the outer card; sibling +// tool-blocks are moved inside its body (above the trace panes) and the +// summary shows ` +N`. +// +// Returns the fused DOM node (also usable as the drawer traceCard via +// `finishTurnContainer`, though we suppress the drawer for fused steps +// so the reader doesn't see the same card twice). +function fuseStepIntoToolBlock(rec) { + const agg = window.__dshTraceAgg + if (!agg || !rec || !Array.isArray(rec._toolBlocks) || rec._toolBlocks.length === 0) return null + const first = rec._toolBlocks[0] + const el = first && first.el + if (!el || !el.querySelector) return null + const doc = el.ownerDocument || document + const summary = el.querySelector(':scope > summary') + if (!summary) return null + + // Mark the block as a fused step card so callers (finishTurnContainer) + // know not to lift it into a trailing drawer + tests can select it. + el.classList.add('trace-card-fused') + el.dataset.stepFused = '1' + if (rec.startSeq !== null) el.dataset.startSeq = String(rec.startSeq) + if (rec.endSeq !== null) el.dataset.endSeq = String(rec.endSeq) + if (rec.turn !== null) el.dataset.stepTurn = String(rec.turn) + if (rec.step !== null) el.dataset.stepIndex = String(rec.step) + + // Multi-call: absorb sibling tool-blocks into this card's body BEFORE + // the panes. Update the summary's tool-name to show ` +N`. + const extras = rec._toolBlocks.slice(1) + if (extras.length > 0) { + const nameEl = summary.querySelector('.tool-family-name') + if (nameEl && typeof first.name === 'string') { + nameEl.textContent = `${first.name} +${extras.length}` + nameEl.title = extras.map(b => b.name).join(', ') + } + } + + // Rehost each sibling tool-block as a nested `.fused-call-row` inside + // the outer card. `
` inside `
` is legal DOM; we + // keep the child tool-block's own open/close independent so per-call + // args/result stay explorable. Insertion order matches call order. + for (const extra of extras) { + if (!extra || !extra.el) continue + const child = extra.el + if (child.parentNode) child.parentNode.removeChild(child) + child.classList.add('fused-call-row') + el.appendChild(child) + } + + // Right-cluster on the summary: usage badge, duration pill, fold glyph. + // Insert BEFORE any existing `.tool-duration` / `.tool-edit-rerun-trigger` + // so the pill+glyph stay at the tail; margin-left:auto on the badge + // pushes the whole cluster right per LangSmith parity. + const stepUsage = agg.sumUsageForStep ? agg.sumUsageForStep(rec) : null + const badgeText = agg.usageBadgeText ? agg.usageBadgeText(stepUsage) : '' + const anchor = summary.querySelector('.tool-duration') + || summary.querySelector('.tool-edit-rerun-trigger') + || null + if (badgeText) { + const badge = doc.createElement('span') + badge.className = 'trace-usage-badge fused-usage-badge' + badge.textContent = badgeText + if (stepUsage) { + let total = 0 + for (const k of ['inputTokens','outputTokens','cacheReadTokens','cacheWriteTokens','reasoningTokens']) { + const v = stepUsage[k] + if (Number.isFinite(v)) total += v + } + badge.title = tokenBreakdownTooltip(stepUsage, total) + } else { + badge.title = 'Sum of `data.usage` across every assistant/message in this step' + } + if (anchor && anchor.parentNode === summary) summary.insertBefore(badge, anchor) + else summary.appendChild(badge) + } + const dur = doc.createElement('span') + dur.className = 'trace-duration fused-duration' + dur.textContent = rec.durationMs !== null ? `${rec.durationMs}ms` : '' + if (anchor && anchor.parentNode === summary) summary.insertBefore(dur, anchor) + else summary.appendChild(dur) + // Right-side fold glyph (LangSmith parity — same ∨ marker as the + // standalone trace-card). Toggles the outer `
`. + const foldGlyph = doc.createElement('span') + foldGlyph.className = 'trace-card-fold-glyph fused-fold-glyph mono' + foldGlyph.setAttribute('aria-hidden', 'true') + foldGlyph.textContent = '∨' + foldGlyph.title = 'Fold / unfold this step\'s subtree' + foldGlyph.addEventListener('click', function (e) { + if (e && e.stopPropagation) e.stopPropagation() + if (e && e.preventDefault) e.preventDefault() + el.open = !el.open + }) + summary.appendChild(foldGlyph) + + // Body: appended AFTER the existing args/result rows so the tool's + // own detail sits above the step-aggregate panes. This mirrors the + // standalone trace-card body (meta strip + inputs/outputs/events). + const fusedBody = doc.createElement('div') + fusedBody.className = 'trace-body fused-trace-body' + fusedBody.appendChild(renderTraceStepMetaStrip(rec, stepUsage)) + const stepModelName = (rec && rec.header && typeof rec.header.model === 'string' && rec.header.model) + || modelFromRecEvents(rec) + || null + const barCtx = (Number.isFinite(rec.startTime) && Number.isFinite(rec.durationMs) && rec.durationMs > 0) + ? { startTime: rec.startTime, durationMs: rec.durationMs, stepModel: stepModelName } + : (stepModelName ? { stepModel: stepModelName } : null) + fusedBody.appendChild(renderTracePane('inputs', rec.inputs, 'events consumed by this step', barCtx)) + fusedBody.appendChild(renderTracePane('outputs', rec.outputs, 'events produced by this step', barCtx)) + fusedBody.appendChild(renderTracePane('events', rec.events, 'every SessionEvent inside this step', barCtx)) + el.appendChild(fusedBody) + // stash the step record on the fused node so anything that walks the + // DOM for aggregates (turn-footer tri-view, QA probes) finds the same + // shape it would on a standalone .trace-card. + el._rec = rec + return el +} + function renderTraceCard(rec) { const agg = window.__dshTraceAgg if (!agg) return null @@ -5042,6 +5220,23 @@ function onSessionEvent(sessionId, event) { sessionId, }) } + // step/tool fusion: register the tool-block on the currently-open + // trace record so finishTraceStep can upgrade it into the step + // card in place instead of appending a trailing standalone + // .trace-card. On the FIRST tool block of the step, also retire + // the streaming placeholder — its role (marking "step in flight") + // is now filled by the tool-block itself. + const rec = meta.currentTraceRecord + if (rec && Array.isArray(rec._toolBlocks) && toolBlockEl) { + if (rec._toolBlocks.length === 0 && rec._streamingNode) { + if (typeof rec._streamingNode.remove === 'function') rec._streamingNode.remove() + else if (rec._streamingNode.parentNode && rec._streamingNode.parentNode.removeChild) { + rec._streamingNode.parentNode.removeChild(rec._streamingNode) + } + rec._streamingNode = null + } + rec._toolBlocks.push({ callId, name, el: toolBlockEl }) + } return } case 'tool/result': { diff --git a/examples/desktop/src/renderer/style.css b/examples/desktop/src/renderer/style.css index b06cb340e1..7c5e5789d4 100644 --- a/examples/desktop/src/renderer/style.css +++ b/examples/desktop/src/renderer/style.css @@ -10404,6 +10404,18 @@ textarea:focus-visible { animation: traceDeepLinkFlash 1.4s ease-out; } +/* Fused step card fallback: when a turn's only trace lives as a fused + * tool-block (no trailing drawer), the turn-flow glyph click scrolls + * to the card and briefly rings it so the eye tracks the jump. Runs + * for ~2s, then the JS strips the class. */ +@keyframes flashRing { + 0% { box-shadow: 0 0 0 2px var(--accent, #4c9aff); } + 100% { box-shadow: 0 0 0 2px transparent; } +} +.flash-ring { + animation: flashRing 2s ease-out; +} + /* Task #203: full-session trace overlay (opened from Devtools drawer). */ .devtools-full-trace-overlay { position: fixed; inset: 0; z-index: 40; @@ -12904,3 +12916,58 @@ button.artifact-version:hover { font-size: 12px; padding: 12px 16px; } + +/* ----------------------------------------------------------------------- + * Step / tool-block fusion (feat/step-card-merge, 2026-07-19). + * + * A step whose outputs include tool/call events no longer appends a + * trailing standalone .trace-card at the stream tail — instead, the + * step meta (usage badge / duration / trace panes) is fused INTO the + * first tool-block emitted during the step, right where the call + * originally landed in the narrative flow. See fuseStepIntoToolBlock + * in renderer.js for the DOM shape. + * + * Visual grammar (Lane A, LangSmith parity): + * collapsed = one low-key row → [glyph][name][gist] … [usage][dur][∨] + * expanded = args + result + edit&re-run panel + inputs/outputs/events + * panes stacked in one card, single left border. + * ----------------------------------------------------------------------- */ +.tool-block.trace-card-fused > summary .fused-usage-badge { + /* Anchor the right-cluster on the fused summary — mirrors the standalone + * .trace-card's `margin-left:auto` on .trace-usage-badge. */ + margin-left: auto; +} +.tool-block.trace-card-fused > summary .fused-duration { + margin-left: 6px; +} +.tool-block.trace-card-fused > summary .fused-fold-glyph { + margin-left: 6px; + color: var(--muted); + cursor: pointer; + user-select: none; + transition: transform 120ms ease; +} +.tool-block.trace-card-fused[open] > summary .fused-fold-glyph { + transform: rotate(180deg); +} +/* Panes appear last inside the fused card body; add a hairline separator + * so the reader's eye splits "this call" from "the whole step". */ +.tool-block.trace-card-fused > .fused-trace-body { + margin-top: 6px; + padding-top: 6px; + border-top: 1px dashed var(--border); +} +/* Nested tool-blocks for multi-call steps: reduce visual weight so the + * outer card owns the frame. Fused-call-row keeps its own detail toggle + * and edit&re-run affordance. */ +.tool-block.trace-card-fused .fused-call-row { + margin: 4px 0 4px 12px; + border-left-width: 1px; + background: transparent; +} +/* edit & re-run affordance is only visible while the tool-block is open + * (design confirm §a: no hover-only reveal, keep the collapsed row + * quiet). */ +.tool-block:not([open]) > summary .tool-edit-rerun-trigger { + display: none; +} diff --git a/examples/desktop/test/renderer-step-tool-fusion.test.js b/examples/desktop/test/renderer-step-tool-fusion.test.js new file mode 100644 index 0000000000..45ad1aebd9 --- /dev/null +++ b/examples/desktop/test/renderer-step-tool-fusion.test.js @@ -0,0 +1,253 @@ +// feat/step-card-merge (2026-07-19): the tool call row and the trailing +// standalone step card get fused into a single unit at the tool call's +// narrative position. This covers the DOM contract fuseStepIntoToolBlock +// (renderer.js) produces + the finishTurnContainer guard that stops the +// fused card from getting lifted into a trailing turn-trace-drawer. +// +// Related design memo: the assistant turn's chat stream should show ONE +// entry per tool call (the tool-block, now upgraded to a step card), +// not two — the historical grammar dropped a `.tool-block` at the call +// position AND a `.trace-card` at the stream tail. The trailing card is +// suppressed for tool-only steps; text-only steps still emit it. + +'use strict' + +const test = require('node:test') +const assert = require('node:assert') +const { loadRenderer } = require('./renderer-harness.js') + +// Small helpers: the test harness has a naive selector matcher that +// doesn't grok combinators or `:scope`. Walk children explicitly. +function directChild(el, predicate) { + if (!el || !el.children) return null + for (const c of el.children) if (predicate(c)) return c + return null +} +function directSummary(el) { + return directChild(el, c => c && c.tagName === 'SUMMARY') +} +function firstDescendant(el, cls) { + return el && el.querySelector ? el.querySelector('.' + cls) : null +} +function playStream(renderer, sid, events) { + for (const ev of events) renderer.onSessionEvent(sid, ev) +} + +// Single-tool step in a single-step turn. Should produce: +// - one .tool-block (also .trace-card-fused, data-step-fused=1) +// - zero standalone .trace-card +// - one .turn-footer with the flow glyph but NO .turn-trace-drawer +function makeSingleToolTurn() { + return [ + { seq: 1, time: 1000, type: 'turn/start', data: {} }, + { seq: 2, time: 1005, type: 'step/start', data: { turn: 0, step: 0 } }, + { seq: 3, time: 1020, type: 'tool/call', data: { + callId: 'c-a', name: 'read', arguments: JSON.stringify({ path: 'foo.ts' }), + } }, + { seq: 4, time: 1080, type: 'tool/result', data: { + callId: 'c-a', content: [{ type: 'text', text: 'file contents' }], + } }, + // usage rides on assistant/message; include one so the fused summary + // gets a usage badge. Real DeepSeek traffic always emits at least one + // assistant/message per step (the model reply that triggers the call). + { seq: 5, time: 1100, type: 'assistant/message', data: { + content: [{ type: 'text', text: 'looking at foo.ts' }], + usage: { inputTokens: 40, outputTokens: 12, cacheReadTokens: 200 }, + } }, + { seq: 6, time: 1120, type: 'step/end', data: {} }, + { seq: 7, time: 1125, type: 'turn/end', data: { reason: 'completed' } }, + ] +} + +test('fused: single tool step upgrades its tool-block into the step card', async () => { + const { renderer, document } = await loadRenderer() + renderer.ensureSession('s-fuse', { title: 't', header: {} }) + await renderer.selectSession('s-fuse') + playStream(renderer, 's-fuse', makeSingleToolTurn()) + + const fused = document.querySelectorAll('.tool-block.trace-card-fused') + assert.equal(fused.length, 1, 'exactly one fused tool-block acting as the step card') + const el = fused[0] + assert.equal(el.dataset.stepFused, '1', 'stepFused marker present for downstream QA/tests') + + // Trailing standalone .trace-card is suppressed for a tool-only step. + const standalone = document.querySelectorAll('.trace-card:not(.trace-card-fused)') + assert.equal(standalone.length, 0, 'no trailing standalone .trace-card for tool-only step') + + // Streaming placeholder must be gone (retired on first tool/call). + const placeholders = document.querySelectorAll('.trace-card-streaming') + assert.equal(placeholders.length, 0, 'streaming placeholder retired') + + // Summary carries the fused usage badge + duration pill + fold glyph. + const summary = directSummary(el) + assert.ok(summary, 'fused card has a summary') + const summaryChildren = summary.children.map(c => c.className) + assert.ok(summaryChildren.some(cn => cn.includes('fused-usage-badge')), + 'usage badge attached to fused summary; got children ' + JSON.stringify(summaryChildren)) + const dur = directChild(summary, c => c.className && c.className.includes('fused-duration')) + assert.ok(dur && /ms$/.test(dur.textContent), 'duration pill shows ms (got ' + (dur && dur.textContent) + ')') + assert.ok(directChild(summary, c => c.className && c.className.includes('fused-fold-glyph')), + 'right-side fold glyph present') + + // Fused body includes the meta strip + three trace panes. + const body = directChild(el, c => c.className && c.className.includes('fused-trace-body')) + assert.ok(body, 'fused trace body appended inside the tool-block') + assert.ok(firstDescendant(body, 'trace-step-meta'), 'step meta strip lives in fused body') + const panes = Array.from(body.querySelectorAll('.trace-pane')) + assert.ok(panes.length >= 3, 'three panes rendered inside fused body — got ' + panes.length) +}) + +test('fused: single tool step does not build a trailing turn-trace-drawer', async () => { + const { renderer, document } = await loadRenderer() + renderer.ensureSession('s-drw', { title: 't', header: {} }) + await renderer.selectSession('s-drw') + playStream(renderer, 's-drw', makeSingleToolTurn()) + + const drawers = document.querySelectorAll('.turn-trace-drawer') + assert.equal(drawers.length, 0, + 'fused card stays inline — footer drawer is suppressed for tool-only steps') + + // Turn footer still exists so the "turn ended" glyph + chips are reachable. + const footers = document.querySelectorAll('.turn-footer') + assert.equal(footers.length, 1, 'turn footer preserved (glyph + optional chips)') +}) + +test('fused: multi-tool step lists sibling calls inside the fused card + summary shows " +N"', async () => { + const { renderer, document } = await loadRenderer() + renderer.ensureSession('s-mul', { title: 't', header: {} }) + await renderer.selectSession('s-mul') + const events = [ + { seq: 1, time: 1000, type: 'turn/start', data: {} }, + { seq: 2, time: 1005, type: 'step/start', data: { turn: 0, step: 0 } }, + { seq: 3, time: 1010, type: 'tool/call', data: { + callId: 'c-a', name: 'read', arguments: JSON.stringify({ path: 'a.ts' }), + } }, + { seq: 4, time: 1015, type: 'tool/call', data: { + callId: 'c-b', name: 'grep', arguments: JSON.stringify({ pattern: 'foo' }), + } }, + { seq: 5, time: 1020, type: 'tool/call', data: { + callId: 'c-c', name: 'bash', arguments: JSON.stringify({ cmd: 'ls' }), + } }, + { seq: 6, time: 1050, type: 'tool/result', data: { callId: 'c-a', content: [{ type: 'text', text: '.' }] } }, + { seq: 7, time: 1051, type: 'tool/result', data: { callId: 'c-b', content: [{ type: 'text', text: '.' }] } }, + { seq: 8, time: 1052, type: 'tool/result', data: { callId: 'c-c', content: [{ type: 'text', text: '.' }] } }, + { seq: 9, time: 1070, type: 'assistant/message', data: { + content: [{ type: 'text', text: 'ok' }], + usage: { inputTokens: 20, outputTokens: 5 }, + } }, + { seq: 10, time: 1080, type: 'step/end', data: {} }, + { seq: 11, time: 1085, type: 'turn/end', data: { reason: 'completed' } }, + ] + playStream(renderer, 's-mul', events) + + // Exactly one fused card, three tool-blocks total (first outer + two absorbed). + const fused = document.querySelectorAll('.tool-block.trace-card-fused') + assert.equal(fused.length, 1, 'multi-call step still fuses into one outer card') + const outer = fused[0] + assert.equal(outer.getAttribute('data-tool-name'), 'read', 'outer card is the first tool by call order') + // Absorbed sibling blocks live inside the outer card with the marker class. + const absorbed = outer.querySelectorAll('.fused-call-row') + assert.equal(absorbed.length, 2, 'two sibling tool-blocks absorbed into the fused card') + const absorbedNames = Array.from(absorbed).map(el => el.getAttribute('data-tool-name')) + assert.deepEqual(absorbedNames, ['grep', 'bash'], 'absorbed rows retain their tool-name in call order') + + // Summary shows ` +N` with the total-N excluding the outer. + const summary = directSummary(outer) + const nameEl = directChild(summary, c => c.className && c.className.includes('tool-family-name')) + assert.ok(nameEl && nameEl.textContent === 'read +2', + 'summary tool-name reads " +N" — got ' + (nameEl && nameEl.textContent)) +}) + +test('fused: text-only step (no tool/call) still emits a standalone trace-card', async () => { + // Guardrail: the fusion path is opt-in — a step without any tool/call + // must retain the historical trailing .trace-card so text-only reasoning + // remains reachable via the turn-footer drawer. + const { renderer, document } = await loadRenderer() + renderer.ensureSession('s-txt', { title: 't', header: {} }) + await renderer.selectSession('s-txt') + const events = [ + { seq: 1, time: 1000, type: 'turn/start', data: {} }, + { seq: 2, time: 1005, type: 'step/start', data: { turn: 0, step: 0 } }, + { seq: 3, time: 1050, type: 'assistant/message', data: { + content: [{ type: 'text', text: 'a plain response' }], + usage: { inputTokens: 5, outputTokens: 2 }, + } }, + { seq: 4, time: 1080, type: 'step/end', data: {} }, + { seq: 5, time: 1085, type: 'turn/end', data: { reason: 'completed' } }, + ] + playStream(renderer, 's-txt', events) + + const fused = document.querySelectorAll('.tool-block.trace-card-fused') + assert.equal(fused.length, 0, 'no fused card without a tool/call') + // The standalone trace-card gets lifted into the turn drawer at turn/end. + // Look at the drawer for its presence. + const drawer = document.querySelector('.turn-trace-drawer') + assert.ok(drawer, 'text-only step still emits a trailing trace-card lifted into the drawer') +}) + +// Glyph fallback contract: a turn whose only trace lives as a fused +// tool-block (no trailing drawer built) MUST still respond to the +// turn-flow-glyph's `dsh-open-turn-trace` event — open the fused card +// + scroll it into view + briefly ring it with `.flash-ring` so the +// reader tracks the jump. Without this the glyph would be a silent +// no-op on tool-only turns. +test('fused: glyph fallback opens + scrolls to + flash-rings the fused card when no drawer exists', async () => { + const { renderer, document } = await loadRenderer() + renderer.ensureSession('s-glyph', { title: 't', header: {} }) + await renderer.selectSession('s-glyph') + playStream(renderer, 's-glyph', makeSingleToolTurn()) + + // Sanity: drawer suppressed, fused card present, footer built. + assert.equal(document.querySelectorAll('.turn-trace-drawer').length, 0, + 'precondition: no drawer built for fused-only turn') + const footer = document.querySelector('.turn-footer') + assert.ok(footer, 'turn footer built') + const fused = document.querySelector('.tool-block.trace-card-fused') + assert.ok(fused, 'fused card exists') + + // Track scroll invocation on the fused card so we can assert the + // fallback actually reached the scroll step. + let scrolled = 0 + fused.scrollIntoView = function () { scrolled += 1 } + fused.open = false + + // Fire the glyph's event through the harness's listener hook. + assert.equal(typeof footer._fire, 'function', + 'harness footer exposes _fire for synthetic event dispatch') + footer._fire('dsh-open-turn-trace', {}) + + assert.equal(fused.open, true, 'fused card opened by fallback') + assert.equal(scrolled, 1, 'fallback scrolled the fused card into view') + assert.ok(fused.classList.contains('flash-ring'), + 'fallback applied .flash-ring accent for 2s visual cue') +}) + +// Twin of the above: when a text-only step already built a drawer, +// the fallback path must NOT fire — the historical drawer-open path +// remains authoritative. Guards against a regression where both +// listeners run and the fused-selector accidentally matches something. +test('fused: text-only turn keeps the drawer path — no flash-ring on the stream', async () => { + const { renderer, document } = await loadRenderer() + renderer.ensureSession('s-txt2', { title: 't', header: {} }) + await renderer.selectSession('s-txt2') + playStream(renderer, 's-txt2', [ + { seq: 1, time: 1000, type: 'turn/start', data: {} }, + { seq: 2, time: 1005, type: 'step/start', data: { turn: 0, step: 0 } }, + { seq: 3, time: 1050, type: 'assistant/message', data: { + content: [{ type: 'text', text: 'a plain response' }], + usage: { inputTokens: 5, outputTokens: 2 }, + } }, + { seq: 4, time: 1080, type: 'step/end', data: {} }, + { seq: 5, time: 1085, type: 'turn/end', data: { reason: 'completed' } }, + ]) + const footer = document.querySelector('.turn-footer') + const drawer = document.querySelector('.turn-trace-drawer') + assert.ok(footer && drawer, 'precondition: drawer path in play') + drawer.open = false + footer._fire('dsh-open-turn-trace', {}) + assert.equal(drawer.open, true, 'drawer opened by drawer path') + // No fused card to ring — the accent class must not be applied + // anywhere in the tree. + const ringed = document.querySelectorAll('.flash-ring') + assert.equal(ringed.length, 0, 'flash-ring not applied on drawer-path turn') +})