@@ -10,8 +10,9 @@ import 'prismjs/components/prism-typescript'
1010import 'prismjs/components/prism-bash'
1111import 'prismjs/components/prism-css'
1212import 'prismjs/components/prism-markup'
13+ import type { Grammar } from 'prismjs'
1314import '@sim/emcn/components/code/code.css'
14- import { Checkbox , CopyCodeButton , cn , highlight , languages } from '@sim/emcn'
15+ import { Checkbox , CopyCodeButton , cn , languages , highlight as prismHighlight } from '@sim/emcn'
1516import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils'
1617import { extractTextContent } from '@/lib/core/utils/react-node-text'
1718import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
@@ -161,6 +162,37 @@ function fileIconLabel(ref: string, fallback: string): string {
161162 return fallback
162163}
163164
165+ /**
166+ * Prism highlighting is a pure function of (grammar, source) but costs several
167+ * milliseconds per block. Chat rows are virtualized, so a message re-highlights
168+ * every time it scrolls back into the overscan window — and a component-level
169+ * `useMemo` would not survive the unmount between those scrolls. This
170+ * module-level cache keeps the highlighted HTML across remounts, keyed by
171+ * resolved language + source, so scrolling a code-heavy transcript re-highlights
172+ * each block at most once. It is bounded by {@link HIGHLIGHT_CACHE_LIMIT} with
173+ * LRU eviction so a long session cannot grow it without limit.
174+ */
175+ const HIGHLIGHT_CACHE_LIMIT = 512
176+ const highlightCache = new Map < string , string > ( )
177+
178+ function highlight ( code : string , grammar : Grammar | undefined , language : string ) : string {
179+ const key = `${ language } \n${ code } `
180+ const cached = highlightCache . get ( key )
181+ if ( cached !== undefined ) {
182+ // Move the hit to the newest slot so eviction sheds genuinely cold entries.
183+ highlightCache . delete ( key )
184+ highlightCache . set ( key , cached )
185+ return cached
186+ }
187+ const html = prismHighlight ( code , grammar , language )
188+ highlightCache . set ( key , html )
189+ if ( highlightCache . size > HIGHLIGHT_CACHE_LIMIT ) {
190+ const oldest = highlightCache . keys ( ) . next ( ) . value
191+ if ( oldest !== undefined ) highlightCache . delete ( oldest )
192+ }
193+ return html
194+ }
195+
164196const MARKDOWN_COMPONENTS = {
165197 table ( { children } : { children ?: React . ReactNode } ) {
166198 return (
@@ -412,9 +444,9 @@ function ChatContentInner({
412444 * position (`E`/`qe` in streamdown 2.5), so a re-parse of unchanged content
413445 * without the animate plugin bails at every unoverridden element (`p`,
414446 * `strong`, `tr`, headings, …) and leaves the stale per-char span DOM in
415- * place. Every instance renders through the streaming parser (see
416- * `streamingTree` below) so the remount only sheds the spans, never
417- * re-interprets the markdown.
447+ * place. The settled instance keeps the streaming parser (`parserTree`
448+ * below) so the remount only sheds the spans, never re-interprets the
449+ * markdown.
418450 *
419451 * The drain is deliberately one-way: a stream that resumes afterwards
420452 * (reconnect/continuation) reveals paced but unfaded, because re-arming
@@ -459,18 +491,28 @@ function ChatContentInner({
459491 } , [ isRevealing , animationDrained , streamedThisSession ] )
460492
461493 /**
462- * Every mount renders through the streaming parser (remend +
463- * incomplete-markdown repair + block-split) — `mode='static'` is never used.
464- * The two pipelines parse edge-case markdown differently (unbalanced fences,
465- * list continuation across blocks), so a message you watched stream would
466- * render subtly differently from the same message reloaded from the DB; one
467- * pipeline makes in-session and refreshed renders byte-identical. The rows
468- * are virtualized, so only visible messages pay the block-split mount cost.
469- * `streamingTree` (the remount key and animation props) still drops at
470- * drain, so a settled instance re-renders through the SAME parser minus the
471- * per-word animation spans — identical pixels.
494+ * `parserTree` (drives `mode`) stays latched for the mount's life: streaming
495+ * mode is the only one that applies remend/incomplete-markdown repair and
496+ * block-split parsing, so an in-session message must KEEP the streaming
497+ * parser once it has streamed — swapping to `mode='static'` at drain
498+ * re-parses the same source through a different pipeline (no remend,
499+ * whole-doc parse) and visibly flashes on any reply with unbalanced markdown.
500+ * `streamingTree` (drives the remount key and animation props) additionally
501+ * drops at drain, so the settled instance re-renders through the SAME parser
502+ * minus the per-word animation spans — byte-identical pixels.
503+ *
504+ * A NEVER-streamed mount — reloaded history, or an in-session message
505+ * scrolled out of the virtualized window and back — has `parserTree === false`
506+ * for its whole life and renders `mode='static'`: one whole-document parse
507+ * instead of streaming's per-block re-parse, which runs the rehype
508+ * raw/sanitize/harden chain once PER block. Because chat rows are virtualized,
509+ * every up/down scroll remounts the messages crossing the overscan boundary;
510+ * paying streaming's N-block cost on those reloads is what made scrolling long
511+ * transcripts lag, so the cheap static parse is the correct mode for settled
512+ * content (a completed message needs no incomplete-markdown repair).
472513 */
473- const streamingTree = ( isRevealing || streamedThisSession ) && ! animationDrained
514+ const parserTree = isRevealing || streamedThisSession
515+ const streamingTree = parserTree && ! animationDrained
474516
475517 /**
476518 * One-way fade cutoff (see {@link FADE_MAX_REVEALED_CHARS}). Latched so a
@@ -573,6 +615,7 @@ function ChatContentInner({
573615 >
574616 < Streamdown
575617 key = { streamingTree ? 'stream' : 'settled' }
618+ mode = { parserTree ? undefined : 'static' }
576619 animated = { fadeActive ? STREAM_ANIMATION : false }
577620 isAnimating = { streamingTree }
578621 components = { MARKDOWN_COMPONENTS }
0 commit comments