Files
deepseek-harness/examples/desktop/src/renderer/chat-side-drawer.js
ZiyaZhang 88bd4ac244 fix(desktop): stability batch — LCS bound / barge-in flush / refresh throttle / history bucket / fusion seed dedupe
P0-2 artifacts-board.js: guard against LCS OOM on large blob diffs; fall back to note-based render when either side exceeds bound.
P1-2 chat-side-drawer.js: flush pending turn on barge-in (user sends before assistant frame flushes), so drawer stays in sync with graph.
P1-3 chat-refresh-throttle.js (new) + renderer.js: coalesce chat + graph refreshes into per-frame flush to prevent visual jitter under high-frequency intervention/refresh events.
P1-4 artifacts.js: history entries bucket by sessionId; cross-session artifacts no longer bleed into the currently-open session's history rail.
P1-5 rubric-fusion-model.js: seedOnce idempotency guard — repeated seed calls no longer duplicate rubric fixture rows (was surfacing as double-counted evolution nodes).
test/code-bugs-batch.test.js (new, 9 assertions) covers all 5 fixes; test/artifact-evolution-board.test.js updated to align with bySession bucket shape.

9 files, +520/-43. Behavioral fixes only, no API surface change.
2026-07-19 01:31:39 -07:00

278 lines
10 KiB
JavaScript

