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

+25 -5
View File
@@ -110,10 +110,16 @@ const attemptedAttachments = new Set<string>()
// A replayed question's images fetch the same way — the stream carries
// their attachment ids, never the bytes; locally sent rows hold their
// own object URLs.
//
// The watch is deep (rows mutate in place as their bytes land), and it
// fires on every streamed delta, so the scan covers only the items the
// last pass did not see — the transcript only ever appends.
let scannedItems = 0
watch(
transcriptItems,
(items) => {
for (const item of items) {
for (const item of items.slice(scannedItems)) {
if (item.kind === 'attachment') {
const { id, media, url } = item.attachment
if (media !== 'image' || url !== null || attemptedAttachments.has(id)) continue
@@ -134,6 +140,7 @@ watch(
}
}
}
scannedItems = items.length
},
{ deep: true },
)
@@ -435,7 +442,7 @@ async function removeProvider(id: string): Promise<void> {
async function send(message: { text: string; images: StagedImage[] }): Promise<void> {
// The optimistic row renders the staged previews; the stream's
// user_message echo carries the stored ids and dedupes against it.
transcript.addSent(
const rowId = transcript.addSent(
message.text,
message.images.map(({ media_type, url, name }) => ({ id: null, media_type, url, name })),
)
@@ -451,6 +458,9 @@ async function send(message: { text: string; images: StagedImage[] }): Promise<v
)
apiError.value = null
} catch (err) {
// The claw never took the message: retract the optimistic row, or
// it would sit there forever waiting for a reply that cannot come.
transcript.removeSent(rowId)
noteApiError(err)
} finally {
busy.value = false
@@ -503,10 +513,14 @@ function saveToken(): void {
authRequired.value = false
apiError.value = null
// The stream's retry loop reads the token fresh each attempt, so it
// recovers on its own; the polls below confirm the token works.
// recovers on its own; everything the 401 froze reloads now — the
// polls confirm the token works, and the identity and accounts pages
// were left empty by the failed fetches that raised the banner.
void checkHealth()
void refreshProviders()
void refreshSessions()
void refreshIdentity()
void refreshAccounts()
}
// --- the one live subscription ---------------------------------------
@@ -524,8 +538,14 @@ function resubscribe(): void {
streamStatus.value = status
// A retry reopens the stream with the claw's replay of the
// recorded turns: drop the mirror and let it rebuild, or the
// replay would double everything already on screen.
if (status === 'reconnecting') transcript.reset()
// replay would double everything already on screen. The
// attempted-attachment set goes too — a fetch that failed during
// the outage must retry against the recovered stream.
if (status === 'reconnecting') {
transcript.reset()
attemptedAttachments.clear()
scannedItems = 0
}
if (status === 'open') void refreshSessions()
},
onNotice: pushNotice,