diff --git a/docs/superpowers/plans/2026-05-20-server-console-log-autoscroll.md b/docs/superpowers/plans/2026-05-20-server-console-log-autoscroll.md new file mode 100644 index 0000000..7926292 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-server-console-log-autoscroll.md @@ -0,0 +1,540 @@ +# 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/` 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 `
`; 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`:
+
+```python
+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'
]*>(.*?)
\s*]*>(.*?)\s* 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 `
`): + +Change: +```jinja +{% for h in console_history %} +``` +to: +```jinja +{% 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** + +```bash +cd l4d2web && pytest tests/test_servers.py::test_server_detail_inline_console_caps_at_20_modal_keeps_all -v +``` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +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: + +```js +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/` and run in the devtools console: + +```js +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** + +```bash +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: + +```js + // 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: + +```js + 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-autoscroll` to the log-stream elements** + +In `l4d2web/l4d2web/templates/server_detail.html`: + +- Inline log (~L61): + ```jinja +

+  ```
+- Modal log (~L101):
+  ```jinja
+  

+  ```
+- Modal job-log (~L159):
+  ```jinja
+  

+  ```
+
+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+):
+
+```bash
+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:
+
+```js
+(() => {
+  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**
+
+```bash
+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):
+
+```jinja
+
+```
+
+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 `` line with:
+
+```jinja
+
+```
+
+…and **immediately before `{% endblock %}`** (end of file), add:
+
+```jinja
+
+```
+
+- [ ] **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:
+
+```js
+(() => {
+  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**
+
+```bash
+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`:
+
+```python
+@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`:
+
+```python
+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**
+
+```bash
+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**
+
+```bash
+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:
+
+1. Reset / re-seed the dev DB if you used the `verify_cmd_*` seed above:
+   ```bash
+   sqlite3 .tmp/dev-server/l4d2web.db "DELETE FROM command_history WHERE command LIKE 'verify_cmd_%' OR command LIKE 'seed_cmd_%';"
+   ```
+2. Restart `scripts/dev-server.py`.
+3. In a browser, log in as `dev` / `devdevdev`, open `/servers/1`.
+4. Click **Console** — transcript shows ≤20 most-recent commands, scrolled to bottom.
+5. Submit any command (e.g. `status`) — the response error appends as a new line and the transcript scrolls to keep it visible.
+6. Click ⛶ to open the Console modal — modal transcript shows all 50 most-recent (or however many exist), scrolled to bottom.
+7. 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 `
`s get `data-autoscroll` — **Task 3 Step 2**
+- ✅ Helper walks ancestors to handle htmx:load on appended child — **Task 2**
+- ✅ Helper exposed on `window` — **Task 2**
+- ✅ `tabs.js` pins 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**