diff --git a/web/claw/app/app.vue b/web/claw/app/app.vue index cf7ed95..f12c6b4 100644 --- a/web/claw/app/app.vue +++ b/web/claw/app/app.vue @@ -110,10 +110,16 @@ const attemptedAttachments = new Set() // 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 { async function send(message: { text: string; images: StagedImage[] }): Promise { // 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 { 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 | 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) +})