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
+150 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import httpx
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy import func, select
from lembas.db.models import Chat, Connection, Folder, Message, Model
from lembas.services import chat as chat_service
@@ -352,3 +352,152 @@ def test_streaming_reports_an_unreachable_endpoint_in_the_thread(
db.refresh(message)
assert message.complete is True
assert message.error
# --- Stopping a stream -------------------------------------------------------
def test_stopping_marks_the_message_and_keeps_what_arrived(
client: TestClient, db, registered, make_chat
):
"""A half-written answer the reader chose to cut short is still worth
having; discarding it would be a surprise."""
from lembas.api.chats import _CANCELLED
_add_connection(db)
chat_id = make_chat()
client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
message = db.scalar(select(Message).where(Message.role == "assistant"))
assert client.post(
f"/api/chats/{chat_id}/messages/{message.id}/stop"
).status_code == 204
assert message.id in _CANCELLED
_CANCELLED.discard(message.id)
def test_stopping_someone_elses_message_is_refused(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
message = db.scalar(select(Message).where(Message.role == "assistant"))
client.post("/auth/logout", follow_redirects=False)
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
follow_redirects=False,
)
assert client.post(
f"/api/chats/{chat_id}/messages/{message.id}/stop"
).status_code == 404
def test_the_streaming_bubble_offers_a_stop_button(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
assert "/stop" in response.text
assert "msg__stop" in response.text
def test_the_streaming_bubble_renders_markdown_not_raw_tokens(
client: TestClient, db, registered, make_chat
):
"""The body receives re-rendered Markdown, so formatting appears as the
model writes rather than snapping in at the end."""
_add_connection(db)
chat_id = make_chat()
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
assert 'sse-swap="render"' in response.text
assert 'hx-swap="innerHTML"' in response.text
def test_reasoning_starts_closed(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
block = response.text[response.text.index("reasoning--live"):]
assert not block[: block.index(">")].strip().endswith("open")
# --- Rewinding ---------------------------------------------------------------
def _exchange(client: TestClient, db, chat_id: str, text: str) -> Message:
client.post(f"/api/chats/{chat_id}/messages", data={"content": text})
assistant = db.scalars(
select(Message).where(Message.role == "assistant").order_by(Message.created_at)
).all()[-1]
assistant.content = f"reply to {text}"
assistant.complete = True
db.commit()
return assistant
def test_editing_rewinds_and_discards_later_messages(
client: TestClient, db, registered, make_chat
):
_add_connection(db)
chat_id = make_chat()
_exchange(client, db, chat_id, "first")
_exchange(client, db, chat_id, "second")
assert db.scalar(select(func.count()).select_from(Message)) == 4
first_user = db.scalars(
select(Message).where(Message.role == "user").order_by(Message.created_at)
).first()
client.post(
f"/api/chats/{chat_id}/messages/{first_user.id}/edit",
data={"content": "first, revised"},
)
# The edit happened in the request's session; this one still holds the old
# instance in its identity map.
db.expire_all()
remaining = db.scalars(select(Message).order_by(Message.created_at)).all()
assert [m.role for m in remaining] == ["user", "assistant"]
assert remaining[0].content == "first, revised"
# The fresh assistant row is incomplete, which is what restarts the stream.
assert remaining[1].complete is False
def test_the_edit_form_says_how_much_will_be_lost(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
_exchange(client, db, chat_id, "first")
_exchange(client, db, chat_id, "second")
first_user = db.scalars(
select(Message).where(Message.role == "user").order_by(Message.created_at)
).first()
page = client.get(f"/api/chats/{chat_id}/messages/{first_user.id}/edit").text
assert "3 messages after this one will be deleted" in page
def test_only_your_own_turns_can_be_edited(client: TestClient, db, registered, make_chat):
"""Rewriting what the model said would be inventing history."""
_add_connection(db)
chat_id = make_chat()
assistant = _exchange(client, db, chat_id, "hello")
assert client.get(
f"/api/chats/{chat_id}/messages/{assistant.id}/edit"
).status_code == 404
def test_an_edit_cannot_empty_a_message(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
_exchange(client, db, chat_id, "hello")
user_message = db.scalar(select(Message).where(Message.role == "user"))
assert client.post(
f"/api/chats/{chat_id}/messages/{user_message.id}/edit", data={"content": " "}
).status_code == 400
def test_cancelling_an_edit_restores_the_bubble(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
_exchange(client, db, chat_id, "unchanged")
user_message = db.scalar(select(Message).where(Message.role == "user"))
page = client.get(f"/api/chats/{chat_id}/messages/{user_message.id}/cancel-edit").text
assert "unchanged" in page
assert "edit-form" not in page
+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