// Shared terminal pane: sticky-bottom log + "↓ N new" pill + history // backfill + live SSE. Pages provide a kind→renderer map; this module // owns scroll behaviour, animation suppression on backfill, and the // EventSource lifecycle. // // Usage: // // HiveTerminal.create({ // logEl: document.getElementById('msgflow'), // historyUrl: '/messages/history?limit=200', // optional // streamUrl: '/messages/stream', // renderers: { // sent: (ev, api) => api.row('msgrow sent', ...), // delivered: (ev, api) => api.row('msgrow delivered', ...), // _default: (ev, api) => api.row('note', JSON.stringify(ev)), // }, // onLiveEvent: (ev) => { /* live-only side effects (notif, state pokes) */ }, // onAnyEvent: (ev, { fromHistory }) => { /* runs for every event in // both backfill replay and live — use for derived views that need // the full picture (e.g. a per-recipient inbox built from broker // events) */ }, // onBackfillDone: (count) => { /* one-shot after history replay */ }, // pillAnchor: document.getElementById('msgflow').parentElement, // }); // // Renderers receive (ev, api) where api exposes: // // api.row(cls, text) → appends a flat
// api.details(cls, summary, body) → appends
// with a // api.detailsDiff(cls, summary, body) → ditto but body is line-coloured by // leading "+ " / "- " prefix // api.placeholder(text) → replaces log content with a single // muted "(placeholder)" row, cleared // on the next real row // api.fromHistory → true while backfill is replaying // // Each kind is dispatched to `renderers[ev.kind]`; unknown kinds fall // through to `renderers._default` (which itself defaults to a JSON-dump // note row). The convention is that the SSE/history endpoints emit // objects with a `kind` field. // // Backfill is best-effort: if `historyUrl` is unset or the fetch fails, // we skip straight to SSE. The optional `onBackfillDone(count)` hook // fires after replay finishes (or after a failed/skipped fetch with // count=0); pages use it to set state flags from the replayed history. (function () { const NEAR_BOTTOM_PX = 48; function create(opts) { const log = opts.logEl; if (!log) throw new Error('HiveTerminal.create: logEl is required'); const renderers = opts.renderers || {}; const defaultRender = renderers._default || ((ev, api) => api.row('note', JSON.stringify(ev))); const pillAnchor = opts.pillAnchor || log.parentElement || log; let placeholderEl = null; let pill = null; let unseen = 0; let currentNoAnim = false; function isNearBottom() { return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX; } function ensurePill() { if (pill) return pill; pill = document.createElement('button'); pill.type = 'button'; pill.className = 'tail-pill'; pill.addEventListener('click', () => { log.scrollTop = log.scrollHeight; }); pillAnchor.appendChild(pill); return pill; } function updatePill() { if (unseen <= 0) { if (pill) pill.classList.remove('visible'); return; } ensurePill(); pill.textContent = '↓ ' + unseen + ' new'; pill.classList.add('visible'); } log.addEventListener('scroll', () => { if (isNearBottom()) { unseen = 0; updatePill(); } }); function afterAppend() { if (currentNoAnim || isNearBottom()) { log.scrollTop = log.scrollHeight; } else { unseen += 1; updatePill(); } } function clearPlaceholder() { if (placeholderEl && placeholderEl.parentElement === log) { log.removeChild(placeholderEl); } placeholderEl = null; } function placeholder(text) { clearPlaceholder(); const e = document.createElement('div'); e.className = 'row note'; e.textContent = text; log.appendChild(e); placeholderEl = e; } function row(cls, text) { clearPlaceholder(); const e = document.createElement('div'); e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); e.textContent = text; log.appendChild(e); afterAppend(); return e; } function details(cls, summary, body) { clearPlaceholder(); const d = document.createElement('details'); d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); const s = document.createElement('summary'); s.textContent = summary; d.appendChild(s); const pre = document.createElement('pre'); pre.className = 'tool-body'; pre.textContent = body; d.appendChild(pre); log.appendChild(d); afterAppend(); return d; } function detailsDiff(cls, summary, body) { clearPlaceholder(); const d = document.createElement('details'); d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); const s = document.createElement('summary'); s.textContent = summary; d.appendChild(s); const pre = document.createElement('pre'); pre.className = 'tool-body diff-body'; for (const line of String(body).split('\n')) { const span = document.createElement('span'); if (line.startsWith('+ ')) span.className = 'diff-add'; else if (line.startsWith('- ')) span.className = 'diff-del'; else span.className = 'diff-ctx'; span.textContent = line + '\n'; pre.appendChild(span); } d.appendChild(pre); log.appendChild(d); afterAppend(); return d; } function api(extra) { return Object.assign({ row, details, detailsDiff, placeholder, fromHistory: false, }, extra || {}); } function dispatch(ev, fromHistory) { const r = renderers[ev.kind] || defaultRender; try { r(ev, api({ fromHistory })); } catch (err) { console.error('terminal renderer threw', ev, err); row('note', '[render err] ' + (err && err.message ? err.message : err)); } if (opts.onAnyEvent) { try { opts.onAnyEvent(ev, { fromHistory }); } catch (err) { console.error('onAnyEvent threw', err); } } } // Subscribe → buffer → fetch history → dedupe → apply. // // Race the SSE subscription opens before the history fetch starts. // Live events that land before history resolves are buffered, not // rendered. Once the history response (`{ seq, events }`) arrives we: // 1. Replay `events` (fromHistory=true). // 2. Drop buffered events with `seq <= history.seq` — they're // already reflected in the history rows above. // 3. Apply remaining buffered events (fromHistory=false). // 4. Switch to live mode: each new SSE event dispatches immediately. // // Without this dance an event that fires between history-fetch and // SSE-subscribe goes missing; without seq dedupe the same event // shows twice (once via history, once via live buffer). Both bugs // were latent before. // // If `historyUrl` is unset we skip the dance: buffered events apply // as live the moment the buffer flushes (no dedupe possible without // a boundary seq). function start() { let live = false; let buffered = []; const es = new EventSource(opts.streamUrl); es.onmessage = (e) => { let ev; try { ev = JSON.parse(e.data); } catch (err) { row('note', '[parse err] ' + e.data); return; } if (!live) { buffered.push(ev); return; } dispatch(ev, false); if (opts.onLiveEvent) { try { opts.onLiveEvent(ev); } catch (err) { console.error('onLiveEvent threw', err); } } }; es.onerror = () => { if (es.readyState === EventSource.CONNECTING) row('note', '[reconnecting…]'); else row('note', '[disconnected]'); }; function flushBuffered(boundarySeq) { const drained = buffered; buffered = []; live = true; for (const ev of drained) { // ev.seq is set by the server on live frames; absent/0 means // "no dedupe possible, apply." Historical replays via the // history endpoint carry no seq either way. if (boundarySeq != null && typeof ev.seq === 'number' && ev.seq <= boundarySeq) { continue; } dispatch(ev, false); if (opts.onLiveEvent) { try { opts.onLiveEvent(ev); } catch (err) { console.error('onLiveEvent threw', err); } } } } async function backfill() { if (!opts.historyUrl) { flushBuffered(null); if (opts.onBackfillDone) opts.onBackfillDone(0); return; } try { const resp = await fetch(opts.historyUrl); if (!resp.ok) { flushBuffered(null); if (opts.onBackfillDone) opts.onBackfillDone(0); return; } const body = await resp.json(); // Accept the envelope `{ seq, events }`. A bare array means // the server hasn't been updated to include seq yet — treat // it as "no dedupe possible." const events = Array.isArray(body) ? body : (body.events || []); const boundarySeq = Array.isArray(body) ? null : (body.seq ?? null); currentNoAnim = true; for (const ev of events) dispatch(ev, true); currentNoAnim = false; if (events.length) row('note', '─── live (older above) ───'); else placeholder('(connected — waiting for events)'); flushBuffered(boundarySeq); if (opts.onBackfillDone) opts.onBackfillDone(events.length); } catch (err) { console.warn('history backfill failed', err); flushBuffered(null); if (opts.onBackfillDone) opts.onBackfillDone(0); } } return backfill(); } const ready = start(); return { row, details, detailsDiff, placeholder, ready }; } window.HiveTerminal = { create }; })();