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