diff --git a/examples/desktop/src/renderer/artifacts-board.js b/examples/desktop/src/renderer/artifacts-board.js
index 51c8083d98..c240799013 100644
--- a/examples/desktop/src/renderer/artifacts-board.js
+++ b/examples/desktop/src/renderer/artifacts-board.js
@@ -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--) {
diff --git a/examples/desktop/src/renderer/artifacts.js b/examples/desktop/src/renderer/artifacts.js
index 67cf82418a..9a6c33d70b 100644
--- a/examples/desktop/src/renderer/artifacts.js
+++ b/examples/desktop/src/renderer/artifacts.js
@@ -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,
}
})()
diff --git a/examples/desktop/src/renderer/chat-refresh-throttle.js b/examples/desktop/src/renderer/chat-refresh-throttle.js
new file mode 100644
index 0000000000..fcb2629aed
--- /dev/null
+++ b/examples/desktop/src/renderer/chat-refresh-throttle.js
@@ -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
+
+})()
diff --git a/examples/desktop/src/renderer/chat-side-drawer.js b/examples/desktop/src/renderer/chat-side-drawer.js
index 27ae169ed3..38bbc49a2e 100644
--- a/examples/desktop/src/renderer/chat-side-drawer.js
+++ b/examples/desktop/src/renderer/chat-side-drawer.js
@@ -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',
diff --git a/examples/desktop/src/renderer/index.html b/examples/desktop/src/renderer/index.html
index d9b99ae016..a011a6a3ce 100644
--- a/examples/desktop/src/renderer/index.html
+++ b/examples/desktop/src/renderer/index.html
@@ -1407,6 +1407,7 @@
+
diff --git a/examples/desktop/src/renderer/renderer.js b/examples/desktop/src/renderer/renderer.js
index 82d0c796cc..87c24af7ec 100644
--- a/examples/desktop/src/renderer/renderer.js
+++ b/examples/desktop/src/renderer/renderer.js
@@ -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 /
diff --git a/examples/desktop/src/renderer/rubric-fusion-model.js b/examples/desktop/src/renderer/rubric-fusion-model.js
index fedcd7f5cd..42c4b994e9 100644
--- a/examples/desktop/src/renderer/rubric-fusion-model.js
+++ b/examples/desktop/src/renderer/rubric-fusion-model.js
@@ -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()
}
diff --git a/examples/desktop/test/artifact-evolution-board.test.js b/examples/desktop/test/artifact-evolution-board.test.js
index 66b8fd18ca..c771294dcc 100644
--- a/examples/desktop/test/artifact-evolution-board.test.js
+++ b/examples/desktop/test/artifact-evolution-board.test.js
@@ -274,11 +274,19 @@ test('artifacts.js: version chip is a