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.
This commit is contained in:
iris 2026-05-31 15:14:30 +02:00 committed by mara
commit f60a90d752
3 changed files with 110 additions and 71 deletions

View file

@ -213,7 +213,7 @@ details.row > pre.diff-body .diff-ctx { color: var(--fg); }
border-radius: 0;
}
.live .row .md a { color: var(--cyan); text-decoration: underline; }
/* Auto-linkified bare URLs in plain rows + tool-body blocks (issue #233). */
/* Auto-linkified bare URLs in plain rows + tool-body blocks. */
.live .row a { color: var(--cyan); text-decoration: underline; }
.live .row a:hover { color: var(--fg); }
.live .row .md strong { color: inherit; font-weight: bold; }

View file

@ -50,12 +50,9 @@
// count=0); pages use it to set state flags from the replayed history.
const NEAR_BOTTOM_PX = 48;
// Snap-to-bottom animation duration (#400 + mara feedback). Browser
// default `scrollTo({ behavior: 'smooth' })` runs ~500ms, which read
// as "still smooth, but visibly slow." 140ms with ease-out is fast
// enough to feel snap-y, slow enough that the row's destination
// reads as motion (not a jump). Distances under SCROLL_SNAP_PX
// short-circuit to instant — animating a 12px nudge is just jitter.
// 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;
@ -77,16 +74,9 @@ export function create(opts) {
// handler so both programmatic scrollTop assignments and
// operator-driven wheel/drag stay in sync.
let stickToBottom = true;
// Guards scroll-event-handler from misreading the position while
// our own animation is mid-flight (#400). The animation drives
// scrollTop with rAF, which fires a stream of scroll events as
// the position eases toward the target — the position passes
// through "not near bottom" before settling. Without this gate,
// the scroll handler flips `stickToBottom` to false mid-animation,
// which then causes the MutationObserver to skip the next snap
// and leaves the operator stranded mid-scroll. Set to the
// animation's nominal end + small headroom; each fresh snap
// re-arms it so back-to-back snaps stay gated.
// 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
@ -96,16 +86,10 @@ export function create(opts) {
function isNearBottom() {
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
}
// Snap the log to the bottom with a brief eased animation
// (#400 + mara: snappier than the browser's default 500ms smooth
// scroll). Each call cancels the previous frame loop and starts a
// fresh one, so a burst of mutations coalesces into one ride to
// the latest bottom. Re-evaluates the target each frame so a
// renderer mutation landing mid-animation extends the destination
// without a visible jump. Falls back to instant scroll when
// `currentNoAnim` is true (backfill replay — operator never sees
// intermediate positions, animation is wasted frames) or when the
// remaining distance is under SCROLL_SNAP_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) {
@ -161,48 +145,26 @@ export function create(opts) {
pill.classList.add('visible');
}
log.addEventListener('scroll', () => {
// Mid-smooth-scroll: ignore the intermediate scroll events. The
// gate releases when the animation has had time to settle (or
// when the next snap re-arms it). Without this, easing toward
// bottom would flip `stickToBottom` false partway and the next
// MO callback would skip the snap.
// 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 mutations (issue #393). Renderers commonly call
// `api.row(cls, text)` to create the row shell, then mutate it
// by appending more children (badges, multi-line bodies, tool
// result panes) AFTER api.row returned. The afterAppend scroll
// below only sees the row's INITIAL height — once the renderer
// adds the body, the row's grown past the visible bottom and
// the operator is left scrolled to the row's TOP, breaking
// stick-to-bottom for every subsequent event.
//
// Fix: MutationObserver on the log subtree. Fires once per
// microtask after each batch of synchronous mutations, so it
// runs once per renderer call regardless of how many children
// the renderer appends. When `stickToBottom` is true, snap to
// bottom again — catches whatever the renderer added after the
// afterAppend hop. Programmatic `scrollTop = scrollHeight`
// assignments don't re-trigger MO (the scroll itself isn't a
// DOM mutation), so no feedback loop.
// 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 });
// Auto-scroll decision uses the PRE-append scroll position
// (issue #375). Checking after the append underestimates
// "nearness" because the new row's own height has already pushed
// `scrollHeight - scrollTop - clientHeight` past the threshold,
// even when the user was visually at the bottom an instant ago.
// Each row/details/detailsDiff captures `nearBottomBeforeAppend`
// and hands it to afterAppend so the auto-scroll triggers
// whenever the operator was at the bottom when the row landed.
// (The MutationObserver above catches the AFTER-row mutations
// too, but this initial scroll keeps the visual lag to one
// frame instead of one microtask + frame.)
// 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();
@ -318,12 +280,11 @@ export function create(opts) {
let live = false;
let buffered = [];
// #448: callers can supply a `streamFactory(url)` that returns an
// EventSource-shaped object (must expose onmessage/onopen/onerror
// + .close()). The dashboard pages pass a SharedWorker-backed
// factory so all open hyperhive tabs share ONE upstream SSE
// connection. Default keeps the direct `new EventSource(url)`
// behaviour so non-dashboard consumers (per-agent UI) are unchanged.
// 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);
@ -351,7 +312,7 @@ export function create(opts) {
// 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 (issue #163).
// reload.
if (opts.onStreamOpen) {
try { opts.onStreamOpen(); }
catch (err) { console.error('onStreamOpen threw', err); }
@ -370,7 +331,7 @@ export function create(opts) {
// 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 (issue #163). ev.seq absent/0 → no dedupe possible.
// fetch. ev.seq absent/0 → no dedupe possible.
if (boundarySeq != null
&& typeof ev.seq === 'number' && ev.seq <= boundarySeq
&& historyKinds && historyKinds.has(ev.kind)) {
@ -427,9 +388,9 @@ export function create(opts) {
}
// Build a DocumentFragment from `text`, turning bare http(s) URLs into
// clickable links that open in a new tab. Non-URL text stays as plain
// text nodes — no innerHTML, so this is XSS-safe. Trailing sentence
// punctuation is kept out of the link. (issue #233)
// 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);