From 94b82e2636b7b4e7d265d785a3447b4b60280a1b Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sat, 7 Nov 2026 14:16:24 +0100 Subject: [PATCH] fix(web): the bugs a chat that never closes for a turn exposes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/claw/app/app.vue | 30 +++++++++-- web/claw/app/components/AppMarkdown.vue | 28 ++++++++++- web/claw/app/components/ChatPanel.vue | 14 ++++++ .../components/settings/AgentAccountsPage.vue | 4 +- .../components/settings/AgentGeneralPage.vue | 2 +- .../app/components/settings/ProvidersPage.vue | 21 +++++--- web/claw/app/composables/useClaw.ts | 50 ++++++++++++++++--- web/claw/app/composables/useTranscript.ts | 41 ++++++++++++--- 8 files changed, 160 insertions(+), 30 deletions(-) diff --git a/web/claw/app/app.vue b/web/claw/app/app.vue index cf7ed95..f12c6b4 100644 --- a/web/claw/app/app.vue +++ b/web/claw/app/app.vue @@ -110,10 +110,16 @@ const attemptedAttachments = new Set() // 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 { async function send(message: { text: string; images: StagedImage[] }): Promise { // 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 { return defaultLinkOpen(tokens, idx, options, env, self) } -const html = computed(() => md.render(props.text)) +// Streaming re-parses the whole reply per delta — quadratic over the +// reply's length, and every render lands on the main thread. A short +// trailing debounce keeps the render at a readable cadence while the +// turn streams; a settled text renders at once, so a finished reply is +// never a frame behind its last delta. +const html = ref('') +let renderTimer: ReturnType | null = null + +watch( + () => props.text, + (text) => { + if (renderTimer !== null) clearTimeout(renderTimer) + if (!props.streaming) { + html.value = md.render(text) + return + } + renderTimer = setTimeout(() => { + renderTimer = null + html.value = md.render(props.text) + }, 80) + }, + { immediate: true }, +) + +onUnmounted(() => { + if (renderTimer !== null) clearTimeout(renderTimer) +})