From 8f5306db094d18d699e90d1b9647394f29243007 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Thu, 21 May 2026 09:53:06 +0200 Subject: [PATCH] fix(server-detail): scroll the actual container, not the autoscroll target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline Log tab uses .tab-pane (height:18rem, overflow:auto) as its scroll container. .log-stream has overflow:auto too but max-height:none in tab-pane context, so it grows to fit and scrollHeight === clientHeight — setting scrollTop on the
 was a no-op.

scrollAutoscrollTargets now walks up from each [data-autoscroll] target
until it finds an element whose CSS allows scrolling AND whose content
is actually overflowing (scrollHeight > clientHeight). sse.js delegates
to the same helper so per-line log appends scroll the right container.

e2e: new test asserts the .tab-pane is pinned to its bottom after 200
log lines are injected and the helper runs.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 l4d2web/l4d2web/static/js/console-history.js | 16 +++++++++-
 l4d2web/l4d2web/static/js/sse.js             | 10 +++++-
 l4d2web/tests/e2e/test_server_detail.py      | 32 ++++++++++++++++++++
 3 files changed, 56 insertions(+), 2 deletions(-)

diff --git a/l4d2web/l4d2web/static/js/console-history.js b/l4d2web/l4d2web/static/js/console-history.js
index 70e3907..7ce652d 100644
--- a/l4d2web/l4d2web/static/js/console-history.js
+++ b/l4d2web/l4d2web/static/js/console-history.js
@@ -174,7 +174,21 @@ function scrollAutoscrollTargets(root) {
     if (up) targets.push(up);
   }
   targets.forEach((el) => {
-    el.scrollTop = el.scrollHeight;
+    // The element marked data-autoscroll may not itself be the actual
+    // scrolling container. In the inline Log tab, .log-stream has
+    // overflow:auto AND max-height:none — CSS says scrollable, but it
+    // grows to fit content (scrollHeight === clientHeight). The real
+    // scrollbar lives on the .tab-pane (height:18rem, overflow:auto).
+    // Walk up until we find an element that both CAN scroll (CSS) and
+    // IS scrolling (content overflows).
+    let scroller = el;
+    while (scroller && scroller !== document.body) {
+      const oy = getComputedStyle(scroller).overflowY;
+      const cssScrolls = oy === "auto" || oy === "scroll" || oy === "overlay";
+      if (cssScrolls && scroller.scrollHeight > scroller.clientHeight) break;
+      scroller = scroller.parentElement;
+    }
+    if (scroller) scroller.scrollTop = scroller.scrollHeight;
   });
 }
 
diff --git a/l4d2web/l4d2web/static/js/sse.js b/l4d2web/l4d2web/static/js/sse.js
index 47ac76b..d1da511 100644
--- a/l4d2web/l4d2web/static/js/sse.js
+++ b/l4d2web/l4d2web/static/js/sse.js
@@ -12,7 +12,15 @@ function streamTextToElement(element) {
 
   const appendLine = (line) => {
     element.textContent += `${line}\n`;
-    element.scrollTop = element.scrollHeight;
+    // Delegate to the shared autoscroll helper so the actual scroll
+    // container (which may be an ancestor — see scrollAutoscrollTargets)
+    // is the one moved. console-history.js is `defer`red and loads after
+    // DCL, so the helper is defined before any SSE event fires.
+    if (window.scrollAutoscrollTargets) {
+      window.scrollAutoscrollTargets(element);
+    } else {
+      element.scrollTop = element.scrollHeight;
+    }
   };
 
   source.onmessage = (event) => {
diff --git a/l4d2web/tests/e2e/test_server_detail.py b/l4d2web/tests/e2e/test_server_detail.py
index 766b2d6..ee500b7 100644
--- a/l4d2web/tests/e2e/test_server_detail.py
+++ b/l4d2web/tests/e2e/test_server_detail.py
@@ -161,3 +161,35 @@ def test_console_pane_pinned_after_command_submit(page: Page, server_with_consol
         "(el) => el.scrollHeight - el.scrollTop - el.clientHeight"
     )
     assert abs(bottom_distance) < 2, f"transcript not pinned after submit: {bottom_distance}px"
+
+
+def test_log_pane_pinned_after_lines_appended(page: Page, server_with_files) -> None:
+    """The inline Log tab's scroll container is .tab-pane (not the 
+    itself, which has max-height:none in tab-pane context). Inject log
+    lines into the 
 and call the autoscroll helper, then assert the
+    .tab-pane — the actual scroller — is pinned to its bottom.
+
+    Real L4D2 SSE isn't available in e2e, so we drive the autoscroll path
+    by hand. This pins the contract that scrollAutoscrollTargets walks up
+    to the nearest CSS-scrollable ancestor.
+    """
+    base = server_with_files["base_url"]
+    sid = server_with_files["server_id"]
+    login(page, base)
+    page.goto(f"{base}/servers/{sid}")
+
+    # Inject enough text to overflow the 18rem tab-pane, then run the
+    # helper. The Log tab is the default-active tab; no click needed.
+    page.evaluate(
+        """() => {
+            const pre = document.querySelector('[role="tabpanel"][data-tab="log"] .log-stream');
+            pre.textContent = Array.from({length: 200}, (_, i) => `log line ${i}`).join('\\n');
+            window.scrollAutoscrollTargets(pre);
+        }"""
+    )
+
+    pane = page.locator('[role="tabpanel"][data-tab="log"]')
+    bottom_distance = pane.evaluate(
+        "(el) => el.scrollHeight - el.scrollTop - el.clientHeight"
+    )
+    assert abs(bottom_distance) < 2, f"log pane not pinned to bottom: {bottom_distance}px"