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
+36 -8
View File
@@ -208,8 +208,21 @@
the dots go, but Stop must stay reachable until the stream ends. */
.msg__body--live:not(:empty) + .msg__waiting .dots { display: none; }
.msg__stop { color: var(--ink-muted); }
.msg__stop:hover { color: var(--danger); border-color: var(--danger); }
.composer__stop {
background: var(--danger);
border-color: var(--danger);
color: var(--ink-inverse);
}
.composer__stop:hover:not(:disabled) {
background: var(--danger-hover);
border-color: var(--danger-hover);
}
.composer__stop-square {
width: 0.7rem;
height: 0.7rem;
border-radius: 2px;
background: currentColor;
}
.msg__note {
display: flex;
@@ -480,25 +493,28 @@
.attachments {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
}
/* inline-block, not block: as a block the anchor filled the column and drew its
border at full width around a narrow image. Now the frame is the picture. */
/* No frame: an attachment is a picture, and a border around it only ever drew
at the wrong width. The anchor shrink-wraps its image rather than filling the
column, and the width/height attributes on the <img> are overridden so a
small image is shown at its own size instead of being stretched. */
.attachments__image {
display: inline-block;
display: inline-flex;
max-width: 100%;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
line-height: 0;
}
.attachments__image img {
display: block;
max-width: min(22rem, 100%);
max-height: 20rem;
width: auto;
height: auto;
max-width: min(22rem, 100%);
max-height: 20rem;
object-fit: contain;
}
.attachments__doc {
display: flex;
@@ -549,3 +565,15 @@
/* Alpine sets x-cloak until it has initialised; without this, collapsed
folders flash open on every page load. */
[x-cloak] { display: none !important; }
/* --- Unread indicator ------------------------------------------------------ */
.unread-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: var(--radius-full);
background: var(--success);
flex: none;
/* A ring so it stays visible against the active row's lighter background. */
box-shadow: 0 0 0 2px color-mix(in srgb, var(--success) 25%, transparent);
}
.unread-dot[hidden] { display: none; }
+85
View File
@@ -378,3 +378,88 @@
options[next].focus();
});
})();
/*
Unread replies.
The sidebar polls /api/chats/unread; the response carries out-of-band spans
for the dots and, when something has just landed, an HX-Trigger asking for a
toast. Announcing it here rather than server-side keeps the wording and the
timing in one place.
*/
document.addEventListener("lembas:unread", function (event) {
var titles = (event.detail && event.detail.titles) || [];
if (!titles.length || !window.lembas || !window.lembas.notify) return;
var message = titles.length === 1
? "Reply ready in “" + titles[0] + "”"
: titles.length + " chats have new replies";
window.lembas.notify(message, { kind: "success", timeout: 6000 });
});
/*
Send becomes Stop while a reply is being written.
The composer and the streaming bubble are far apart in the document, so the
link between them is made here: whenever the thread changes, look for a
message that is still streaming and point the button at it. A MutationObserver
rather than htmx events, because the bubble is replaced by an SSE swap that
does not always surface as one.
*/
(function () {
"use strict";
function streamingMessage() {
var live = document.querySelector(".msg[sse-connect]");
if (!live) return null;
var id = live.id.replace(/^msg-/, "");
var chat = (live.getAttribute("sse-connect") || "").match(/\/api\/chats\/([^/]+)\//);
return chat ? { messageId: id, chatId: chat[1] } : null;
}
function sync() {
var form = document.querySelector(".composer__form");
if (!form) return;
var send = form.querySelector('[type="submit"]');
var stop = form.querySelector("[data-composer-stop]");
var active = streamingMessage();
if (active) {
if (send) send.hidden = true;
if (!stop) {
stop = document.createElement("button");
stop.type = "button";
stop.className = "btn btn--icon composer__btn composer__stop";
stop.setAttribute("data-composer-stop", "");
stop.setAttribute("aria-label", "Stop generating");
stop.title = "Stop generating";
stop.innerHTML = '<span class="composer__stop-square"></span>';
stop.addEventListener("click", function () {
var target = streamingMessage();
if (!target) return;
stop.disabled = true;
fetch(
"/api/chats/" + target.chatId + "/messages/" + target.messageId + "/stop",
{ method: "POST", credentials: "same-origin" }
).catch(function () { stop.disabled = false; });
});
(send ? send.parentNode : form).appendChild(stop);
}
stop.hidden = false;
stop.disabled = false;
} else {
if (send) send.hidden = false;
if (stop) stop.hidden = true;
}
}
function watch() {
var thread = document.getElementById("thread");
if (!thread) return;
new MutationObserver(sync).observe(thread, { childList: true, subtree: true });
sync();
}
document.addEventListener("DOMContentLoaded", watch);
document.body && document.body.addEventListener("htmx:afterSwap", sync);
})();