Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485<script lang="ts"> import type { TranscriptEntry } from "../connection"; import type { RenderDescriptor } from "@/contracts/render-descriptor"; import { transcriptAnchorKey } from "../session-history"; import { createEventDispatcher, onMount, tick } from "svelte"; import FlightPlan from "./FlightPlan.svelte"; import type { ViewportState } from "../viewport-state";
// ───────────────────────────────────────────────────────────────────────────── // SessionTranscriptViewport — the reusable scroll surface for a session transcript. // // Owns the incremental-history / scroll-anchoring / unread behavior the long-session // web surface needs (saga vr7kgx.2), inheriting FlightPlan's visual language verbatim: // • dispatches `loadOlder` at the top when `hasOlder && !loadingOlder` (with a retry // affordance after error); // • preserves the visible anchor entry + pixel offset across prepends (older pages) so // the view never jumps; // • auto-follows appended / live output ONLY when near the bottom; // • when not near the bottom, holds position and shows a calm "new output" control with // an unread count; activating it scrolls to the end and clears unread; // • dispatches `stateChange` with `{ anchorEntryId, offset, atBottom, unreadCount }` so // the parent can persist per session, and restores a saved anchor/offset (or saved // bottom) after mount. // // Boundary: a presentational scroll surface only. It never talks to the connection; the // parent feeds it `transcript` + replay flags and persists the emitted viewport state. No // browser globals are touched at module initialization (jsdom-safe). // ─────────────────────────────────────────────────────────────────────────────
/** Persistable viewport snapshot — emitted on `stateChange`, restored via `savedState`. */ export let transcript: TranscriptEntry[] = []; export let streaming = false; export let callsign = "agent"; /** Older history exists beyond the current page boundary (gates the top load trigger). */ export let hasOlder = false; /** An older-page request is in flight (gates the top load trigger). */ export let loadingOlder = false; /** Last older-page error — shows a retry affordance at the top when set. */ export let loadError: string | null = null; /** Persisted viewport state to restore once on mount (`null` ⇒ pin to newest). */ export let savedState: ViewportState | null = null;
const dispatcher = createEventDispatcher<{ loadOlder: null; stateChange: ViewportState; expand: RenderDescriptor; fork: { entryId: string }; }>();
// px from top that arms the load-older trigger const TOP_THRESHOLD = 8; // px from bottom considered "near the bottom" (auto-follow window) const BOTTOM_THRESHOLD = 64;
let scrollEl: HTMLElement; let mounted = false; let restored = false;
// live viewport state (the snapshot we emit / restore) let atBottom = true; let unreadCount = 0; let anchorEntryId: string | null = null; let offset = 0;
// marker of the last entry seen while at the bottom — unread is derived from it. Storing // the MARKER (not an index) keeps unread stable across prepends (which shift indices). let seenMarker: string | null = null;
// load-older debounce: prevent re-dispatching before the parent acknowledges via // `loadingOlder`. Re-armed on the true→false `loadingOlder` transition (see below) so the // retry control stays live even when an older page FAILS without changing the transcript. let pendingLoad = false; // tracks the previous `loadingOlder` value to detect the request-completion transition. let prevLoadingOlder = false;
// previous transcript length — distinguishes prepend/append (grow) from in-place updates. let prevLength = 0; // cancels stale async restore passes when transcript changes faster than a tick. let changeToken = 0;
// ── content-visibility measure passes ── // Rows skip rendering while offscreen (FlightPlan `.row`); placeholder heights would // corrupt the offset-based restores below (prepend no-jump, saved-anchor restore). A // `measure` pass forces one full layout around head changes so every row's real size is // recorded by `contain-intrinsic-size: auto`, after which placeholders are exact and // the skipping re-arms. let measuring = false; let lastHeadMarker: string | null = null; let measureReleaseToken = 0;
/** Pre-patch: detect head changes (initial load, older-page prepend, session switch — * anything that replaces the first entry) and arm a measure pass for the coming * layout. Runs in the reactive pre-flush so the flag is set before the DOM patch. */ function trackHeadChange(): void { const head = transcript[0]; const first = head ? markerOf(head) : null; if (first === lastHeadMarker) return; lastHeadMarker = first; if (first === null) return; measuring = true; }
/** Re-arm content-visibility after the current pass's scroll restore has applied and * painted with real sizes. Double-rAF: one frame for the layout, one for the paint. */ function releaseMeasuring(): void { if (!measuring) return; const token = ++measureReleaseToken; const raf = typeof requestAnimationFrame === "function" ? requestAnimationFrame.bind(globalThis) : null; if (raf === null) { measuring = false; return; } raf(() => raf(() => { if (token === measureReleaseToken) measuring = false; })); }
// ── pure helpers (no browser globals at module init) ──
function markerOf(e: TranscriptEntry): string { return transcriptAnchorKey(e); } function lastMarker(): string | null { if (transcript.length === 0) return null; const last = transcript[transcript.length - 1]; return last ? markerOf(last) : null; }
function computeAtBottom(): boolean { if (!scrollEl) return true; const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight; return distance <= BOTTOM_THRESHOLD; }
/** The topmost entry whose bottom is below the viewport top, plus its relative offset. */ function visibleAnchor(): { id: string; relTop: number } | null { if (!scrollEl) return null; const top = scrollEl.scrollTop; const nodes = scrollEl.querySelectorAll<HTMLElement>("[data-entry-id]"); for (const node of nodes) { const bottom = node.offsetTop + node.offsetHeight; if (bottom > top) { return { id: node.getAttribute("data-entry-id") as string, relTop: node.offsetTop - top }; } } return null; }
function findEntryEl(id: string): HTMLElement | null { if (!scrollEl) return null; return scrollEl.querySelector<HTMLElement>(`[data-entry-id="${id}"]`); }
function scrollToBottom(): void { if (!scrollEl) return; scrollEl.scrollTop = scrollEl.scrollHeight; }
/** Recompute unread from the seen marker. Prepends shift the seen index equally, so unread * is unaffected by older-page loads; only tail appends grow it. */ function recomputeUnread(): void { if (seenMarker === null) { unreadCount = 0; return; } const seenIndex = transcript.findIndex((e) => markerOf(e) === seenMarker); if (seenIndex === -1) { // the seen entry is gone (shouldn't happen for append-only history) — re-anchor seen // at the tail so unread reads 0 rather than a stale inflated value. seenMarker = lastMarker(); unreadCount = 0; return; } unreadCount = Math.max(0, transcript.length - 1 - seenIndex); }
let lastEmitted = ""; function emit(): void { const snap: ViewportState = { anchorEntryId, offset, atBottom, unreadCount }; const key = `${snap.anchorEntryId ?? ""}|${snap.offset}|${snap.atBottom}|${snap.unreadCount}`; if (key === lastEmitted) return; lastEmitted = key; dispatcher("stateChange", snap); }
function refreshAnchorForPersistence(): void { if (atBottom) { anchorEntryId = null; offset = 0; } else { const a = visibleAnchor(); if (a) { anchorEntryId = a.id; offset = a.relTop; } } }
// ── transcript change: capture pre-state (old DOM) → await flush → restore ──
function scheduleTranscriptChange(): void { if (!restored) return; const token = ++changeToken; void handleTranscriptChange(token); }
async function handleTranscriptChange(token: number): Promise<void> { if (!scrollEl) return; // Pre-state is read from the OLD DOM (reactive runs before the DOM patch). This is the // anchor we will preserve across the prepend / the at-bottom flag that drives follow. const preAtBottom = computeAtBottom(); // At the tail we will scroll straight back to the bottom after the patch, so walking // every transcript row to find a throwaway anchor is pure overhead. On long sessions // that O(rows) DOM/layout scan used to run for every streamed token publication. const preAnchor = preAtBottom ? null : visibleAnchor(); await tick(); // DOM now reflects the new transcript if (token !== changeToken) return; // a newer change superseded this one
if (preAtBottom) { // auto-follow: keep pinned to the newest output (live streaming + appends) scrollToBottom(); atBottom = true; unreadCount = 0; seenMarker = lastMarker(); } else if (preAnchor) { // hold position: restore the anchor entry's viewport offset (no jump on prepend) const el = findEntryEl(preAnchor.id); if (el) { const target = el.offsetTop - preAnchor.relTop; if (Math.abs(scrollEl.scrollTop - target) > 0.5) scrollEl.scrollTop = target; } atBottom = computeAtBottom(); } else { atBottom = computeAtBottom(); }
if (!atBottom) recomputeUnread(); if (!loadingOlder) pendingLoad = false; prevLength = transcript.length; refreshAnchorForPersistence(); emit(); releaseMeasuring(); }
// ── scroll handling ──
function onScroll(): void { if (!mounted || !scrollEl) return; atBottom = computeAtBottom(); if (atBottom) { unreadCount = 0; seenMarker = lastMarker(); anchorEntryId = null; offset = 0; } else { // track the topmost visible entry for anchor persistence const a = visibleAnchor(); if (a) { anchorEntryId = a.id; offset = a.relTop; } }
// top-reached load trigger (gated by hasOlder / in-flight / error / debounce) if (scrollEl.scrollTop <= TOP_THRESHOLD && hasOlder && !loadingOlder && !loadError && !pendingLoad) { pendingLoad = true; dispatcher("loadOlder", null); } emit(); }
// ── controls ──
function gotoNew(): void { scrollToBottom(); atBottom = true; unreadCount = 0; seenMarker = lastMarker(); anchorEntryId = null; offset = 0; emit(); }
function retryLoadOlder(): void { if (!hasOlder || loadingOlder || pendingLoad) return; pendingLoad = true; dispatcher("loadOlder", null); }
// keyboard navigation inside the transcript region (parity with the keyboard-driven model) function onKeydown(event: KeyboardEvent): void { if (!scrollEl) return; switch (event.key) { case "Home": { event.preventDefault(); scrollEl.scrollTop = 0; break; } case "End": { event.preventDefault(); scrollToBottom(); break; } case "PageDown": { event.preventDefault(); scrollEl.scrollTop += scrollEl.clientHeight; break; } case "PageUp": { event.preventDefault(); scrollEl.scrollTop -= scrollEl.clientHeight; break; } } }
// ── mount: restore saved state (or pin to newest) ──
function restoreSaved(): void { if (!scrollEl) return; if (savedState) { if (savedState.atBottom) { scrollToBottom(); atBottom = true; unreadCount = 0; seenMarker = lastMarker(); } else if (savedState.anchorEntryId) { const el = findEntryEl(savedState.anchorEntryId); if (el) { scrollEl.scrollTop = el.offsetTop - savedState.offset; atBottom = computeAtBottom(); } else { // saved anchor no longer present — fall back to newest scrollToBottom(); atBottom = true; } // reconstruct the seen marker from the persisted unread count so unread continuity // survives a remount (seen = the entry just before the unread tail). if (savedState.unreadCount > 0 && transcript.length > savedState.unreadCount) { const seenIdx = transcript.length - 1 - savedState.unreadCount; const seen = transcript[seenIdx]; if (seen) seenMarker = markerOf(seen); recomputeUnread(); } else { seenMarker = lastMarker(); unreadCount = 0; } } else { scrollToBottom(); atBottom = true; seenMarker = lastMarker(); } } else { // default: land on the newest output scrollToBottom(); atBottom = true; seenMarker = lastMarker(); } refreshAnchorForPersistence(); emit(); releaseMeasuring(); }
onMount(() => { mounted = true; prevLength = transcript.length; // Defer the restore one tick so the DOM (and, in tests, stubbed geometry) is in place // before we measure anchor offsets / pin to the bottom. void tick().then(() => { restoreSaved(); restored = true; }); });
// reactive: re-run the capture/restore pass whenever the transcript identity changes. // trackHeadChange runs first (same pre-flush) so a head change arms the measure pass // before the DOM patch that scheduleTranscriptChange's restore pass will read. $: { transcript; trackHeadChange(); scheduleTranscriptChange(); }
// Re-arm the top-load trigger when an in-flight older-page request completes — whether it // prepended a page (transcript grew) or failed with `loadError` (transcript unchanged). The // true→false `loadingOlder` transition is the authoritative completion signal; without it // the error path leaves `pendingLoad` latched and the retry control dead-ends. $: { if (prevLoadingOlder && !loadingOlder) pendingLoad = false; prevLoadingOlder = loadingOlder; }
function onExpand(e: CustomEvent<RenderDescriptor>): void { dispatcher("expand", e.detail); } function onFork(e: CustomEvent<{ entryId: string }>): void { dispatcher("fork", e.detail); }</script>
<div class="transcript-viewport" class:at-bottom={atBottom} class:has-unread={unreadCount > 0 && !atBottom}> <div class="top-sentinel" aria-live="polite"> {#if loadingOlder} <span class="status-line">Loading older…</span> {:else if loadError} <button class="retry" on:click={retryLoadOlder} aria-label="Retry loading older history"> <span class="retry-text">Couldn’t load older —</span><span class="retry-action">Retry</span> </button> {/if} </div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- The scroll region is intentionally a keyboard-focusable (tabindex=0) live log (role=log) with scroll/keydown handlers: this is the established a11y pattern for a focusable, scrollable transcript surface a keyboard/screen-reader user must be able to navigate. The lint rules are overly strict for this case; suppressing here. --> <div class="scroll" bind:this={scrollEl} on:scroll={onScroll} on:keydown={onKeydown} role="log" aria-label="Session transcript" aria-busy={loadingOlder ? "true" : "false"} tabindex="0" > <FlightPlan {transcript} {streaming} {callsign} measure={measuring} on:expand={onExpand} on:fork={onFork} /> </div>
{#if unreadCount > 0 && !atBottom} <button class="new-output" on:click={gotoNew} aria-label={`Jump to newest output (${unreadCount} new)`}> <span class="new-glyph" aria-hidden="true">↓</span> <span class="new-count">{unreadCount} new</span> </button> {/if}</div>
<style> .transcript-viewport { position: relative; display: flex; flex-direction: column; min-height: 0; height: 100%; background: var(--surface); }
.top-sentinel { flex: none; min-height: 0; display: flex; justify-content: center; padding: 0 16px; } .status-line { font-size: 11px; font-family: var(--font-mono); color: var(--ink-3); padding: 4px 0; } .retry { font: inherit; border: 0; background: none; padding: 4px 8px; cursor: pointer; display: inline-flex; gap: 4px; font-size: 11px; font-family: var(--font-mono); color: var(--sev-error-ink); border-radius: var(--r-xs); } .retry:hover { background: var(--sev-error-bg); } .retry:focus-visible { outline: none; box-shadow: var(--focus-ring); } .retry-action { color: var(--accent-ink); font-weight: 600; }
.scroll { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; padding: 4px 16px 8px; scroll-behavior: auto; /* no decorative motion; anchor restore is instant */ outline: none; } .scroll:focus-visible { box-shadow: inset 0 0 0 2px var(--accent-soft); }
.new-output { position: absolute; left: 50%; transform: translateX(-50%); bottom: 10px; display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border: 1px solid var(--hairline-strong); border-radius: var(--r-pill); background: var(--surface); color: var(--ink); font: inherit; font-size: 11.5px; cursor: pointer; box-shadow: var(--shadow-veil); z-index: 2; } .new-output:hover { border-color: var(--accent); color: var(--accent-ink); } .new-output:focus-visible { outline: none; box-shadow: var(--focus-ring); } .new-glyph { color: var(--accent); font-size: 12px; } .new-count { font-family: var(--font-mono); }
/* narrow viewport: keep the new-output control within reach without overlapping the rail */ @media (max-width: 560px) { .scroll { padding: 4px 10px 8px; } .new-output { bottom: 6px; } } @media (prefers-reduced-motion: reduce) { .scroll { scroll-behavior: auto; } }</style>