Procházet zdrojové kódy

event log: fix the dead SSE listener, flip the feed to oldest-first with bottom stick, and page older history correctly

Three coupled client-side bugs in task_log.html, all fixed in one pass:

1. The SSE stream never updated the page. The server's frames carry
   only id: and data: lines - no event: name (SseEvent has no event
   name field; the heartbeat's payload is the literal string 'ping')
   - so the addEventListener('append') handler could never fire. The
   stream is now wired through stream.onmessage, heartbeat frames are
   skipped on message.data === 'ping', and every other frame is
   JSON.parsed and ingested with the existing id-dedupe (which also
   absorbs EventSource's replay-after-reconnect). EventSource's own
   retry behaviour is untouched.

2. Chronology was backwards. state.events is now kept oldest-first
   (ascending id) and rendered oldest at the top / newest at the
   bottom, like a terminal transcript. The first render carrying rows
   seeds the viewport at the bottom (the reading position); afterwards
   the feed sticks to the bottom only when the reader was already at
   (or within 48px of) the bottom - a reader scrolled up into the
   history is never yanked down. The retired terminal view used the
   same rule.

3. Load older events loaded the wrong window: the ingest/render logic
   assumed newest-first display, so a before=<oldest> page effectively
   prepended a newer window. With bottom-anchored rendering the
   control (above the feed, where older history lives) fetches
   GET /tasks/<id>/events?before=<oldest>&limit=30, reverses the
   server's newest-first page into chronological order, prepends it,
   and restores the viewport by the scrollHeight delta so the rows
   under the reader's cursor do not move. A page with more=false (or
   no rows) marks the ledger exhausted and hides the control.

Also: every non-ASCII character in the script body - comments
included - is replaced. The page compiler HTML-escapes script bodies
(non-ASCII becomes named entities), and the browser decodes exactly
once, so the entity TEXT landed in the JS strings and rendered
literally (the served page carried ' &middot; ' in the actor line).
The script is now ASCII-only, with the separators it needs written as
JS unicode escapes (\u00b7, \u2026, \u2014) - immune to the escaping
because the compiler has nothing to escape. The header comment now
documents the chronology/scroll/paging/stream contract.

Verification (the app cannot be built here - the statum toolchain is
a build-time dependency absent from this container), so
test/test_task_log_js.py runs standalone: static checks that the
script body is ASCII-only, that onmessage is wired (and no 'append'
listener remains), that the paging URL and page reversal exist, and
that the load-more control precedes the feed markup; plus a faithful
python port of the ingest/ordering/scroll-stick/load-more logic,
unit-tested for the initial newest-first page [30..1] rendering as
[1..30], the 48px stick threshold on both sides, no-yank when
scrolled up, prepend-anchoring, dedupe, the heartbeat no-op, and
exhausted on more=false. 25 tests, all green; wired into meson as the
task-log-js target beside web-stack.
saddle-Clanker-Saddle před 1 měsícem
rodič
revize
0a16e7b09f
3 změnil soubory, kde provedl 586 přidání a 35 odebrání
  1. 11 0
      meson.build
  2. 151 35
      src/pages/task_log.html
  3. 424 0
      test/test_task_log_js.py

+ 11 - 0
meson.build

@@ -263,3 +263,14 @@ test('web-stack', python_test,
     args: [files('test/web_stack_test.py'), clanker_saddle_exe],
     timeout: 3600,
 )
+
+# The event-log page's client JS: static source checks plus a python
+# port of the ingest/ordering/scroll-stick logic. Standalone by
+# design - it must run where the app binary cannot be built (the
+# statum toolchain is a build-time dependency that not every
+# container carries), so it reads src/pages/task_log.html directly
+# instead of driving a live server.
+test('task-log-js', python_test,
+    args: [files('test/test_task_log_js.py')],
+    timeout: 120,
+)

+ 151 - 35
src/pages/task_log.html

@@ -4,28 +4,71 @@
     <pstm-uri>/tasks/{task_id}/log</pstm-uri>
     <pstm-template>main</pstm-template>
     <script>
-    // The task's event log (plan 17 §3.2): the unified ledger's human
-    // view. The feed is client-side over the plan-17 endpoints — the
-    // SSE stream seeds the most-recent-30 window and streams live
-    // appends, load-more pages backwards through the JSON endpoint in
-    // batches of 30 (the button pins at the top and appears when the
-    // window is scrolled to it while older events remain). Turn cards
-    // group a run's turn_text + tool_call + tool_result events by
-    // payload.turn (per session); results collapse behind a summary
-    // carrying head+tail truncation display and byte counts.
-    // Filters (commands/results/comments/transitions/usage) and the
-    // full-text search run client-side over the loaded window. All
-    // rendering goes through textContent/createElement — event
-    // payloads never touch innerHTML.
+    // The task's event log (plan 17 section 3.2): the unified
+    // ledger's human view. The feed is client-side over the
+    // plan-17 endpoints, and its contract is:
+    //
+    // CHRONOLOGY - rows render OLDEST at the top and NEWEST at
+    // the bottom, like a terminal transcript. The SSE stream
+    // replays the most-recent-30 window oldest-first and then
+    // appends every new row, so live traffic just extends the
+    // tail of the feed.
+    //
+    // SCROLL - the feed ([data-log-feed], max-height 72vh,
+    // overflow-y auto) is scrolled to the bottom on the first
+    // render that carries rows, and afterwards sticks to the
+    // bottom ONLY when the reader was already at (or within
+    // 48px of) the bottom before the render. A reader who
+    // scrolled up into the history is never yanked down.
+    //
+    // PAGING - the load-older control sits above the feed
+    // (older history lives above it) and pages backwards via
+    // GET /tasks/<id>/events?before=<oldest loaded id>&limit=30.
+    // The server serves each page newest-first; the client
+    // reverses it to oldest-first and prepends, restoring the
+    // viewport by the scrollHeight delta so the rows under the
+    // reader's cursor do not move. A page whose `more` flag is
+    // false marks the ledger exhausted and hides the control.
+    //
+    // STREAM - the server's SSE frames carry only id: and
+    // data: lines (no `event:` name), so every frame lands on
+    // `onmessage` and no named listener can ever fire. The
+    // heartbeat frame's data is the literal string "ping";
+    // every other frame is one JSON ledger row, ingested with
+    // the id-dedupe (which also absorbs EventSource's replay
+    // after a reconnect, resent from the last seen id).
+    //
+    // Turn cards group a run's turn_text + tool_call +
+    // tool_result events by payload.turn (per session);
+    // results collapse behind a summary carrying head+tail
+    // truncation display and byte counts. Filters
+    // (commands/results/comments/transitions/usage) and the
+    // full-text search run client-side over the loaded window.
+    // All rendering goes through textContent/createElement -
+    // event payloads never touch innerHTML.
+    //
+    // This whole script body is deliberately ASCII-only
+    // (comments included): the page compiler HTML-escapes
+    // script bodies, so any non-ASCII literal would come back
+    // out of the browser as literal entity text - the entity
+    // name spelled out - instead of the character it names.
+    // Non-ASCII characters are written as JS unicode escapes
+    // (\u00b7 etc).
     if (window.statum) {
         (function () {
             'use strict';
 
+            // How close to the bottom (px) still counts as
+            // "reading the tail" for the stick rule.
+            var STICK_THRESHOLD = 48;
+            var PAGE_SIZE = 30;
+
             var state = {
                 events: [],
                 oldest: 0,
                 exhausted: false,
                 loading: false,
+                seeded: false,
                 filters: { commands: true, results: true, comments: true,
                            transitions: true, usage: true },
                 search: ''
@@ -58,10 +101,14 @@
                 return true;
             }
 
+            // state.events is kept oldest-first (ascending id):
+            // insert before the first newer row, skip ids we
+            // already hold (SSE replay after a reconnect sends
+            // the last frame again).
             function ingest(evt) {
-                for (var i = 0; i < state.events.length; i++) {
-                    if (state.events[i].id === evt.id) return;
-                    if (state.events[i].id < evt.id) break;
+                var i = 0;
+                while (i < state.events.length && state.events[i].id < evt.id) {
+                    i++;
                 }
                 if (i < state.events.length && state.events[i].id === evt.id) return;
                 state.events.splice(i, 0, evt);
@@ -70,6 +117,21 @@
                 }
             }
 
+            // True when the feed is scrolled to (or within
+            // STICK_THRESHOLD of) its bottom - a feed shorter
+            // than its viewport counts as at-bottom.
+            function atBottom() {
+                if (!feed) return true;
+                return feed.scrollHeight - feed.scrollTop - feed.clientHeight
+                    <= STICK_THRESHOLD;
+            }
+
+            // Pin the feed to its bottom edge (the newest row).
+            function scrollToBottom() {
+                if (!feed) return;
+                feed.scrollTop = feed.scrollHeight;
+            }
+
             function payloadBytes(evt, member) {
                 var value = evt.payload && evt.payload[member];
                 return typeof value === 'string' ? value.length : 0;
@@ -141,7 +203,8 @@
                 head.appendChild(badge);
                 var actor = document.createElement('span');
                 actor.className = 'muted';
-                actor.textContent = ' ' + (evt.actor || '') + ' · ' + (evt.created_display || '');
+                actor.textContent = ' ' + (evt.actor || '') + ' \u00b7 '
+                    + (evt.created_display || '');
                 head.appendChild(actor);
                 row.appendChild(head);
                 var body = document.createElement('div');
@@ -207,7 +270,7 @@
                     bits.push('turn ' + events[0].payload.turn);
                 }
                 if (events[0].session_id) bits.push('session ' + events[0].session_id);
-                head.textContent = bits.length > 0 ? bits.join(' · ') : 'agent turn';
+                head.textContent = bits.length > 0 ? bits.join(' \u00b7 ') : 'agent turn';
                 card.appendChild(head);
                 events.forEach(function (evt) {
                     card.appendChild(eventNode(evt));
@@ -215,15 +278,30 @@
                 return card;
             }
 
-            function render() {
+            // Rebuild the feed from the loaded window. `mode`
+            // is 'append' (live SSE rows landed at the tail -
+            // stick to the bottom only if the reader was
+            // there), 'prepend' (older history was added
+            // above - keep the viewport on the same rows), or
+            // undefined (first render, filter or search change
+            // - bottom is the reading position, so seed there;
+            // afterwards behave like 'append'). Every branch
+            // captures the scroll state before the rebuild and
+            // only moves the viewport per its rule - a reader
+            // scrolled up into the history is never yanked.
+            function render(mode) {
                 if (!feed) return;
+                var wasAtBottom = atBottom();
+                var heightBefore = feed.scrollHeight;
+                var scrollTopBefore = feed.scrollTop;
                 while (feed.firstChild) feed.removeChild(feed.firstChild);
                 var visibleRows = state.events.filter(visible);
                 if (visibleRows.length === 0) {
                     var empty = document.createElement('p');
                     empty.className = 'muted';
                     empty.textContent = state.events.length === 0
-                        ? 'Waiting for the first events…' : 'No events match the filters.';
+                        ? 'Waiting for the first events\u2026'
+                        : 'No events match the filters.';
                     feed.appendChild(empty);
                 }
                 // Grouping: consecutive turn-family events sharing a
@@ -263,7 +341,40 @@
                     moreButton.hidden = state.exhausted;
                     moreButton.disabled = state.loading;
                     moreButton.textContent = state.loading
-                        ? 'Loading…' : 'Load older events';
+                        ? 'Loading\u2026' : 'Load older events';
+                }
+                if (!state.seeded && visibleRows.length > 0) {
+                    // First render carrying rows: newest is the
+                    // reading position, start pinned to it.
+                    state.seeded = true;
+                    scrollToBottom();
+                } else if (mode === 'prepend') {
+                    if (wasAtBottom) {
+                        scrollToBottom();
+                    } else {
+                        // Grow the viewport downward by exactly
+                        // the height added above it, so the
+                        // rows under the cursor stay put.
+                        feed.scrollTop = scrollTopBefore
+                            + (feed.scrollHeight - heightBefore);
+                    }
+                } else if (wasAtBottom) {
+                    scrollToBottom();
+                } else {
+                    feed.scrollTop = scrollTopBefore;
+                }
+            }
+
+            function onStreamMessage(message) {
+                // Heartbeat frames carry the literal string
+                // "ping" as data; named listeners never fire
+                // because the server sends no event: line.
+                if (message.data === 'ping') return;
+                try {
+                    ingest(JSON.parse(message.data));
+                    render('append');
+                } catch (error) {
+                    console.warn('[log] bad event:', error);
                 }
             }
 
@@ -272,16 +383,11 @@
                 if (!log) return;
                 var base = '/tasks/' + log.task_id;
                 stream = new EventSource(base + '/events/stream');
-                stream.addEventListener('append', function (message) {
-                    try {
-                        ingest(JSON.parse(message.data));
-                        render();
-                    } catch (error) {
-                        console.warn('[log] bad event:', error);
-                    }
-                });
+                stream.onmessage = onStreamMessage;
                 stream.onerror = function () {
-                    // EventSource retries on its own (retry: 3s hint).
+                    // EventSource retries on its own (retry: 3s
+                    // hint); the reconnect replays from the
+                    // last seen id and ingest dedupes it.
                 };
                 if (moreButton) {
                     moreButton.addEventListener('click', function () {
@@ -290,19 +396,26 @@
                 }
             }
 
+            // Page one window of history older than the oldest
+            // row we hold. The endpoint serves newest-first
+            // within the page; reverse it so ingest prepends in
+            // chronological order, and keep the viewport put.
             function loadMore(base) {
                 if (state.loading || state.exhausted) return;
                 state.loading = true;
                 render();
                 var url = base + '/events?before=' + (state.oldest || 0)
-                    + '&limit=30';
+                    + '&limit=' + PAGE_SIZE;
                 fetch(url, { credentials: 'same-origin' })
                     .then(function (response) { return response.json(); })
                     .then(function (page) {
-                        (page.events || []).forEach(ingest);
-                        if (!page.more) state.exhausted = true;
+                        var rows = page.events || [];
+                        rows.slice().reverse().forEach(ingest);
+                        if (!page.more || rows.length === 0) {
+                            state.exhausted = true;
+                        }
                         state.loading = false;
-                        render();
+                        render('prepend');
                     })
                     .catch(function (error) {
                         console.warn('[log] paging failed:', error);
@@ -337,7 +450,10 @@
                 feed = document.querySelector('[data-log-feed]');
                 moreButton = document.querySelector('[data-log-more]');
                 var header = document.querySelector('[data-log-title]');
-                if (header) header.textContent = '#' + page.task_id + ' — ' + page.task_title;
+                if (header) {
+                    header.textContent = '#' + page.task_id + ' \u2014 '
+                        + page.task_title;
+                }
                 if (!stream && feed) connect();
                 render();
             });

+ 424 - 0
test/test_task_log_js.py

@@ -0,0 +1,424 @@
+#!/usr/bin/env python3
+"""Static + logic tests for the task event-log page's client JS.
+
+The app binary cannot be built in every container (statum-mkpstm /
+libstatum are build-time dependencies), so this suite verifies
+src/pages/task_log.html two ways:
+
+1. Statically - the <script> body must be ASCII-only (the page
+   compiler HTML-escapes script bodies, so any non-ASCII literal is
+   re-served as literal entity text like "&middot;" after the
+   browser's one decode pass), must wire the SSE stream through
+   `onmessage` (the server sends id:/data: frames with no `event:`
+   name, so named listeners can never fire), must not listen for an
+   'append' event name, and its delimiter structure must balance
+   after strings and comments are stripped.
+
+2. Behaviourally - the ingest / render-order / scroll-stick /
+   load-more logic is ported verbatim below as pure python functions
+   and unit-tested: oldest-first chronology, id dedupe, the 48px
+   bottom-stick rule, prepend-mode scroll anchoring, and the
+   newest-first-page reversal with the `more` -> exhausted flag.
+
+Runnable standalone: python3 test/test_task_log_js.py
+"""
+
+import os
+import re
+import unittest
+
+PAGE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+                    os.pardir, "src", "pages", "task_log.html")
+
+
+def script_body():
+    with open(PAGE, encoding="utf-8") as handle:
+        source = handle.read()
+    head, rest = source.split("<script>", 1)
+    body, _ = rest.split("</script>", 1)
+    assert head, "no content before the script block"
+    return body
+
+
+# ---------------------------------------------------------------------------
+# Static checks
+# ---------------------------------------------------------------------------
+
+class ScriptSourceTests(unittest.TestCase):
+    """What the served bytes must look like after compilation."""
+
+    def setUp(self):
+        self.body = script_body()
+
+    def test_script_is_ascii_only(self):
+        """Non-ASCII in a script body comes back as literal entity
+        text ("&middot;") because the compiler escapes script bodies
+        and the browser decodes exactly once. ASCII (with \\uXXXX
+        escapes for the characters we want) is immune."""
+        offenders = [(index, hex(ord(char)))
+                     for index, char in enumerate(self.body) if ord(char) > 127]
+        self.assertEqual(offenders, [])
+
+    def test_unicode_escapes_used_for_separators(self):
+        """The separator characters must still render - as escapes,
+        not as raw bytes and not as HTML entities."""
+        for escape in ("\\u00b7", "\\u2026", "\\u2014"):
+            self.assertIn(escape, self.body)
+        self.assertNotIn("&middot;", self.body)
+        self.assertNotIn("&mdash;", self.body)
+
+    def test_stream_wired_through_onmessage(self):
+        self.assertRegex(self.body, r"stream\.onmessage\s*=")
+        self.assertNotIn("addEventListener('append'", self.body)
+        self.assertNotIn('addEventListener("append"', self.body)
+
+    def test_heartbeat_ping_is_skipped(self):
+        self.assertRegex(self.body, r"message\.data\s*===\s*'ping'")
+
+    def test_load_more_pages_before_oldest(self):
+        self.assertRegex(self.body, r"/events\?before='\s*\+\s*\(state\.oldest")
+
+    def test_page_is_reversed_before_ingest(self):
+        self.assertIn("slice().reverse().forEach(ingest)", self.body)
+
+    def test_exhausted_set_when_no_more(self):
+        self.assertIn("state.exhausted = true", self.body)
+
+    def test_more_button_sits_above_the_feed(self):
+        # Search the page markup only - the script's comments also
+        # mention [data-log-feed], before the elements do.
+        with open(PAGE, encoding="utf-8") as handle:
+            markup = handle.read().split("</script>", 1)[1]
+        more = markup.index("data-log-more")
+        feed = markup.index("data-log-feed")
+        self.assertLess(more, feed,
+                        "the load-older control must precede the feed markup")
+
+    def test_delimiters_balance(self):
+        """A cheap structural stand-in for a JS parser (none is
+        installed here): after stripping comments and string literals
+        every bracket must close."""
+        body = self.body
+        out = []
+        i = 0
+        mode = None  # None | 'line' | 'block' | "'" | '"'
+        while i < len(body):
+            char = body[i]
+            nxt = body[i + 1] if i + 1 < len(body) else ""
+            if mode is None:
+                if char == "/" and nxt == "/":
+                    mode = "line"
+                    i += 2
+                    continue
+                if char == "/" and nxt == "*":
+                    mode = "block"
+                    i += 2
+                    continue
+                if char in "'\"":
+                    mode = char
+                    i += 1
+                    continue
+                out.append(char)
+            elif mode == "line":
+                if char == "\n":
+                    mode = None
+                    out.append("\n")
+            elif mode == "block":
+                if char == "*" and nxt == "/":
+                    mode = None
+                    i += 2
+                    continue
+            else:  # inside a string literal
+                if char == "\\":
+                    i += 2
+                    continue
+                if char == mode:
+                    mode = None
+            i += 1
+        self.assertEqual(mode, None, "unterminated comment or string literal")
+        pairs = {")": "(", "]": "[", "}": "{"}
+        stack = []
+        for char in out:
+            if char in "([{":
+                stack.append(char)
+            elif char in pairs:
+                self.assertTrue(stack and stack[-1] == pairs[char],
+                                "unbalanced %r at depth %s" % (char, stack))
+                stack.pop()
+        self.assertEqual(stack, [], "unclosed delimiters %s" % stack)
+
+
+# ---------------------------------------------------------------------------
+# Port of the client logic (keep in step with task_log.html)
+# ---------------------------------------------------------------------------
+
+STICK_THRESHOLD = 48  # px that still counts as "reading the tail"
+PAGE_SIZE = 30
+
+
+class Feed(object):
+    """Scroll-state stand-in for [data-log-feed]."""
+
+    def __init__(self, scroll_height=0, client_height=600, scroll_top=0):
+        self.scroll_height = scroll_height
+        self.client_height = client_height
+        self.scroll_top = scroll_top
+
+
+def at_bottom(feed):
+    """PORT: a feed shorter than its viewport counts as at-bottom."""
+    return feed.scroll_height - feed.scroll_top - feed.client_height \
+        <= STICK_THRESHOLD
+
+
+class State(object):
+    """PORT: the module-level `state` object."""
+
+    def __init__(self):
+        self.events = []
+        self.oldest = 0
+        self.exhausted = False
+        self.loading = False
+        self.seeded = False
+
+
+def ingest(state, evt):
+    """PORT: keep state.events oldest-first (ascending id); skip ids
+    already held (SSE replay after a reconnect resends a frame)."""
+    i = 0
+    while i < len(state.events) and state.events[i]["id"] < evt["id"]:
+        i += 1
+    if i < len(state.events) and state.events[i]["id"] == evt["id"]:
+        return
+    state.events.insert(i, evt)
+    if evt["id"] < state.oldest or state.oldest == 0:
+        state.oldest = evt["id"]
+
+
+def render(state, feed, mode=None, height_after=None):
+    """PORT of render(mode): decide the post-render scroll position.
+    height_after stands in for the DOM rebuild's new scrollHeight."""
+    was_at_bottom = at_bottom(feed)
+    height_before = feed.scroll_height
+    scroll_top_before = feed.scroll_top
+    if height_after is not None:
+        feed.scroll_height = height_after
+    if not state.seeded and len(state.events) > 0:
+        state.seeded = True
+        feed.scroll_top = feed.scroll_height  # scrollToBottom()
+    elif mode == "prepend":
+        if was_at_bottom:
+            feed.scroll_top = feed.scroll_height
+        else:
+            feed.scroll_top = scroll_top_before + (feed.scroll_height
+                                                   - height_before)
+    elif was_at_bottom:
+        feed.scroll_top = feed.scroll_height
+    else:
+        feed.scroll_top = scroll_top_before
+    return feed.scroll_top
+
+
+def server_page(newest, oldest, more):
+    """The endpoint's wire shape: newest-first within the page."""
+    return {"events": [{"id": i} for i in range(newest, oldest - 1, -1)],
+            "more": more}
+
+
+def load_more(state, feed, page, height_after):
+    """PORT of loadMore's success path: reverse the newest-first page
+    into chronological order, ingest it, set exhausted, render."""
+    rows = page["events"]
+    for evt in reversed(rows):
+        ingest(state, evt)
+    if not page["more"] or len(rows) == 0:
+        state.exhausted = True
+    return render(state, feed, mode="prepend", height_after=height_after)
+
+
+def on_stream_message(state, feed, data, height_after):
+    """PORT of onStreamMessage: skip heartbeats, ingest one row."""
+    if data == "ping":
+        return feed.scroll_top
+    ingest(state, {"id": int(data)})
+    return render(state, feed, mode="append", height_after=height_after)
+
+
+# ---------------------------------------------------------------------------
+# Behaviour tests
+# ---------------------------------------------------------------------------
+
+def ids(state):
+    return [evt["id"] for evt in state.events]
+
+
+class ChronologyTests(unittest.TestCase):
+
+    def test_initial_window_renders_oldest_first(self):
+        """The SSE replay / first JSON page arrives newest-first
+        ([30..1]); the feed must render [1..30] - oldest at the top."""
+        state = State()
+        for evt in server_page(30, 1, True)["events"]:
+            ingest(state, evt)
+        self.assertEqual(ids(state), list(range(1, 31)))
+        self.assertEqual(state.oldest, 1)
+
+    def test_live_appends_land_in_order(self):
+        state = State()
+        for i in range(30, 0, -1):
+            ingest(state, {"id": i})
+        ingest(state, {"id": 31})
+        ingest(state, {"id": 33})
+        ingest(state, {"id": 32})  # out of order arrival
+        self.assertEqual(ids(state)[-3:], [31, 32, 33])
+
+    def test_duplicate_ids_are_dropped(self):
+        state = State()
+        ingest(state, {"id": 5})
+        ingest(state, {"id": 5})  # EventSource reconnect replay
+        self.assertEqual(ids(state), [5])
+
+
+class ScrollStickTests(unittest.TestCase):
+
+    def test_first_render_with_rows_seeds_at_the_bottom(self):
+        state = State()
+        feed = Feed(scroll_height=0, client_height=600)
+        for evt in server_page(30, 1, True)["events"]:
+            ingest(state, evt)
+        render(state, feed, height_after=3000)
+        self.assertTrue(state.seeded)
+        self.assertEqual(feed.scroll_top, 3000)
+
+    def test_append_sticks_when_reading_the_tail(self):
+        state = State()
+        feed = Feed(scroll_height=3000, client_height=600)
+        state.seeded = True
+        feed.scroll_top = 2400  # exactly at the bottom
+        self.assertTrue(at_bottom(feed))
+        ingest(state, {"id": 31})
+        render(state, feed, mode="append", height_after=3100)
+        self.assertEqual(feed.scroll_top, 3100)
+
+    def test_append_within_threshold_still_sticks(self):
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=2360)
+        self.assertTrue(at_bottom(feed))  # 40px shy: close enough
+
+    def test_just_past_threshold_is_not_the_tail(self):
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=2351)
+        self.assertFalse(at_bottom(feed))  # 49px shy: reading history
+
+    def test_scrolled_up_reader_is_never_yanked(self):
+        state = State()
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
+        state.seeded = True
+        self.assertFalse(at_bottom(feed))
+        ingest(state, {"id": 31})
+        render(state, feed, mode="append", height_after=3100)
+        self.assertEqual(feed.scroll_top, 500)
+
+    def test_short_feed_counts_as_at_bottom(self):
+        """A feed shorter than its viewport cannot be scrolled away
+        from the newest row."""
+        feed = Feed(scroll_height=100, client_height=600, scroll_top=0)
+        self.assertTrue(at_bottom(feed))
+
+    def test_heartbeat_does_not_touch_anything(self):
+        state = State()
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
+        state.seeded = True
+        before = (list(ids(state)), feed.scroll_top)
+        on_stream_message(state, feed, "ping", height_after=3100)
+        self.assertEqual((list(ids(state)), feed.scroll_top), before)
+
+
+class LoadMoreTests(unittest.TestCase):
+
+    def test_older_page_is_reversed_and_prepended(self):
+        """before=<oldest>=1 returns [60..31] newest-first on the
+        wire; the display must gain [31..60] above [1..30]."""
+        state = State()
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
+        for evt in server_page(30, 1, True)["events"]:
+            ingest(state, evt)
+        state.seeded = True
+        render(state, feed, height_after=3000)
+        feed.scroll_top = 500  # the reader scrolled up into history
+        load_more(state, feed, server_page(60, 31, True), height_after=6000)
+        self.assertEqual(ids(state), list(range(1, 61)))
+        self.assertEqual(state.oldest, 1)
+        self.assertFalse(state.exhausted)
+
+    def test_prepend_keeps_the_viewport_anchored(self):
+        """Content added above must not move the rows under the
+        reader's cursor: scrollTop grows by the added height."""
+        state = State()
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
+        state.seeded = True
+        self.assertFalse(at_bottom(feed))
+        delta = 3000
+        load_more(state, feed, server_page(60, 31, True),
+                  height_after=3000 + delta)
+        self.assertEqual(feed.scroll_top, 500 + delta)
+
+    def test_prepend_while_at_bottom_stays_at_bottom(self):
+        state = State()
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
+        state.seeded = True
+        self.assertTrue(at_bottom(feed))
+        load_more(state, feed, server_page(60, 31, True), height_after=6000)
+        self.assertEqual(feed.scroll_top, 6000)
+
+    def test_exhausted_when_more_is_false(self):
+        state = State()
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
+        state.seeded = True
+        load_more(state, feed, server_page(60, 31, False), height_after=6000)
+        self.assertTrue(state.exhausted)
+
+    def test_empty_page_exhausts_too(self):
+        """A defensive guard: a page with no rows means no older
+        history, whatever the flag says."""
+        state = State()
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
+        state.seeded = True
+        ingest(state, {"id": 1})
+        load_more(state, feed, {"events": [], "more": True}, height_after=3000)
+        self.assertTrue(state.exhausted)
+
+
+class EndToEndSequenceTests(unittest.TestCase):
+    """The whole first-minute story: replay, live tail, history."""
+
+    def test_replay_then_append_then_history(self):
+        state = State()
+        feed = Feed(scroll_height=0, client_height=600)
+        # 1. SSE replay: most-recent-30 oldest-first on the wire.
+        for evt in server_page(30, 1, True)["events"]:
+            ingest(state, evt)
+        render(state, feed, height_after=3000)
+        self.assertEqual(ids(state), list(range(1, 31)))
+        self.assertEqual(feed.scroll_top, 3000)  # seeded at the tail
+
+        # 2. Two live rows arrive; the reader is at the bottom.
+        on_stream_message(state, feed, "31", height_after=3100)
+        on_stream_message(state, feed, "32", height_after=3200)
+        self.assertEqual(ids(state)[-2:], [31, 32])
+        self.assertEqual(feed.scroll_top, 3200)
+
+        # 3. A heartbeat is ignored, then the reader scrolls up.
+        on_stream_message(state, feed, "ping", height_after=3200)
+        feed.scroll_top = 100
+        on_stream_message(state, feed, "33", height_after=3300)
+        self.assertEqual(feed.scroll_top, 100)  # never yanked
+
+        # 4. Load older: before=1 -> [60..31] newest-first wire page.
+        self.assertEqual(state.oldest, 1)
+        load_more(state, feed, server_page(60, 31, False), height_after=6300)
+        self.assertEqual(ids(state), list(range(1, 61)))
+        self.assertTrue(state.exhausted)
+        self.assertEqual(feed.scroll_top, 100 + 3000)  # anchored
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)