Live Markdown, stop, rewind, custom picker, dialogs

Seven things.

**Reasoning starts closed.** The answer is what the reader is waiting
for; the thinking is one click away.

**Image borders.** .attachments__image was a block-level <a>, so its
border stretched the full column around a narrow picture. inline-block,
and the frame is the picture. Same fix for the composer thumbnail.

**Markdown now renders during the stream.** The generator re-renders the
answer so far and sends it as a `render` event at most every 100ms,
swapped with innerHTML, instead of appending escaped tokens and
formatting everything at the end. Re-rendering whole rather than
appending is the point: a list or a code fence is only correct once its
context exists, and partial syntax resolves itself as more arrives.
Measured against a live model: 29 render events, formatting visible from
the first content token.

**Stop button.** A stop request goes into an in-process set the
generator checks between chunks; whatever arrived is kept, because a
half-written answer the reader chose to cut short is still worth having.
Measured: stream ended 0.2s after the request, 1155 characters
preserved, message marked stopped rather than errored. Navigating away
does the same thing via CancelledError.

**Rewind and edit.** Edit one of your own turns and everything after it
is deleted, then the conversation runs on from there. Deliberately not
branching: that needs a UI for choosing between versions, and "go back
and try again from here" is what was asked for. The form states how many
messages will be discarded before you confirm.

**Custom model picker.** A <select> renders only text in an <option>, so
it can never show an avatar. Built from buttons and a hidden input, with
descriptions, capability tags, a filter box past eight models, and
arrow-key navigation written out by hand since there is no native widget
doing it.

**Notification system.** lembas.notify/confirm/prompt in ui.js, built on
<dialog> so focus trapping, Escape and page inertness come from the
browser. htmx:confirm is intercepted, so every existing hx-confirm gets
the themed dialog with no change at the call site; the browser's grey
confirm() is gone from every template.

230 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-21 14:33:04 +02:00
parent 476812f119
commit 5f020ef33f
19 changed files with 1122 additions and 53 deletions
+28 -11
View File
@@ -87,26 +87,32 @@
{% endif %}
{% if streaming %}
{# Reasoning arrives before the answer, so this block sits above it. It
starts open (watching a model think is the point) and the :has() rule
in chat.css hides the whole thing while it is still empty, so models
that emit no reasoning never show an empty box. #}
<details class="reasoning reasoning--live" id="reasoning-{{ message.id }}" open>
{# Reasoning arrives before the answer, so this block sits above it.
Closed by default -- the answer is what the reader is waiting for, and
the thinking is one click away. The :has() rule in chat.css hides the
whole thing while it is still empty, so models that emit no reasoning
never show an empty box. #}
<details class="reasoning reasoning--live" id="reasoning-{{ message.id }}">
<summary class="reasoning__summary">
{{ icon("sparkle", "icon--sm reasoning__icon") }}
<span class="reasoning__label">Thinking</span>
<span class="reasoning__label">Thinking</span>
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
</summary>
<div class="reasoning__body" sse-swap="reasoning" hx-swap="beforeend"></div>
</details>
{# Tokens are appended here as they arrive. The cursor is a CSS
pseudo-element on the empty parent, so it disappears by itself once
the first token lands. #}
<div class="msg__body msg__body--streaming" id="stream-{{ message.id }}"
sse-swap="token" hx-swap="beforeend"></div>
{# The server re-renders the answer as Markdown a few times a second and
replaces this whole block, so formatting appears as the model writes
rather than snapping into place at the end. #}
<div class="msg__body msg__body--live" id="stream-{{ message.id }}"
sse-swap="render" hx-swap="innerHTML"></div>
<div class="msg__waiting">
<span class="dots"><i></i><i></i><i></i></span>
<button class="btn btn--sm msg__stop" type="button"
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/stop"
hx-swap="none">
{{ icon("x", "icon--sm") }} Stop
</button>
</div>
{% elif message.reasoning and not message.error %}
{# Collapsed once finished: the answer is what the reader came for, and
@@ -140,6 +146,9 @@
{% endif %}
{% elif message.role == "assistant" %}
<div class="msg__body">{{ body_html|safe }}</div>
{% if message.stopped %}
<p class="msg__note">{{ icon("x", "icon--sm") }} Stopped. This reply is cut short.</p>
{% endif %}
{% elif message.content %}
<div class="msg__body msg__body--plain">{{ message.content }}</div>
{% endif %}
@@ -152,6 +161,14 @@
data-copy="msg-body-{{ message.id }}" aria-label="Copy message">
{{ icon("copy", "icon--sm") }}
</button>
{% if message.role == "user" %}
<button class="btn btn--icon btn--sm" type="button"
hx-get="/api/chats/{{ chat.id }}/messages/{{ message.id }}/edit"
hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"
aria-label="Edit and retry from here">
{{ icon("pencil", "icon--sm") }}
</button>
{% endif %}
{% if message.role == "assistant" %}
<button class="btn btn--icon btn--sm" type="button"
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/regenerate"