5 tasks, TDD style: 1. server-side slice console_history_overview = console_history[-20:] 2. generalise scrollConsolesToBottom -> scrollAutoscrollTargets (ancestor walk) 3. tabs.js pins autoscroll targets on tab activation 4. console-modal pin on modal:opened event 5. e2e: pin-to-bottom on tab + on command submit Bug confirmed in live browser before plan was written: after switching to the Console tab on /servers/1, transcript scrollTop=0 with bottomDistance=1873px (top-of-history visible, not newest). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 KiB
Server detail — console + log autoscroll Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the Log and Console transcripts on /servers/<id> stay pinned to their bottom on initial load, tab activation, and command append; cap the inline Console to the 20 newest entries while the modal keeps all 50.
Architecture: Single opt-in data-autoscroll attribute on any scroll-pinned region. One helper (scrollAutoscrollTargets) handles root, descendants, and ancestors so HTMX beforeend swaps that fire htmx:load on the inserted child still find and scroll the parent transcript. tabs.js calls the helper after activating a tab. modals.js already dispatches modal:opened on showModal(), so the Console modal hooks that event to scroll on first open.
Tech Stack: Flask + Jinja2 templates, vanilla JS, HTMX, Playwright for e2e, Claude-in-Chrome for live-browser verification.
Reference spec: docs/superpowers/specs/2026-05-20-server-console-log-autoscroll-design.md
File map
| Path | Action | Responsibility |
|---|---|---|
l4d2web/l4d2web/routes/page_routes.py |
modify (~L318-345) | Add console_history_overview = console_history[-20:] to the render context |
l4d2web/l4d2web/templates/server_detail.html |
modify (L60-73, L101, L111-117, L159) | Inline loop uses console_history_overview; add data-autoscroll to both <pre class="log-stream">; Console modal transcript wires modal:opened → scroll |
l4d2web/l4d2web/static/js/console-history.js |
modify (L159-179) | Rename and generalise scrollConsolesToBottom → scrollAutoscrollTargets with ancestor walk; expose on window |
l4d2web/l4d2web/static/js/tabs.js |
modify (~L9-19) | After activateTab toggles hidden, scroll any [data-autoscroll] in the newly-active pane |
l4d2web/tests/test_servers.py |
extend | Server-side: assert inline pane caps at 20 and modal keeps 50 |
l4d2web/tests/e2e/test_server_detail.py |
extend | E2E: Console tab pinned to bottom on activation; Console pane pinned to bottom after command submit |
Task 1: Server-side slice for inline Console history
Files:
-
Modify:
l4d2web/l4d2web/routes/page_routes.py:318-347 -
Test:
l4d2web/tests/test_servers.py -
Step 1: Write the failing test
test_servers.py uses the user_client_with_blueprints fixture (returns (client, data) where data carries user_id and blueprint_id) plus direct DB writes via session_scope(). Mirror that pattern:
Append to l4d2web/tests/test_servers.py:
def test_server_detail_inline_console_caps_at_20_modal_keeps_all(user_client_with_blueprints) -> None:
"""When > 20 CommandHistory rows exist for the server, the inline
Console transcript renders only the 20 newest (chronological order),
while the modal transcript renders the full set (capped at the
route-level 50)."""
import re
from datetime import UTC, datetime, timedelta
from l4d2web.models import CommandHistory, Server
client, data = user_client_with_blueprints
with session_scope() as db:
server = Server(
user_id=data["user_id"],
blueprint_id=data["blueprint_id"],
name="console-cap",
port=27123,
rcon_password="x",
)
db.add(server)
db.flush()
sid = server.id
for i in range(35):
db.add(CommandHistory(
user_id=data["user_id"],
server_id=sid,
command=f"cmd_{i:02d}",
reply=f"reply {i}",
is_error=False,
created_at=datetime.now(UTC) - timedelta(minutes=40 - i),
))
resp = client.get(f"/servers/{sid}")
assert resp.status_code == 200
body = resp.get_data(as_text=True)
inline_match = re.search(
rf'<div id="console-transcript-inline-{sid}"[^>]*>(.*?)</div>\s*<form',
body,
re.DOTALL,
)
assert inline_match, "inline transcript container not found"
inline_lines = inline_match.group(1).count('class="console-line')
assert inline_lines == 20, f"inline expected 20, got {inline_lines}"
modal_match = re.search(
rf'<div id="console-transcript-modal-{sid}"[^>]*>(.*?)</div>\s*<form',
body,
re.DOTALL,
)
assert modal_match, "modal transcript container not found"
modal_lines = modal_match.group(1).count('class="console-line')
assert modal_lines == 35, f"modal expected 35, got {modal_lines}"
- Step 2: Run the test to verify it fails
cd l4d2web && pytest tests/test_servers.py::test_server_detail_inline_console_caps_at_20_modal_keeps_all -v
Expected: FAIL (inline returns 35, not 20)
- Step 3: Modify the route
In l4d2web/l4d2web/routes/page_routes.py, locate server_detail (~L306) and the console_history = list(reversed(...)) block (~L318-330). After it, add:
console_history_overview = console_history[-20:]
Then in the return render_template("server_detail.html", …) call (~L335-347), add the new kwarg:
return render_template(
"server_detail.html",
server=server,
blueprint=blueprint,
connect_host=connect_host,
file_tree_root_entries=file_tree_root_entries,
file_tree_truncated=file_tree_truncated_count > 0
if file_tree_root_entries is not None
else False,
file_tree_truncated_count=file_tree_truncated_count,
console_history=console_history,
console_history_overview=console_history_overview,
**ctx,
)
- Step 4: Update the template to use the new variable
In l4d2web/l4d2web/templates/server_detail.html at the inline Console pane (~L65-71, inside <div role="tabpanel" data-tab="console">):
Change:
{% for h in console_history %}
to:
{% for h in console_history_overview %}
Leave the modal Console transcript (~L112-116) iterating over console_history unchanged.
- Step 5: Run the test to verify it passes
cd l4d2web && pytest tests/test_servers.py::test_server_detail_inline_console_caps_at_20_modal_keeps_all -v
Expected: PASS
- Step 6: Commit
git add l4d2web/l4d2web/routes/page_routes.py \
l4d2web/l4d2web/templates/server_detail.html \
l4d2web/tests/test_servers.py
git commit -m "feat(server-detail): cap inline console to 20 newest; modal keeps 50"
Task 2: Generalise scrollConsolesToBottom to walk ancestors
Files:
- Modify:
l4d2web/l4d2web/static/js/console-history.js:159-179
The current helper only matches root and its descendants. When htmx:load fires after hx-swap="beforeend", event.detail.elt is the newly inserted child line — neither it nor its descendants match [data-autoscroll], so the transcript never scrolls. Adding an ancestor walk fixes this case without affecting the existing one.
- Step 1: Rewrite the helper
Replace lines 159-179 of l4d2web/l4d2web/static/js/console-history.js with:
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 — walk up. Handles htmx:load firing with the inserted
// child as the root after hx-swap="beforeend" on a console line.
if (targets.length === 0 && root.closest) {
const up = root.closest("[data-autoscroll]");
if (up) targets.push(up);
}
targets.forEach((el) => {
el.scrollTop = el.scrollHeight;
});
}
// Expose for tabs.js (and any future cross-module consumer). The script
// is `defer`red in base.html, so it runs before DOMContentLoaded and the
// global is defined by the time tabs.js's DCL-deferred initStrips runs.
window.scrollAutoscrollTargets = scrollAutoscrollTargets;
document.addEventListener("DOMContentLoaded", () => {
scrollAutoscrollTargets(document);
bindAllConsoleForms(document);
});
document.addEventListener("htmx:load", (event) => {
scrollAutoscrollTargets(event.detail.elt);
bindAllConsoleForms(event.detail.elt);
});
That is: rename the function, add the third (ancestor-walk) case, expose on window, and update the two listeners to call the new name. Behavior preserved for the existing two cases.
- Step 2: Smoke-verify in a browser
Start the dev server (or use a running one). Open /servers/<id> and run in the devtools console:
typeof window.scrollAutoscrollTargets
Expected: "function".
Then with a Console tab that already has > clientHeight of content, click into it and verify (manual eye check) that the transcript is no longer at the top. (It still won't be — tabs.js doesn't call the helper yet; that's Task 3. The smoke-check here is only that the helper is defined and reachable.)
- Step 3: Commit
git add l4d2web/l4d2web/static/js/console-history.js
git commit -m "feat(console): scrollAutoscrollTargets walks ancestors; expose on window"
Task 3: tabs.js — scroll on tab activation
Files:
-
Modify:
l4d2web/l4d2web/static/js/tabs.js:9-19 -
Step 1: Edit
activateTab
In l4d2web/l4d2web/static/js/tabs.js, at the end of the activateTab(strip, name) function, after strip.dataset.activeTab = name;, append:
// Pin any scroll-locked regions (log streams, console transcripts) in
// the newly-visible pane to the bottom. While the pane was hidden,
// their scrollHeight was 0 so previous appends couldn't anchor.
const activePane = strip.querySelector('[role="tabpanel"]:not([hidden])');
if (activePane && window.scrollAutoscrollTargets) {
window.scrollAutoscrollTargets(activePane);
}
The full block now looks like:
function activateTab(strip, name) {
strip.querySelectorAll('[role="tab"]').forEach((t) => {
const on = t.dataset.tab === name;
t.setAttribute("aria-selected", on ? "true" : "false");
t.tabIndex = on ? 0 : -1;
});
strip.querySelectorAll('[role="tabpanel"]').forEach((p) => {
p.hidden = p.dataset.tab !== name;
});
strip.dataset.activeTab = name;
const activePane = strip.querySelector('[role="tabpanel"]:not([hidden])');
if (activePane && window.scrollAutoscrollTargets) {
window.scrollAutoscrollTargets(activePane);
}
}
- Step 2: Add
data-autoscrollto the log-stream elements
In l4d2web/l4d2web/templates/server_detail.html:
- Inline log (~L61):
<pre class="log-stream" data-autoscroll data-sse-url="/servers/{{ server.id }}/logs/stream"></pre> - Modal log (~L101):
<pre class="log-stream tall" data-autoscroll data-sse-url="/servers/{{ server.id }}/logs/stream"></pre> - Modal job-log (~L159):
<pre class="log-stream tall" data-autoscroll data-sse-url="/jobs/{{ latest_job.id }}/stream"></pre>
The Console transcripts already carry data-autoscroll and don't need editing.
- Step 3: Live-browser verification
This is the moment to confirm the user-visible bug is fixed. Start (or reuse) the dev server and seed > 20 console rows for demo-server (the dev seed has only 9; bring that to 30+):
sqlite3 .tmp/dev-server/l4d2web.db "
WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n<30)
INSERT INTO command_history (user_id, server_id, command, reply, is_error, created_at)
SELECT 1, 1, 'verify_cmd_' || printf('%02d', n),
'reply ' || n, 0, datetime('now', '-'||(35-n)||' minutes') FROM seq;"
Then in a browser, log into /login as dev / devdevdev, open /servers/1, click the Console tab, and run in devtools:
(() => {
const t = document.querySelector('[id^="console-transcript-inline-"]');
return { scrollTop: t.scrollTop, scrollHeight: t.scrollHeight, clientHeight: t.clientHeight,
bottomDistance: t.scrollHeight - t.scrollTop - t.clientHeight,
inline_lines: t.querySelectorAll('.console-line').length };
})();
Expected: bottomDistance < 2 (pinned to bottom), inline_lines == 20.
Same exercise on the Log tab: switch to Console, then back to Log; the SSE-streamed log should be pinned to bottom. If the log stream has no content yet (server isn't running and never has been), this assertion vacuously passes (scrollHeight === clientHeight).
- Step 4: Commit
git add l4d2web/l4d2web/static/js/tabs.js \
l4d2web/l4d2web/templates/server_detail.html
git commit -m "feat(server-detail): pin transcripts/logs to bottom on tab activation"
Task 4: Pin Console-modal transcript on modal:opened
Files:
- Modify:
l4d2web/l4d2web/templates/server_detail.html:105-120(Console modal)
When the Console modal opens via data-inline-modal-open="console-modal", the dialog goes from display:none to displayed. The transcript inside it has scrollHeight=0 while hidden, so any earlier autoscroll attempt did nothing. modals.js:35 dispatches a modal:opened CustomEvent on dialog.showModal(); we listen for it on the dialog and re-pin.
- Step 1: Add the inline listener via
hx-on(no JS file changes)
In l4d2web/l4d2web/templates/server_detail.html, change the Console modal opening tag (~L105):
<dialog id="console-modal" class="modal" aria-labelledby="console-modal-title"
onmodal-opened="if(window.scrollAutoscrollTargets){window.scrollAutoscrollTargets(this)}">
The non-standard onmodal-opened attribute only works through an addEventListener registration — dialog.dispatchEvent doesn't invoke onevent handlers for custom events. So instead add a small DOMContentLoaded hook inside the template (one-off, not worth a new JS file):
Replace the opening <dialog id="console-modal" …> line with:
<dialog id="console-modal" class="modal" aria-labelledby="console-modal-title">
…and immediately before {% endblock %} (end of file), add:
<script>
// Pin the Console modal transcript to its bottom each time the modal
// opens. While the <dialog> is closed, its descendants have scrollHeight=0,
// so neither the page-load autoscroll nor htmx:load can anchor them.
// The 'modal:opened' CustomEvent is dispatched by modals.js on
// dialog.showModal().
(() => {
const dlg = document.getElementById("console-modal");
if (!dlg) return;
dlg.addEventListener("modal:opened", () => {
if (window.scrollAutoscrollTargets) {
window.scrollAutoscrollTargets(dlg);
}
});
})();
</script>
- Step 2: Live-browser verification
Reload /servers/1 (after seeding from Task 3 Step 3). Click the ⛶ expand button on the Console tab to open #console-modal. In devtools:
(() => {
const t = document.querySelector('[id^="console-transcript-modal-"]');
return { scrollTop: t.scrollTop, scrollHeight: t.scrollHeight,
clientHeight: t.clientHeight,
bottomDistance: t.scrollHeight - t.scrollTop - t.clientHeight };
})();
Expected: bottomDistance < 2.
- Step 3: Commit
git add l4d2web/l4d2web/templates/server_detail.html
git commit -m "feat(server-detail): pin Console-modal transcript on modal:opened"
Task 5: e2e — Console tab pinned on activation; pinned after submit
Files:
- Modify:
l4d2web/tests/e2e/test_server_detail.py(append) - Possibly:
l4d2web/tests/e2e/conftest.py(add seeded-history fixture if helpful)
The dev server seed has too few rows to trigger overflow; the e2e fixture seeds its own. Reusing the server_with_files fixture is fine — it already builds a Server; we just need to insert CommandHistory rows before navigating.
- Step 1: Add a fixture that seeds console history
Append to l4d2web/tests/e2e/conftest.py:
@pytest.fixture(scope="function")
def server_with_console_history(server_with_files):
"""server_with_files + 30 seeded CommandHistory rows for that server,
so the inline Console transcript exceeds its visible height and the
autoscroll behaviour is observable."""
from datetime import UTC, datetime, timedelta
from l4d2web.models import CommandHistory
sid = server_with_files["server_id"]
uid = server_with_files["user_id"]
with session_scope() as session:
for i in range(30):
session.add(CommandHistory(
user_id=uid,
server_id=sid,
command=f"seed_{i:02d}",
reply=f"reply {i}",
is_error=False,
created_at=datetime.now(UTC) - timedelta(minutes=35 - i),
))
return server_with_files
- Step 2: Write the failing e2e tests
Append to l4d2web/tests/e2e/test_server_detail.py:
def test_console_tab_pinned_to_bottom_on_activation(page: Page, server_with_console_history) -> None:
"""Clicking the Console tab leaves the transcript scrolled to its
bottom — the newest seeded command must be visible, not the oldest."""
base = server_with_console_history["base_url"]
sid = server_with_console_history["server_id"]
login(page, base)
page.goto(f"{base}/servers/{sid}")
strip = page.locator("[data-tab-strip]")
strip.locator('[role="tab"][data-tab="console"]').click()
transcript = page.locator(f"#console-transcript-inline-{sid}")
expect(transcript).to_be_visible()
# Pinned to bottom: |scrollHeight - scrollTop - clientHeight| < 2
bottom_distance = transcript.evaluate(
"(el) => el.scrollHeight - el.scrollTop - el.clientHeight"
)
assert abs(bottom_distance) < 2, f"transcript not pinned to bottom: {bottom_distance}px"
# Inline pane caps at 20 lines.
line_count = transcript.locator(".console-line").count()
assert line_count == 20, f"inline expected 20 lines, got {line_count}"
def test_console_pane_pinned_after_command_submit(page: Page, server_with_console_history) -> None:
"""After submitting a command, the transcript scrolls so the new line
is visible at the bottom.
The dev server has no live RCON, but the POST still records a
CommandHistory row and HTMX appends a console-line to the transcript;
that's enough to exercise the autoscroll path.
"""
base = server_with_console_history["base_url"]
sid = server_with_console_history["server_id"]
login(page, base)
page.goto(f"{base}/servers/{sid}")
strip = page.locator("[data-tab-strip]")
strip.locator('[role="tab"][data-tab="console"]').click()
transcript = page.locator(f"#console-transcript-inline-{sid}")
pane = page.locator('[role="tabpanel"][data-tab="console"]')
cmd_input = pane.locator('input[name="command"]')
cmd_input.fill("verify_submit")
cmd_input.press("Enter")
# Wait for the new line to appear in the DOM.
expect(transcript.locator(".console-line", has_text="verify_submit")).to_be_visible()
bottom_distance = transcript.evaluate(
"(el) => el.scrollHeight - el.scrollTop - el.clientHeight"
)
assert abs(bottom_distance) < 2, f"transcript not pinned after submit: {bottom_distance}px"
- Step 3: Run e2e
cd l4d2web && pytest tests/e2e/test_server_detail.py -m e2e -v
If the dev-machine doesn't have Chromium installed: playwright install chromium first.
Expected: PASS for both new tests; existing tests still pass.
- Step 4: Commit
git add l4d2web/tests/e2e/conftest.py l4d2web/tests/e2e/test_server_detail.py
git commit -m "test(e2e): console transcript pinned to bottom on tab + submit"
Final verification (live browser)
After all tasks merge:
- Reset / re-seed the dev DB if you used the
verify_cmd_*seed above:sqlite3 .tmp/dev-server/l4d2web.db "DELETE FROM command_history WHERE command LIKE 'verify_cmd_%' OR command LIKE 'seed_cmd_%';" - Restart
scripts/dev-server.py. - In a browser, log in as
dev/devdevdev, open/servers/1. - Click Console — transcript shows ≤20 most-recent commands, scrolled to bottom.
- Submit any command (e.g.
status) — the response error appends as a new line and the transcript scrolls to keep it visible. - Click ⛶ to open the Console modal — modal transcript shows all 50 most-recent (or however many exist), scrolled to bottom.
- Switch to Log, then between Console and Log a few times — each transition leaves the active tab's transcript pinned to its bottom.
Spec coverage check
- ✅ Server-side slice to 20 newest inline; modal keeps 50 — Task 1
- ✅ Both log-stream
<pre>s getdata-autoscroll— Task 3 Step 2 - ✅ Helper walks ancestors to handle htmx:load on appended child — Task 2
- ✅ Helper exposed on
window— Task 2 - ✅
tabs.jspins on activation — Task 3 Step 1 - ✅ Console-modal pin on
modal:opened— Task 4 - ✅ Unit/template coverage for cap — Task 1
- ✅ e2e coverage for tab activation + submit pin — Task 5