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
+4 -7
View File
@@ -355,7 +355,7 @@ def test_deactivating_a_user_revokes_their_sessions(client: TestClient, db, regi
def test_a_pinned_model_appears_once_in_the_picker(client: TestClient, db, registered, make_chat):
"""Two options with the same value, both selected, is not a picker."""
"""Listing a model twice would let two rows claim to be selected."""
connection = _connection(db)
db.add_all(
[
@@ -367,8 +367,8 @@ def test_a_pinned_model_appears_once_in_the_picker(client: TestClient, db, regis
chat_id = make_chat()
page = client.get(f"/chat/{chat_id}").text
assert page.count('<option value="favourite"') == 1
assert page.count('<option value="ordinary"') == 1
assert page.count('data-picker-value="favourite"') == 1
assert page.count('data-picker-value="ordinary"') == 1
# --- System prompt layering --------------------------------------------------
@@ -678,10 +678,7 @@ def test_the_new_chat_composer_preselects_the_default_model(
settings_store.update(db, {"default_model": "the-default"})
page = client.get("/chat").text
assert 'value="the-default"' in page
assert '<option value="the-default"\n selected' in page or (
'value="the-default"' in page and "selected" in page
)
assert 'data-picker-value="the-default"' in page
# And the hidden field the composer submits carries it too.
assert 'name="model_id" value="the-default"' in page