feat(desktop): workflow.event live consumption + inspector Feedback tab + upstream ledger L-5

This commit is contained in:
ZiyaZhang
2026-07-20 02:57:59 -07:00
parent fa640ec9fe
commit 60dfa8d504
21 changed files with 1875 additions and 31 deletions

View File

@@ -364,16 +364,33 @@ becomes a visualiser of the runtime's actual state.
that exact seq, edit the user turn, and let a different reply
stream in. The child is anchored back in the session tree with a
`⑂ forks from here (N)` card so the branching is legible.
- **Workflow cards — live.** When a composition mounts the workflow
engine, the runtime bridges its `workflow/*` Cordis lifecycle events
onto the wire as `workflow.event` notifications. The shell folds those
incremental frames (start / phase / log / agent-start / agent-end /
end, keyed by `runId`) into a live workflow card anchored to the
`workflow` tool call that spawned the run — each workflow-agent shows
as a step, `running` then `done`/`failed` as it settles, under a
`live · workflow.event` chip. On profiles/runtimes that never mount
`ctx.workflows` the notification simply never fires, so nothing
renders. (The Debug popover still mints the five fixture shapes
— seq / fan-out / dag / iter / branch — carrying a `mock` chip.)
- **`{ }` Inspector — zero loss.** Every inspectable element in the
chat stream — user + assistant bubbles, reasoning blocks, tool calls
and results, compaction cards, context 📎 injections, subagent
cards — carries an unobtrusive `{ }` badge that opens one unified
right-side Inspector with three tabs: **Pretty** (a readable,
right-side Inspector with four tabs: **Pretty** (a readable,
type-specific view of that element), **Raw** (the verbatim
`session.event` from the session log, with a seq/type/time header +
copy), and **JSON** (the same event through the recursive collapsible
Fields tree). The pretty renderer is a projection; the Raw/JSON tabs
are the source of truth, always one click away.
copy), **JSON** (the same event through the recursive collapsible
Fields tree), and **Feedback** (a per-event RL annotation: thumbs
up/down + a note + an optional rubric dimension drawn from the same
locked dimension set the Evals rubrics use). Annotated events show a
small ✓ on their `{ }` badge; the record —
`{sessionId, seq, verdict, note, rubricDim?, at}` — persists to
`~/.dsh-desktop/feedback-annotations.json` as the RL-annotation seed.
The pretty renderer is a projection; the Raw/JSON tabs are the source
of truth, always one click away.
- **Compaction visualisation.** When the runtime emits a
`session/compact` outcome, the compacted range renders as a
collapsed banner inline in chat with the summary + token delta

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

View File

@@ -274,3 +274,91 @@ small wire addition that lets the workaround retire.
**Reference.** PR #374 review comment by @ZiyaZhang
https://github.com/deepseek-harness/deepseek-harness/pull/374#issuecomment-5016306211
---
## L-5 Runtime should expose a mid-turn steer / context-inject RPC on the wire
**Symptom.** The composer cannot send a "steer" while a turn is running. A
researcher watching the agent go down the wrong path mid-turn has no way to
nudge it ("actually, check the other file first") without waiting for the
turn to end. The receiving half of this feature already renders — steering
and context-injection events paint as 📎 cards in the stream and the context
rail — but there is no *send* path: the shell can start a turn
(`session/prompt`) and cancel a turn (`session/cancel`), and nothing in
between.
**Root cause.** The kernel already has the seam; the wire never exposes it.
- `packages/core/agent-loop/src/agent.ts:219` — `Agent.steer(content,
options)` exists: when a turn is running it accepts the message into the
inbox as a *steering* message (`steering: true`) rather than starting a new
turn.
- `packages/core/agent-loop/src/agent.ts:227` — `Agent.inject(content,
options)` exists: it appends a `context/message` event, turn-enclosed when
a turn is open (`agent.ts:235`) or wrapped in a one-shot turn when not
(`agent.ts:247`). This is the model-visible⟺logged injection primitive.
- `packages/ui/jsonrpc/src/protocol.ts:30-43` — the client→server `METHOD`
map exposes `session/prompt` (`:34`) and `session/cancel` (`:35`) but no
`session/steer` or `context/inject`. The one mid-turn channel that *does*
cross the wire goes the other direction: `HOST_METHOD.sessionInterrupt`
(`protocol.ts:57`) is server→client. There is no client→server verb that
reaches `agent.steer` / `agent.inject`.
So the runtime can already *do* the thing (the kernel method is shipped and
tested — see `packages/core/agent-loop/tests/agent.spec.ts:196`, a balanced
`turn/start · context/message · turn/end`); the wire just never gave the
shell a way to *ask* for it.
**Local workaround.** Message queue (merged, lane-msg-queue): a mid-turn
Enter doesn't drop the text and doesn't error on the wire's one-in-flight-
prompt rule — it parks the text in a per-session FIFO
(`src/renderer/msg-queue-model.js`) and auto-sends the head as a fresh
`session/prompt` when the current turn ends
(`src/renderer/renderer.js:107` + `drainMsgQueueOnce`). So a steer degrades
to a *queued next-turn message*: the intent survives, but it lands after the
turn instead of redirecting it mid-flight, and it arrives as an ordinary
user prompt rather than a `steering: true` inbox message.
Spot the workaround in code review by `src/renderer/msg-queue-model.js` (the
whole file) and the `drainMsgQueueOnce` / enqueue-on-inflight path in
`renderer.js`. The receiving-side render that's already waiting for the real
feature is `src/renderer/context-rail.js:38` (`context/message`) and
`:46` (`steering/message`) — the 📎 cards light up today for injects the
runtime emits on its own; they'd light up for shell-originated steers the
moment the send path exists.
**Upstream fix (needed).** Add a client→server RPC that rides the existing
kernel seam, e.g.:
```
Method: session/steer (or context/inject)
Params: {
sessionId: string,
content: ContentBlock[], // the steer/injection payload
mode?: 'steer' | 'inject', // steer → agent.steer (running turn only);
// inject → agent.inject (context/message)
source?: { kind, ... } // provenance, same shape context/message carries
}
Result: {
accepted: boolean, // false if the session id was unknown / disposed
applied: 'steered' | 'injected' | 'queued'
}
```
The handler in `HarnessSdkServer` routes to `agent.steer(content, options)`
when a turn is running and `agent.inject(content, options)` otherwise — both
already exist (`agent.ts:219,227`). The load-bearing invariant to preserve is
**model-visible ⟺ logged**: whatever the model sees mid-turn must appear on
the session event stream as a `context/message` / `steering/message` (which
`agent.inject` already guarantees via `session.append`), so replay and the
📎 cards stay faithful — no side-channel that reaches the model without a
log record, and no log record the model never saw.
**Precedent in ledger.** Same shape as L-1 / L-2 / L-4: the shell paints
over a gap the runtime should own (here, the receiving half already renders;
only the send verb is missing), and the fix is a small wire addition that
lets the queue workaround retire — once `session/steer` lands, a mid-turn
Enter can route to it instead of the FIFO, and queued-message degradation
becomes the fallback for older runtimes only.

View File

