268 lines
9.9 KiB
JavaScript
268 lines
9.9 KiB
JavaScript
// 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) => { /* side effects: notifications, state pokes */ },
|
|
// 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 <div class="row cls">
|
|
// api.details(cls, summary, body) → appends <details class="row cls">
|
|
// with a <pre.tool-body>
|
|
// 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));
|
|
}
|
|
}
|
|
|
|
// 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 };
|
|
})();
|