feat(desktop): fuse step card with tool row at narrative position + glyph fallback
This commit is contained in:
BIN
examples/desktop/docs/qa-step-card-merge/01-fused-collapsed.png
Normal file
BIN
examples/desktop/docs/qa-step-card-merge/01-fused-collapsed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 267 KiB |
BIN
examples/desktop/docs/qa-step-card-merge/02-fused-expanded.png
Normal file
BIN
examples/desktop/docs/qa-step-card-merge/02-fused-expanded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 363 KiB |
BIN
examples/desktop/docs/qa-step-card-merge/03-fused-edit-rerun.png
Normal file
BIN
examples/desktop/docs/qa-step-card-merge/03-fused-edit-rerun.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 311 KiB |
258
examples/desktop/scripts/qa-cdp-shoot-step-card-merge.mjs
Normal file
258
examples/desktop/scripts/qa-cdp-shoot-step-card-merge.mjs
Normal 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) })
|
||||
@@ -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': {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
253
examples/desktop/test/renderer-step-tool-fusion.test.js
Normal file
253
examples/desktop/test/renderer-step-tool-fusion.test.js
Normal 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')
|
||||
})
|
||||
Reference in New Issue
Block a user