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.
63 lines
1.9 KiB
Vue
63 lines
1.9 KiB
Vue
<script setup lang="ts">
|
|
// Agent replies are markdown; this mounts the rendered HTML. The parser
|
|
// keeps raw HTML in the source escaped (html stays false) and refuses
|
|
// javascript: link targets, so v-html only ever receives generated
|
|
// markup.
|
|
import MarkdownIt from 'markdown-it'
|
|
import type { RendererRule } from 'markdown-it'
|
|
|
|
const props = defineProps<{
|
|
text: string
|
|
/** While true, a blinking cursor trails the last rendered block. */
|
|
streaming?: boolean
|
|
}>()
|
|
|
|
const md = new MarkdownIt({ html: false, linkify: true })
|
|
|
|
// Rendered links leave the dashboard; open them in a new tab so the
|
|
// transcript and its event stream survive the click.
|
|
const defaultLinkOpen: RendererRule =
|
|
md.renderer.rules.link_open ??
|
|
((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options))
|
|
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
|
const token = tokens[idx]
|
|
if (token) {
|
|
token.attrSet('target', '_blank')
|
|
token.attrSet('rel', 'noopener noreferrer')
|
|
}
|
|
return defaultLinkOpen(tokens, idx, options, env, self)
|
|
}
|
|
|
|
// 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>
|
|
<div class="md" :class="{ streaming }" v-html="html" />
|
|
</template>
|