@@ -0,0 +1,315 @@
// QA verification script for lane-wf-feedback. Boots an isolated Electron on a
// private CDP port (≥9330 to dodge the live demo instance + sibling lanes),
// then captures two proof shots:
//
// 01-workflow-live A live workflow card, minted by feeding the REAL wire
// shape (workflow.event frames from runtime commit
// dd29d8631) through the __dshOnWorkflowEvent seam — the
// same onWorkflowEvent path the live notification uses.
// The card wears the "live · workflow.event" chip.
// 02-feedback-tab The inspector's 4th tab (Feedback) with a filled
// annotation (verdict + note + rubric dim) and the ✓
// marker painted on the source event's { } badge.
//
// Isolation follows the 2026-07-18 postmortem (qa-cdp-shoot-p0-inspector.mjs):
// 1. --user-data-dir=<tmp> isolates Chromium userdata.
// 2. DSH_DESKTOP_HOME=<tmp> isolates the main-process config root so we
// never write into ~/.dsh-desktop (incl. the feedback-annotations.json
// this lane writes).
// 3. Own CDP port (≥9330) so we never attach to the user's live 9223.
// Electron binary comes from the PARENT repo (dsh-desktop-demo) per the brief.
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, writeFileSync, rmSync, statSync, readFileSync } 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_WF_FEEDBACK_PORT || 9331)
const OUTDIR = join(WORKTREE, 'docs/qa-wf-feedback')
if (!existsSync(ELECTRON)) {
console.error(`electron binary not found at ${ELECTRON}`)
process.exit(2)
}
mkdirSync(OUTDIR, { recursive: true })
function seedHome(dshHome) {
const seedOverlay = [
'# QA wf-feedback-shoot seed overlay (tmp, per-run).',
'plugins:',
' - "@cordisjs/plugin-include":',
` path: ${join(WORKTREE, 'config/daemon-echo.yml')}`,
'',
].join('\n')
writeFileSync(join(dshHome, 'user-overlay.cordis.yml'), seedOverlay)
writeFileSync(join(dshHome, 'config.json'), JSON.stringify({ role: 'coding', approvalMode: 'never' }, null, 2))
writeFileSync(join(dshHome, '.onboarded'), new Date().toISOString())
}
async function bootElectron(dshHome, userData, port) {
const child = spawn(ELECTRON, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${userData}`,
'--disable-gpu',
'--no-sandbox',
'.',
], {
cwd: WORKTREE,
env: {
...process.env,
DSH_DESKTOP_HOME: dshHome,
DSH_MAXIMIZE: '1',
DSH_QA: '1', // exposes window.__dshOnSessionEvent + __dshOnWorkflowEvent
},
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:${port}/json/list`)
if (r.ok) return { child, logs }
} catch {}
}
child.kill('SIGKILL')
console.error('electron CDP did not come up in 20s. logs:\n' + logs.join(''))
process.exit(3)
}
async function newCdp(port) {
const targets = await (await fetch(`http://localhost:${port}/json/list`)).json()
const target = targets.find((t) => t.type === 'page')
if (!target) throw new Error('no page target on port ' + 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 data = typeof ev.data === 'string' ? ev.data : String(ev.data)
let msg; try { msg = JSON.parse(data) } catch { return }
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 = 20000) => 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 }
}
const SESSION_ID = 'qa-wf-feedback-sess'
// A minimal turn with a workflow tool/call so the live card has an anchor,
// plus the assistant bubble we'll annotate.
const TURN_EVENTS = [
{ type: 'turn/start', seq: 1, data: { turnId: 't0', trigger: 'user' } },
{ type: 'user/message', seq: 2, data: { text: 'Audit the three fs packages.' } },
{ type: 'tool/call', seq: 3, data: { callId: 'call-wf', name: 'workflow', arguments: { name: 'audit-flow', kind: 'seq' } } },
{ type: 'assistant/message', seq: 4, data: { content: [{ type: 'text', text: 'Kicking off the audit workflow across the three packages.' }], usage: { inputTokens: 210, outputTokens: 24, totalTokens: 234 } } },
{ type: 'turn/end', seq: 5, data: { turnId: 't0' } },
]
// The REAL workflow.event wire shape (runtime commit dd29d8631): incremental
// frames keyed by runId, meta{name,description}, per-kind payload. Named the
// same as the tool/call args so onWorkflowEvent anchors the card to the block.
const RUN_ID = 'run-audit-1'
const WF_META = { name: 'audit-flow', description: 'audit three fs packages' }
const WF_FRAMES = [
{ kind: 'workflow/start', runId: RUN_ID, meta: WF_META },
{ kind: 'workflow/phase', runId: RUN_ID, meta: WF_META, payload: 'Scan' },
{ kind: 'workflow/log', runId: RUN_ID, meta: WF_META, payload: 'starting with 3 packages' },
{ kind: 'workflow/agent-start', runId: RUN_ID, meta: WF_META, payload: { seq: 1, label: 'audit dsh-fs', phase: 'Scan', childId: 'c1' } },
{ kind: 'workflow/agent-end', runId: RUN_ID, meta: WF_META, payload: { seq: 1, label: 'audit dsh-fs', phase: 'Scan', childId: 'c1', outcome: 'completed' } },
{ kind: 'workflow/agent-start', runId: RUN_ID, meta: WF_META, payload: { seq: 2, label: 'audit dsh-bash', phase: 'Scan', childId: 'c2' } },
{ kind: 'workflow/agent-end', runId: RUN_ID, meta: WF_META, payload: { seq: 2, label: 'audit dsh-bash', phase: 'Scan', childId: 'c2', outcome: 'failed' } },
{ kind: 'workflow/agent-start', runId: RUN_ID, meta: WF_META, payload: { seq: 3, label: 'audit dsh-web', phase: 'Scan', childId: 'c3' } },
]
async function injectTurn(evj) {
return evj(`
(async () => {
const dispatch = window.__dshOnSessionEvent
const wf = window.__dshOnWorkflowEvent
if (typeof dispatch !== 'function') return { err: 'no __dshOnSessionEvent seam (DSH_QA=1?)' }
if (typeof wf !== 'function') return { err: 'no __dshOnWorkflowEvent seam (DSH_QA=1?)' }
if (window.__dshTabs && typeof window.__dshTabs.switchTo === 'function') window.__dshTabs.switchTo('chat')
// Clear the daemon-echo boot fixtures so our turn + live card is the only
// content on the stream (auto-follow otherwise re-pins to the boot noise
// and pushes our card below the fold — see qa-cdp-shoot-p0-inspector.mjs
// note on the flex/min-height:0 container).
const stream = document.getElementById('stream')
if (stream) stream.innerHTML = ''
for (const ev of ${JSON.stringify(TURN_EVENTS)}) dispatch('${SESSION_ID}', ev)
// Feed the REAL wire frames through the live path.
for (const f of ${JSON.stringify(WF_FRAMES)}) wf(f)
return {
ok: true,
liveCards: document.querySelectorAll('.workflow-card-live').length,
liveChip: document.querySelectorAll('.workflow-card-chip--live').length,
steps: document.querySelectorAll('.workflow-card-live .workflow-step').length,
toolBlocks: document.querySelectorAll('.tool-block[data-tool-name="workflow"]').length,
}
})()
`)
}
async function screenshot(call, outName) {
let shot = null
let lastErr = null
const attempts = [
{ format: 'png' }, { format: 'png' },
{ format: 'jpeg', quality: 82 }, { format: 'jpeg', quality: 82 },
]
let usedFormat = 'png'
for (const a of attempts) {
try {
shot = await call('Page.captureScreenshot', { ...a, captureBeyondViewport: false }, 25000)
if (shot && shot.data) { usedFormat = a.format; break }
} catch (e) { lastErr = e; await new Promise((r) => setTimeout(r, 1200)) }
}
if (!shot || !shot.data) throw new Error('captureScreenshot failed for ' + outName + (lastErr ? ': ' + lastErr.message : ''))
const finalName = usedFormat === 'jpeg' ? outName.replace(/\.png$/, '.jpg') : outName
const outPath = join(OUTDIR, finalName)
writeFileSync(outPath, Buffer.from(shot.data, 'base64'))
const kb = Math.round(statSync(outPath).size / 1024)
console.log(` wrote ${outPath} (${kb} KB, ${usedFormat})`)
if (kb < 20) console.warn(`${outName} is only ${kb} KB (<20 KB) — likely a blank/trivial frame`)
return { path: outPath, kb }
}
async function main() {
const dshHome = join(tmpdir(), 'dsh-wf-feedback-home')
const userData = join(tmpdir(), 'dsh-wf-feedback-userdata')
for (const dir of [dshHome, userData]) {
try { rmSync(dir, { recursive: true, force: true }) } catch {}
mkdirSync(dir, { recursive: true })
}
seedHome(dshHome)
console.log(`[wf-feedback] booting on CDP port ${CDP_PORT}`)
const { child } = await bootElectron(dshHome, userData, CDP_PORT)
const results = []
try {
await sleep(1500)
const { call, evj } = await newCdp(CDP_PORT)
await call('Page.enable')
await call('Runtime.enable')
// Let the daemon-echo scripted boot session finish streaming before we
// inject — otherwise its late events re-append after our clear and
// auto-follow pins to them, burying our card. Poll the status dot for
// `idle` (bounded).
for (let i = 0; i < 16; i++) {
const st = await evj(`(document.querySelector('#status-text')||{}).textContent||''`)
if (String(st).toLowerCase().includes('idle')) break
await sleep(500)
}
await sleep(600)
const injected = await injectTurn(evj)
console.log(' injected:', JSON.stringify(injected))
if (!injected || !injected.ok) throw new Error('turn injection failed: ' + JSON.stringify(injected))
if (!injected.liveCards) console.warn(' ⚠ no live workflow card minted — the seam may not be wired')
await sleep(400)
// --- 01: the live workflow card (live chip + steps) ---
// Re-clear + re-inject atomically right before capture so the daemon-echo
// scripted session (which keeps re-rendering the stream) can't bury the
// card between inject and shot, then scroll it into view.
const scrolled = await evj(`
(() => {
const dispatch = window.__dshOnSessionEvent
const wf = window.__dshOnWorkflowEvent
const stream = document.getElementById('stream')
if (stream) stream.innerHTML = ''
for (const ev of ${JSON.stringify(TURN_EVENTS)}) dispatch('${SESSION_ID}', ev)
for (const f of ${JSON.stringify(WF_FRAMES)}) wf(f)
const card = document.querySelector('.workflow-card-live')
if (!card) return { err: 'no live card to scroll to' }
if (card.scrollIntoView) card.scrollIntoView({ block: 'center' })
const chip = card.querySelector('.workflow-card-chip--live')
return { chip: chip ? chip.textContent : null, steps: card.querySelectorAll('.workflow-step').length }
})()
`)
console.log(' 01 scroll:', JSON.stringify(scrolled))
await sleep(150)
results.push(await screenshot(call, '01-workflow-live.png'))
// --- 02: inspector Feedback tab, filled annotation + ✓ marker ---
const fb = await evj(`
(async () => {
const ins = window.__dshInspector
if (!ins) return { err: 'no __dshInspector' }
// Open the inspector on the assistant event, Feedback tab.
const ev = { type: 'assistant/message', seq: 4, time: Date.now(),
data: { content: [{ type: 'text', text: 'Kicking off the audit workflow across the three packages.' }] } }
ins.open({ event: ev, tab: 'feedback', sessionId: '${SESSION_ID}' })
const drawer = document.getElementById('inspector-drawer')
const panel = drawer.querySelector('.inspector-panel[data-panel="feedback"]')
// Fill: thumbs-up, a note, a rubric dim, then Save.
const up = panel.querySelector('[aria-label="Thumbs up"]')
if (up) up.click()
const note = panel.querySelector('.inspector-feedback-note-input')
if (note) note.value = 'Good — kicked off the right workflow for the audit.'
const sel = panel.querySelector('.inspector-feedback-dim-select')
if (sel && sel.options && sel.options.length > 1) sel.value = sel.options[1].value
const save = panel.querySelector('.inspector-feedback-save')
if (save) save.click()
await new Promise(r => setTimeout(r, 350))
// Attach a badge on a stream bubble so the ✓ marker is visible, then
// refresh markers so the annotated (sessionId, seq) paints.
if (typeof ins.refreshInspectMarkers === 'function') ins.refreshInspectMarkers()
return {
open: drawer.classList.contains('open'),
verdictActive: !!panel.querySelector('.inspector-feedback-thumb.active'),
status: (panel.querySelector('.inspector-feedback-status') || {}).textContent,
markedBadges: document.querySelectorAll('.inspect-badge-annotated').length,
}
})()
`)
console.log(' 02 feedback:', JSON.stringify(fb))
if (!fb || fb.open !== true) console.warn(' ⚠ Feedback tab did not open:', JSON.stringify(fb))
await sleep(250)
results.push(await screenshot(call, '02-feedback-tab.png'))
// Confirm the annotation actually persisted to the isolated home file.
let persisted = null
try {
const p = join(dshHome, 'feedback-annotations.json')
persisted = existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null
} catch (e) { persisted = { __err: String(e) } }
console.log(' persisted annotations:', JSON.stringify(persisted))
console.log('\n--- SUMMARY ---')
console.log('inject :', JSON.stringify(injected))
console.log('02 :', JSON.stringify(fb))
console.log('persist:', JSON.stringify(persisted))
for (const r of results) console.log(`shot : ${r.path} (${r.kb} KB)`)
} finally {
try { child.kill('SIGKILL') } catch {}
for (let i = 0; i < 6; i++) {
await sleep(500)
try { await fetch(`http://localhost:${CDP_PORT}/json/list`) } catch { break }
}
}
process.exit(0)
}
main().catch((err) => { console.error(err); process.exit(1) })

View File

@@ -0,0 +1,131 @@
// feedback-annotations.js — per-event RL-annotation store (lane-wf-feedback).
//
// The inspector's Feedback tab lets a researcher annotate any single session
// event: thumbs up/down + a free-text note + an optional rubric dimension.
// This is the RL-annotation seed — the record shape is deliberately forward-
// compatible so a later trajectory-GRM pipeline can consume it as-is:
//
// { sessionId, seq, verdict, note, rubricDim?, at }
//
// Why a new side file rather than growth-v2? growth-v2 stores rubrics/errors
// keyed by COMPACT WINDOW (one file per window). Event-level annotations are
// keyed by (sessionId, seq) — a different grain that doesn't fit that shape.
// So this mirrors the growth-v2 IPC style (main-process module + preload
// namespace) but lands in its own append-safe JSON file under ~/.dsh-desktop,
// next to growth-log.jsonl / user-overlay.cordis.yml.
//
// Layout: `~/.dsh-desktop/feedback-annotations.json` — a single flat array of
// records. One file (not per-session) because the demo scale is small and a
// single file keeps the "export the whole RL seed" story a one-liner. Writes
// rewrite the whole file (no partial-write tearing).
//
// Upsert semantics: (sessionId, seq) is the identity. Re-annotating the same
// event overwrites its record (verdict/note/rubricDim), refreshing `at`. A
// verdict of null with an empty note clears the annotation (delete).
'use strict'
const fs = require('fs')
const path = require('path')
const os = require('os')
function shellHome() {
return process.env.DSH_DESKTOP_HOME || path.join(os.homedir(), '.dsh-desktop')
}
function annotationsPath() {
return path.join(shellHome(), 'feedback-annotations.json')
}
const VALID_VERDICTS = new Set(['up', 'down'])
function readAllRaw() {
try {
const text = fs.readFileSync(annotationsPath(), 'utf8')
const v = JSON.parse(text)
return Array.isArray(v) ? v : []
} catch (err) {
if (err && err.code === 'ENOENT') return []
console.debug(`feedback-annotations read failed: ${err.message}`)
return []
}
}
function writeAll(arr) {
const p = annotationsPath()
fs.mkdirSync(path.dirname(p), { recursive: true })
fs.writeFileSync(p, JSON.stringify(arr, null, 2), 'utf8')
}
// Return every annotation. The renderer indexes them by (sessionId, seq) to
// paint the ✓ marker + prefill the Feedback tab, so the wire is a flat list.
function list() {
return { ok: true, entries: readAllRaw() }
}
// Normalize + validate one incoming annotation form. Returns a clean record
// or null when it isn't a usable annotation (no verdict AND no note → clear).
function normalize(form) {
if (!form || typeof form !== 'object') return null
const sessionId = String(form.sessionId || '').trim()
const seq = Number(form.seq)
if (!sessionId || !Number.isFinite(seq)) return null
const verdict = VALID_VERDICTS.has(form.verdict) ? form.verdict : null
const note = typeof form.note === 'string' ? form.note.trim() : ''
const rubricDim = (typeof form.rubricDim === 'string' && form.rubricDim.trim())
? form.rubricDim.trim()
: undefined
// No verdict and no note → nothing to store (treated as a clear upstream).
if (verdict === null && !note) return null
const rec = { sessionId, seq, verdict, note, at: Date.now() }
if (rubricDim) rec.rubricDim = rubricDim
return rec
}
// Upsert one annotation keyed by (sessionId, seq). Returns { ok, entry } on a
// write, { ok:true, cleared:true } when the form clears an existing record,
// or { ok:false, reason } on a malformed form.
function upsert(form) {
const sessionId = form && String(form.sessionId || '').trim()
const seq = form && Number(form.seq)
if (!sessionId || !Number.isFinite(seq)) return { ok: false, reason: 'sessionId-and-seq-required' }
const arr = readAllRaw()
const idx = arr.findIndex((r) => r && r.sessionId === sessionId && Number(r.seq) === seq)
const rec = normalize(form)
if (rec === null) {
// Clear: drop any existing record for this (sessionId, seq).
if (idx >= 0) {
arr.splice(idx, 1)
writeAll(arr)
return { ok: true, cleared: true }
}
return { ok: false, reason: 'empty-annotation' }
}
if (idx >= 0) arr[idx] = rec
else arr.push(rec)
writeAll(arr)
return { ok: true, entry: rec }
}
// Remove one annotation. Returns { ok, removed:boolean }.
function remove(form) {
const sessionId = form && String(form.sessionId || '').trim()
const seq = form && Number(form.seq)
if (!sessionId || !Number.isFinite(seq)) return { ok: false, reason: 'sessionId-and-seq-required' }
const arr = readAllRaw()
const idx = arr.findIndex((r) => r && r.sessionId === sessionId && Number(r.seq) === seq)
if (idx < 0) return { ok: true, removed: false }
arr.splice(idx, 1)
writeAll(arr)
return { ok: true, removed: true }
}
module.exports = {
shellHome,
annotationsPath,
list,
upsert,
remove,
normalize,
VALID_VERDICTS,
}

