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:
+150
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user