Y3R C4LL is the operator inbox alone: no P3NDING APPR0VALS section, no approval cards or history, no approval_resolved subscription, no approval browser notifications, and the tab count and title prefix count unread operator messages only. The H0M3 tile text drops "approvals". The deny form was the only data-prompt form, so bindAsyncForms' prompt branch, themedPrompt and its dialog styles go too, along with the approval-only CSS (.approval*, .approvals-history, .btn-deny, the status/glyph colours and .history-note).
629 lines
24 KiB
JavaScript
629 lines
24 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.
|
|
//
|
|
// create(opts) — full options list + renderer api (api.row, api.details,
|
|
// api.detailsDiff, api.placeholder, api.fromHistory) + behavioral notes:
|
|
// docs/web-ui/shape.md §Shared terminal pane.
|
|
|
|
const NEAR_BOTTOM_PX = 48;
|
|
// Scroll distance from the top of the log that triggers an automatic
|
|
// "load older" fetch — fires via the scroll event handler so the operator
|
|
// never has to click the pill; the pill stays as a visual indicator.
|
|
const LOAD_MORE_SCROLL_PX = 80;
|
|
// Snap-to-bottom animation duration. See docs/web-ui/shape.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) {
|
|
// `log` is `let` not `const` so loadMore() can temporarily redirect
|
|
// row/details/etc. into a detached temp element while prepending older
|
|
// history (restored before any scrollTop adjustments).
|
|
let log = opts.logEl;
|
|
if (!log) throw new Error("HiveTerminal.create: logEl is required");
|
|
const rootLog = log; // always the real DOM element — never reassigned
|
|
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;
|
|
// Pagination state for the "load older" feature.
|
|
let histMinId = null;
|
|
let histHasMore = false;
|
|
let histLoading = false;
|
|
let loadMoreBtn = null;
|
|
// 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/shape.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/shape.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", () => {
|
|
// Sticky-bottom intent tracking. Outside an animation this is
|
|
// straightforward — stickToBottom = isNearBottom(). During a
|
|
// smooth-snap animation we swallow most of the event to avoid a
|
|
// feedback loop (programmatic scrollTop changes → scroll events →
|
|
// new snap → cancels current rAF), but we MUST still let the user
|
|
// break out of sticky mode: if the user scrolls away from the
|
|
// bottom while a snap animation is in flight, honour that intent
|
|
// immediately so the MutationObserver stops re-firing snapToBottom()
|
|
// and the animation-guard window can expire naturally. Without this,
|
|
// live events arriving < 220ms apart permanently block scroll-to-top
|
|
// and loadMore() never fires. See docs/web-ui/shape.md::Shared terminal
|
|
// pane (Mid-animation scroll-event guard).
|
|
const inAnim = Date.now() < smoothScrollingUntil;
|
|
const nearBottom = isNearBottom();
|
|
if (!inAnim) {
|
|
stickToBottom = nearBottom;
|
|
} else if (!nearBottom) {
|
|
// User scrolled up during an animation — break sticky mode so the
|
|
// MO stops calling snapToBottom() and the gate expires.
|
|
stickToBottom = false;
|
|
}
|
|
if (stickToBottom) {
|
|
unseen = 0;
|
|
updatePill();
|
|
}
|
|
if (inAnim) return;
|
|
// Auto-fetch older history when the user scrolls near the top — no
|
|
// click required; the load-more pill stays as a visual indicator.
|
|
if (
|
|
rootLog.scrollTop <= LOAD_MORE_SCROLL_PX &&
|
|
histHasMore &&
|
|
!histLoading
|
|
) {
|
|
loadMore();
|
|
}
|
|
});
|
|
// Post-append mutation snap — catches renderer mutations that land
|
|
// after `api.row` returns (badges, multi-line bodies, tool
|
|
// panes). See docs/web-ui/shape.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/shape.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.appendChild(contentSpan(document.createTextNode(text)));
|
|
log.appendChild(e);
|
|
placeholderEl = e;
|
|
}
|
|
// A leading icon (`→ ← 🧠 🖥️ …`) goes in a fixed-width `.row-glyph`
|
|
// element so every row's icon lands in one column regardless of the
|
|
// glyph's rendered width (emoji vary; some carry variation selectors).
|
|
// Optional: callers that pass no `icon` keep the bare first-character
|
|
// prefix the older rows rely on.
|
|
function glyphSpan(icon) {
|
|
const g = document.createElement("span");
|
|
g.className = "row-glyph";
|
|
g.textContent = icon;
|
|
return g;
|
|
}
|
|
// Wraps a flat row's actual content (text/links, or the mutable-row
|
|
// text node) in a real element the row's own grid can place by class
|
|
// (`grid-column: 2` in terminal.css) — `linkify()` returns a
|
|
// `DocumentFragment`, which doesn't persist as a node once appended,
|
|
// so without this wrapper an icon-less row's content has no element of
|
|
// its own for the grid to target and would auto-place into the icon
|
|
// column instead (see terminal.css's own comment on why that matters).
|
|
function contentSpan(node) {
|
|
const c = document.createElement("span");
|
|
c.className = "row-content";
|
|
c.appendChild(node);
|
|
return c;
|
|
}
|
|
// Build a <summary> whose icon (if any) sits in the shared `.row-glyph`
|
|
// column and whose text lives in a `.summary-text` span — the disclosure
|
|
// caret (CSS `.summary-text::before`) then leads the text, not the icon,
|
|
// so the icon stays aligned with flat-row icons.
|
|
function buildSummary(summary, icon) {
|
|
const s = document.createElement("summary");
|
|
if (icon != null && icon !== "") s.appendChild(glyphSpan(icon));
|
|
const st = document.createElement("span");
|
|
st.className = "summary-text";
|
|
st.textContent = summary;
|
|
s.appendChild(st);
|
|
return s;
|
|
}
|
|
function row(cls, text, icon) {
|
|
clearPlaceholder();
|
|
const wasNearBottom = isNearBottom();
|
|
const e = document.createElement("div");
|
|
e.className = "row " + (cls || "") + (currentNoAnim ? " no-anim" : "");
|
|
if (icon != null && icon !== "") e.appendChild(glyphSpan(icon));
|
|
e.appendChild(contentSpan(linkify(text)));
|
|
log.appendChild(e);
|
|
afterAppend(wasNearBottom);
|
|
return e;
|
|
}
|
|
// Like row(), but returns [element, textNode] so the caller can update
|
|
// the text in place via textNode.nodeValue. Use for rows whose content
|
|
// changes after initial render (e.g. live-updating counters).
|
|
// Text is stored as a plain text node — no linkify, no innerHTML.
|
|
function mutableRow(cls, text, icon) {
|
|
clearPlaceholder();
|
|
const wasNearBottom = isNearBottom();
|
|
const e = document.createElement("div");
|
|
e.className = "row " + (cls || "") + (currentNoAnim ? " no-anim" : "");
|
|
if (icon != null && icon !== "") e.appendChild(glyphSpan(icon));
|
|
const tn = document.createTextNode(text == null ? "" : String(text));
|
|
e.appendChild(contentSpan(tn));
|
|
log.appendChild(e);
|
|
afterAppend(wasNearBottom);
|
|
return [e, tn];
|
|
}
|
|
// `opts.expandDetails` (boolean or zero-arg function returning one) lets
|
|
// the caller default every otherwise-collapsed `<details>` row open —
|
|
// e.g. an "expand panels by default" browser-local preference. Read
|
|
// live (not captured once) so a mid-session preference flip takes
|
|
// effect on the next row without recreating the terminal. Renderers
|
|
// that need a row open regardless (message-bearing tool_use/tool_result)
|
|
// already set `d.open = true` themselves after calling this — this only
|
|
// changes the *default* for panels that would otherwise start closed.
|
|
function wantsExpandedDefault() {
|
|
return typeof opts.expandDetails === "function"
|
|
? !!opts.expandDetails()
|
|
: !!opts.expandDetails;
|
|
}
|
|
function details(cls, summary, body, icon) {
|
|
clearPlaceholder();
|
|
const wasNearBottom = isNearBottom();
|
|
const d = document.createElement("details");
|
|
d.className = "row " + (cls || "") + (currentNoAnim ? " no-anim" : "");
|
|
if (wantsExpandedDefault()) d.open = true;
|
|
d.appendChild(buildSummary(summary, icon));
|
|
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, icon) {
|
|
clearPlaceholder();
|
|
const wasNearBottom = isNearBottom();
|
|
const d = document.createElement("details");
|
|
d.className = "row " + (cls || "") + (currentNoAnim ? " no-anim" : "");
|
|
if (wantsExpandedDefault()) d.open = true;
|
|
d.appendChild(buildSummary(summary, icon));
|
|
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,
|
|
mutableRow,
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Load-older machinery ───────────────────────────────────────
|
|
//
|
|
// When the initial backfill response includes `has_more: true`, a
|
|
// "↑ load older" button appears at the top of the log. Clicking it
|
|
// fetches the next page (`?before=<min_id>`) and prepends the events
|
|
// while holding the viewport steady so the operator's reading position
|
|
// doesn't jump.
|
|
|
|
function updateLoadMoreBtn() {
|
|
if (!histHasMore || !opts.historyUrl) {
|
|
if (loadMoreBtn && loadMoreBtn.parentElement) {
|
|
loadMoreBtn.parentElement.removeChild(loadMoreBtn);
|
|
}
|
|
loadMoreBtn = null;
|
|
return;
|
|
}
|
|
if (!loadMoreBtn) {
|
|
loadMoreBtn = document.createElement("button");
|
|
loadMoreBtn.type = "button";
|
|
loadMoreBtn.className = "load-more-pill";
|
|
loadMoreBtn.addEventListener("click", loadMore);
|
|
rootLog.prepend(loadMoreBtn);
|
|
}
|
|
loadMoreBtn.textContent = "↑ load older";
|
|
loadMoreBtn.disabled = false;
|
|
}
|
|
|
|
async function loadMore() {
|
|
if (!histHasMore || histLoading || !opts.historyUrl || histMinId === null)
|
|
return;
|
|
histLoading = true;
|
|
if (loadMoreBtn) {
|
|
loadMoreBtn.textContent = "↑ loading…";
|
|
loadMoreBtn.disabled = true;
|
|
}
|
|
try {
|
|
const sep = opts.historyUrl.includes("?") ? "&" : "?";
|
|
const url = opts.historyUrl + sep + "before=" + histMinId;
|
|
const resp = await fetch(url);
|
|
if (!resp.ok) {
|
|
updateLoadMoreBtn();
|
|
return;
|
|
}
|
|
const body = await resp.json();
|
|
const events = Array.isArray(body) ? body : body.events || [];
|
|
histHasMore = body.has_more || false;
|
|
if (typeof body.min_id === "number") histMinId = body.min_id;
|
|
|
|
// Resolve load-more button state before capturing the scroll
|
|
// baseline so that any button removal is already reflected in
|
|
// beforeH — otherwise the button's height would be missing from
|
|
// the delta and the viewport would drift up by that amount.
|
|
updateLoadMoreBtn();
|
|
|
|
if (events.length > 0) {
|
|
// Render into a detached element; `log` is temporarily redirected
|
|
// so that row/details/etc. append there instead of rootLog.
|
|
const tempEl = document.createElement("div");
|
|
log = tempEl;
|
|
currentNoAnim = true;
|
|
for (const ev of events) dispatch(ev, true);
|
|
currentNoAnim = false;
|
|
log = rootLog;
|
|
|
|
// Separator to mark the boundary between loaded-older and newer.
|
|
const sepEl = document.createElement("div");
|
|
sepEl.className = "row note no-anim";
|
|
sepEl.textContent = "─── older above ───";
|
|
tempEl.appendChild(sepEl);
|
|
|
|
// Insert before the "live" divider (i.e. right after the load-more
|
|
// button if present, else at the very top of rootLog).
|
|
const anchor = loadMoreBtn
|
|
? loadMoreBtn.nextSibling
|
|
: rootLog.firstChild;
|
|
const beforeH = rootLog.scrollHeight;
|
|
while (tempEl.firstChild)
|
|
rootLog.insertBefore(tempEl.firstChild, anchor);
|
|
// Compensate scroll so the viewport stays on the same content.
|
|
// overflow-anchor: none on .live ensures the browser does not
|
|
// also auto-adjust scrollTop (which would double the delta).
|
|
rootLog.scrollTop += rootLog.scrollHeight - beforeH;
|
|
}
|
|
} catch (err) {
|
|
console.warn("loadMore failed", err);
|
|
updateLoadMoreBtn();
|
|
} finally {
|
|
histLoading = false;
|
|
}
|
|
}
|
|
|
|
// 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/shape.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 (container/transient/…) 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, min_id?, has_more? }`.
|
|
// A bare array means the server hasn't been updated — treat it
|
|
// as "no dedupe possible, no pagination."
|
|
const events = Array.isArray(body) ? body : body.events || [];
|
|
const boundarySeq = Array.isArray(body) ? null : (body.seq ?? null);
|
|
// Pagination cursors — set on the outer load-more state.
|
|
if (!Array.isArray(body)) {
|
|
histHasMore = body.has_more || false;
|
|
if (typeof body.min_id === "number") histMinId = body.min_id;
|
|
}
|
|
// 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);
|
|
// Show load-older button if the server reports more history.
|
|
updateLoadMoreBtn();
|
|
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, mutableRow, 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/shape.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;
|
|
}
|