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

+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()
},
}
}