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

@@ -20,8 +20,8 @@ const emit = defineEmits<{
const adding = ref(false)
/// The form's own shape: every field a string (empty = omit on the
/// wire), so the template never fights `NewAccount`'s optional fields.
// The form's own shape: every field a string (empty = omit on the
// wire), so the template never fights `NewAccount`'s optional fields.
const draft = ref({
label: '',
service: '',