perf: memoize message HTML to avoid re-parsing on every keystroke

marked.parse runs on all messages each render. With long conversations
this makes typing sluggish. useMemo caches rendered HTML per message,
only recomputing when messages or cwd change.

Also deferred textarea resize to requestAnimationFrame.
This commit is contained in:
Levi Neely 2026-06-24 19:20:04 +02:00
parent aa0e99213d
commit 0f97e50a49
2 changed files with 17 additions and 5 deletions

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'preact/hooks' import { useEffect, useRef, useState, useMemo } from 'preact/hooks'
import { marked } from 'marked' import { marked } from 'marked'
import mermaid from 'mermaid' import mermaid from 'mermaid'
@ -201,6 +201,15 @@ export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, b
const [renaming, setRenaming] = useState(false) const [renaming, setRenaming] = useState(false)
const [renameVal, setRenameVal] = useState('') const [renameVal, setRenameVal] = useState('')
// Pre-render message HTML so typing in PromptBar doesn't re-parse everything
const renderedHtml = useMemo(() =>
messages.map(msg =>
(msg.role === 'user' || msg.role === 'assistant')
? linkifyPaths((marked.parse(msg.content) as string), session.cwd)
: ''),
[messages, session.cwd]
)
const startRename = () => { setRenaming(true); setRenameVal(sid) } const startRename = () => { setRenaming(true); setRenameVal(sid) }
const commitRename = () => { const commitRename = () => {
setRenaming(false) setRenaming(false)
@ -251,7 +260,7 @@ export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, b
<div key={i} class={`message ${msg.role}`}> <div key={i} class={`message ${msg.role}`}>
<div class="msg-bubble"> <div class="msg-bubble">
{msg.role === 'user' ? ( {msg.role === 'user' ? (
<div class="md" dangerouslySetInnerHTML={{ __html: linkifyPaths((marked.parse(msg.content) as string).replace(/^<p>/, '<p><span class="msg-prefix">u: </span>'), session.cwd) }} /> <div class="md" dangerouslySetInnerHTML={{ __html: renderedHtml[i].replace(/^<p>/, '<p><span class="msg-prefix">u: </span>') }} />
) : msg.role === 'call' ? ( ) : msg.role === 'call' ? (
<ToolCall name={msg.name ?? ''} args={msg.content} output={messages[i+1]?.role === 'tool' && messages[i+1]?.name === msg.name ? messages[i+1].content : ''} /> <ToolCall name={msg.name ?? ''} args={msg.content} output={messages[i+1]?.role === 'tool' && messages[i+1]?.name === msg.name ? messages[i+1].content : ''} />
) : msg.role === 'tool' ? ( ) : msg.role === 'tool' ? (
@ -265,7 +274,7 @@ export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, b
) : ( ) : (
<div <div
class="md" class="md"
dangerouslySetInnerHTML={{ __html: linkifyPaths((marked.parse(msg.content) as string).replace(/^<p>/, '<p><span class="msg-prefix">a: </span>'), session.cwd) }} dangerouslySetInnerHTML={{ __html: renderedHtml[i].replace(/^<p>/, '<p><span class="msg-prefix">a: </span>') }}
/> />
)} )}
</div> </div>

View File

@ -31,8 +31,11 @@ export function PromptBar({ running, onSend, onStop }: Props) {
const handleInput = (e: Event) => { const handleInput = (e: Event) => {
const el = e.target as HTMLTextAreaElement const el = e.target as HTMLTextAreaElement
setValue(el.value) setValue(el.value)
el.style.height = 'auto' // Use requestAnimationFrame to batch the resize reflow
el.style.height = Math.min(el.scrollHeight, 160) + 'px' requestAnimationFrame(() => {
el.style.height = '0'
el.style.height = Math.min(el.scrollHeight, 160) + 'px'
})
} }
return ( return (