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,
+27 -1
View File
@@ -28,7 +28,33 @@ md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
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<typeof setTimeout> | 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)
})
</script>
<template>
+14
View File
@@ -50,6 +50,20 @@ const emit = defineEmits<{
}>()
const draft = ref('')
/** The draft each session typed before the view moved on: the composer
* belongs to the session it types into, so a draft never sends into a
* session it was not written for. Staged attachments and pastes stay
* with the composer itself — they sit visible in the chips, so the
* user still sees them carry. */
const drafts = new Map<string, string>()
watch(
() => props.activeKey,
(key, previous) => {
if (previous !== undefined) drafts.set(previous, draft.value)
draft.value = drafts.get(key) ?? ''
void nextTick(resizePrompt)
},
)
const scroller = ref<HTMLElement | null>(null)
/** Pin to the bottom only while the reader is already there. */
const pinned = ref(true)
@@ -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: '',
@@ -207,7 +207,7 @@ const CAPS: Record<keyof IdentityTexts, number> = {
<SettingCard
v-if="identity"
title="Avatar & logo"
meta="png or jpeg, up to 2 MiB — uploads apply immediately"
meta="png, jpeg, or webp, up to 2 MiB — uploads apply immediately"
>
<SettingRow
title="Avatar"
@@ -199,19 +199,24 @@ function sourceTitle(source: string): string {
}
}
// A login landed: the claw answered with a new state object (a failed
// one leaves the old, and the banner names the error), so the form
// forgets what it held and closes.
// A login landed: the claw answered with a new state object whose
// roster carries the new id, so the form forgets what it held and
// closes. A state change without the id means the login failed (the
// banner names the error) or an unrelated edit replaced the state —
// either way the form stays open, no longer waiting on a response that
// already came back.
watch(
() => props.state,
(state, previous) => {
if (pendingAdd.value !== null && state !== previous) {
if (state?.providers.some((p) => p.id === pendingAdd.value)) {
loginOpen.value = false
addName.value = ''
addApiKey.value = ''
addUrl.value = ''
addKind.value = ''
}
pendingAdd.value = null
loginOpen.value = false
addName.value = ''
addApiKey.value = ''
addUrl.value = ''
addKind.value = ''
}
},
)
+44 -6
View File
@@ -235,6 +235,13 @@ export type StreamStatus = 'connecting' | 'open' | 'reconnecting'
const INTERACTIVE_PREFIX = 'interactive:'
/** How long the stream may go silent before this client gives up on the
* connection and reconnects: the claw sends a keep-alive comment every
* fifteen seconds (hub.rs), so three missed ones means the path is
* black-holed — a half-open TCP after a laptop sleep shows the same
* 'live' badge a healthy stream does without this check. */
const STREAM_SILENCE_TIMEOUT_MS = 45000
/** Interactive keys are the only ones message ingest accepts (http.rs `messages`). */
export function isInteractive(key: string): boolean {
return key.startsWith(INTERACTIVE_PREFIX)
@@ -530,9 +537,27 @@ export function useClaw(token: Ref<string | null>): ClawApi {
}
function subscribeEvents(key: string, handlers: StreamHandlers): { close: () => void } {
const controller = new AbortController()
let closed = false
let attempt = 0
/** The attempt in flight; `close` aborts it mid-read. */
let current: AbortController | null = null
/** Set when the watchdog, not the server, cut the attempt — the
* notice says so instead of naming an opaque abort error. */
let timedOut = false
let silence: ReturnType<typeof setTimeout> | null = null
const rearmSilence = (): void => {
if (silence !== null) clearTimeout(silence)
silence = setTimeout(() => {
if (closed) return
timedOut = true
current?.abort()
}, STREAM_SILENCE_TIMEOUT_MS)
}
const stopSilence = (): void => {
if (silence !== null) clearTimeout(silence)
silence = null
}
const status = (next: StreamStatus) => {
if (!closed) handlers.onStatus?.(next)
@@ -541,6 +566,10 @@ export function useClaw(token: Ref<string | null>): ClawApi {
void (async () => {
while (!closed) {
status(attempt === 0 ? 'connecting' : 'reconnecting')
const controller = new AbortController()
current = controller
timedOut = false
rearmSilence()
try {
const response = await fetch(
`${base()}/v1/sessions/${encodeURIComponent(key)}/events`,
@@ -560,12 +589,15 @@ export function useClaw(token: Ref<string | null>): ClawApi {
await readFrames(
response.body,
(data) => {
rearmSilence()
const event = parseTurnEvent(data)
if (event) handlers.onEvent(event)
},
(comment) => {
// Keep-alives come through here too; only the hub's
// dropped-events notice is worth raising.
// Keep-alives come through here too; each one proves the
// path alive, and only the hub's dropped-events notice is
// worth raising.
rearmSilence()
if (comment.startsWith('lagged')) {
handlers.onNotice?.(`stream lagged — ${comment.replace(/^lagged;\s*/, '')}`)
}
@@ -574,15 +606,20 @@ export function useClaw(token: Ref<string | null>): ClawApi {
if (closed) break
throw new ClawError(0, 'stream closed by the claw')
} catch (cause) {
if (closed || controller.signal.aborted) break
if (closed) break
// Backoff caps at 15s: a live stream of a running turn is the
// UI's whole job, so retries stay eager but not tight-looped.
const backoff = Math.min(15000, 1000 * 2 ** attempt++)
handlers.onNotice?.(
`event stream dropped (${cause instanceof Error ? cause.message : String(cause)}); retrying in ${backoff / 1000}s`,
timedOut
? `the event stream went quiet (no keep-alive for ${STREAM_SILENCE_TIMEOUT_MS / 1000}s); reconnecting in ${backoff / 1000}s`
: `event stream dropped (${cause instanceof Error ? cause.message : String(cause)}); retrying in ${backoff / 1000}s`,
)
status('reconnecting')
await new Promise((resolve) => setTimeout(resolve, backoff))
} finally {
stopSilence()
if (current === controller) current = null
}
}
})()
@@ -590,7 +627,8 @@ export function useClaw(token: Ref<string | null>): ClawApi {
return {
close() {
closed = true
controller.abort()
stopSilence()
current?.abort()
},
}
}
+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,
}
}