View File

@@ -18,6 +18,7 @@ const M = require('./plugin-market.js')
const GH = require('./gh-prs.js')
const Growth = require('./growth-log.js')
const GrowthV2 = require('./growth-v2.js')
const FeedbackAnnotations = require('./feedback-annotations.js')
const { normalizeInterruptRequest } = require('./interrupt-normalize.js')
const { classifyForkErrorMessage } = require('./fork-error-classify.js')
const { revealWindow } = require('./window-reveal.js')
@@ -830,6 +831,14 @@ app.whenReady().then(async () => {
ipcMain.handle('growth:v2AddRubric', (_e, { compactWindowId, form } = {}) => GrowthV2.addRubric(compactWindowId, form))
ipcMain.handle('growth:v2AddError', (_e, { compactWindowId, form } = {}) => GrowthV2.addError(compactWindowId, form))
// lane-wf-feedback: per-event RL-annotation store (inspector Feedback tab).
// Records land in ~/.dsh-desktop/feedback-annotations.json as a flat list of
// { sessionId, seq, verdict, note, rubricDim?, at }. Read on drawer open to
// prefill + paint the ✓ marker; upsert keyed by (sessionId, seq).
ipcMain.handle('feedback:list', () => FeedbackAnnotations.list())
ipcMain.handle('feedback:upsert', (_e, { form } = {}) => FeedbackAnnotations.upsert(form))
ipcMain.handle('feedback:remove', (_e, { form } = {}) => FeedbackAnnotations.remove(form))
// ---------------------------------------------------------------------
// Hub page (#186 + #190).
//

View File

@@ -164,6 +164,15 @@ contextBridge.exposeInMainWorld('dsh', {
v2AddRubric: (compactWindowId, form) => ipcRenderer.invoke('growth:v2AddRubric', { compactWindowId, form }),
v2AddError: (compactWindowId, form) => ipcRenderer.invoke('growth:v2AddError', { compactWindowId, form }),
},
// -- feedback annotations (inspector Feedback tab / RL seed) ----------------
// Per-event annotation store at ~/.dsh-desktop/feedback-annotations.json.
// list() returns { ok, entries:[{sessionId,seq,verdict,note,rubricDim?,at}] };
// upsert(form)/remove(form) key on (sessionId, seq) and return { ok, ... }.
feedback: {
list: () => ipcRenderer.invoke('feedback:list'),
upsert: (form) => ipcRenderer.invoke('feedback:upsert', { form }),
remove: (form) => ipcRenderer.invoke('feedback:remove', { form }),
},
// -- pull requests page ----------------------------------------------------
// `list` returns the 60s-cached payload; `refresh` bypasses the cache.
// Both go through the same normalized shape: { rows, repo, source, viewer,

View File

@@ -0,0 +1,132 @@
// feedback-annotation-model.js — pure model for the inspector Feedback tab
// (lane-wf-feedback). The RL-annotation seed.
//
// The Feedback tab annotates a single session event: thumbs up/down + a free-
// text note + an optional rubric dimension. The persisted record is keyed by
// (sessionId, seq) and shaped for a downstream trajectory-GRM pipeline:
//
// { sessionId, seq, verdict: 'up'|'down'|null, note: string,
// rubricDim?: string, at: number }
//
// This module is the DATA + PURE HELPERS only — no DOM, no IPC. The renderer
// owns "read via window.dsh.feedback on open, write on submit"; this file:
// - derives the identity key from an inspector event,
// - normalizes a form into the forward-compatible record shape,
// - maintains an in-memory index (sessionId,seq → record) so the ✓ marker
// and prefill are synchronous (no round-trip on every badge render).
//
// The main-process feedback-annotations.js is the source of truth on disk;
// this index is a renderer-side cache hydrated from feedback.list() at boot
// and kept in step on each upsert/clear.
'use strict'
const VALID_VERDICTS = ['up', 'down']
// Stable string key for the (sessionId, seq) identity. Kept as one function so
// the index writer and the marker reader can never drift.
function keyFor(sessionId, seq) {
const sid = String(sessionId == null ? '' : sessionId)
const s = Number(seq)
if (!sid || !Number.isFinite(s)) return null
return `${sid}::${s}`
}
// Pull (sessionId, seq) out of an inspector event + its owning session. The
// inspector opens on a session.event that carries `seq`; the sessionId comes
// from the caller (the active/owning session), since a reconstructed event may
// not carry it. Returns { sessionId, seq } | null.
function identityFor(event, sessionId) {
if (!event || typeof event !== 'object') return null
const seq = Number(event.seq)
if (!Number.isFinite(seq)) return null
const sid = String(sessionId == null ? '' : sessionId)
if (!sid) return null
return { sessionId: sid, seq }
}
// Normalize a raw form ({ sessionId, seq, verdict, note, rubricDim }) into the
// persisted record shape, or null when there's nothing worth storing (no
// verdict AND no note → a clear). Mirrors the main-process normalize() so the
// renderer's optimistic cache matches what lands on disk.
function normalize(form) {
if (!form || typeof form !== 'object') return null
const id = identityFor({ seq: form.seq }, form.sessionId)
if (!id) return null
const verdict = VALID_VERDICTS.indexOf(form.verdict) >= 0 ? form.verdict : null
const note = typeof form.note === 'string' ? form.note.trim() : ''
const rubricDim = (typeof form.rubricDim === 'string' && form.rubricDim.trim())
? form.rubricDim.trim()
: undefined
if (verdict === null && !note) return null
const rec = { sessionId: id.sessionId, seq: id.seq, verdict, note }
if (rubricDim) rec.rubricDim = rubricDim
rec.at = Number.isFinite(form.at) ? form.at : Date.now()
return rec
}
function createAnnotationIndex() {
/** @type {Map<string, object>} */
const byKey = new Map()
// Hydrate from a flat list (feedback.list().entries). Ignores malformed
// records so a hand-edited file can't crash the marker pass.
function hydrate(entries) {
byKey.clear()
if (!Array.isArray(entries)) return
for (const e of entries) {
const k = keyFor(e && e.sessionId, e && e.seq)
if (k) byKey.set(k, e)
}
}
// Look up the record for (sessionId, seq), or null. Drives the ✓ marker +
// Feedback-tab prefill.
function get(sessionId, seq) {
const k = keyFor(sessionId, seq)
return k ? (byKey.get(k) || null) : null
}
function has(sessionId, seq) {
const k = keyFor(sessionId, seq)
return k ? byKey.has(k) : false
}
// Apply a normalized record (or a clear) to the cache. Pass the FORM; a form
// that normalizes to null clears the (sessionId, seq) entry. Returns the
// stored record, or null on a clear/invalid.
function put(form) {
const id = identityFor({ seq: form && form.seq }, form && form.sessionId)
if (!id) return null
const k = keyFor(id.sessionId, id.seq)
const rec = normalize(form)
if (rec === null) {
byKey.delete(k)
return null
}
byKey.set(k, rec)
return rec
}
function remove(sessionId, seq) {
const k = keyFor(sessionId, seq)
if (!k) return false
return byKey.delete(k)
}
function size() { return byKey.size }
function all() { return Array.from(byKey.values()) }
return { hydrate, get, has, put, remove, size, all }
}
const feedbackModelApi = {
VALID_VERDICTS,
keyFor,
identityFor,
normalize,
createAnnotationIndex,
}
if (typeof module !== 'undefined' && module.exports) module.exports = feedbackModelApi
if (typeof window !== 'undefined') window.__dshFeedbackModel = feedbackModelApi

View File

@@ -1555,6 +1555,7 @@
<script src="./subagent-drilldown.js"></script>
<script src="./context-rail.js"></script>
<script src="./workflow-view.js"></script>
<script src="./workflow-live-model.js"></script><!-- lane-wf-feedback: fold on-wire workflow.event frames into the aggregate card model -->
<script src="./subagent-view.js"></script>
<script src="./subagent-lineage.js"></script><!-- ticket #15 A: live child event router -->
<script src="./debug-fixtures.js"></script>
@@ -1566,7 +1567,8 @@
<script src="./trace-graph.js"></script><!-- task #203: view C agent node graph -->
<script src="./trace-detail-pane.js"></script><!-- task #205: right-side detail pane (4 tabs) -->
<script src="./stream-follow.js"></script><!-- lane-p0-inspector: pure at-bottom auto-follow controller (consumed by renderer stream scroll) -->
<script src="./inspector-drawer.js"></script><!-- lane-p0-inspector: unified right-side Inspector (Pretty/Raw/JSON); reuses trace-detail-pane.buildJsonTree above -->
<script src="./feedback-annotation-model.js"></script><!-- lane-wf-feedback: per-event RL annotation index (consumed by inspector Feedback tab) -->
<script src="./inspector-drawer.js"></script><!-- lane-p0-inspector: unified right-side Inspector (Pretty/Raw/JSON/Feedback); reuses trace-detail-pane.buildJsonTree above -->
<script src="./trace-tri-view.js"></script><!-- task #203: view chips + panels -->
<script src="./price-table.js"></script><!-- task #158: cost chip data source -->
<script src="./parse-incremental-json.js"></script><!-- task #162 rec 22: partial-JSON tool row -->
@@ -1696,11 +1698,13 @@
<button type="button" class="inspector-tab active" data-tab="pretty" role="tab" aria-selected="true">Pretty</button>
<button type="button" class="inspector-tab" data-tab="raw" role="tab" aria-selected="false">Raw</button>
<button type="button" class="inspector-tab" data-tab="json" role="tab" aria-selected="false">JSON</button>
<button type="button" class="inspector-tab" data-tab="feedback" role="tab" aria-selected="false">Feedback</button><!-- lane-wf-feedback: per-event RL annotation -->
</div>
<div class="inspector-drawer-body">
<div class="inspector-panel" data-panel="pretty" role="tabpanel"></div>
<div class="inspector-panel" data-panel="raw" role="tabpanel" hidden></div>
<div class="inspector-panel" data-panel="json" role="tabpanel" hidden></div>
<div class="inspector-panel" data-panel="feedback" role="tabpanel" hidden></div>
</div>
</aside>

View File

@@ -32,7 +32,7 @@
;(function () {
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'
const TABS = ['pretty', 'raw', 'json']
const TABS = ['pretty', 'raw', 'json', 'feedback']
// --- pure helpers ------------------------------------------------------
@@ -345,9 +345,186 @@
host.appendChild(pre)
}
// The rubric dimension list the Feedback tab's dropdown offers. Reused from
// the SAME source Evals/rubrics-model.js uses (MULTI_TURN_DIMENSIONS) so a
// researcher annotates against the locked RL dimension vocabulary rather
// than an inspector-local copy. Falls back to an empty list pre-load.
function feedbackDimensions () {
const rm = (typeof window !== 'undefined') ? window.__dshRubricsModel : null
const dims = rm && Array.isArray(rm.MULTI_TURN_DIMENSIONS) ? rm.MULTI_TURN_DIMENSIONS : []
return dims.map((d) => ({ id: d.id, label: d.label }))
}
// Read the annotation index (renderer cache) for an event's (sessionId, seq)
// so the tab prefills + the marker paints without an IPC round-trip. Returns
// the stored record or null.
function currentAnnotation (sessionId, event) {
const fm = (typeof window !== 'undefined') ? window.__dshFeedbackModel : null
if (!fm || !fm.createAnnotationIndex) return null
const idx = feedbackIndex()
if (!idx) return null
const seq = event && Number(event.seq)
if (!Number.isFinite(seq) || !sessionId) return null
return idx.get(sessionId, seq)
}
// Lazily-created singleton annotation index, hydrated from disk on first use.
let _idx = null
function feedbackIndex () {
const fm = (typeof window !== 'undefined') ? window.__dshFeedbackModel : null
if (!fm || !fm.createAnnotationIndex) return null
if (!_idx) _idx = fm.createAnnotationIndex()
return _idx
}
// The session id the Feedback tab keys annotations to. Prefer the explicit
// `open({ sessionId })` value; fall back to the renderer's active session
// (the tool-card `{ }` path routes through openFromDrawer without a sessionId,
// so the active session is the honest owner of the event on screen).
function effectiveSessionId () {
if (state.sessionId != null && state.sessionId !== '') return state.sessionId
const chat = (typeof window !== 'undefined') ? window.__dshChat : null
if (chat && typeof chat.getActiveSessionId === 'function') {
const sid = chat.getActiveSessionId()
if (sid != null && sid !== '') return String(sid)
}
return null
}
// Hydrate the annotation index from persisted records (feedback.list()).
// Called at boot by the renderer; safe to call repeatedly.
function hydrateFeedback (entries) {
const idx = feedbackIndex()
if (idx) idx.hydrate(entries)
}
// Feedback tab renderer (doc-injected for tests). Builds the up/down verdict
// buttons, the rubric-dimension <select>, the note <textarea>, and a Save
// row. `opts.onSave(form)` receives the collected form on submit; the browser
// wiring passes a handler that persists via window.dsh.feedback + updates the
// cache. `existing` prefills from the current annotation.
function renderFeedback (doc, host, ctx) {
if (!host) return
host.textContent = ''
const o = ctx || {}
const existing = o.existing || null
const dims = Array.isArray(o.dimensions) ? o.dimensions : feedbackDimensions()
const wrap = doc.createElement('div')
wrap.className = 'inspector-feedback'
// Identity line — which event this annotation is keyed to.
const idLine = doc.createElement('div')
idLine.className = 'inspector-feedback-id muted'
const seqTxt = (o.event && typeof o.event.seq === 'number') ? `seq ${o.event.seq}` : 'seq —'
idLine.textContent = o.sessionId ? `${seqTxt} · session ${String(o.sessionId).slice(0, 8)}` : seqTxt
wrap.appendChild(idLine)
// Verdict row: thumbs up / down. A second click on the active verdict
// clears it (toggle to null).
const verdictRow = doc.createElement('div')
verdictRow.className = 'inspector-feedback-verdict'
let verdict = existing && (existing.verdict === 'up' || existing.verdict === 'down')
? existing.verdict : null
const upBtn = doc.createElement('button')
upBtn.type = 'button'
upBtn.className = 'inspector-feedback-thumb up'
upBtn.textContent = '↑ good'
upBtn.setAttribute('aria-label', 'Thumbs up')
const downBtn = doc.createElement('button')
downBtn.type = 'button'
downBtn.className = 'inspector-feedback-thumb down'
downBtn.textContent = '↓ bad'
downBtn.setAttribute('aria-label', 'Thumbs down')
const reflectVerdict = () => {
upBtn.classList.toggle('active', verdict === 'up')
downBtn.classList.toggle('active', verdict === 'down')
upBtn.setAttribute('aria-pressed', verdict === 'up' ? 'true' : 'false')
downBtn.setAttribute('aria-pressed', verdict === 'down' ? 'true' : 'false')
}
upBtn.addEventListener('click', () => { verdict = verdict === 'up' ? null : 'up'; reflectVerdict() })
downBtn.addEventListener('click', () => { verdict = verdict === 'down' ? null : 'down'; reflectVerdict() })
verdictRow.appendChild(upBtn); verdictRow.appendChild(downBtn)
reflectVerdict()
wrap.appendChild(verdictRow)
// Rubric dimension select (optional).
const dimRow = doc.createElement('div')
dimRow.className = 'inspector-feedback-dim'
const dimLabel = doc.createElement('label')
dimLabel.className = 'inspector-feedback-dim-label muted'
dimLabel.textContent = 'rubric dimension'
const dimSelect = doc.createElement('select')
dimSelect.className = 'inspector-feedback-dim-select'
const noneOpt = doc.createElement('option')
noneOpt.value = ''
noneOpt.textContent = '(none)'
dimSelect.appendChild(noneOpt)
for (const d of dims) {
const opt = doc.createElement('option')
opt.value = d.id
opt.textContent = d.label
if (existing && existing.rubricDim === d.id) opt.selected = true
dimSelect.appendChild(opt)
}
if (existing && existing.rubricDim) dimSelect.value = existing.rubricDim
dimRow.appendChild(dimLabel); dimRow.appendChild(dimSelect)
wrap.appendChild(dimRow)
// Note textarea.
const noteWrap = doc.createElement('div')
noteWrap.className = 'inspector-feedback-note'
const noteLabel = doc.createElement('label')
noteLabel.className = 'inspector-feedback-note-label muted'
noteLabel.textContent = 'note'
const note = doc.createElement('textarea')
note.className = 'inspector-feedback-note-input'
note.rows = 4
note.placeholder = 'Why? (free text — this is the RL-annotation seed)'
if (existing && typeof existing.note === 'string') note.value = existing.note
noteWrap.appendChild(noteLabel); noteWrap.appendChild(note)
wrap.appendChild(noteWrap)
// Save / status row.
const actions = doc.createElement('div')
actions.className = 'inspector-feedback-actions'
const save = doc.createElement('button')
save.type = 'button'
save.className = 'inspector-feedback-save primary small'
save.textContent = 'Save annotation'
const status = doc.createElement('span')
status.className = 'inspector-feedback-status muted'
actions.appendChild(save); actions.appendChild(status)
wrap.appendChild(actions)
save.addEventListener('click', () => {
const form = {
sessionId: o.sessionId,
seq: o.event && o.event.seq,
verdict,
note: note.value || '',
rubricDim: dimSelect.value || undefined,
}
if (typeof o.onSave === 'function') {
const r = o.onSave(form)
// onSave may be sync (tests) or return a promise (browser IPC).
if (r && typeof r.then === 'function') {
status.textContent = 'saving…'
r.then((res) => { status.textContent = (res && res.cleared) ? 'cleared' : 'saved ✓' },
() => { status.textContent = 'save failed' })
} else {
status.textContent = 'saved ✓'
}
}
})
host.appendChild(wrap)
return wrap
}
// --- drawer state + wiring (browser only) ------------------------------
const state = { event: null, tab: 'pretty', title: '' }
const state = { event: null, tab: 'pretty', title: '', sessionId: null }
function drawerEl () {
return (isBrowser && document.getElementById) ? document.getElementById('inspector-drawer') : null
@@ -370,7 +547,15 @@
if (!host) return
if (state.tab === 'pretty') renderPretty(doc, host, projectPretty(state.event))
else if (state.tab === 'raw') renderRaw(doc, host, formatRaw(state.event))
else renderJson(doc, host, state.event)
else if (state.tab === 'feedback') {
const sid = effectiveSessionId()
renderFeedback(doc, host, {
event: state.event,
sessionId: sid,
existing: currentAnnotation(sid, state.event),
onSave: persistAnnotation,
})
} else renderJson(doc, host, state.event)
const titleEl = drawer.querySelector('.inspector-drawer-title')
if (titleEl) titleEl.textContent = state.title || projectPretty(state.event).title || 'inspector'
@@ -385,13 +570,16 @@
// Open the inspector anchored to `event` (a real or reconstructed
// session.event). `tab` selects the initial tab; `title` overrides the
// derived headline.
// derived headline. `sessionId` keys the Feedback tab's annotation record
// (the wire event may not carry it, so the caller supplies the owning
// session).
function open (input) {
const opts = input || {}
if (!opts.event) return null
state.event = opts.event
state.tab = normalizeTab(opts.tab)
state.title = opts.title || ''
state.sessionId = opts.sessionId != null ? String(opts.sessionId) : null
const drawer = drawerEl()
if (!drawer) return null
renderActivePanel(drawer)
@@ -403,6 +591,62 @@
return drawer
}
// Browser wiring for the Feedback tab's Save button: persist via
// window.dsh.feedback, update the renderer cache, and refresh any ✓ markers
// on inspect badges for this (sessionId, seq). Returns the IPC promise so the
// renderer can show saving/saved state. In tests (no window.dsh) this is a
// no-op that still updates the in-memory index so the marker logic is
// exercisable without IPC.
function persistAnnotation (form) {
const idx = feedbackIndex()
// Optimistic cache update first so the marker + prefill are immediate.
if (idx) idx.put(form)
refreshInspectMarkers()
const bridge = (typeof window !== 'undefined' && window.dsh && window.dsh.feedback) ? window.dsh.feedback : null
if (!bridge || typeof bridge.upsert !== 'function') {
return { ok: true, offline: true }
}
return bridge.upsert(form).then((res) => {
// Reconcile the cache with the authoritative server record.
if (res && res.entry && idx) idx.put(res.entry)
else if (res && res.cleared && idx) idx.remove(form.sessionId, form.seq)
refreshInspectMarkers()
return res
})
}
// Repaint the ✓ marker on every mounted inspect badge that resolves to an
// annotated (sessionId, seq). A badge's own data-annot-seq is set at attach
// time, but some hosts (assistant bubbles) stamp their data-inspect-seq
// AFTER the badge attaches, so we fall back to the nearest ancestor carrying
// data-inspect-seq / data-seq. Cheap — badges are few on screen.
function refreshInspectMarkers () {
if (!isBrowser || !document.querySelectorAll) return
const idx = feedbackIndex()
if (!idx) return
const activeSid = effectiveSessionId()
const badges = document.querySelectorAll('.inspect-badge')
badges.forEach((b) => {
let sid = b.getAttribute('data-annot-session') || activeSid
let seqAttr = b.getAttribute('data-annot-seq')
if (seqAttr == null && b.closest) {
const host = b.closest('[data-inspect-seq],[data-seq]')
if (host) seqAttr = host.getAttribute('data-inspect-seq') || host.getAttribute('data-seq')
}
const seq = Number(seqAttr)
if (!sid || !Number.isFinite(seq)) { setBadgeAnnotated(b, false); return }
setBadgeAnnotated(b, idx.has(sid, seq))
})
}
// Toggle the ✓ marker class on one badge.
function setBadgeAnnotated (badge, on) {
if (!badge) return
badge.classList.toggle('inspect-badge-annotated', !!on)
if (on) badge.setAttribute('data-annotated', '1')
else badge.removeAttribute('data-annotated')
}
// Adapter for the legacy tool-cards.openJsonDrawer contract, so its call
// sites (tool-card `{ }` badge, devtools / trace raw badges, context page)
// all route into the one inspector. When `call`/`result` are present it is
@@ -431,7 +675,7 @@
} else {
event = { type: 'event', data: {}, __reconstructed: true }
}
return open({ event, tab: opts.tab || 'json', title: opts.title })
return open({ event, tab: opts.tab || 'json', title: opts.title, sessionId: opts.sessionId })
}
function close () {
@@ -444,10 +688,12 @@
}
// Build an unobtrusive "{ }" inspect affordance and hang it on `el`. The
// caller supplies `getTarget()` returning { event, tab, title } resolved at
// click time (so bubbles can synthesize from live DOM text). `opts.hover`
// makes it a hover-revealed badge (bubble / reasoning) rather than an
// always-visible one.
// caller supplies `getTarget()` returning { event, tab, title, sessionId }
// resolved at click time (so bubbles can synthesize from live DOM text).
// `opts.hover` makes it a hover-revealed badge (bubble / reasoning) rather
// than an always-visible one. If the target's (sessionId, seq) already has a
// feedback annotation, the badge paints a ✓ marker up front (and refreshes
// after a save via refreshInspectMarkers).
function attachInspectBadge (el, getTarget, opts) {
if (!isBrowser || !el) return null
const o = opts || {}
@@ -455,13 +701,27 @@
btn.type = 'button'
btn.className = 'inspect-badge' + (o.hover ? ' inspect-badge-hover' : '')
btn.textContent = '{ }'
btn.title = 'Inspect · Pretty / Raw / JSON'
btn.setAttribute('aria-label', 'Inspect this element (Pretty, Raw, JSON)')
btn.title = 'Inspect · Pretty / Raw / JSON / Feedback'
btn.setAttribute('aria-label', 'Inspect this element (Pretty, Raw, JSON, Feedback)')
// Record identity on the badge so refreshInspectMarkers can find + repaint
// it after an annotation lands. Resolve once at attach time; a re-resolve
// on click keeps it current if the target's seq changes.
const stamp = (target) => {
if (!target) return
if (target.sessionId != null) btn.setAttribute('data-annot-session', String(target.sessionId))
const seq = target.event && Number(target.event.seq)
if (Number.isFinite(seq)) btn.setAttribute('data-annot-seq', String(seq))
const idx = feedbackIndex()
if (idx && target.sessionId != null && Number.isFinite(seq)) {
setBadgeAnnotated(btn, idx.has(String(target.sessionId), seq))
}
}
try { stamp(typeof getTarget === 'function' ? getTarget() : null) } catch { /* resolve is best-effort at attach */ }
btn.addEventListener('click', (e) => {
if (e && e.stopPropagation) e.stopPropagation()
if (e && e.preventDefault) e.preventDefault()
const target = typeof getTarget === 'function' ? getTarget() : null
if (target && target.event) open(target)
if (target && target.event) { stamp(target); open(target) }
})
el.appendChild(btn)
return btn
@@ -478,6 +738,15 @@
tabs.forEach((btn) => {
btn.addEventListener('click', () => { if (btn.dataset) setTab(btn.dataset.tab) })
})
// lane-wf-feedback: hydrate the annotation index from disk so ✓ markers
// paint on first badge attach. Best-effort — a missing bridge (early boot /
// headless) leaves the index empty.
const bridge = (window.dsh && window.dsh.feedback) ? window.dsh.feedback : null
if (bridge && typeof bridge.list === 'function') {
Promise.resolve(bridge.list()).then((res) => {
if (res && Array.isArray(res.entries)) { hydrateFeedback(res.entries); refreshInspectMarkers() }
}).catch(() => { /* annotations are additive; a read miss is non-fatal */ })
}
}
if (isBrowser) {
@@ -489,7 +758,9 @@
// pure
normalizeTab, projectPretty, formatRaw, kindForEvent, TABS,
// dom renderers (doc-injected)
renderPretty, renderRaw, renderJson,
renderPretty, renderRaw, renderJson, renderFeedback,
// feedback annotation cache
hydrateFeedback, refreshInspectMarkers, feedbackDimensions,
// drawer
open, openFromDrawer, close, setTab, install, attachInspectBadge,
}

View File

@@ -990,7 +990,7 @@ function attachBubbleInspect(el, body, role) {
__reconstructed: true,
}
}
return { event, tab: 'pretty' }
return { event, tab: 'pretty', sessionId: state.activeSessionId }
}, { hover: true })
}
@@ -1007,6 +1007,7 @@ function attachReasoningInspect(r) {
return {
event: { type: 'reasoning', data: { text }, __reconstructed: true },
tab: 'pretty',
sessionId: state.activeSessionId,
}
}, { hover: true })
}
@@ -1019,7 +1020,7 @@ function attachEventInspect(el, event, opts) {
const ins = window.__dshInspector
if (!ins || typeof ins.attachInspectBadge !== 'function' || !el || !event) return
const o = opts || {}
ins.attachInspectBadge(el, () => ({ event, tab: o.tab || 'pretty' }), { hover: !!o.hover })
ins.attachInspectBadge(el, () => ({ event, tab: o.tab || 'pretty', sessionId: state.activeSessionId }), { hover: !!o.hover })
}
// Hover-revealed "fork from here" button on assistant bubbles. The boundary
@@ -2651,6 +2652,20 @@ if (typeof window !== 'undefined' && window.dshQa) {
// defensively (introduced in an earlier lane) — this exposes it under
// the same DSH_QA=1 gate so a driver can write, not just read.
window.__dshRendererState = state
// lane-wf-feedback: direct-dispatch seam for the on-wire `workflow.event`
// notification, so the CDP shoot can feed the REAL wire shape through the
// same onWorkflowEvent path the live notification uses — no daemon + no
// workflow engine required. Ensures the accumulator exists (bootUi may not
// have run yet in a headless QA boot).
window.__dshOnWorkflowEvent = function (workflowEventParams) {
if (!state.workflowLiveModel) {
const wfMod = window.__dshWorkflowLiveModel
if (wfMod && typeof wfMod.createWorkflowLiveModel === 'function') {
state.workflowLiveModel = wfMod.createWorkflowLiveModel()
}
}
onWorkflowEvent(workflowEventParams)
}
}
// the step's meta strip — a horizontal chip row (turn / step
@@ -6666,6 +6681,63 @@ async function cancel() {
// -- event wiring ------------------------------------------------------------
// lane-wf-feedback: consume one on-wire `workflow.event` notification param
// ({ kind, runId, meta, payload }). Fold it through the accumulator, then
// mount or refresh the aggregate workflow card. Correlation: the wire carries
// no sessionId, so we anchor to the enclosing `workflow` tool/call block on
// the active stream (matching how the bridge documents runId ⟷ tool_call). If
// no such block is on screen we append the card at the stream tail as a
// standalone live card. Unknown-kind / malformed frames fold to null → no-op.
function onWorkflowEvent(params) {
const model = state.workflowLiveModel
const view = window.__dshWorkflowView
if (!model || !view || typeof view.buildWorkflowCard !== 'function') return
const runId = model.apply(params)
if (!runId) return // unknown kind / malformed — render nothing
const wf = model.toCard(runId)
if (!wf) return
const card = view.buildWorkflowCard(document, wf, {
isLive: true,
showReplayBar: false,
})
card.dataset.workflowRunId = runId
card.classList.add('workflow-card-live')
// Replace an existing live card for the same run (incremental re-render),
// else mount fresh. Anchor priority: (a) directly after the workflow
// tool/call block whose args named this run, (b) stream tail.
const existing = streamEl.querySelector(
`.workflow-card-live[data-workflow-run-id="${cssEscape(runId)}"]`)
if (existing && existing.parentElement) {
existing.parentElement.replaceChild(card, existing)
return
}
const anchor = findWorkflowToolAnchor(wf.name)
if (anchor && anchor.parentElement) {
anchor.parentElement.insertBefore(card, anchor.nextSibling)
} else {
streamEl.appendChild(card)
}
scrollToBottom()
}
// Best-effort: find the `workflow` tool/call block on the active stream whose
// arguments named `wfName`, so a live workflow card mounts inline with the
// call that spawned it. Returns null when there's no match (→ tail append).
function findWorkflowToolAnchor(wfName) {
const blocks = streamEl.querySelectorAll('.tool-block[data-tool-name="workflow"]')
if (!blocks || blocks.length === 0) return null
if (!wfName) return blocks[blocks.length - 1]
for (let i = blocks.length - 1; i >= 0; i -= 1) {
const b = blocks[i]
const txt = b.textContent || ''
if (txt.indexOf(wfName) >= 0) return b
}
// No arg match — fall back to the most recent workflow call so the card
// still lands next to a workflow tool block rather than orphaned at the tail.
return blocks[blocks.length - 1]
}
window.dsh.onNotify(({ method, params }) => {
// Forward every notification to Mission Control so its aggregate stays
// live regardless of which tab is active. The mission module is a no-op
@@ -6857,6 +6929,15 @@ window.dsh.onNotify(({ method, params }) => {
renderSessionList()
void refreshSessionList()
appendSystem(`subagent finished: ${params.agentId} (${params.status})`)
} else if (method === 'workflow.event') {
// lane-wf-feedback: live workflow card. The wire (runtime commit
// dd29d8631) ships incremental `workflow.event` frames keyed by `runId`
// with NO sessionId — the run correlates back to the enclosing `workflow`
// tool/call on the active session's stream. We fold each frame into the
// accumulator and (re)mount one aggregate card anchored to that tool
// block; on a runtime that never mounts ctx.workflows this branch never
// fires, so nothing renders.
onWorkflowEvent(params)
}
})
window.dsh.onStatus(({ status, profile, model, supportedModels }) => {
@@ -8058,6 +8139,15 @@ async function bootUi() {
state.subagentStore = lineageMod.createSubagentLineage()
}
}
// lane-wf-feedback: instantiate the workflow-live accumulator once. Folds
// the on-wire `workflow.event` frames into aggregate card models; kept on
// `state` so reset paths and the renderer-harness seam can inspect it.
if (!state.workflowLiveModel) {
const wfMod = window.__dshWorkflowLiveModel
if (wfMod && typeof wfMod.createWorkflowLiveModel === 'function') {
state.workflowLiveModel = wfMod.createWorkflowLiveModel()
}
}
// Expose the send-side handles used by the Plugins tab (vibeStart hands
// back a session id and the plugins module wants to switch to it) and by
// Mission Control (needs the current server-authoritative entry list).

View File

@@ -1781,6 +1781,41 @@ body.layout-monitor .stream {
}
.inspector-panel[hidden] { display: none; }
/* Feedback tab (lane-wf-feedback) — per-event RL annotation form. */
.inspector-feedback { display: flex; flex-direction: column; gap: 12px; }
.inspector-feedback-id { font-family: var(--mono); font-size: 11px; }
.inspector-feedback-verdict { display: flex; gap: 8px; }
.inspector-feedback-thumb {
padding: 6px 12px; font-size: 12px; line-height: 1;
border: 1px solid var(--border); border-radius: 6px;
background: var(--bg-elev); color: var(--text); cursor: pointer;
}
.inspector-feedback-thumb:hover { border-color: var(--accent); }
.inspector-feedback-thumb.up.active {
border-color: color-mix(in oklab, var(--ok) 55%, var(--border));
background: color-mix(in oklab, var(--ok) 14%, var(--bg-elev));
}
.inspector-feedback-thumb.down.active {
border-color: color-mix(in oklab, var(--danger, var(--warn)) 55%, var(--border));
background: color-mix(in oklab, var(--danger, var(--warn)) 14%, var(--bg-elev));
}
.inspector-feedback-dim { display: flex; flex-direction: column; gap: 4px; }
.inspector-feedback-dim-label,
.inspector-feedback-note-label { font-size: 11px; }
.inspector-feedback-dim-select {
padding: 5px 8px; border: 1px solid var(--border); border-radius: 5px;
background: var(--bg-elev); color: var(--text); font-size: 12px;
}
.inspector-feedback-note { display: flex; flex-direction: column; gap: 4px; }
.inspector-feedback-note-input {
width: 100%; box-sizing: border-box; resize: vertical;
padding: 8px; border: 1px solid var(--border); border-radius: 6px;
background: var(--bg-elev); color: var(--text);
font-family: inherit; font-size: 12px; line-height: 1.4;
}
.inspector-feedback-actions { display: flex; align-items: center; gap: 10px; }
.inspector-feedback-status { font-size: 11px; }
/* Pretty tab */
.inspector-pretty-title {
font-size: 13px; color: var(--text); font-weight: 600; margin-bottom: 8px;
@@ -1832,6 +1867,11 @@ body.layout-monitor .stream {
cursor: pointer;
}
.inspect-badge:hover { color: var(--accent); border-color: var(--accent); }
/* lane-wf-feedback: a badge whose event carries a feedback annotation wears a
* small ✓ after the "{ }" glyph so a reader can see, at a glance, which events
* are already annotated. Rendered via ::after so the badge text stays "{ }". */
.inspect-badge-annotated { color: var(--ok); border-color: color-mix(in oklab, var(--ok) 45%, var(--border)); }
.inspect-badge-annotated::after { content: '✓'; margin-left: 3px; font-size: 9px; }
/* Hover-revealed variant for bubbles / reasoning blocks — mirrors the
* .fork-here reveal pattern. */
.inspect-badge-hover {
@@ -6361,6 +6401,12 @@ textarea:focus-visible {
color: color-mix(in oklab, var(--warn) 65%, var(--text));
border: 1px dashed color-mix(in oklab, var(--warn) 45%, var(--border));
}
.workflow-card-chip--live {
padding: 1px 6px; border-radius: 4px; font-size: 10px;
background: color-mix(in oklab, var(--ok) 12%, var(--bg-elev));
color: color-mix(in oklab, var(--ok) 70%, var(--text));
border: 1px solid color-mix(in oklab, var(--ok) 40%, var(--border));
}
.workflow-body { display: flex; flex-direction: column; gap: 4px; }
.workflow-body-empty { color: var(--muted); font-style: italic; padding: 4px 2px; }

View File

@@ -0,0 +1,219 @@
// workflow-live-model.js — fold the on-wire `workflow.event` train into the
// aggregate {name, kind, steps[]} shape that workflow-view.buildWorkflowCard
// draws.
//
// Why a separate model? The wire (runtime commit dd29d8631, `workflow.event`
// notification) ships INCREMENTAL lifecycle frames keyed by `runId`:
//
// workflow/start → { runId, meta:{name,description} }
// workflow/phase → { runId, meta, payload: <title:string> }
// workflow/log → { runId, meta, payload: <message:string> }
// workflow/agent-start → { runId, meta, payload: { seq, label, phase?, childId } }
// workflow/agent-end → { runId, meta, payload: { seq, label, phase?, childId, outcome } }
// workflow/end → { runId, meta, payload: { stopReason, error?, agentsStarted } }
//
// but buildWorkflowCard wants an AGGREGATE object with a `steps[]` list. The
// wire also carries no `kind` discriminator (seq/dag/iter/…) and no
// per-agent adjacency — only a flat run of agents keyed by `seq`. So the
// honest projection is the `seq` (linear stepper) family: each workflow-agent
// becomes one step, ordered by its `seq`, `running` on agent-start and
// `done`/`failed` on agent-end. Phases and log lines are folded onto the
// active step's meta so the demo still narrates progress without inventing a
// graph shape the wire never described.
//
// This module is the data structure ONLY — no DOM, no wire subscription. The
// renderer owns "subscribe to the notification, feed events here, re-render
// the card"; this file just accumulates run state so the fold semantics are
// lockable under `node --test` without an Electron harness.
//
// Shape:
// runs: Map<runId, { runId, name, description, steps: Map<seq, step>,
// logs: string[], phase: string|null,
// stopReason: string|null, error: unknown,
// done: boolean, order: number }>
// step: { id, name, status: 'running'|'done'|'failed'|'pending',
// phase?, seq, output? }
//
// `toCard(runId)` projects one run into the `{ name, kind:'seq', steps[] }`
// object buildWorkflowCard consumes; the renderer passes that straight through
// with `{ isLive: true }` so the card wears the live chip, not the mock chip.
'use strict'
// The six wire kinds, mirrored from WorkflowEventNotification.WorkflowEventKind
// (protocol.ts). Anything else is dropped — additive-tolerant per the wire
// contract ("hosts must fall through unknown kinds").
const WORKFLOW_EVENT_KINDS = new Set([
'workflow/start',
'workflow/phase',
'workflow/log',
'workflow/agent-start',
'workflow/agent-end',
'workflow/end',
])
function isPlainObject(v) {
return v !== null && typeof v === 'object' && !Array.isArray(v)
}
// Map a workflow-agent `outcome` (engine vocabulary) onto the step status the
// card renderer understands. Anything non-completed that isn't an explicit
// failure stays 'done' so a settled agent never looks stuck.
function statusForOutcome(outcome) {
if (outcome === 'failed' || outcome === 'error' || outcome === 'aborted' || outcome === 'cancelled') {
return 'failed'
}
return 'done'
}
function createWorkflowLiveModel() {
/** @type {Map<string, any>} */
const runs = new Map()
let _order = 0
function ensureRun(runId, meta) {
let run = runs.get(runId)
if (!run) {
run = {
runId,
name: (meta && typeof meta.name === 'string') ? meta.name : '',
description: (meta && typeof meta.description === 'string') ? meta.description : '',
steps: new Map(),
logs: [],
phase: null,
stopReason: null,
error: null,
done: false,
order: (_order += 1),
}
runs.set(runId, run)
} else if (meta) {
// A later frame may carry a better name/description than start did.
if (!run.name && typeof meta.name === 'string') run.name = meta.name
if (!run.description && typeof meta.description === 'string') run.description = meta.description
}
return run
}
// Accept one notification param object ({ kind, runId, meta, payload }).
// Returns the runId the event landed on, or null when the frame is
// malformed / unknown-kind (so the caller can no-op — no card, no error).
function apply(notif) {
if (!isPlainObject(notif)) return null
const { kind } = notif
if (!WORKFLOW_EVENT_KINDS.has(kind)) return null
const runId = (notif.runId === undefined || notif.runId === null) ? '' : String(notif.runId)
if (!runId) return null
const meta = isPlainObject(notif.meta) ? notif.meta : null
const run = ensureRun(runId, meta)
const payload = notif.payload
switch (kind) {
case 'workflow/start':
// Identity only; ensureRun already captured name/description.
break
case 'workflow/phase':
if (typeof payload === 'string') run.phase = payload
else if (isPlainObject(payload) && typeof payload.title === 'string') run.phase = payload.title
break
case 'workflow/log': {
const msg = typeof payload === 'string'
? payload
: (isPlainObject(payload) && typeof payload.message === 'string' ? payload.message : null)
if (msg != null) run.logs.push(msg)
break
}
case 'workflow/agent-start': {
if (!isPlainObject(payload)) break
const seq = Number(payload.seq)
if (!Number.isFinite(seq)) break
const id = payload.childId != null ? String(payload.childId) : `agent-${seq}`
run.steps.set(seq, {
id,
seq,
name: typeof payload.label === 'string' && payload.label ? payload.label : id,
status: 'running',
phase: typeof payload.phase === 'string' ? payload.phase : (run.phase || undefined),
output: undefined,
})
break
}
case 'workflow/agent-end': {
if (!isPlainObject(payload)) break
const seq = Number(payload.seq)
if (!Number.isFinite(seq)) break
const existing = run.steps.get(seq)
const id = payload.childId != null ? String(payload.childId) : (existing ? existing.id : `agent-${seq}`)
const step = existing || { id, seq, name: id, phase: undefined, output: undefined }
step.status = statusForOutcome(payload.outcome)
if (typeof payload.label === 'string' && payload.label) step.name = payload.label
if (payload.outcome != null) step.output = String(payload.outcome)
run.steps.set(seq, step)
break
}
case 'workflow/end':
run.done = true
if (isPlainObject(payload)) {
if (typeof payload.stopReason === 'string') run.stopReason = payload.stopReason
if (payload.error != null) run.error = payload.error
}
break
default:
return null
}
return runId
}
function getRun(runId) {
return runs.get(String(runId)) || null
}
function hasRun(runId) {
return runs.has(String(runId))
}
// Project a run into the aggregate card model buildWorkflowCard consumes.
// Steps are ordered by their engine `seq` (stable, monotonic). Returns null
// for an unknown run.
function toCard(runId) {
const run = runs.get(String(runId))
if (!run) return null
const steps = Array.from(run.steps.values())
.sort((a, b) => a.seq - b.seq)
.map((s) => ({
id: s.id,
name: s.name,
status: s.status,
...(s.output ? { output: s.output } : {}),
...(s.phase ? { phase: s.phase } : {}),
}))
return {
name: run.name || run.runId,
kind: 'seq',
steps,
// Carried through for callers that want to surface run-level state
// (chip label, footer). buildWorkflowCard ignores unknown keys.
_live: true,
_runId: run.runId,
_phase: run.phase,
_logs: run.logs.slice(),
_done: run.done,
_stopReason: run.stopReason,
}
}
function forget(runId) {
runs.delete(String(runId))
}
function clear() {
runs.clear()
}
return { apply, getRun, hasRun, toCard, forget, clear, runs }
}
// Dual export — module.exports for node --test, window for renderer.
const workflowLiveModelApi = { createWorkflowLiveModel, WORKFLOW_EVENT_KINDS, statusForOutcome }
if (typeof module !== 'undefined' && module.exports) module.exports = workflowLiveModelApi
if (typeof window !== 'undefined') window.__dshWorkflowLiveModel = workflowLiveModelApi

View File

@@ -17,13 +17,18 @@
// The DOM produced here is container-agnostic; the caller decides where it
// lands.
//
// The workflow/* Cordis events don't cross the JSON-RPC wire yet (see
// coverage doc §1 "workflow/* Cordis events"; wire patch owned by
// impl-plugin-wire). So the fixtures each carry `_mock:true` at the top
// and this module quietly renders a "mock · workflow/* not on wire" chip
// so a reader can tell demo pixels from real ones. Once the wire lands,
// callers pass through the same {name, kind, ...shape} object and the chip
// swaps to a live spinner.
// The workflow/* Cordis events NOW cross the JSON-RPC wire as `workflow.event`
// notifications (runtime commit dd29d8631, integration/desktop-demo). Two feed
// paths land in this module:
// - LIVE: renderer.js subscribes to the `workflow.event` notification, folds
// the incremental frames through workflow-live-model.js into an aggregate
// {name, kind:'seq', steps[]} object, and passes it with `{ isLive:true }`.
// Live cards wear a small "live · workflow.event" chip. On profiles/runtimes
// that never mount ctx.workflows the notification simply never fires, so
// nothing renders — no chip, no error.
// - MOCK: the Debug popover still mints fixture cards (each fixture carries
// `_mock:true`) with `{ isMock:true }`; those keep the "mock · workflow/*
// not on wire yet" chip so a reader can tell demo pixels from real ones.
//
// Exports:
// classifyWorkflowKind(kind) → 'seq'|'fan-out'|'dag'|'iter'|'branch'|'unknown'
@@ -31,7 +36,9 @@
// buildWorkflowCard(doc, wf, opts)→ HTMLElement
//
// buildWorkflowCard opts:
// isMock?: boolean — show the "mock" chip in the head
// isMock?: boolean — show the "mock · not on wire" chip
// isLive?: boolean — show the "live · workflow.event" chip
// (mutually exclusive with isMock)
// activeStepId?: string — highlight the current step (replay pointer)
// onStepClick?(stepId, wf) — click handler for a step node
// showReplayBar?: boolean — mount replay bar with prev/next buttons
@@ -355,7 +362,16 @@ function buildWorkflowCard(doc, wf, opts = {}) {
const chip = doc.createElement('span');
chip.className = 'workflow-card-chip workflow-card-chip--mock';
chip.textContent = 'mock · workflow/* not on wire yet';
chip.title = 'workflow/* Cordis events do not cross the JSON-RPC transport yet; this card is fixture-driven to demo the shape.';
chip.title = 'This card is fixture-driven (Debug popover) to demo the shape — not a live run.';
head.appendChild(chip);
} else if (opts.isLive) {
// Live cards are fed off the on-wire `workflow.event` notification via
// workflow-live-model.js. The chip is the honest counterpart to the mock
// chip — no "not on wire" caveat, and it never appears on fixture cards.
const chip = doc.createElement('span');
chip.className = 'workflow-card-chip workflow-card-chip--live';
chip.textContent = 'live · workflow.event';
chip.title = 'Fed from the runtime\'s workflow.event notifications (workflow/* Cordis events bridged onto the JSON-RPC wire).';
head.appendChild(chip);
}
card.appendChild(head);

View File

@@ -0,0 +1,100 @@
// feedback-annotation-model.test.js — lane-wf-feedback item 2 (renderer model).
//
// The pure annotation model backs the inspector Feedback tab: identity keying,
// forward-compatible record normalization, and the in-memory index that drives
// the ✓ marker + prefill without an IPC round-trip.
'use strict'
const test = require('node:test')
const assert = require('node:assert')
const model = require('../src/renderer/feedback-annotation-model.js')
test('keyFor: stable (sessionId, seq) key; rejects missing pieces', () => {
assert.strictEqual(model.keyFor('s1', 7), 's1::7')
assert.strictEqual(model.keyFor('s1', '7'), 's1::7')
assert.strictEqual(model.keyFor('', 7), null)
assert.strictEqual(model.keyFor('s1', NaN), null)
assert.strictEqual(model.keyFor('s1', undefined), null)
})
test('identityFor: pulls sessionId + seq from event + owning session', () => {
assert.deepStrictEqual(model.identityFor({ seq: 4 }, 's1'), { sessionId: 's1', seq: 4 })
assert.strictEqual(model.identityFor({ seq: 4 }, ''), null)
assert.strictEqual(model.identityFor({}, 's1'), null)
assert.strictEqual(model.identityFor(null, 's1'), null)
})
test('normalize: builds the forward-compatible record shape', () => {
const rec = model.normalize({ sessionId: 's1', seq: 7, verdict: 'up', note: ' good ', rubricDim: 'convergence', at: 111 })
assert.deepStrictEqual(rec, { sessionId: 's1', seq: 7, verdict: 'up', note: 'good', rubricDim: 'convergence', at: 111 })
})
test('normalize: verdict-only (no note) still stores; note-only stores with null verdict', () => {
const v = model.normalize({ sessionId: 's', seq: 1, verdict: 'down', note: '' })
assert.strictEqual(v.verdict, 'down')
assert.strictEqual(v.note, '')
const n = model.normalize({ sessionId: 's', seq: 1, note: 'just a note' })
assert.strictEqual(n.verdict, null)
assert.strictEqual(n.note, 'just a note')
})
test('normalize: nothing to store (no verdict + empty note) → null (a clear)', () => {
assert.strictEqual(model.normalize({ sessionId: 's', seq: 1, verdict: null, note: ' ' }), null)
assert.strictEqual(model.normalize({ sessionId: 's', seq: 1 }), null)
})
test('normalize: invalid verdict is coerced to null; bad rubricDim dropped', () => {
const rec = model.normalize({ sessionId: 's', seq: 1, verdict: 'meh', note: 'x', rubricDim: ' ' })
assert.strictEqual(rec.verdict, null)
assert.strictEqual('rubricDim' in rec, false)
})
test('normalize: no identity → null', () => {
assert.strictEqual(model.normalize({ seq: 1, verdict: 'up' }), null)
assert.strictEqual(model.normalize(null), null)
})
test('index: put/get/has/remove round-trip', () => {
const idx = model.createAnnotationIndex()
assert.strictEqual(idx.has('s1', 7), false)
const rec = idx.put({ sessionId: 's1', seq: 7, verdict: 'up', note: 'ok' })
assert.strictEqual(rec.verdict, 'up')
assert.strictEqual(idx.has('s1', 7), true)
assert.strictEqual(idx.get('s1', 7).note, 'ok')
assert.strictEqual(idx.size(), 1)
assert.strictEqual(idx.remove('s1', 7), true)
assert.strictEqual(idx.has('s1', 7), false)
})
test('index: put with a clearing form drops the entry', () => {
const idx = model.createAnnotationIndex()
idx.put({ sessionId: 's1', seq: 7, verdict: 'up', note: 'ok' })
assert.strictEqual(idx.has('s1', 7), true)
const cleared = idx.put({ sessionId: 's1', seq: 7, verdict: null, note: '' })
assert.strictEqual(cleared, null)
assert.strictEqual(idx.has('s1', 7), false)
})
test('index: hydrate replaces the whole set from a flat list', () => {
const idx = model.createAnnotationIndex()
idx.put({ sessionId: 'x', seq: 1, verdict: 'up', note: 'a' })
idx.hydrate([
{ sessionId: 's1', seq: 2, verdict: 'down', note: 'b' },
{ sessionId: 's1', seq: 3, verdict: 'up', note: 'c' },
{ bad: 'record' }, // ignored — no key
])
assert.strictEqual(idx.has('x', 1), false, 'prior entries cleared')
assert.strictEqual(idx.size(), 2)
assert.strictEqual(idx.get('s1', 2).note, 'b')
})
test('index: two events in the same session are keyed independently', () => {
const idx = model.createAnnotationIndex()
idx.put({ sessionId: 's', seq: 1, verdict: 'up', note: 'first' })
idx.put({ sessionId: 's', seq: 2, verdict: 'down', note: 'second' })
assert.strictEqual(idx.get('s', 1).verdict, 'up')
assert.strictEqual(idx.get('s', 2).verdict, 'down')
assert.strictEqual(idx.size(), 2)
})

View File

@@ -0,0 +1,115 @@
// feedback-annotations.test.js — lane-wf-feedback item 2 (main-process store).
//
// Points DSH_DESKTOP_HOME at a per-test tmp dir so real ~/.dsh-desktop never
// gets touched. Exercises the per-event annotation store's upsert / clear /
// remove semantics + the persisted record shape (the RL seed).
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
function withTmpHome(fn) {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-feedback-'))
const prev = process.env.DSH_DESKTOP_HOME
process.env.DSH_DESKTOP_HOME = home
try { fn(home) }
finally {
if (prev == null) delete process.env.DSH_DESKTOP_HOME
else process.env.DSH_DESKTOP_HOME = prev
fs.rmSync(home, { recursive: true, force: true })
}
}
function freshRequire() {
delete require.cache[require.resolve('../src/main/feedback-annotations.js')]
return require('../src/main/feedback-annotations.js')
}
test('list: empty before any write', () => {
withTmpHome(() => {
const F = freshRequire()
assert.deepEqual(F.list(), { ok: true, entries: [] })
})
})
test('upsert: writes a record and list reads it back with the RL-seed shape', () => {
withTmpHome(() => {
const F = freshRequire()
const r = F.upsert({ sessionId: 's1', seq: 7, verdict: 'up', note: 'good turn', rubricDim: 'convergence' })
assert.equal(r.ok, true)
assert.equal(r.entry.sessionId, 's1')
assert.equal(r.entry.seq, 7)
assert.equal(r.entry.verdict, 'up')
assert.equal(r.entry.note, 'good turn')
assert.equal(r.entry.rubricDim, 'convergence')
assert.equal(typeof r.entry.at, 'number')
const { entries } = F.list()
assert.equal(entries.length, 1)
assert.equal(entries[0].sessionId, 's1')
})
})
test('upsert: re-annotating the same (sessionId, seq) overwrites in place', () => {
withTmpHome(() => {
const F = freshRequire()
F.upsert({ sessionId: 's1', seq: 7, verdict: 'up', note: 'first' })
F.upsert({ sessionId: 's1', seq: 7, verdict: 'down', note: 'revised' })
const { entries } = F.list()
assert.equal(entries.length, 1, 'still one record for the same key')
assert.equal(entries[0].verdict, 'down')
assert.equal(entries[0].note, 'revised')
})
})
test('upsert: distinct (sessionId, seq) pairs accumulate', () => {
withTmpHome(() => {
const F = freshRequire()
F.upsert({ sessionId: 's1', seq: 1, verdict: 'up', note: 'a' })
F.upsert({ sessionId: 's1', seq: 2, verdict: 'down', note: 'b' })
F.upsert({ sessionId: 's2', seq: 1, verdict: 'up', note: 'c' })
assert.equal(F.list().entries.length, 3)
})
})
test('upsert: an empty annotation clears an existing record', () => {
withTmpHome(() => {
const F = freshRequire()
F.upsert({ sessionId: 's1', seq: 7, verdict: 'up', note: 'x' })
const r = F.upsert({ sessionId: 's1', seq: 7, verdict: null, note: ' ' })
assert.equal(r.ok, true)
assert.equal(r.cleared, true)
assert.equal(F.list().entries.length, 0)
})
})
test('upsert: missing sessionId/seq is rejected, no file written', () => {
withTmpHome(() => {
const F = freshRequire()
assert.equal(F.upsert({ seq: 7, verdict: 'up' }).ok, false)
assert.equal(F.upsert({ sessionId: 's1', verdict: 'up' }).ok, false)
assert.equal(F.list().entries.length, 0)
})
})
test('remove: drops a record; removing a missing one is a no-op ok', () => {
withTmpHome(() => {
const F = freshRequire()
F.upsert({ sessionId: 's1', seq: 7, verdict: 'up', note: 'x' })
assert.deepEqual(F.remove({ sessionId: 's1', seq: 7 }), { ok: true, removed: true })
assert.deepEqual(F.remove({ sessionId: 's1', seq: 7 }), { ok: true, removed: false })
assert.equal(F.list().entries.length, 0)
})
})
test('annotationsPath lands under DSH_DESKTOP_HOME', () => {
withTmpHome((home) => {
const F = freshRequire()
assert.equal(F.annotationsPath(), path.join(home, 'feedback-annotations.json'))
F.upsert({ sessionId: 's1', seq: 7, verdict: 'up', note: 'x' })
assert.equal(fs.existsSync(path.join(home, 'feedback-annotations.json')), true)
})
})

View File

@@ -25,6 +25,7 @@ test('normalizeTab: known tabs pass through; unknown falls back to pretty', () =
assert.equal(inspector.normalizeTab('pretty'), 'pretty')
assert.equal(inspector.normalizeTab('raw'), 'raw')
assert.equal(inspector.normalizeTab('json'), 'json')
assert.equal(inspector.normalizeTab('feedback'), 'feedback')
assert.equal(inspector.normalizeTab('bogus'), 'pretty')
assert.equal(inspector.normalizeTab(undefined), 'pretty')
})
@@ -342,9 +343,11 @@ test('index.html: #inspector-drawer aside with three tabs + panels exists', () =
assert.match(html, /data-tab="pretty"/, 'Pretty tab button')
assert.match(html, /data-tab="raw"/, 'Raw tab button')
assert.match(html, /data-tab="json"/, 'JSON tab button')
assert.match(html, /data-tab="feedback"/, 'Feedback tab button')
assert.match(html, /data-panel="pretty"/, 'Pretty panel')
assert.match(html, /data-panel="raw"/, 'Raw panel')
assert.match(html, /data-panel="json"/, 'JSON panel')
assert.match(html, /data-panel="feedback"/, 'Feedback panel')
assert.match(html, /id="inspector-drawer-close"/, 'close button target for the × / Escape bindings')
})
@@ -385,14 +388,14 @@ function buildDrawerDom(doc) {
drawer.setAttribute('aria-hidden', 'true')
const title = doc.createElement('div'); title.className = 'inspector-drawer-title'; title.textContent = 'inspector'
drawer.appendChild(title)
for (const t of ['pretty', 'raw', 'json']) {
for (const t of ['pretty', 'raw', 'json', 'feedback']) {
const tab = doc.createElement('button')
tab.className = 'inspector-tab' + (t === 'pretty' ? ' active' : '')
tab.dataset.tab = t
tab.setAttribute('aria-selected', t === 'pretty' ? 'true' : 'false')
drawer.appendChild(tab)
}
for (const p of ['pretty', 'raw', 'json']) {
for (const p of ['pretty', 'raw', 'json', 'feedback']) {
const panel = doc.createElement('div')
panel.className = 'inspector-panel'
panel.dataset.panel = p
@@ -512,3 +515,113 @@ test('close(): removes the open class + marks aria-hidden', () => {
assert.equal(drawer.getAttribute('aria-hidden'), 'true')
} finally { cleanupDom() }
})
// --- Feedback tab (lane-wf-feedback) --------------------------------------
test('renderFeedback: builds verdict thumbs, rubric select, note, and Save; injected dims populate the select', () => {
const { doc } = makeShim()
const host = doc.createElement('div')
inspector.renderFeedback(doc, host, {
event: { type: 'assistant/message', seq: 7 },
sessionId: 'sess-1',
dimensions: [{ id: 'convergence', label: 'Convergence' }, { id: 'no-regression', label: 'No regression' }],
existing: null,
onSave: () => {},
})
assert.ok(host.querySelector('.inspector-feedback'), 'feedback form root renders')
assert.ok(host.querySelector('[aria-label="Thumbs up"]'), 'thumbs-up button')
assert.ok(host.querySelector('[aria-label="Thumbs down"]'), 'thumbs-down button')
const select = host.querySelector('.inspector-feedback-dim-select')
assert.ok(select, 'rubric dimension select renders')
// (none) + 2 injected dims = 3 options
assert.equal(select.children.length, 3)
assert.ok(host.querySelector('.inspector-feedback-note-input'), 'note textarea')
assert.ok(host.querySelector('.inspector-feedback-save'), 'Save button')
})
test('renderFeedback: an existing annotation prefills verdict, note, and rubric dim', () => {
const { doc } = makeShim()
const host = doc.createElement('div')
inspector.renderFeedback(doc, host, {
event: { type: 'assistant/message', seq: 7 },
sessionId: 'sess-1',
dimensions: [{ id: 'convergence', label: 'Convergence' }],
existing: { sessionId: 'sess-1', seq: 7, verdict: 'up', note: 'good turn', rubricDim: 'convergence' },
onSave: () => {},
})
const up = host.querySelector('[aria-label="Thumbs up"]')
assert.equal(up.classList.contains('active'), true, 'thumbs-up reflects the stored verdict')
const note = host.querySelector('.inspector-feedback-note-input')
assert.equal(note.value, 'good turn')
const select = host.querySelector('.inspector-feedback-dim-select')
assert.equal(select.value, 'convergence')
})
test('renderFeedback: Save collects the form and hands it to onSave', () => {
const { doc } = makeShim()
const host = doc.createElement('div')
let captured = null
inspector.renderFeedback(doc, host, {
event: { type: 'assistant/message', seq: 12 },
sessionId: 'sess-9',
dimensions: [{ id: 'convergence', label: 'Convergence' }],
existing: null,
onSave: (form) => { captured = form; return { ok: true } },
})
// Toggle thumbs-up, type a note, pick a dim, then Save.
host.querySelector('[aria-label="Thumbs up"]').dispatch('click')
host.querySelector('.inspector-feedback-note-input').value = 'needs work'
host.querySelector('.inspector-feedback-dim-select').value = 'convergence'
host.querySelector('.inspector-feedback-save').dispatch('click')
assert.ok(captured, 'onSave fired')
assert.equal(captured.sessionId, 'sess-9')
assert.equal(captured.seq, 12)
assert.equal(captured.verdict, 'up')
assert.equal(captured.note, 'needs work')
assert.equal(captured.rubricDim, 'convergence')
})
test('renderFeedback: a second click on the active verdict clears it (toggle to null)', () => {
const { doc } = makeShim()
const host = doc.createElement('div')
let captured = null
inspector.renderFeedback(doc, host, {
event: { type: 'assistant/message', seq: 3 },
sessionId: 's',
dimensions: [],
existing: { sessionId: 's', seq: 3, verdict: 'down', note: '' },
onSave: (form) => { captured = form },
})
const down = host.querySelector('[aria-label="Thumbs down"]')
assert.equal(down.classList.contains('active'), true)
down.dispatch('click') // toggle off
assert.equal(down.classList.contains('active'), false)
host.querySelector('.inspector-feedback-save').dispatch('click')
assert.equal(captured.verdict, null)
})
test('open(): Feedback tab renders the annotation form anchored to the event', () => {
const { ins, drawer } = loadInspectorWithDom()
try {
ins.open({ event: { type: 'assistant/message', seq: 7, data: { content: [] } }, tab: 'feedback', sessionId: 'sess-x' })
const panel = drawer.querySelector('.inspector-panel[data-panel="feedback"]')
assert.equal(panel.hidden, false, 'feedback panel shows')
assert.ok(panel.querySelector('.inspector-feedback'), 'feedback form mounted')
const feedbackTab = drawer.querySelector('.inspector-tab[data-tab="feedback"]')
assert.equal(feedbackTab.getAttribute('aria-selected'), 'true')
} finally { cleanupDom() }
})
test('attachInspectBadge: stamps (sessionId, seq) on the badge for marker refresh', () => {
const { ins, doc } = loadInspectorWithDom()
try {
const host = doc.createElement('div')
const badge = ins.attachInspectBadge(host, () => ({
event: { type: 'assistant/message', seq: 42 }, tab: 'pretty', sessionId: 'sess-7',
}))
assert.ok(badge, 'badge created')
assert.equal(badge.getAttribute('data-annot-session'), 'sess-7')
assert.equal(badge.getAttribute('data-annot-seq'), '42')
} finally { cleanupDom() }
})

View File

@@ -149,6 +149,15 @@ const NON_IIFE_ALLOWLIST = new Set([
// require()s it so it must not be IIFE-wrapped. Sole top-level binding is
// `function createMsgQueue`, unique across the shared scope.
'msg-queue-model.js',
// lane-wf-feedback (2026-07-20) two dual-exported pure models — same shape
// as msg-queue-model.js. CommonJS require for node --test, window.__dsh*
// for the renderer; neither is IIFE-wrapped.
// workflow-live-model.js — folds on-wire workflow.event frames into
// the aggregate buildWorkflowCard model.
// feedback-annotation-model.js — per-event RL-annotation index behind the
// inspector Feedback tab.
'workflow-live-model.js',
'feedback-annotation-model.js',
])
function listRendererScripts() {

View File

@@ -0,0 +1,149 @@
// workflow-live-model.test.js — lane-wf-feedback item 1.
//
// Verifies the pure accumulator that folds the on-wire `workflow.event` train
// (runtime commit dd29d8631) into the aggregate {name, kind:'seq', steps[]}
// model workflow-view.buildWorkflowCard consumes.
//
// Covered:
// 1. A full six-event run projects a linear seq card (agents → steps).
// 2. agent-start → running; agent-end outcome=completed → done; failed → failed.
// 3. Steps are ordered by engine `seq`, not arrival order.
// 4. phase/log frames fold onto run state without inventing steps.
// 5. Unknown kind + malformed frames are dropped (apply → null), no run made.
// 6. The wire shape from the runtime's own server.spec fixture round-trips.
// 7. toCard on an unknown run returns null; forget/clear drop runs.
'use strict'
const test = require('node:test')
const assert = require('node:assert')
const { createWorkflowLiveModel, statusForOutcome } = require('../src/renderer/workflow-live-model.js')
// The exact emit shapes from packages/ui/jsonrpc/tests/server.spec.ts in the
// runtime repo (the bridge's own test). runId 'run-42', one agent.
const META = { name: 'test-flow', description: 'demo' }
function frame(kind, payload) {
const f = { kind, runId: 'run-42', meta: META }
if (payload !== undefined) f.payload = payload
return f
}
test('folds the six-event run into a linear seq card', () => {
const m = createWorkflowLiveModel()
assert.strictEqual(m.apply(frame('workflow/start')), 'run-42')
m.apply(frame('workflow/phase', 'Scan'))
m.apply(frame('workflow/log', 'starting with 2 files'))
m.apply(frame('workflow/agent-start', { seq: 1, label: 'read a.ts', phase: 'Scan', childId: 'child-1' }))
m.apply(frame('workflow/agent-end', { seq: 1, label: 'read a.ts', phase: 'Scan', childId: 'child-1', outcome: 'completed' }))
m.apply(frame('workflow/end', { stopReason: 'completed', agentsStarted: 1 }))
const card = m.toCard('run-42')
assert.strictEqual(card.name, 'test-flow')
assert.strictEqual(card.kind, 'seq')
assert.strictEqual(card.steps.length, 1)
assert.strictEqual(card.steps[0].id, 'child-1')
assert.strictEqual(card.steps[0].name, 'read a.ts')
assert.strictEqual(card.steps[0].status, 'done')
assert.strictEqual(card._live, true)
assert.strictEqual(card._done, true)
assert.strictEqual(card._stopReason, 'completed')
assert.strictEqual(card._phase, 'Scan')
assert.deepStrictEqual(card._logs, ['starting with 2 files'])
})
test('agent-start marks running until agent-end settles the status', () => {
const m = createWorkflowLiveModel()
m.apply(frame('workflow/agent-start', { seq: 1, label: 'step one', childId: 'c1' }))
let card = m.toCard('run-42')
assert.strictEqual(card.steps[0].status, 'running')
m.apply(frame('workflow/agent-end', { seq: 1, childId: 'c1', outcome: 'completed' }))
card = m.toCard('run-42')
assert.strictEqual(card.steps[0].status, 'done')
assert.strictEqual(card.steps[0].output, 'completed')
})
test('a failed outcome maps to a failed step', () => {
const m = createWorkflowLiveModel()
m.apply(frame('workflow/agent-start', { seq: 2, label: 'boom', childId: 'c2' }))
m.apply(frame('workflow/agent-end', { seq: 2, childId: 'c2', outcome: 'failed' }))
const card = m.toCard('run-42')
assert.strictEqual(card.steps[0].status, 'failed')
assert.strictEqual(statusForOutcome('failed'), 'failed')
assert.strictEqual(statusForOutcome('aborted'), 'failed')
assert.strictEqual(statusForOutcome('completed'), 'done')
assert.strictEqual(statusForOutcome(undefined), 'done')
})
test('steps are ordered by engine seq regardless of arrival order', () => {
const m = createWorkflowLiveModel()
m.apply(frame('workflow/agent-start', { seq: 3, label: 'third', childId: 'c3' }))
m.apply(frame('workflow/agent-start', { seq: 1, label: 'first', childId: 'c1' }))
m.apply(frame('workflow/agent-start', { seq: 2, label: 'second', childId: 'c2' }))
const card = m.toCard('run-42')
assert.deepStrictEqual(card.steps.map((s) => s.name), ['first', 'second', 'third'])
})
test('phase and log frames fold onto run state without inventing steps', () => {
const m = createWorkflowLiveModel()
m.apply(frame('workflow/start'))
m.apply(frame('workflow/phase', 'Plan'))
m.apply(frame('workflow/log', 'line 1'))
m.apply(frame('workflow/log', 'line 2'))
const card = m.toCard('run-42')
assert.strictEqual(card.steps.length, 0)
assert.strictEqual(card._phase, 'Plan')
assert.deepStrictEqual(card._logs, ['line 1', 'line 2'])
})
test('phase accepts either a bare string or a { title } object', () => {
const m = createWorkflowLiveModel()
m.apply(frame('workflow/start'))
m.apply(frame('workflow/phase', { title: 'Verify' }))
assert.strictEqual(m.toCard('run-42')._phase, 'Verify')
})
test('unknown kind and malformed frames are dropped', () => {
const m = createWorkflowLiveModel()
assert.strictEqual(m.apply({ kind: 'workflow/bogus', runId: 'x', meta: META }), null)
assert.strictEqual(m.apply(null), null)
assert.strictEqual(m.apply(undefined), null)
assert.strictEqual(m.apply({ kind: 'workflow/start' }), null) // no runId
assert.strictEqual(m.apply({ kind: 'workflow/start', runId: null }), null)
assert.strictEqual(m.apply('not-an-object'), null)
assert.strictEqual(m.runs.size, 0)
})
test('agent frames with a non-finite seq are dropped', () => {
const m = createWorkflowLiveModel()
m.apply(frame('workflow/start'))
m.apply(frame('workflow/agent-start', { label: 'no-seq', childId: 'c' }))
m.apply(frame('workflow/agent-start', { seq: 'abc', label: 'bad-seq', childId: 'c2' }))
assert.strictEqual(m.toCard('run-42').steps.length, 0)
})
test('toCard on an unknown run is null; forget/clear drop runs', () => {
const m = createWorkflowLiveModel()
assert.strictEqual(m.toCard('missing'), null)
m.apply(frame('workflow/start'))
assert.strictEqual(m.hasRun('run-42'), true)
m.forget('run-42')
assert.strictEqual(m.hasRun('run-42'), false)
m.apply(frame('workflow/start'))
m.clear()
assert.strictEqual(m.runs.size, 0)
})
test('a run named only by runId falls back to runId as the card name', () => {
const m = createWorkflowLiveModel()
m.apply({ kind: 'workflow/start', runId: 'run-9' }) // no meta
assert.strictEqual(m.toCard('run-9').name, 'run-9')
})
test('a later frame backfills a name the start frame lacked', () => {
const m = createWorkflowLiveModel()
m.apply({ kind: 'workflow/start', runId: 'run-7' })
m.apply({ kind: 'workflow/phase', runId: 'run-7', meta: { name: 'late-name', description: 'd' }, payload: 'P' })
assert.strictEqual(m.toCard('run-7').name, 'late-name')
})

View File

@@ -137,6 +137,17 @@ test('isMock flag adds the mock chip; without it, chip is absent', () => {
assert.strictEqual(collectByClass(withoutMock, 'workflow-card-chip--mock').length, 0)
})
test('isLive flag adds the live chip (not the mock chip); the two are exclusive', () => {
const seq = loadFixture('1.6-workflow-seq.json')
const live = view.buildWorkflowCard(makeDoc(), seq.workflow, { isLive: true })
assert.strictEqual(collectByClass(live, 'workflow-card-chip--live').length, 1)
assert.strictEqual(collectByClass(live, 'workflow-card-chip--mock').length, 0)
// isMock wins when both are (wrongly) set — a card is never both live+mock.
const both = view.buildWorkflowCard(makeDoc(), seq.workflow, { isMock: true, isLive: true })
assert.strictEqual(collectByClass(both, 'workflow-card-chip--mock').length, 1)
assert.strictEqual(collectByClass(both, 'workflow-card-chip--live').length, 0)
})
test('showReplayBar mounts prev/next and clamps at ends', () => {
const doc = makeDoc()
const seq = loadFixture('1.6-workflow-seq.json')