// chat-side-drawer.js — right-side fold-out drawer for the Chat pane.
//
// Three sections rendered in order:
// 1. Current Turn — model / tokens / duration / latency / session id /
// turn seq for the selected turn (defaults to the newest one).
// 2. Session Overview — running totals: tokens, duration, turn count.
// 3. History — one row per turn (role tag + first-line summary); click
// jumps main stream to that turn.
//
// State: pure over a session-like snapshot. The renderer wires it via
// `renderChatSideDrawer(container, snapshot)` and toggle by adding /
// removing `hidden` on the drawer aside. Nothing here talks to the DOM
// beyond the passed-in container.
'use strict'
;(function () {
// Derive the ordered turn list from a cachedEvents ring. A turn is
// bounded by turn/start .. turn/end pairs; user/message events sit
// between turns. We flatten to a stream of "history rows" the drawer
// renders — one row per user message + one row per assistant turn.
function deriveTurnRows(events) {
if (!Array.isArray(events) || events.length === 0) return []
const rows = []
let currentTurn = null
let turnIdx = 0
for (const evt of events) {
if (!evt || typeof evt !== 'object') continue
const type = evt.type || evt.event || ''
const data = evt.data || {}
if (type === 'user/message') {
// Barge-in: if a turn is still open (assistant was streaming when the
// user sent a new message and no turn/end has arrived yet), seal it
// as interrupted before we push the incoming user row. Otherwise the
// next turn/start would overwrite currentTurn and the in-flight turn
// would lose its tokens/duration/summary — History renders it blank.
if (currentTurn) {
currentTurn.interrupted = true
currentTurn = null
}
const text = extractText(data)
rows.push({
kind: 'user',
role: 'user',
summary: firstLine(text) || '(empty)',
turnIndex: null,
seq: evt.seq || 0,
turnId: null,
})
} else if (type === 'turn/start' || type === 'turn.start') {
// Same guard on the turn/start side: a rapid double-turn (turn/start
// arriving before the previous turn/end) shouldn't drop the older
// turn's summary either. Seal current before overwriting.
if (currentTurn) {
currentTurn.interrupted = true
}
currentTurn = {
kind: 'turn',
role: 'agent',
summary: '',
turnIndex: turnIdx,
seq: evt.seq || 0,
turnId: data.turnId || data.turn_id || `t${turnIdx}`,
model: data.model || '',
tokens: 0,
durationMs: 0,
latencyMs: 0,
interrupted: false,
}
turnIdx += 1
rows.push(currentTurn)
} else if (currentTurn && (type === 'assistant/message' || type === 'assistant.message')) {
const text = extractText(data)
if (!currentTurn.summary) currentTurn.summary = firstLine(text)
} else if (currentTurn && (type === 'turn/end' || type === 'turn.end')) {
const usage = data.usage || {}
currentTurn.tokens = num(usage.total_tokens || usage.totalTokens || usage.tokens || data.tokens)
currentTurn.durationMs = num(data.durationMs || data.duration_ms || 0)
currentTurn.latencyMs = num(data.latencyMs || data.latency_ms || 0)
currentTurn.model = currentTurn.model || data.model || ''
const stop = (data.stopReason || data.stop_reason || '').toString().toLowerCase()
if (stop.includes('cancel') || stop.includes('interrupt') || stop.includes('reject')) {
currentTurn.interrupted = true
}
currentTurn = null
} else if (currentTurn && (type === 'user/interrupt' || type === 'user/cancel')) {
currentTurn.interrupted = true
}
}
return rows
}
function extractText(data) {
if (!data) return ''
if (typeof data === 'string') return data
if (typeof data.text === 'string') return data.text
if (typeof data.content === 'string') return data.content
if (Array.isArray(data.content)) {
return data.content.map((c) => (c && typeof c.text === 'string') ? c.text : '').join(' ')
}
if (typeof data.delta === 'string') return data.delta
return ''
}
function firstLine(text) {
if (typeof text !== 'string') return ''
const trimmed = text.trim()
if (!trimmed) return ''
const nl = trimmed.indexOf('\n')
const line = nl === -1 ? trimmed : trimmed.slice(0, nl)
return line.length > 80 ? line.slice(0, 79) + '…' : line
}
function num(x) {
const n = Number(x)
return Number.isFinite(n) ? n : 0
}
function formatMs(ms) {
if (!Number.isFinite(ms) || ms <= 0) return '—'
if (ms < 1000) return `${Math.round(ms)}ms`
return `${(ms / 1000).toFixed(1)}s`
}
// Compute session-level overview from the derived rows.
function summarize(rows) {
const turns = rows.filter((r) => r.kind === 'turn')
const tokens = turns.reduce((acc, t) => acc + (t.tokens || 0), 0)
const duration = turns.reduce((acc, t) => acc + (t.durationMs || 0), 0)
return {
turnCount: turns.length,
userCount: rows.filter((r) => r.kind === 'user').length,
tokens,
duration,
interrupted: turns.filter((t) => t.interrupted).length,
}
}
// Render a drawer body into a container element. `snapshot`:
// { sessionId, model?, events, selectedTurnId? }
function renderChatSideDrawer(container, snapshot) {
if (!container) return
// Clear
container.textContent = ''
container.className = 'chat-side-drawer-body'
const doc = container.ownerDocument || document
const rows = deriveTurnRows(snapshot && snapshot.events)
const overview = summarize(rows)
const selectedTurnId = snapshot && snapshot.selectedTurnId
const turns = rows.filter((r) => r.kind === 'turn')
const selected = turns.find((t) => t.turnId === selectedTurnId) || turns[turns.length - 1] || null
// Section 1: current turn
container.appendChild(renderCurrentTurn(doc, snapshot || {}, selected))
// Section 2: session overview
container.appendChild(renderOverview(doc, overview))
// Section 3: history
container.appendChild(renderHistory(doc, rows, selectedTurnId, snapshot && snapshot.onSelect))
}
function renderCurrentTurn(doc, snapshot, turn) {
const section = doc.createElement('section')
section.className = 'chat-side-drawer-section chat-side-drawer-section--current'
const title = doc.createElement('div')
title.className = 'chat-side-drawer-section-title'
title.textContent = 'Current Turn'
section.appendChild(title)
const dl = doc.createElement('dl')
dl.className = 'chat-side-drawer-meta'
const entries = []
if (turn) {
entries.push(['seq', turn.turnIndex != null ? `#${turn.turnIndex}` : '—'])
entries.push(['model', turn.model || snapshot.model || '—'])
entries.push(['tokens', turn.tokens ? String(turn.tokens) : '—'])
entries.push(['duration', formatMs(turn.durationMs)])
entries.push(['latency', formatMs(turn.latencyMs)])
entries.push(['turn id', turn.turnId || '—'])
entries.push(['session', shortSid(snapshot.sessionId)])
if (turn.interrupted) entries.push(['state', 'interrupted'])
} else {
entries.push(['state', 'no turn yet'])
entries.push(['session', shortSid(snapshot.sessionId)])
}
for (const [k, v] of entries) {
const dt = doc.createElement('dt'); dt.textContent = k
const dd = doc.createElement('dd'); dd.textContent = v
dl.appendChild(dt); dl.appendChild(dd)
}
section.appendChild(dl)
return section
}
function renderOverview(doc, overview) {
const section = doc.createElement('section')
section.className = 'chat-side-drawer-section chat-side-drawer-section--overview'
const title = doc.createElement('div')
title.className = 'chat-side-drawer-section-title'
title.textContent = 'Session Overview'
section.appendChild(title)
const dl = doc.createElement('dl')
dl.className = 'chat-side-drawer-meta'
const entries = [
['turns', String(overview.turnCount)],
['user msgs', String(overview.userCount)],
['tokens', String(overview.tokens)],
['duration', formatMs(overview.duration)],
['interrupts', String(overview.interrupted)],
]
for (const [k, v] of entries) {
const dt = doc.createElement('dt'); dt.textContent = k
const dd = doc.createElement('dd'); dd.textContent = v
dl.appendChild(dt); dl.appendChild(dd)
}
section.appendChild(dl)
return section
}
function renderHistory(doc, rows, selectedTurnId, onSelect) {
const section = doc.createElement('section')
section.className = 'chat-side-drawer-section chat-side-drawer-section--history'
const title = doc.createElement('div')
title.className = 'chat-side-drawer-section-title'
title.textContent = 'History'
section.appendChild(title)
if (rows.length === 0) {
const empty = doc.createElement('div')
empty.className = 'chat-side-drawer-empty'
empty.textContent = 'No turns yet — send a message to start.'
section.appendChild(empty)
return section
}
const ul = doc.createElement('ul')
ul.className = 'chat-side-drawer-history'
for (const row of rows) {
const li = doc.createElement('li')
li.className = 'chat-side-drawer-history-item'
if (row.turnId && row.turnId === selectedTurnId) li.classList.add('active')
if (row.turnId) li.dataset.turnId = row.turnId
if (row.seq) li.dataset.seq = String(row.seq)
li.dataset.kind = row.kind
const roleEl = doc.createElement('span')
roleEl.className = 'chat-side-drawer-history-role'
roleEl.textContent = row.role
const sumEl = doc.createElement('span')
sumEl.className = 'chat-side-drawer-history-summary'
sumEl.textContent = row.summary || (row.kind === 'turn' ? `(turn ${row.turnIndex})` : '(user)')
li.appendChild(roleEl)
li.appendChild(sumEl)
if (typeof onSelect === 'function') {
li.addEventListener('click', () => onSelect(row))
}
ul.appendChild(li)
}
section.appendChild(ul)
return section
}
function shortSid(sid) {
if (typeof sid !== 'string' || !sid) return '—'
return sid.length > 10 ? sid.slice(0, 8) + '…' : sid
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
deriveTurnRows,
summarize,
firstLine,
formatMs,
renderChatSideDrawer,
}
}
if (typeof window !== 'undefined') {
window.__dshChatSideDrawer = {
deriveTurnRows,
summarize,
firstLine,
formatMs,
renderChatSideDrawer,
}
}
})()