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:
parent
0307416b92
commit
8f5306db09
3 changed files with 56 additions and 2 deletions
|
|
@ -174,7 +174,21 @@ function scrollAutoscrollTargets(root) {
|
||||||
if (up) targets.push(up);
|
if (up) targets.push(up);
|
||||||
}
|
}
|
||||||
targets.forEach((el) => {
|
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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,15 @@ function streamTextToElement(element) {
|
||||||
|
|
||||||
const appendLine = (line) => {
|
const appendLine = (line) => {
|
||||||
element.textContent += `${line}\n`;
|
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) => {
|
source.onmessage = (event) => {
|
||||||
|
|
|
||||||
|
|
@ -161,3 +161,35 @@ def test_console_pane_pinned_after_command_submit(page: Page, server_with_consol
|
||||||
"(el) => el.scrollHeight - el.scrollTop - el.clientHeight"
|
"(el) => el.scrollHeight - el.scrollTop - el.clientHeight"
|
||||||
)
|
)
|
||||||
assert abs(bottom_distance) < 2, f"transcript not pinned after submit: {bottom_distance}px"
|
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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue