Three usability fixes for the inspection strip on server_detail: 1. Pin transcripts/logs to bottom on tab activation. 2. Cap inline Console to 20 entries; modal keeps 50. 3. Pin to bottom after a console-line is appended via HTMX. Approach B: single data-autoscroll opt-in attribute + one helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 KiB
Server detail — console + log autoscroll and inline-history cap
Date: 2026-05-20
Status: Design approved (pending user review of this spec)
Touches: server_detail page only (inline tab strip + modals)
Problem
Three usability issues on the server detail page's inspection strip:
- Console transcript doesn't land at the bottom when the Console tab is first opened. The pane is
hiddenat page load, so the existingscrollConsolesToBottomcall onDOMContentLoadedruns against adisplay:noneelement and effectively setsscrollTopto 0. When the user clicks the Console tab they see the top of 50 entries. - Console transcript shows too many past commands inline. The route loads 50
CommandHistoryrows; both inline pane and modal render all of them. 50 is right for the modal but heavy for an 18rem inline pane. - Submitting a command doesn't scroll the transcript to the new line.
htmx:loadfires withevent.detail.eltset to the newly-inserted<div class="console-line">. The currentscrollConsolesToBottomlooks at that element and its descendants — neither matches[data-autoscroll], so nothing is scrolled. (The transcript container is an ancestor of the inserted line.)
The Log tab has the same latent bug as #1: if the user switches away from Log and back, the <pre class="log-stream"> may not be at the bottom even though sse.js scrolls on each append, because per-append scroll while the pane is display:none is a no-op in practice.
Goal
When the user opens, switches to, or appends to any auto-pinned scroll region (Log stream or Console transcript) on the server detail page, the region is scrolled to its bottom.
Cap the inline Console pane at 20 entries; keep the modal at 50.
Non-goals
- No change to the SSE per-append scroll in
sse.js. It already handles its own case. - No change to the visual height of
.tab-pane(stays at 18rem). - No new endpoint. The existing
/servers/<id>/console/historypaging API is unaffected. - No change to the recent-players or files tabs.
Approach (selected: B — single generic attribute)
The Log stream and Console transcript are semantically the same: a scroll-locked terminal that must stay pinned to the bottom on (a) initial render, (b) tab activation, (c) content append. Treat them with one mechanism: an opt-in data-autoscroll attribute and a single helper that scrolls any such element.
Rejected: hardcoding .log-stream and .console-transcript selectors inside tabs.js ("Approach A"). That couples the tab subsystem to two unrelated CSS classes and means any future scroll-pinned region needs a third entry.
Architecture
┌──────────────────────────────────────────────────────────┐
│ page_routes.py │
│ loads console_history (50) │
│ passes: │
│ console_history → modal Console pane │
│ console_history_overview → inline Console pane (20) │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ server_detail.html │
│ inline transcript: data-autoscroll, loops overview │
│ modal transcript: data-autoscroll, loops full │
│ both log-stream <pre>s: data-autoscroll added │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ console-history.js (or split into autoscroll.js) │
│ scrollAutoscrollTargets(root): │
│ - if root matches [data-autoscroll]: scroll it │
│ - else scroll any descendants with [data-autoscroll] │
│ - else walk up; if ancestor [data-autoscroll]: scroll │
│ Exposed on window for cross-module use. │
│ Wired to htmx:load. │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ tabs.js │
│ after activateTab(): call window.scrollAutoscrollTargets│
│ on the newly-active tabpanel │
└──────────────────────────────────────────────────────────┘
Detailed design
1. Route — l4d2web/l4d2web/routes/page_routes.py (~L318-345)
Add:
console_history_overview = console_history[-20:]
Pass it to render_template:
return render_template(
"server_detail.html",
...
console_history=console_history,
console_history_overview=console_history_overview,
**ctx,
)
console_history is already chronological (oldest → newest after the reversed(...)), so [-20:] returns the 20 newest in chronological order — correct for top-down rendering.
2. Template — l4d2web/l4d2web/templates/server_detail.html
- Inline Console pane (currently iterates
console_history): switch toconsole_history_overview. - Modal Console pane: unchanged, still iterates
console_history. - Both
<pre class="log-stream">elements (inline and modal): adddata-autoscroll. - Both
.console-transcriptdivs already havedata-autoscroll; no change.
3. JS — autoscroll helper
Rename scrollConsolesToBottom to scrollAutoscrollTargets in console-history.js (or extract it into a tiny new autoscroll.js; either is fine — pick whichever keeps the diff small). New behavior:
function scrollAutoscrollTargets(root) {
if (!root) return;
const targets = [];
// Case 1: root itself opts in.
if (root.matches && root.matches("[data-autoscroll]")) {
targets.push(root);
}
// Case 2: descendants opt in.
if (root.querySelectorAll) {
root.querySelectorAll("[data-autoscroll]").forEach((el) => targets.push(el));
}
// Case 3: neither — try walking up. This handles htmx:load firing with
// the inserted child as the root after hx-swap="beforeend".
if (targets.length === 0 && root.closest) {
const up = root.closest("[data-autoscroll]");
if (up) targets.push(up);
}
targets.forEach((el) => {
el.scrollTop = el.scrollHeight;
});
}
window.scrollAutoscrollTargets = scrollAutoscrollTargets;
The htmx:load listener already calls the helper; it just needs the renamed function.
4. JS — tabs.js
In activateTab(strip, name), after the loop that toggles hidden, add:
const activePane = strip.querySelector('[role="tabpanel"]:not([hidden])');
if (activePane && window.scrollAutoscrollTargets) {
window.scrollAutoscrollTargets(activePane);
}
This runs on both initial activation (initStrips) and click-driven activation.
5. CSS
No changes. The 18rem fixed-height .tab-pane and existing .console-transcript / .log-stream styles already provide the scroll container.
Tests
Unit / template render (l4d2web/tests/test_servers.py):
test_server_detail_inline_console_pane_caps_at_20_lines— seed 30CommandHistoryrows, render page, assert the inlineconsole-transcript-inline-<id>container has exactly 20.console-linechildren and the modal container has 30.
e2e (l4d2web/tests/e2e/test_server_detail.py):
test_console_tab_scrolled_to_bottom_on_first_activation— seed > 20 history rows so the transcript overflows, open page, click Console tab, assertawait transcript.evaluate("(el) => Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 2")returns true.test_console_tab_scrolled_to_bottom_after_command_submit— open Console, send a command via the input, after the swap settles assert the same predicate.test_log_tab_scrolled_to_bottom_on_reactivation— switch to Console, then back to Log (skipped if no log lines flowed; otherwise assert the predicate against the log-stream).
The "scrolled to bottom" predicate is a 2-pixel tolerance to absorb subpixel rounding across browsers.
Risk and edge cases
- Empty transcript / empty log:
scrollHeight === clientHeight; settingscrollTop = scrollHeightis a no-op. Safe. - Modal Console transcript not yet open: On page load the modal is
display:none(via<dialog>not yetshowModal()-ed). Setting scrollTop on it does nothing useful — but the modal<dialog>Console transcript also hasdata-autoscroll, and HTMX/dialog open events would need a separate hook to pin it on first display. Scope decision: modal open already triggers re-pin via the existinghtmx:loadflow when the modal's content is HTMX-loaded; for the Console modal, transcript content is server-rendered into the dialog at page load, so we add a one-linemodal:openedlistener (consistent with the existingrecent-players-modalpattern that usesmodal:openedtriggers). Ifmodal:openeddoesn't exist as an event inmodals.js, fall back to running the helper inside the modal's open hook. Implementation plan will confirm which. window.scrollAutoscrollTargetsundefined at the timetabs.jsactivates a tab:tabs.jsandconsole-history.jsare both loaded viabase.htmlin the same<script>block order. As long asconsole-history.jsis included beforetabs.js, the function is defined when needed. Plan will verify and reorder if necessary.- 20 is a hard-coded magic number: Acceptable for a UI cap; deliberately not made configurable. If a future change wants it tunable, lift to a Flask config constant in one place.
Out of scope (for follow-up if desired)
- A "Pause autoscroll if user scrolled up" affordance (common in chat UIs). Not requested; current behavior is "always pin to bottom".
- A "Clear console history" button. Not requested.
- Modal-Console initial scroll-to-bottom on
dialog.showModal(). Covered conditionally by the modal-opened hook above; if that wiring proves brittle, falls out of scope into a separate task.