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
+25
-5
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user