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 a071d8486b
commit de178837b8
14 changed files with 840 additions and 210 deletions
+22 -10
View File
@@ -20,6 +20,7 @@ lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin
pytest # 230 tests, ~9s
# PLAN.md tracks what is and is not built
ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate all SVG artwork
python scripts/fetch_vendor.py # verify vendored JS against the lockfile
@@ -191,17 +192,28 @@ null in the composer; `files.claim()` binds them, and only unclaimed rows owned
by that user, so a forged id cannot pull in someone else's file. Abandoned ones
are swept at startup.
**Markdown renders progressively, server-side.** The stream sends `render`
events carrying the whole answer re-rendered from Markdown, at most every
`RENDER_INTERVAL`, swapped with `innerHTML`. Appending raw tokens instead would
mean formatting only appearing at the end -- a list or code fence is only
correct once its context exists, so partial output must be re-rendered whole
rather than appended to.
**Generation is a background task; the SSE endpoint only follows it.**
`services/generation.py` owns the work and the registry; `api/chats.py:_follow`
watches a `Generation` and streams what it sees. Closing the connection does
NOT stop the reply -- that was the old behaviour and it cut answers off when
the reader navigated away. Any route that creates an assistant placeholder must
also call `generation.ensure()`.
**Stopping a stream is an in-process set.** `api/chats.py:_CANCELLED` holds
message ids the reader asked to stop; the generator checks it between chunks.
Correct for the single-worker deployment this ships with; multiple workers
would need it in the database or a broker.
**Stream frames carry whole blocks, not deltas.** Both `render` and `reasoning`
send the complete text each time. That is what makes reattaching mid-reply
work: a follower arriving late has no earlier fragments to append to. It also
means Markdown is re-rendered whole, which is required anyway -- a list or code
fence is only correct once its context exists.
**Stopping sets a flag the producer checks.** `generation.request_stop()`;
whatever arrived is kept and the message is marked `stopped`, which is distinct
from `error`. In-process, so single-worker only.
**Unread is polled, not pushed.** A browser on another chat has no connection
to the one that finished. `/api/chats/unread` returns out-of-band dot spans and
an `HX-Trigger` for the toast; `unread_notified` stops the same arrival being
announced every tick. Re-rendering the whole sidebar instead would reset the
folder open/closed state every 10 seconds.
**Editing rewinds, it does not branch.** `POST .../messages/{id}/edit` rewrites
a user turn and **deletes everything after it**. Branching would need a UI for