Files
LLeMbas/src/lembas/web/templates/messages/index.html
T
HomerandClaude Opus 5 201281d616 New markup over an old stylesheet
Reported from a desktop browser: a stray close button beside the logo, badly
drawn, and a page that would not scroll. None of it was in the code that was
running -- it was the code the browser had not fetched.

The worker caches /static/ under a cache named for the release while the files
in it carried no version, and a page is fetched network-first. That only ever
worked because the worker used to seize every open tab the instant it installed
and wipe the old cache. 1.1.0 stopped it doing that, rightly -- it was swapping
stylesheets out from under a streaming reply -- and a momentary mismatch became
a permanent one: new markup over the previous release's CSS for as long as the
old worker lived. `.sidebar__close` had no rule there, so `.btn--icon` made it
inline-flex: visible everywhere, placed by nothing.

Every /static/ URL carries the release now, written by `templating.asset` and
precached by `sw.js:versioned` -- both halves, because caches.match compares the
query too and precaching the bare path would cache entries nothing requests.
Self-correcting: updating is enough.

The header was also a brand with a button appended and margin-left:auto doing
the placing, which holds exactly while that button is last. Two slots now: a
brand that shrinks and truncates, and a rail on the trailing edge.

Verified before changing anything: with the current stylesheet the button is
display:none at 1280 and, with thirty chats and forty messages, both scrollers
scroll. The first measurement said the thread did not -- that was
scroll-behavior: smooth reporting where it started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 18:16:24 +00:00

129 lines
4.7 KiB
HTML

{% extends "base.html" %}
{% from "_macros.html" import icon, mark %}
{#
Messages: one conversation per person, opened at the most recent turns.
The ordinary chat shell, with two differences. It opens on the live chunk
rather than on everything, and above that sits a sentinel that fetches the
page before it when scrolled into view. Everything else — the composer, the
bubbles, the tail poller, the streaming shell — is the same machinery, which
is the whole reason this is a `Chat` with a different `kind`.
#}
{% block title %}Messages - {{ brand.name }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
{% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %}
{% block body %}
<div class="shell">
{% include "partials/sidebar.html" %}
<main class="main">
<header class="topbar">
{% include "partials/_sidebar_toggle.html" %}
<h1 class="topbar__title">{{ icon("chat", "icon--sm") }} Messages</h1>
<div class="topbar__actions">
{% if schedules %}
<details class="picker">
<summary class="btn btn--sm">
{{ icon("clock", "icon--sm") }} {{ schedules | length }} scheduled
</summary>
<div class="picker__menu">
<div class="picker__label">Posting into this conversation</div>
{% for item in schedules %}
<a class="jobs__row jobs__row--open" href="/scheduled/{{ item.row.id }}/edit">
<span>
<strong>{{ item.row.title }}</strong>
<span class="text-xs faint">{{ item.summary }}</span>
</span>
{% if not item.row.enabled %}
<span class="badge badge--warning">paused</span>
{% endif %}
</a>
{% endfor %}
</div>
</details>
{% endif %}
{% if can.get("schedule.use") %}
<a class="btn btn--sm" href="/scheduled/new">{{ icon("plus", "icon--sm") }} Schedule</a>
{% endif %}
</div>
</header>
<div class="thread-scroll" id="thread-scroll">
<div class="thread" id="thread">
{% if not messages %}
<div class="thread__intro">
{{ mark(cls="empty__mark", uid="messages") }}
<h2 class="empty__title">Nothing said yet</h2>
<p class="empty__text">
One conversation that keeps going. Anything scheduled to write here
will arrive in it.
</p>
</div>
{% endif %}
{#
The sentinel goes above the turns and replaces itself with the page
before, plus a fresh sentinel. Rendered only when there is something
earlier, so a short conversation has no loading row at the top.
#}
{% if more_before %}
<div class="history-sentinel"
hx-get="/api/messages/history?before={{ oldest_id }}"
hx-trigger="revealed"
hx-target="this"
hx-swap="outerHTML"
hx-sync="this:drop">
<span class="visually-hidden" role="status">Loading earlier messages</span>
<div class="history-sentinel__shape" aria-hidden="true">
<div class="skeleton skeleton--line"></div>
<div class="skeleton skeleton--line skeleton--short"></div>
</div>
</div>
{% endif %}
{% for message in messages %}
{% include "chat/_message.html" %}
{% endfor %}
</div>
</div>
{#
Outside `#thread` and outside the composer form, for the two reasons
`tests/test_chat_tail.py` already walks the chat page to enforce.
#}
<div hidden id="thread-tail"
hx-get="/api/chats/{{ chat.id }}/tail"
hx-trigger="every 5s"
hx-target="#thread" hx-swap="beforeend"
hx-sync="this:drop"></div>
{% include "chat/_composer.html" %}
</main>
</div>
{% endblock %}
{#
`steps.js` only. `composer.js` and `commands.js` were listed here too, and
`base.html` already loads both on every page -- so this screen ran each of
them **twice**.
Each is an IIFE with its own state, and `stopPropagation()` does not stop a
second listener already bound to the same node. So: two composer menus stacked
on each other; one Enter on a highlighted `/` item running the command twice
(`/help` opening two dialogs, `/image` posting the message twice); an `@`
mention attaching its file twice; and `Alt+B`, `Alt+E`, `Alt+T` and `Alt+I`
toggling their panel twice, which is to say doing nothing at all.
None of it looks like a script loaded twice. Found by driving the file under a
DOM stub, which is the rule the working notes set out and the reason it does.
#}
{% block scripts %}
<script src="{{ asset('js/steps.js') }}" defer></script>
{% endblock %}