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 f744232d25
commit a071d8486b
20 changed files with 1151 additions and 54 deletions
@@ -0,0 +1,48 @@
{% from "_macros.html" import icon %}
{#
A user turn switched into edit mode, replacing its bubble in place.
Saving rewinds: the message is rewritten and everything after it is deleted,
then the conversation runs again from that point. That is destructive, so the
button says so and asks first.
#}
<article class="msg msg--user msg--editing" id="msg-{{ message.id }}">
<div class="msg__gutter" aria-hidden="true">
<span class="msg__initial">{{ (user.name or "?")[0]|upper }}</span>
</div>
<div class="msg__main">
<header class="msg__meta">
<span class="msg__author">{{ user.name or "You" }}</span>
<span class="msg__model">editing</span>
</header>
<form class="edit-form"
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/edit"
hx-target="#thread" hx-swap="innerHTML"
hx-confirm="Rewind here? The {{ following }} message{{ '' if following == 1 else 's' }} after this one will be deleted."
data-confirm-title="Rewind the conversation"
data-confirm-label="Rewind and send">
<textarea class="textarea" name="content" rows="3" data-autosize
data-max-height="320" aria-label="Edit message"
autofocus>{{ message.content }}</textarea>
<div class="btn-row">
<button class="btn btn--primary" type="submit">
{{ icon("refresh", "icon--sm") }}
{% if following %}Rewind and send{% else %}Send again{% endif %}
</button>
<button class="btn" type="button"
hx-get="/api/chats/{{ chat.id }}/messages/{{ message.id }}/cancel-edit"
hx-target="#msg-{{ message.id }}" hx-swap="outerHTML">
Cancel
</button>
{% if following %}
<span class="text-xs faint">
{{ following }} later message{{ '' if following == 1 else 's' }} will be discarded.
</span>
{% endif %}
</div>
</form>
</div>
</article>