hyperhive/frontend/packages/shared/src/terminal.js
iris f60a90d752 shared terminal + docs: migrate sticky-scroll + backfill prose (#714 batch 1)
Substantial prose migration from @hive/shared/terminal.js (the
shared HiveTerminal factory backing #msgflow + #live across both
dashboard and per-agent UIs) into a new docs subsection.

Added to docs/web-ui.md as a new ### Shared terminal pane
subsection under ## Shape (shared by both) — ~78 lines of new
substantive prose:

- **api shape**: row / details / detailsDiff factory contract
- **Sticky-bottom + snap animation**: stickToBottom semantics +
  140ms ease-out vs 500ms browser default + 24px short-circuit;
  per-frame target re-eval extends destination through
  mid-animation mutations
- **Mid-animation scroll-event guard**: smoothScrollingUntil
  timestamp swallows the rAF-driven scroll events so the eased
  positions don't flip stickToBottom false partway
- **Post-append MutationObserver**: catches renderer mutations
  after api.row returns (badges, multi-line bodies, tool panes)
  + why programmatic scrollTop writes don't feedback-loop
- **Backfill + SSE**: history/stream envelope shape (seq, events),
  kind-aware seq dedupe at the boundary, .no-anim during replay,
  optional streamFactory for SharedWorker integration
- **linkify**: text-node-only autolink, XSS-safe by construction,
  trailing-punctuation strip

Collapsed in terminal.js (cookies en passant):
- #400 (snap animation timing — closed) × 3 → docs pointers
- #393 (post-append MutationObserver — closed) × 1 → docs pointer
- #375 (pre-append nearBottom snapshot — closed) × 1 → docs pointer
- #448 (streamFactory SharedWorker hook — closed) × 1 → docs pointer
- #163 (seq dedupe + onStreamOpen resync — closed) × 2 → drop
  cookies; substance lives in docs
- #233 (linkify) × 1 → docs pointer + terminal.css cookie scrub

terminal.js: 8 → 0 #NNN cookies (100% reduction).
terminal.css: 1 → 0 issue-ref cookies (remaining 1 match is a
hex color literal).
Net effect: ~50 lines of substantive WHY-prose moved out of
shared frontend into docs/web-ui.md, where it documents the
factory's contract for both consumer pages.
2026-05-31 15:18:31 +02:00

432 lines
17 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:
//
// import { create, linkify } from '@hive/shared/terminal.js';
//
// 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 */ },
// onStreamOpen: () => { /* fires on every EventSource (re)connect —
// use to re-sync snapshot-derived state after a reconnect gap */ },
// 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.
const NEAR_BOTTOM_PX = 48;
// Snap-to-bottom animation duration. See docs/web-ui.md::Shared
// terminal pane (Sticky-bottom + snap animation) for the 140ms-vs-
// 500ms-browser-default + 24px short-circuit rationale.
const SCROLL_ANIM_MS = 140;
const SCROLL_SNAP_PX = 24;
export 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;
// Sticky-bottom intent. True means "keep snapping to bottom on
// any mutation"; false means "the operator scrolled up — leave
// them alone". Updated synchronously from the scroll event
// handler so both programmatic scrollTop assignments and
// operator-driven wheel/drag stay in sync.
let stickToBottom = true;
// Scroll-handler gate during in-flight snap animations — see
// docs/web-ui.md::Shared terminal pane (Mid-animation scroll-event
// guard) for the eased-through-not-near-bottom rationale.
let smoothScrollingUntil = 0;
// rAF id for the current snap animation. Cancelled when a new
// snap starts so we never have two animations fighting over
// scrollTop.
let scrollAnimRaf = 0;
function isNearBottom() {
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
}
// Snap the log to the bottom with a brief eased animation. Cancels
// any in-flight frame loop so back-to-back snaps coalesce; falls
// back to instant scroll during `currentNoAnim` backfill replay
// or under SCROLL_SNAP_PX. See docs/web-ui.md::Shared terminal pane.
function snapToBottom(immediate) {
stickToBottom = true;
if (scrollAnimRaf) {
cancelAnimationFrame(scrollAnimRaf);
scrollAnimRaf = 0;
}
const target = log.scrollHeight - log.clientHeight;
const start = log.scrollTop;
const distance = target - start;
if (immediate || currentNoAnim || distance <= SCROLL_SNAP_PX) {
smoothScrollingUntil = 0;
log.scrollTop = target;
return;
}
smoothScrollingUntil = Date.now() + SCROLL_ANIM_MS + 80;
const t0 = performance.now();
const easeOut = (t) => 1 - Math.pow(1 - t, 3);
const step = (now) => {
const elapsed = now - t0;
const frac = Math.min(1, elapsed / SCROLL_ANIM_MS);
// Re-read target each frame so mutations landing mid-animation
// (the common case — a renderer appended badge / body bits
// after api.row returned) extend the destination smoothly
// rather than landing short.
const currentTarget = log.scrollHeight - log.clientHeight;
log.scrollTop = start + (currentTarget - start) * easeOut(frac);
if (frac < 1) {
scrollAnimRaf = requestAnimationFrame(step);
} else {
// Final exact settle on the as-of-now bottom.
log.scrollTop = log.scrollHeight - log.clientHeight;
scrollAnimRaf = 0;
}
};
scrollAnimRaf = requestAnimationFrame(step);
}
function ensurePill() {
if (pill) return pill;
pill = document.createElement('button');
pill.type = 'button';
pill.className = 'tail-pill';
pill.addEventListener('click', () => snapToBottom());
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', () => {
// Swallow scroll events during smooth-snap animations — see
// docs/web-ui.md::Shared terminal pane (Mid-animation scroll-event
// guard).
if (Date.now() < smoothScrollingUntil) return;
stickToBottom = isNearBottom();
if (stickToBottom) { unseen = 0; updatePill(); }
});
// Post-append mutation snap — catches renderer mutations that land
// after `api.row` returns (badges, multi-line bodies, tool
// panes). See docs/web-ui.md::Shared terminal pane (Post-append
// MutationObserver) for why the pre-append `afterAppend` hop
// alone isn't enough.
const mo = new MutationObserver(() => {
if (stickToBottom) snapToBottom();
});
mo.observe(log, { childList: true, subtree: true, characterData: true });
// Pre-append nearBottom snapshot drives the initial snap decision —
// see docs/web-ui.md::Shared terminal pane (Post-append
// MutationObserver) for why we need both this and the MO.
function afterAppend(wasNearBottom) {
if (currentNoAnim || wasNearBottom) {
snapToBottom();
} 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 wasNearBottom = isNearBottom();
const e = document.createElement('div');
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
e.appendChild(linkify(text));
log.appendChild(e);
afterAppend(wasNearBottom);
return e;
}
function details(cls, summary, body) {
clearPlaceholder();
const wasNearBottom = isNearBottom();
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.appendChild(linkify(body));
d.appendChild(pre);
log.appendChild(d);
afterAppend(wasNearBottom);
return d;
}
function detailsDiff(cls, summary, body) {
clearPlaceholder();
const wasNearBottom = isNearBottom();
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(wasNearBottom);
return d;
}
function api(extra) {
return Object.assign({
row, details, detailsDiff, placeholder, linkify,
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 = [];
// Optional streamFactory(url) → EventSource-shaped facade. Lets
// the dashboard hand the factory a SharedWorker-backed source so
// open hyperhive tabs share one upstream — see
// docs/web-ui.md::Shared terminal pane (Backfill + SSE). Default
// falls back to `new EventSource(url)`.
const es = opts.streamFactory
? opts.streamFactory(opts.streamUrl)
: 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 = () => {
// SharedWorker-backed facades expose `readyState` mirroring the
// upstream EventSource state; the native EventSource exposes the
// same. Either way the CONNECTING vs. closed distinction works.
if (es.readyState === 0 /* CONNECTING */) row('note', '[reconnecting…]');
else row('note', '[disconnected]');
};
es.onopen = () => {
// Fires on the initial connect and on every automatic
// reconnect. EventSource never replays events that fired
// during a disconnect window, so a consumer with
// snapshot-derived state (the dashboard's /api/state stores)
// must re-sync here or it shows stale state until a manual
// reload.
if (opts.onStreamOpen) {
try { opts.onStreamOpen(); }
catch (err) { console.error('onStreamOpen threw', err); }
}
};
function flushBuffered(boundarySeq, historyKinds) {
const drained = buffered;
buffered = [];
live = true;
for (const ev of drained) {
// Seq-dedupe only events of a kind that actually appeared in
// the history replay — those are the only ones that could
// double (once via history, once via the live buffer).
// Mutation events (approval/question/container/…) are never
// carried by the history endpoint; deduping them against the
// broker-history seq would wrongly drop ones that fired
// between a consumer's own snapshot read and this history
// fetch. ev.seq absent/0 → no dedupe possible.
if (boundarySeq != null
&& typeof ev.seq === 'number' && ev.seq <= boundarySeq
&& historyKinds && historyKinds.has(ev.kind)) {
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);
// Kinds present in the history replay — the only kinds that
// can double and therefore the only ones to seq-dedupe.
const historyKinds = new Set(events.map((ev) => ev.kind));
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, historyKinds);
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 };
}
// Build a DocumentFragment from `text`, turning bare http(s) URLs into
// clickable links that open in a new tab. See docs/web-ui.md::Shared
// terminal pane (linkify) for the text-node-only / no-innerHTML
// XSS-safety + trailing-punctuation strip.
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
export function linkify(text) {
const str = text == null ? '' : String(text);
const frag = document.createDocumentFragment();
if (str.indexOf('://') === -1) { // fast path: no URLs
if (str) frag.appendChild(document.createTextNode(str));
return frag;
}
let last = 0;
let m;
LINKIFY_URL_RE.lastIndex = 0;
while ((m = LINKIFY_URL_RE.exec(str)) !== null) {
let url = m[0];
// Don't swallow trailing punctuation that's really sentence text.
const trail = url.match(/[.,;:!?)\]}'"]+$/);
const tail = trail ? trail[0] : '';
if (tail) url = url.slice(0, -tail.length);
if (m.index > last) {
frag.appendChild(document.createTextNode(str.slice(last, m.index)));
}
if (!url.slice(url.indexOf('://') + 3)) {
// Nothing past the scheme — not a real URL, emit verbatim.
frag.appendChild(document.createTextNode(m[0]));
} else {
const a = document.createElement('a');
a.href = url; // regex only matches https?:// — safe
a.textContent = url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
frag.appendChild(a);
if (tail) frag.appendChild(document.createTextNode(tail));
}
last = m.index + m[0].length;
}
if (last < str.length) {
frag.appendChild(document.createTextNode(str.slice(last)));
}
return frag;
}