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