fix(web): the bugs a chat that never closes for a turn exposes

An investigation pass over the client, driven by steering's new shape —
a composer that is always live makes adjacent latent bugs visible.

The event stream now watches for silence: the claw keeps the
connection alive with a comment every fifteen seconds, and three
missed ones abort the fetch and reconnect, so a half-open TCP after a
laptop sleep no longer reads as 'live' forever. A reconnect clears the
attempted-attachment set with the transcript, or an image whose fetch
failed during the outage never retries against the recovered stream.
Saving a token reloads the identity and accounts the 401 froze: the
banner cleared, but the pages stayed empty until a browser reload.

A failed POST retracts its optimistic sent row (and its pending echo,
so a message the claw did take still renders from the stream's echo);
before, a send into an unreachable claw left a phantom row waiting for
a reply that could never come. Drafts are per-session now — the
composer's text belonged to no session, so a draft typed into one
session sent into whichever was selected at Enter. The providers
login form distinguishes a landed login (the roster carries its id)
from a failed one and no longer closes on an unrelated state refresh.

Two cost fixes that read as jank on long turns: the streamed reply's
markdown re-parsed the whole text per delta (quadratic) and now
re-renders on a short trailing debounce; the attachment-image watcher
walked the whole transcript on every streamed chunk and now scans only
the items it has not seen. The accounts page drops a stray Rust-ism
comment, and the avatar row's meta line admits webp like its accept
attribute always did.
This commit is contained in:
vhaudiquet committed 2026-11-08 18:00:56 +01:00
1 parent 041240f706
commit 94b82e2636
8 files changed
+160 -30

No files matched your search

+34 -7
View File
@@ -178,8 +178,10 @@ export function useTranscript() {
* image count is the whole signature the stream carries, and that is
* enough — the echo dedupes against what this surface sent, not
* against whichever row happens to sit last: a steered turn's end
* marker can land between the optimistic row and its echo. */
const pendingSent: { text: string; images: number }[] = []
* marker can land between the optimistic row and its echo. `itemId`
* links an entry to its row, so a send that failed to post can take
* both the row and its pending echo back out. */
const pendingSent: { text: string; images: number; itemId: number }[] = []
function ensureLive(): TurnBlock {
if (!live.value) live.value = { reply: '', entries: [], outcome: null }
@@ -327,10 +329,25 @@ export function useTranscript() {
/** A locally sent message, shown immediately; the reply streams after.
* Its images render from the staged previews — the claw stores them
* and the stream's echo dedupes against this row. */
function addSent(text: string, images: SentImage[] = []): void {
pendingSent.push({ text, images: images.length })
items.value.push({ kind: 'sent', id: nextId++, text, images })
* and the stream's echo dedupes against this row. Returns the row's
* id, so a failed POST can retract it. */
function addSent(text: string, images: SentImage[] = []): number {
const id = nextId++
pendingSent.push({ text, images: images.length, itemId: id })
items.value.push({ kind: 'sent', id, text, images })
return id
}
/** Retracts a locally sent row whose POST failed: the message never
* reached the claw, so neither the reply nor the echo will ever come.
* (If it did reach the claw and only the answer was lost, the
* pending echo goes with the row — the stream's `user_message` then
* renders a fresh one, with the stored attachment ids.) */
function removeSent(id: number): void {
const index = items.value.findIndex((item) => item.kind === 'sent' && item.id === id)
if (index !== -1) items.value.splice(index, 1)
const pending = pendingSent.findIndex((entry) => entry.itemId === id)
if (pending !== -1) pendingSent.splice(pending, 1)
}
/** A consent card resolved: flipped in place, or ignored when no
@@ -412,5 +429,15 @@ export function useTranscript() {
pendingSent.length = 0
}
return { items, live, applyEvent, addSent, resolveConsent, setAttachmentUrl, setSentImageUrl, reset }
return {
items,
live,
applyEvent,
addSent,
removeSent,
resolveConsent,
setAttachmentUrl,
setSentImageUrl,
reset,
}
}