# 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**