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

@ -72,6 +72,84 @@ since #753). Both are SPAs — `GET /` returns a static shell,
bfcache-restore uses). Recovery is per-tab; pings are
invisible on the healthy path.
### Shared terminal pane
Both surfaces' scrollable log streams (`#msgflow` on the dashboard,
`#live` on the per-agent page) are backed by the shared terminal
factory in `@hive/shared/terminal.js`. The factory wires up
sticky-bottom auto-scroll, a "↓ N new" pill, history backfill, and
SSE replay. Pages register a `kind → renderer` map; unknown kinds
fall through to a JSON-dump note row. The factory ships three row
shapes the renderers call:
- `api.row(cls, text)` — single-line row with an inline `linkify`
pass over the text.
- `api.details(cls, summary, body)` — collapsible `<details>` with
a `<pre>` body (used by long tool-results and stack traces).
- `api.detailsDiff(cls, summary, body)` — same shape, splits the
body on newlines and tags each line as `diff-add` / `diff-del` /
`diff-ctx` so the renderer's diff bodies get coloured without
emitting raw HTML.
**Sticky-bottom + snap animation.** `stickToBottom` is the
operator's intent: true means "keep snapping to bottom on every
mutation", false means "I scrolled up, leave me alone". The flag
flips when a scroll event lands further than `NEAR_BOTTOM_PX = 48`
from the bottom. New rows then either snap to bottom (when sticky)
or bump the unseen-count and surface the "↓ N new" pill. The snap
is a brief 140ms ease-out (`SCROLL_ANIM_MS`) — the browser's
default `behavior: 'smooth'` ~500ms reads as "still smooth, but
visibly slow"; 140ms feels snap-y while still reading as motion
rather than a jump. Distances under `SCROLL_SNAP_PX = 24`
short-circuit to instant — animating a 12px nudge would just be
jitter. Each new snap cancels the previous `requestAnimationFrame`
so a burst of mutations coalesces into one ride to the latest
bottom; the per-frame step re-reads `scrollHeight - clientHeight`
so mutations landing mid-animation extend the destination smoothly
rather than land short.
**Mid-animation scroll-event guard.** The scroll handler's
`isNearBottom` check would flip `stickToBottom` false mid-snap as
the smooth animation eases through positions that are technically
"not near bottom yet", which would strand the operator partway. A
`smoothScrollingUntil` timestamp gates the scroll handler — set to
the animation end + ~80ms headroom, re-armed on each fresh snap.
Programmatic `scrollTop` writes (the animation's per-frame update)
fire scroll events that the gate swallows.
**Post-append `MutationObserver`.** Renderers commonly call
`api.row(cls, text)` to create the row shell then append more
children (badges, multi-line bodies, tool result panes) after the
factory returned. The initial sticky-snap fires off the row's
empty shape; the renderer's later appends grow the row past the
visible bottom. A `MutationObserver` on the log subtree fires once
per microtask after each batch of synchronous mutations and snaps
again when `stickToBottom` is true. Programmatic `scrollTop`
writes don't re-trigger the observer (scroll isn't a DOM
mutation), so no feedback loop. The pre-append
`nearBottomBeforeAppend` snapshot is still useful — it keeps the
initial visual lag to one frame instead of one microtask + frame.
**Backfill + SSE.** Cold load fetches `historyUrl` (replay), then
subscribes to `streamUrl` (live tail). Both endpoints return
`{ seq, events }` so the client can dedupe — events with
`seq <= snapshot.seq` from the SSE stream are dropped silently
(the snapshot already covers them). History rows render with a
`.no-anim` class so they don't stagger in like live events. The
optional `streamFactory(url)` callback lets the dashboard hand
the factory a `SharedWorker`-backed `EventSource` facade (so
multiple tabs share one upstream connection — see *SSE
multiplexing* above); when omitted, the factory falls back to a
plain `new EventSource(url)`.
**`linkify` (text-node based).** Bare `http(s)://` URLs in row
text get wrapped in `<a target="_blank" rel="noopener noreferrer">`
inside a fresh text node, so the autolinker never touches
`innerHTML` and untrusted row content can't smuggle markup. The
trailing-punctuation strip keeps `.,;:` outside the link surface.
Markdown bodies go through `marked` separately and get the same
target rewrite.
The JS app handles all `form[data-async]` submissions via a delegated
listener: read `data-confirm`, swap the button to a spinner, POST
`application/x-www-form-urlencoded`, re-enable the button on success