fix(server-detail): scroll the actual container, not the autoscroll target

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 <pre> 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) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-21 09:53:06 +02:00
parent 0307416b92
commit 8f5306db09
No known key found for this signature in database
3 changed files with 56 additions and 2 deletions

View file

@ -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;
});
}

View file

@ -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) => {

View file

@ -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 <pre>
itself, which has max-height:none in tab-pane context). Inject log
lines into the <pre> 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"