feat(desktop): fuse step card with tool row at narrative position + glyph fallback

This commit is contained in:
ZiyaZhang
2026-07-19 23:31:45 -07:00
parent 3b49a948e0
commit 211ffb4b7c
7 changed files with 776 additions and 3 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

View File

@@ -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 "<first> +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) })

View File

@@ -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 `<first-tool> +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 `<first> +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. `<details>` inside `<details>` 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 `<details>`.
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': {

View File

@@ -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;
}

View File

@@ -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 "<first> +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 `<first> +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 "<first> +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')
})