Background generation, unread replies, send/stop, PLAN.md

**Replies now run in the background.** Generation was driven by the SSE
request, so navigating away or opening another chat cut the answer off
mid-sentence. services/generation.py owns the work as its own task and
the SSE endpoint merely follows it. Verified: attached briefly, closed
the connection, went to another page -- the reply finished anyway, 832
characters, not marked stopped, auto-titled.

Reattaching works because both `render` and `reasoning` frames now carry
the whole block rather than a delta. A follower arriving late has no
earlier fragments to append to, so deltas would leave it permanently
missing the beginning. Verified: attached six seconds in and the first
frame already contained 517 characters written while nobody watched.

**Unread indicator.** A reply that lands with no follower attached marks
its chat unread; the sidebar polls every 10s for out-of-band dot spans
plus an HX-Trigger that raises a toast. Polled rather than pushed: a
browser sitting on another chat has no connection to the one that
finished, and an always-on channel per tab is a lot of machinery for a
green dot. `unread_notified` stops the same arrival being announced
every tick. Follower count is what decides "was anyone watching", so
reading it as it arrives does not mark it unread -- verified both ways.

**Stop is the send button.** While a reply is being written the send
button becomes a red stop square, found via a MutationObserver on the
thread since the composer and the streaming bubble are far apart in the
document. The in-bubble Stop is gone.

**Attachment border removed.** As asked -- an attachment is a picture,
and the frame only ever drew at the wrong width. The anchor now
shrink-wraps and the img's width/height attributes are overridden so a
small image shows at its own size.

Adds PLAN.md: what is built, what is not, known limits, and the
decisions that look like oversights until you know the reason.

239 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:52:28 +02:00
parent 5f020ef33f
commit ca3e4fd04f
12 changed files with 647 additions and 200 deletions
+126 -9
View File
@@ -355,12 +355,10 @@ def test_streaming_reports_an_unreachable_endpoint_in_the_thread(
# --- Stopping a stream -------------------------------------------------------
def test_stopping_marks_the_message_and_keeps_what_arrived(
client: TestClient, db, registered, make_chat
):
def test_stopping_asks_the_generation_to_stop(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
from lembas.services import generation as generation_service
_add_connection(db)
chat_id = make_chat()
@@ -370,8 +368,11 @@ def test_stopping_marks_the_message_and_keeps_what_arrived(
assert client.post(
f"/api/chats/{chat_id}/messages/{message.id}/stop"
).status_code == 204
assert message.id in _CANCELLED
_CANCELLED.discard(message.id)
running = generation_service.get(message.id)
# The endpoint points at 127.0.0.1:1, so the task may already have failed
# and finished; either way the request must be accepted, not error.
assert running is None or running.cancel or running.done
def test_stopping_someone_elses_message_is_refused(client: TestClient, db, registered, make_chat):
@@ -391,12 +392,16 @@ def test_stopping_someone_elses_message_is_refused(client: TestClient, db, regis
).status_code == 404
def test_the_streaming_bubble_offers_a_stop_button(client: TestClient, db, registered, make_chat):
def test_the_streaming_bubble_carries_the_sse_connection(
client: TestClient, db, registered, make_chat
):
"""Stop lives on the composer's send button now, and the JS finds the
running message through this attribute."""
_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
assert "sse-connect" in response.text
assert f"/api/chats/{chat_id}/messages/" in response.text
def test_the_streaming_bubble_renders_markdown_not_raw_tokens(
@@ -501,3 +506,115 @@ def test_cancelling_an_edit_restores_the_bubble(client: TestClient, db, register
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
# --- Background generation ---------------------------------------------------
def test_sending_launches_the_generation_immediately(
client: TestClient, db, registered, make_chat
):
"""The reply is produced by a task, not by the browser watching it. That is
what lets you navigate away without cutting it off."""
from lembas.services import generation as generation_service
_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 generation_service.get(message.id) is not None
def test_starting_a_chat_launches_the_generation(client: TestClient, db, registered):
from lembas.services import generation as generation_service
_add_connection(db)
client.post("/api/chats/start", data={"content": "hi"})
message = db.scalar(select(Message).where(Message.role == "assistant"))
assert generation_service.get(message.id) is not None
def test_asking_twice_does_not_start_a_second_generation(
client: TestClient, db, registered, make_chat
):
"""A page load finding an unfinished reply must attach, not restart."""
from lembas.services import generation as generation_service
_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"))
first = generation_service.get(message.id)
assert generation_service.ensure(chat_id, message.id) is first
# --- Unread -------------------------------------------------------------------
def test_the_unread_poll_reports_dots(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
chat = db.get(Chat, chat_id)
chat.unread = True
db.commit()
response = client.get("/api/chats/unread")
assert f'id="unread-{chat_id}"' in response.text
assert "hidden" not in response.text
assert "lembas:unread" in response.headers.get("HX-Trigger", "")
def test_an_arrival_is_announced_once(client: TestClient, db, registered, make_chat):
"""Otherwise the same reply would toast every ten seconds forever."""
_add_connection(db)
chat_id = make_chat()
chat = db.get(Chat, chat_id)
chat.unread = True
db.commit()
assert "HX-Trigger" in client.get("/api/chats/unread").headers
assert "HX-Trigger" not in client.get("/api/chats/unread").headers
def test_opening_a_chat_marks_it_read(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
chat = db.get(Chat, chat_id)
chat.unread = True
db.commit()
client.get(f"/chat/{chat_id}")
db.expire_all()
assert db.get(Chat, chat_id).unread is False
def test_a_read_chat_reports_a_hidden_dot(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
response = client.get("/api/chats/unread")
assert f'id="unread-{chat_id}"' in response.text
assert "hidden" in response.text
def test_the_unread_poll_only_sees_your_own_chats(client: TestClient, db, registered, make_chat):
_add_connection(db)
mine = make_chat()
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 mine not in client.get("/api/chats/unread").text
def test_the_sidebar_shows_an_unread_dot(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
chat = db.get(Chat, chat_id)
chat.unread = True
db.commit()
# Rendered on another page, so the dot is visible while looking elsewhere.
page = client.get("/chat").text
assert f'id="unread-{chat_id}" class="unread-dot"' in page
assert 'hx-get="/api/chats/unread"' in page