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:
@@ -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--) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})()
|
||||
|
||||
49
examples/desktop/src/renderer/chat-refresh-throttle.js
Normal file
49
examples/desktop/src/renderer/chat-refresh-throttle.js
Normal 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
|
||||
|
||||
})()
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user