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
|
// A replayed question's images fetch the same way — the stream carries
|
||||||
// their attachment ids, never the bytes; locally sent rows hold their
|
// their attachment ids, never the bytes; locally sent rows hold their
|
||||||
// own object URLs.
|
// 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(
|
watch(
|
||||||
transcriptItems,
|
transcriptItems,
|
||||||
(items) => {
|
(items) => {
|
||||||
for (const item of items) {
|
for (const item of items.slice(scannedItems)) {
|
||||||
if (item.kind === 'attachment') {
|
if (item.kind === 'attachment') {
|
||||||
const { id, media, url } = item.attachment
|
const { id, media, url } = item.attachment
|
||||||
if (media !== 'image' || url !== null || attemptedAttachments.has(id)) continue
|
if (media !== 'image' || url !== null || attemptedAttachments.has(id)) continue
|
||||||
@@ -134,6 +140,7 @@ watch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
scannedItems = items.length
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
@@ -435,7 +442,7 @@ async function removeProvider(id: string): Promise<void> {
|
|||||||
async function send(message: { text: string; images: StagedImage[] }): Promise<void> {
|
async function send(message: { text: string; images: StagedImage[] }): Promise<void> {
|
||||||
// The optimistic row renders the staged previews; the stream's
|
// The optimistic row renders the staged previews; the stream's
|
||||||
// user_message echo carries the stored ids and dedupes against it.
|
// user_message echo carries the stored ids and dedupes against it.
|
||||||
transcript.addSent(
|
const rowId = transcript.addSent(
|
||||||
message.text,
|
message.text,
|
||||||
message.images.map(({ media_type, url, name }) => ({ id: null, media_type, url, name })),
|
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
|
apiError.value = null
|
||||||
} catch (err) {
|
} 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)
|
noteApiError(err)
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
@@ -503,10 +513,14 @@ function saveToken(): void {
|
|||||||
authRequired.value = false
|
authRequired.value = false
|
||||||
apiError.value = null
|
apiError.value = null
|
||||||
// The stream's retry loop reads the token fresh each attempt, so it
|
// 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 checkHealth()
|
||||||
void refreshProviders()
|
void refreshProviders()
|
||||||
void refreshSessions()
|
void refreshSessions()
|
||||||
|
void refreshIdentity()
|
||||||
|
void refreshAccounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- the one live subscription ---------------------------------------
|
// --- the one live subscription ---------------------------------------
|
||||||
@@ -524,8 +538,14 @@ function resubscribe(): void {
|
|||||||
streamStatus.value = status
|
streamStatus.value = status
|
||||||
// A retry reopens the stream with the claw's replay of the
|
// A retry reopens the stream with the claw's replay of the
|
||||||
// recorded turns: drop the mirror and let it rebuild, or the
|
// recorded turns: drop the mirror and let it rebuild, or the
|
||||||
// replay would double everything already on screen.
|
// replay would double everything already on screen. The
|
||||||
if (status === 'reconnecting') transcript.reset()
|
// 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()
|
if (status === 'open') void refreshSessions()
|
||||||
},
|
},
|
||||||
onNotice: pushNotice,
|
onNotice: pushNotice,
|
||||||
|
|||||||
@@ -28,7 +28,33 @@ md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
|||||||
return defaultLinkOpen(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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const draft = ref('')
|
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)
|
const scroller = ref<HTMLElement | null>(null)
|
||||||
/** Pin to the bottom only while the reader is already there. */
|
/** Pin to the bottom only while the reader is already there. */
|
||||||
const pinned = ref(true)
|
const pinned = ref(true)
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const adding = ref(false)
|
const adding = ref(false)
|
||||||
|
|
||||||
/// The form's own shape: every field a string (empty = omit on the
|
// The form's own shape: every field a string (empty = omit on the
|
||||||
/// wire), so the template never fights `NewAccount`'s optional fields.
|
// wire), so the template never fights `NewAccount`'s optional fields.
|
||||||
const draft = ref({
|
const draft = ref({
|
||||||
label: '',
|
label: '',
|
||||||
service: '',
|
service: '',
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ const CAPS: Record<keyof IdentityTexts, number> = {
|
|||||||
<SettingCard
|
<SettingCard
|
||||||
v-if="identity"
|
v-if="identity"
|
||||||
title="Avatar & logo"
|
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
|
<SettingRow
|
||||||
title="Avatar"
|
title="Avatar"
|
||||||
|
|||||||
@@ -199,19 +199,24 @@ function sourceTitle(source: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A login landed: the claw answered with a new state object (a failed
|
// A login landed: the claw answered with a new state object whose
|
||||||
// one leaves the old, and the banner names the error), so the form
|
// roster carries the new id, so the form forgets what it held and
|
||||||
// forgets what it held and closes.
|
// 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(
|
watch(
|
||||||
() => props.state,
|
() => props.state,
|
||||||
(state, previous) => {
|
(state, previous) => {
|
||||||
if (pendingAdd.value !== null && 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
|
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:'
|
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`). */
|
/** Interactive keys are the only ones message ingest accepts (http.rs `messages`). */
|
||||||
export function isInteractive(key: string): boolean {
|
export function isInteractive(key: string): boolean {
|
||||||
return key.startsWith(INTERACTIVE_PREFIX)
|
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 } {
|
function subscribeEvents(key: string, handlers: StreamHandlers): { close: () => void } {
|
||||||
const controller = new AbortController()
|
|
||||||
let closed = false
|
let closed = false
|
||||||
let attempt = 0
|
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) => {
|
const status = (next: StreamStatus) => {
|
||||||
if (!closed) handlers.onStatus?.(next)
|
if (!closed) handlers.onStatus?.(next)
|
||||||
@@ -541,6 +566,10 @@ export function useClaw(token: Ref<string | null>): ClawApi {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
while (!closed) {
|
while (!closed) {
|
||||||
status(attempt === 0 ? 'connecting' : 'reconnecting')
|
status(attempt === 0 ? 'connecting' : 'reconnecting')
|
||||||
|
const controller = new AbortController()
|
||||||
|
current = controller
|
||||||
|
timedOut = false
|
||||||
|
rearmSilence()
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${base()}/v1/sessions/${encodeURIComponent(key)}/events`,
|
`${base()}/v1/sessions/${encodeURIComponent(key)}/events`,
|
||||||
@@ -560,12 +589,15 @@ export function useClaw(token: Ref<string | null>): ClawApi {
|
|||||||
await readFrames(
|
await readFrames(
|
||||||
response.body,
|
response.body,
|
||||||
(data) => {
|
(data) => {
|
||||||
|
rearmSilence()
|
||||||
const event = parseTurnEvent(data)
|
const event = parseTurnEvent(data)
|
||||||
if (event) handlers.onEvent(event)
|
if (event) handlers.onEvent(event)
|
||||||
},
|
},
|
||||||
(comment) => {
|
(comment) => {
|
||||||
// Keep-alives come through here too; only the hub's
|
// Keep-alives come through here too; each one proves the
|
||||||
// dropped-events notice is worth raising.
|
// path alive, and only the hub's dropped-events notice is
|
||||||
|
// worth raising.
|
||||||
|
rearmSilence()
|
||||||
if (comment.startsWith('lagged')) {
|
if (comment.startsWith('lagged')) {
|
||||||
handlers.onNotice?.(`stream lagged — ${comment.replace(/^lagged;\s*/, '')}`)
|
handlers.onNotice?.(`stream lagged — ${comment.replace(/^lagged;\s*/, '')}`)
|
||||||
}
|
}
|
||||||
@@ -574,15 +606,20 @@ export function useClaw(token: Ref<string | null>): ClawApi {
|
|||||||
if (closed) break
|
if (closed) break
|
||||||
throw new ClawError(0, 'stream closed by the claw')
|
throw new ClawError(0, 'stream closed by the claw')
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (closed || controller.signal.aborted) break
|
if (closed) break
|
||||||
// Backoff caps at 15s: a live stream of a running turn is the
|
// 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.
|
// UI's whole job, so retries stay eager but not tight-looped.
|
||||||
const backoff = Math.min(15000, 1000 * 2 ** attempt++)
|
const backoff = Math.min(15000, 1000 * 2 ** attempt++)
|
||||||
handlers.onNotice?.(
|
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')
|
status('reconnecting')
|
||||||
await new Promise((resolve) => setTimeout(resolve, backoff))
|
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 {
|
return {
|
||||||
close() {
|
close() {
|
||||||
closed = true
|
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
|
* image count is the whole signature the stream carries, and that is
|
||||||
* enough — the echo dedupes against what this surface sent, not
|
* enough — the echo dedupes against what this surface sent, not
|
||||||
* against whichever row happens to sit last: a steered turn's end
|
* against whichever row happens to sit last: a steered turn's end
|
||||||
* marker can land between the optimistic row and its echo. */
|
* marker can land between the optimistic row and its echo. `itemId`
|
||||||
const pendingSent: { text: string; images: number }[] = []
|
* 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 {
|
function ensureLive(): TurnBlock {
|
||||||
if (!live.value) live.value = { reply: '', entries: [], outcome: null }
|
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.
|
/** A locally sent message, shown immediately; the reply streams after.
|
||||||
* Its images render from the staged previews — the claw stores them
|
* Its images render from the staged previews — the claw stores them
|
||||||
* and the stream's echo dedupes against this row. */
|
* and the stream's echo dedupes against this row. Returns the row's
|
||||||
function addSent(text: string, images: SentImage[] = []): void {
|
* id, so a failed POST can retract it. */
|
||||||
pendingSent.push({ text, images: images.length })
|
function addSent(text: string, images: SentImage[] = []): number {
|
||||||
items.value.push({ kind: 'sent', id: nextId++, text, images })
|
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
|
/** A consent card resolved: flipped in place, or ignored when no
|
||||||
@@ -412,5 +429,15 @@ export function useTranscript() {
|
|||||||
pendingSent.length = 0
|
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