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:
1 parent
041240f706
commit
94b82e2636
8 files changed
+160
-30
No files matched your search
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user