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:
@@ -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);
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user