// 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
// 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));
}
}
async function backfill() {
if (!opts.historyUrl) {
if (opts.onBackfillDone) opts.onBackfillDone(0);
return;
}
try {
const resp = await fetch(opts.historyUrl);
if (!resp.ok) {
if (opts.onBackfillDone) opts.onBackfillDone(0);
return;
}
const events = await resp.json();
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)');
if (opts.onBackfillDone) opts.onBackfillDone(events.length);
} catch (err) {
console.warn('history backfill failed', err);
if (opts.onBackfillDone) opts.onBackfillDone(0);
}
}
function subscribe() {
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; }
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]');
};
return es;
}
const ready = backfill().then(subscribe);
return { row, details, detailsDiff, placeholder, ready };
}
window.HiveTerminal = { create };
})();