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.
This commit is contained in:
ZiyaZhang
2026-07-19 01:31:39 -07:00
parent 6c4d0ae805
commit 88bd4ac244
9 changed files with 520 additions and 43 deletions

View File

@@ -344,9 +344,17 @@
pane.append(note)
return pane
}
const result = diffLines(prev.blob, next.blob)
if (result && result.note) {
const note = document.createElement('div')
note.className = 'artifact-evolution-diff-note muted'
note.textContent = result.note
pane.append(note)
return pane
}
const diffEl = document.createElement('div')
diffEl.className = 'artifact-evolution-diff-body'
const lines = diffLines(prev.blob, next.blob)
const lines = Array.isArray(result) ? result : []
for (const ln of lines) {
const row = document.createElement('div')
row.className = `artifact-evolution-diff-line kind-${ln.kind}`
@@ -373,6 +381,16 @@
const bLines = b.split('\n')
const n = aLines.length
const m = bLines.length
// Blobs from real artifacts are bounded by the demo (single-page HTML,
// short markdown), but nothing in the pipe enforces that — a fixture
// or user-flagged file can be much larger. LCS is O(n·m) space and
// time, so we bail out on anything that would allocate over a small
// Int32 grid. The caller renders a "diff omitted" note in place of
// the pane. Threshold picked so 1e6 cells ≈ 4MB Int32 stays comfortable
// in the renderer heap.
if (n * m > 1e6 || n > 5000 || m > 5000) {
return { note: 'diff omitted (blob too large)', add: 0, del: 0 }
}
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1))
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {

View File

@@ -34,18 +34,47 @@
;(function () {
const streamEl = () => document.getElementById('stream')
// artifactId -> DOM element, so a same-path re-declare updates in place.
const cards = new Map()
// artifactId -> [{ version, seenAt, kind, path, blob? }, …] — populated
// as `artifact:event` fires. Fed to artifacts-board.js for Board/
// Timeline/Evolution renderers.
const history = new Map()
// The tab-bar container ("Artifact panel") wraps every artifact-group
// so the List / Board / Timeline tabs sit above the same event stream.
// Kept as a single, top-of-stream panel — the compact L0 rows still
// live inside it under the List view.
let panelEl = null
let currentView = 'list'
// Session-scoped state. Both `cards` (artifactId -> DOM element) and
// `history` (artifactId -> [{ version, seenAt, kind, path, blob? }, …])
// used to be module-level singletons, which meant switching from
// session A (three versions of foo.md) to session B (a fresh foo.md)
// and back showed the B versions inside A's Board/Timeline. Bucketing
// by sessionId isolates each session's stream while keeping the
// per-session state addressable when the user tabs back to A.
//
// Sentinel `__default__` receives artifact events that arrive before
// any session id is known (typical for early boot / debug fixture
// buttons). Once a real sessionId flows in, that bucket is orphaned;
// it never leaks across real sessions.
const DEFAULT_SESSION = '__default__'
const bySession = new Map() // sessionId -> { cards, history, panelEl, currentView }
let activeSessionId = DEFAULT_SESSION
function ensureBucket(sid) {
let b = bySession.get(sid)
if (!b) {
b = { cards: new Map(), history: new Map(), panelEl: null, currentView: 'list' }
bySession.set(sid, b)
}
return b
}
function bucket() { return ensureBucket(activeSessionId) }
// Accessors kept so the rest of the file reads naturally.
const cards = { get: (id) => bucket().cards.get(id), set: (id, v) => bucket().cards.set(id, v) }
const history = {
get: (id) => bucket().history.get(id),
set: (id, v) => bucket().history.set(id, v),
entries: () => bucket().history.entries(),
clear: () => bucket().history.clear(),
}
function setActiveSession(sid) {
const next = typeof sid === 'string' && sid ? sid : DEFAULT_SESSION
if (next === activeSessionId) return
activeSessionId = next
// Ensure the incoming session's bucket exists so subsequent event/
// switchView calls don't race on an unset entry.
ensureBucket(next)
}
// Kind-to-SVG map — inline stroke icons (currentColor, 1.6px stroke)
// so artifact rows match the minimalist icon language rather than
@@ -116,14 +145,14 @@
// View-level projections re-render on demand — the Board / Timeline
// views read live state on switch, so a dropped-in event during
// those views repaints the panel body.
if (currentView !== 'list') refreshView()
if (bucket().currentView !== 'list') refreshView()
return existing
}
const el = renderCard(entry)
cards.set(entry.artifactId, el)
const s = streamEl()
if (s) appendGrouped(s, el)
if (currentView !== 'list') refreshView()
if (bucket().currentView !== 'list') refreshView()
scrollToBottom()
return el
}
@@ -142,11 +171,12 @@
// First artifact of the session: build the panel + tab bar and
// append it to the stream. The panel owns a `.artifact-group` in
// its body which the List view uses as-is.
if (!panelEl || !panelEl.isConnected) {
panelEl = buildPanel()
stream.appendChild(panelEl)
const b = bucket()
if (!b.panelEl || !b.panelEl.isConnected) {
b.panelEl = buildPanel()
stream.appendChild(b.panelEl)
}
const groupHost = panelEl.querySelector('.artifact-group')
const groupHost = b.panelEl.querySelector('.artifact-group')
groupHost.appendChild(el)
}
@@ -184,19 +214,21 @@
}
function switchView(v) {
if (v === currentView) return
currentView = v
if (!panelEl) return
panelEl.dataset.view = v
for (const tab of panelEl.querySelectorAll('.artifact-panel-tab')) {
const b = bucket()
if (v === b.currentView) return
b.currentView = v
if (!b.panelEl) return
b.panelEl.dataset.view = v
for (const tab of b.panelEl.querySelectorAll('.artifact-panel-tab')) {
tab.setAttribute('aria-selected', tab.dataset.view === v ? 'true' : 'false')
}
refreshView()
}
function refreshView() {
if (!panelEl) return
const body = panelEl.querySelector('.artifact-panel-body')
const b = bucket()
if (!b.panelEl) return
const body = b.panelEl.querySelector('.artifact-panel-body')
if (!body) return
// The List view is stable DOM (the auto-grouped card rows). Board
// and Timeline are re-rendered from state on every switch — cheap,
@@ -205,7 +237,7 @@
const listGroup = body.querySelector('.artifact-group')
const stale = body.querySelectorAll('.artifact-board, .artifact-timeline')
for (const s of stale) s.remove()
if (currentView === 'list') {
if (b.currentView === 'list') {
if (listGroup) listGroup.hidden = false
return
}
@@ -214,11 +246,16 @@
const entries = collectLatestEntries()
const board = window.__dshArtifactsBoard
if (!board) return // module hasn't loaded yet; safe no-op
const view = currentView === 'board'
// Adapter so artifacts-board's Timeline sees a Map-shaped `history`
// (get / entries) even though ours is session-scoped through a
// bucket. Snapshot the current session's Map so the renderer can't
// observe writes from a subsequent session switch mid-render.
const historyMap = b.history
const view = b.currentView === 'board'
? board.renderBoard(entries, { openArtifact: (id) => window.dsh && window.dsh.openArtifact(id) })
: board.renderTimeline(entries, {
openArtifact: (id) => window.dsh && window.dsh.openArtifact(id),
history,
history: historyMap,
})
body.appendChild(view)
}
@@ -227,7 +264,7 @@
// Board tiles show. Timeline reads full history separately.
function collectLatestEntries() {
const out = []
for (const [id, arr] of history) {
for (const [id, arr] of history.entries()) {
if (!arr || arr.length === 0) continue
const latest = arr.reduce((a, b) => (a.version >= b.version ? a : b))
out.push({
@@ -421,11 +458,6 @@
setTimeout(() => el.classList.remove('artifact-flash'), 900)
}
function onArtifactEvent(entry) {
if (!entry || !entry.artifactId) return
ensureCard(entry)
}
// Debug menu button — mocks a write into the artifact dir via IPC.
function bindMockButton() {
const btn = document.getElementById('mock-artifact')
@@ -454,14 +486,35 @@
bindMockButton()
}
// Route artifact events on a session id if the payload carries one.
// Real ArtifactServer broadcasts include `sessionId` on the entry when
// the writing turn was scoped to a session; fixture events also stamp
// it. When missing, we keep writing into the DEFAULT_SESSION bucket
// (early boot / debug button fixtures).
function onArtifactEvent(entry) {
if (!entry || !entry.artifactId) return
if (typeof entry.sessionId === 'string' && entry.sessionId) {
setActiveSession(entry.sessionId)
}
ensureCard(entry)
}
// Expose the small API for the smoke tests + potential renderer-side
// reuse. `history` and `switchView` join the surface so fixture
// drivers can inspect state and QA can screenshot each view directly.
// Session-scoping (P1-4): `setActiveSession(sid)` is called by the
// renderer's selectSession() to swap buckets on session switch, and
// exposed to tests that drive multiple sessions.
window.__dshArtifacts = {
onArtifactEvent,
cards,
history,
switchView,
getView: () => currentView,
getView: () => bucket().currentView,
setActiveSession,
getActiveSessionId: () => activeSessionId,
// Test hook: raw session bucket map so unit tests can assert
// isolation without threading fixtures through the DOM.
_bySession: bySession,
}
})()

View File

@@ -0,0 +1,49 @@
// chat-refresh-throttle.js — rAF-coalesced throttle for the drawer + Session
// Graph refresh path.
//
// Long sessions (500+ events on replay/backfill) fire onSessionEvent
// hundreds of times per tick. The drawer and Session Graph each re-derive
// their full row/node list from cachedEvents (O(N)) on every refresh, so
// an unthrottled call site turns into O(N²) work on the main thread —
// typing visibly lags once the drawer is open. Coalescing multiple
// schedule() calls within a single rAF frame is sound: the derive is a
// pure function of cachedEvents, so intermediate ticks are always
// superseded by the final one.
//
// Kept tiny and dependency-free so tests can drive it with a fake `raf`
// hook without booting the renderer.
'use strict'
;(function () {
function create(callback, opts) {
opts = opts || {}
const raf = typeof opts.raf === 'function'
? opts.raf
: ((typeof window !== 'undefined' && window.requestAnimationFrame)
? window.requestAnimationFrame.bind(window)
: (cb) => setTimeout(cb, 16))
let pending = false
function schedule() {
if (pending) return
pending = true
raf(() => {
pending = false
try { callback() } catch (_) { /* callbacks own their errors */ }
})
}
return {
schedule,
// Test hook — lets a caller assert whether a schedule collapsed into
// an existing pending frame.
isPending() { return pending },
}
}
const api = { create }
if (typeof module !== 'undefined' && module.exports) module.exports = api
if (typeof window !== 'undefined') window.__dshChatRefreshThrottle = api
})()

View File

@@ -30,6 +30,15 @@ function deriveTurnRows(events) {
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',
@@ -40,6 +49,12 @@ function deriveTurnRows(events) {
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',

View File

@@ -1407,6 +1407,7 @@
<script src="./assistant-turn.js"></script><!-- task #162 rec 22-bis: assistant-turn container (consumes the three above) -->
<script src="./chat-side-drawer.js"></script><!-- feat/chat-triple-view: right-side turn/session detail drawer -->
<script src="./chat-session-graph.js"></script><!-- feat/chat-triple-view: session DAG (turn nodes + fork/interrupt edges) -->
<script src="./chat-refresh-throttle.js"></script><!-- fix/code-bugs-batch P1-3: rAF-coalesced throttle for drawer/graph refresh -->
<script src="./event-filter.js"></script>
<script src="./capabilities.js"></script>

View File

@@ -533,6 +533,12 @@ function updateEmptyStateVisibility() {
async function selectSession(id) {
state.activeSessionId = id
// fix/code-bugs-batch P1-4: swap the artifacts module to this session's
// bucket so Board/Timeline/Evolution stop mixing versions across
// sessions. No-op when the module is untouched by the app (tests).
if (window.__dshArtifacts && typeof window.__dshArtifacts.setActiveSession === 'function') {
window.__dshArtifacts.setActiveSession(id)
}
const meta = state.sessions.get(id)
// Header title: real title wins; empty/unnamed sessions read as "New chat"
// in the main pane (never in the sidebar — that's what the smart-title
@@ -4296,6 +4302,21 @@ function refreshChatSideDrawer() {
})
}
function refreshChatSideDrawerIfOpen() { if (isChatDrawerOpen()) refreshChatSideDrawer() }
// Long sessions (500+ events) dispatch onSessionEvent hundreds of times per
// second on replay/backfill. Both the drawer and Session Graph re-derive
// the full row/node list from cachedEvents on every call (O(N)), so an
// unthrottled refresh per event lands O(N²) work on the main thread and
// noticeably lags typing. Coalesce to at most one refresh per rAF frame —
// the derive is idempotent on the same event tail, so dropping intermediate
// ticks is safe. Extracted to chat-refresh-throttle.js for unit testing.
const __chatRefreshThrottle = window.__dshChatRefreshThrottle
? window.__dshChatRefreshThrottle.create(() => {
refreshChatSideDrawerIfOpen()
refreshSessionGraphIfActive()
})
: { schedule() { refreshChatSideDrawerIfOpen(); refreshSessionGraphIfActive() } }
function refreshChatSurfacesCoalesced() { __chatRefreshThrottle.schedule() }
if (chatSideDrawerBtn) {
chatSideDrawerBtn.addEventListener('click', () => setChatDrawerOpen(!isChatDrawerOpen()))
}
@@ -4664,9 +4685,9 @@ function onSessionEvent(sessionId, event) {
// feat/chat-triple-view: keep the right-side detail drawer + Session Graph
// in sync with the same event tick. Both no-op when their surface is
// hidden, so this is cheap when the user hasn't opened the drawer /
// switched to Graph yet.
refreshChatSideDrawerIfOpen()
refreshSessionGraphIfActive()
// switched to Graph yet. Coalesced via rAF so long sessions don't take
// an O(N²) hit from the O(N) derives.
refreshChatSurfacesCoalesced()
// §2.3 (batch 6) template triggers: pure module decides whether the event
// qualifies for a template card (T2 error recovery / T4 artifact preview /

View File

@@ -60,6 +60,12 @@ function createStore() {
events: [], // scoreEvent[]
similarClasses: [], // similarSessionsClass[]
subscribers: new Set(),
loadedFixtures: new WeakSet(), // seed refs already applied — see loadFixture
eventKeys: new Set(), // (ts|rubricId|dimId|sessionId|turnId|rolloutIdx)
// -> dedupe key for scoreEvents, so seedOnce
// getting called twice can't duplicate rows
// even if the caller passes a fresh seed
// literal each time.
}
function notify() {
@@ -134,6 +140,17 @@ function createStore() {
const spec = rubric.dims.find(d => d.id === raw.dimId)
if (!spec) return null
const ts = Number(raw.ts) || Date.now()
// Idempotence key: the three fusion pages (Rubrics / Growth / Runtime)
// each seedOnce() from the same fixture and share this singleton. If
// the seed's identity flag drifts across pages (or a caller passes a
// fresh literal each call), the event log would double up rows for
// the same (ts, rubricId, dimId, sessionId, turnId, rolloutIdx)
// coordinate. Deduping by that key here makes loadFixture safe to
// call from any number of pages without event-count inflation.
const rolloutForKey = Number.isFinite(Number(raw.rolloutIdx)) ? Number(raw.rolloutIdx) : ''
const key = `${ts}|${rubric.id}|${spec.id}|${raw.sessionId || ''}|${raw.turnId || ''}|${rolloutForKey}`
if (state.eventKeys.has(key)) return null
state.eventKeys.add(key)
const evt = {
ts,
rubricId: rubric.id,
@@ -154,11 +171,26 @@ function createStore() {
// Bulk load from a fixture JSON blob. Shape:
// { rubrics: [rubricDef], events: [scoreEvent], similarClasses: [class] }
//
// Called by each of the three pages' seedOnce()s. The per-page flags
// (rubrics-page fusionSeeded, growth-v2 state.seeded) mean each page
// fires it at most once, but the pages share this singleton store —
// so without dedupe here, two-page seeding would double-insert every
// event. Two-tier guard:
// 1. WeakSet on the fixture object ref (fast path for the common
// case where all pages read window.__dshRubricFusionSeed).
// 2. Per-event key dedupe inside addEvent() catches the case where a
// caller passes a fresh literal that happens to carry the same
// rows.
function loadFixture(json) {
if (!json || typeof json !== 'object') return { rubrics: 0, events: 0 }
if (state.loadedFixtures.has(json)) return { rubrics: 0, events: 0 }
state.loadedFixtures.add(json)
let rn = 0, en = 0
for (const r of json.rubrics || []) { if (registerRubric(r)) rn++ }
for (const e of json.events || []) { if (addEvent(e)) en++ }
// similarClasses is a projection, not accumulator — last-wins is fine
// and preserves the "the seed's opinion is authoritative" contract.
state.similarClasses = Array.isArray(json.similarClasses) ? json.similarClasses.slice() : []
notify()
return { rubrics: rn, events: en }
@@ -168,6 +200,11 @@ function createStore() {
state.rubrics.clear()
state.events.length = 0
state.similarClasses.length = 0
state.eventKeys.clear()
// loadedFixtures uses WeakSet — the fixture references outlive the
// store's memory of them anyway (window globals), but we drop the
// dedupe cache so a re-seed after clearAll works cleanly.
state.loadedFixtures = new WeakSet()
notify()
}