11'use client'
22
3- import { type ComponentPropsWithoutRef , memo , useEffect , useMemo , useRef } from 'react'
3+ import { type ComponentPropsWithoutRef , memo , useEffect , useMemo , useRef , useState } from 'react'
44import { Streamdown } from 'streamdown'
55import 'streamdown/styles.css'
66// prismjs core must load before its language components — they register on the
@@ -53,17 +53,42 @@ const PROSE_CLASSES = cn(
5353
5454/**
5555 * Soft fade for newly revealed text. Paired with {@link useSmoothText}, which
56- * paces the reveal: `sep: 'char'` fades each character as the pacer exposes it
57- * (so a growing trailing word never re-animates), and `stagger: 0` keeps the
58- * cadence driven by the pacer rather than an overlapping per-token delay ramp.
56+ * paces the reveal; `stagger: 0` keeps the cadence driven by the pacer rather
57+ * than an overlapping per-token delay ramp — every span revealed in one tick
58+ * fades as a unit, so `sep: 'word'` looks identical to `sep: 'char'` while
59+ * creating ~5x fewer spans. That span count is the dominant mid-stream cost:
60+ * the animate plugin rebuilds a span per token for the WHOLE trailing block on
61+ * every reveal tick, so per-char wrapping of a long paragraph meant thousands
62+ * of hast nodes + React elements reconciled ~40x/sec. Streamdown's
63+ * prev-content tracking keeps a word that grows across two ticks from
64+ * re-fading (its continuation renders unfaded), and the pacer's word-boundary
65+ * snapping makes such splits rare to begin with.
5966 */
6067const STREAM_ANIMATION = {
6168 animation : 'fadeIn' ,
6269 duration : 220 ,
6370 stagger : 0 ,
64- sep : 'char ' ,
71+ sep : 'word ' ,
6572} as const
6673
74+ /**
75+ * How long after the reveal fully settles before the animated tree is dropped.
76+ * Must exceed {@link STREAM_ANIMATION}'s 220ms duration so the last characters
77+ * finish fading at full opacity before their spans are swapped for plain text.
78+ */
79+ const ANIMATION_DRAIN_MS = 300
80+
81+ /**
82+ * Once a segment has revealed this many characters, new text stops fading in;
83+ * the word-paced reveal itself is unchanged. Fade cost scales with segment
84+ * length — every reveal tick rebuilds a span per word for the WHOLE trailing
85+ * markdown block — so on an unbroken wall of text it eventually swamps the
86+ * frame budget (measured: ~9k-char single paragraphs spent ~30% of main-thread
87+ * time in long tasks) while the fade itself is imperceptible detail that deep
88+ * into a reply.
89+ */
90+ const FADE_MAX_REVEALED_CHARS = 6000
91+
6792function startsInlineWord ( value : string ) : boolean {
6893 return / ^ [ A - Z a - z 0 - 9 _ ( ] / . test ( value )
6994}
@@ -306,19 +331,91 @@ function ChatContentInner({
306331 } , [ isRevealing ] )
307332
308333 /**
309- * One-way latch: once a message has streamed in this mount, keep rendering it
310- * through Streamdown's streaming/animation pipeline for the rest of its life.
311- * Drives `mode`, `animated`, AND `isAnimating` together — all three must stay
312- * constant across the completion boundary. Streamdown removes the per-word
313- * `<span>` wrappers (and re-parses the whole message) the instant `isAnimating`
314- * goes false, so wiring `isAnimating` to `isRevealing` (which flips at
315- * completion) reintroduces the streaming→static flash this latch exists to
316- * prevent. Content is stable once revealed, so a permanently-true
317- * `isAnimating` never re-fades anything.
334+ * Streaming-tree lifecycle. While a message streams (and until its reveal
335+ * drains), it renders through Streamdown's streaming/animated pipeline, whose
336+ * animate plugin wraps every character in its own `<span data-sd-animate>` —
337+ * thousands of DOM nodes per streamed message. Holding that tree forever made
338+ * long sessions progressively laggier until a refresh (which renders the same
339+ * transcript static). `animationDrained` flips one-way
340+ * {@link ANIMATION_DRAIN_MS} after the reveal settles and swaps to the static
341+ * pipeline; the drain window lets the last 220ms fades finish so the swap
342+ * trades identical pixels, unlike flipping at `isRevealing`'s edge, which cut
343+ * running fades short (the old completion flash).
344+ *
345+ * The swap must REMOUNT Streamdown (via `key`), not just flip its props:
346+ * Streamdown's default element components are memoized on className + source
347+ * position (`E`/`qe` in streamdown 2.5), so a re-parse of unchanged content
348+ * without the animate plugin bails at every unoverridden element (`p`,
349+ * `strong`, `tr`, headings, …) and leaves the stale per-char span DOM in
350+ * place. The settled instance keeps the streaming parser (`parserTree`
351+ * below) so the remount only sheds the spans, never re-interprets the
352+ * markdown.
353+ *
354+ * The drain is deliberately one-way: a stream that resumes afterwards
355+ * (reconnect/continuation) reveals paced but unfaded, because re-arming
356+ * mounts a fresh animate plugin with no prev-content tracking, which would
357+ * re-fade the entire already-visible message.
318358 */
319- const streamedThisSession = useRef ( false )
320- if ( isStreaming ) streamedThisSession . current = true
321- const keepStreamingTree = isRevealing || streamedThisSession . current
359+ const [ streamedThisSession , setStreamedThisSession ] = useState ( false )
360+ const [ animationDrained , setAnimationDrained ] = useState ( false )
361+ const [ fadeCutoff , setFadeCutoff ] = useState ( false )
362+
363+ /**
364+ * The per-session latches above outlive the content when React reuses this
365+ * instance for a different logical message — parent rows key by turn
366+ * position and text segments by run ordinal (both deliberately stable across
367+ * the live→persisted id swap), so an ordinal shift or regeneration can hand
368+ * a settled instance brand-new content whose stale `animationDrained` would
369+ * silently render the new stream static. Reset the latches when the content
370+ * is REPLACED (not an append of the previous string) after the instance has
371+ * settled. A resumed turn only ever appends, so this never undoes the
372+ * one-way drain; mid-stream sanitize rewrites are excluded by the
373+ * `animationDrained` gate (the drain only fires after settle). All latches
374+ * are render-phase `useState` adjustments (prev-tracker idiom), not refs —
375+ * they are read during render, and state is concurrent-safe where a
376+ * render-phase ref mutation is not.
377+ */
378+ const [ prevDisplayContent , setPrevDisplayContent ] = useState ( displayContent )
379+ if ( prevDisplayContent !== displayContent ) {
380+ setPrevDisplayContent ( displayContent )
381+ if ( ! displayContent . startsWith ( prevDisplayContent ) && animationDrained ) {
382+ setStreamedThisSession ( false )
383+ setFadeCutoff ( false )
384+ setAnimationDrained ( false )
385+ }
386+ }
387+
388+ if ( isStreaming && ! streamedThisSession ) setStreamedThisSession ( true )
389+
390+ useEffect ( ( ) => {
391+ if ( isRevealing || animationDrained || ! streamedThisSession ) return
392+ const timeout = setTimeout ( ( ) => setAnimationDrained ( true ) , ANIMATION_DRAIN_MS )
393+ return ( ) => clearTimeout ( timeout )
394+ } , [ isRevealing , animationDrained , streamedThisSession ] )
395+
396+ /**
397+ * `parserTree` (drives `mode`) stays latched for the mount's life: streaming
398+ * mode is the only one that applies remend/incomplete-markdown repair and
399+ * block-split parsing, so a settled message must KEEP the streaming parser —
400+ * swapping to `mode='static'` at drain re-parses the same source through a
401+ * different pipeline (no remend, whole-doc parse) and visibly flashes on any
402+ * reply with unbalanced markdown. `streamingTree` (drives the remount key
403+ * and animation props) additionally drops at drain, so the settled instance
404+ * re-renders through the SAME parser minus the per-word animation spans —
405+ * byte-identical pixels. Only never-streamed mounts (reloaded history)
406+ * render static.
407+ */
408+ const parserTree = isRevealing || streamedThisSession
409+ const streamingTree = parserTree && ! animationDrained
410+
411+ /**
412+ * One-way fade cutoff (see {@link FADE_MAX_REVEALED_CHARS}). Latched so a
413+ * sanitize-induced content shrink back across the boundary cannot re-arm
414+ * `animated` — a fresh animate plugin has no prev-content tracking and would
415+ * re-fade the entire visible segment.
416+ */
417+ if ( ! fadeCutoff && streamedContent . length > FADE_MAX_REVEALED_CHARS ) setFadeCutoff ( true )
418+ const fadeActive = streamingTree && ! fadeCutoff
322419
323420 useEffect ( ( ) => {
324421 const handler = ( e : Event ) => {
@@ -338,93 +435,89 @@ function ChatContentInner({
338435 ( ) => parseSpecialTags ( streamedContent , isRevealing ) ,
339436 [ streamedContent , isRevealing ]
340437 )
341- const hasSpecialContent = parsed . hasPendingTag || parsed . segments . some ( ( s ) => s . type !== 'text' )
342-
343- if ( hasSpecialContent ) {
344- type BlockSegment = Exclude <
345- ContentSegment ,
346- { type : 'text' } | { type : 'thinking' } | { type : 'workspace_resource' }
347- >
348- type RenderGroup =
349- | { kind : 'inline' ; markdown : string }
350- | { kind : 'block' ; segment : BlockSegment ; index : number }
351-
352- const groups : RenderGroup [ ] = [ ]
353- let pendingMarkdown = ''
354-
355- const flushMarkdown = ( ) => {
356- if ( pendingMarkdown . trim ( ) ) {
357- groups . push ( { kind : 'inline' , markdown : pendingMarkdown } )
358- }
359- pendingMarkdown = ''
360- }
361438
362- for ( let i = 0 ; i < parsed . segments . length ; i ++ ) {
363- const s = parsed . segments [ i ]
364- const nextSegment = parsed . segments [ i + 1 ]
365- if ( s . type === 'workspace_resource' ) {
366- // Files are addressed by their encoded VFS path (copied verbatim from the tag);
367- // workflows/tables/KBs by id. The angle-bracket link destination keeps the path
368- // intact through markdown parsing (tolerates parens) without re-encoding it.
369- const ref = s . data . type === 'file' ? ( s . data . path ?? s . data . id ?? '' ) : ( s . data . id ?? '' )
370- const label = s . data . title || ref
371- pendingMarkdown = appendInlineReferenceMarkdown (
372- pendingMarkdown ,
373- `[${ label } ](<#wsres-${ s . data . type } -${ ref } >)` ,
374- nextSegment
375- )
376- } else if ( s . type === 'text' || s . type === 'thinking' ) {
377- pendingMarkdown += s . content
378- } else {
379- flushMarkdown ( )
380- groups . push ( { kind : 'block' , segment : s , index : i } )
381- }
439+ type BlockSegment = Exclude <
440+ ContentSegment ,
441+ { type : 'text' } | { type : 'thinking' } | { type : 'workspace_resource' }
442+ >
443+ type RenderGroup =
444+ | { kind : 'inline' ; markdown : string }
445+ | { kind : 'block' ; segment : BlockSegment ; index : number }
446+
447+ const groups : RenderGroup [ ] = [ ]
448+ let pendingMarkdown = ''
449+
450+ const flushMarkdown = ( ) => {
451+ if ( pendingMarkdown . trim ( ) ) {
452+ groups . push ( { kind : 'inline' , markdown : pendingMarkdown } )
382453 }
383- flushMarkdown ( )
454+ pendingMarkdown = ''
455+ }
384456
385- return (
386- < div className = 'space-y-3' >
387- { groups . map ( ( group , i ) => {
388- if ( group . kind === 'inline' ) {
389- return (
390- < div
391- key = { `inline-${ i } ` }
392- className = { cn ( PROSE_CLASSES , '[&>:first-child]:mt-0 [&>:last-child]:mb-0' ) }
393- >
394- < Streamdown
395- mode = { keepStreamingTree ? undefined : 'static' }
396- animated = { keepStreamingTree ? STREAM_ANIMATION : false }
397- isAnimating = { keepStreamingTree }
398- components = { MARKDOWN_COMPONENTS }
399- >
400- { group . markdown }
401- </ Streamdown >
402- </ div >
403- )
404- }
405- return (
406- < SpecialTags
407- key = { `special-${ group . index } ` }
408- segment = { group . segment }
409- onOptionSelect = { onOptionSelect }
410- />
411- )
412- } ) }
413- { parsed . hasPendingTag && isRevealing && < PendingTagIndicator /> }
414- </ div >
415- )
457+ for ( let i = 0 ; i < parsed . segments . length ; i ++ ) {
458+ const s = parsed . segments [ i ]
459+ const nextSegment = parsed . segments [ i + 1 ]
460+ if ( s . type === 'workspace_resource' ) {
461+ // Files are addressed by their encoded VFS path (copied verbatim from the tag);
462+ // workflows/tables/KBs by id. The angle-bracket link destination keeps the path
463+ // intact through markdown parsing (tolerates parens) without re-encoding it.
464+ const ref = s . data . type === 'file' ? ( s . data . path ?? s . data . id ?? '' ) : ( s . data . id ?? '' )
465+ const label = s . data . title || ref
466+ pendingMarkdown = appendInlineReferenceMarkdown (
467+ pendingMarkdown ,
468+ `[${ label } ](<#wsres-${ s . data . type } -${ ref } >)` ,
469+ nextSegment
470+ )
471+ } else if ( s . type === 'text' || s . type === 'thinking' ) {
472+ pendingMarkdown += s . content
473+ } else {
474+ flushMarkdown ( )
475+ groups . push ( { kind : 'block' , segment : s , index : i } )
476+ }
416477 }
478+ flushMarkdown ( )
417479
480+ /**
481+ * Plain text and special-tag content share ONE render structure. A message
482+ * with no special tags is simply a single inline group — it must NOT get a
483+ * dedicated JSX branch, because most replies gain a trailing `<options>` tag
484+ * (suggested follow-ups) at the very end, and switching branches at that
485+ * moment re-parents the Streamdown to a different tree position. React then
486+ * remounts it with a fresh animate plugin and the ENTIRE message re-fades
487+ * from transparent — the "flash at the conclusion". With the unified
488+ * structure the leading text group keeps its position (`inline-0`) and only
489+ * the new special block mounts.
490+ */
418491 return (
419- < div className = { cn ( PROSE_CLASSES , '[&>:first-child]:mt-0 [&>:last-child]:mb-0' ) } >
420- < Streamdown
421- mode = { keepStreamingTree ? undefined : 'static' }
422- animated = { keepStreamingTree ? STREAM_ANIMATION : false }
423- isAnimating = { keepStreamingTree }
424- components = { MARKDOWN_COMPONENTS }
425- >
426- { streamedContent }
427- </ Streamdown >
492+ < div className = 'space-y-3' >
493+ { groups . map ( ( group , i ) => {
494+ if ( group . kind === 'inline' ) {
495+ return (
496+ < div
497+ key = { `inline-${ i } ` }
498+ className = { cn ( PROSE_CLASSES , '[&>:first-child]:mt-0 [&>:last-child]:mb-0' ) }
499+ >
500+ < Streamdown
501+ key = { streamingTree ? 'stream' : 'settled' }
502+ mode = { parserTree ? undefined : 'static' }
503+ animated = { fadeActive ? STREAM_ANIMATION : false }
504+ isAnimating = { streamingTree }
505+ components = { MARKDOWN_COMPONENTS }
506+ >
507+ { group . markdown }
508+ </ Streamdown >
509+ </ div >
510+ )
511+ }
512+ return (
513+ < SpecialTags
514+ key = { `special-${ group . index } ` }
515+ segment = { group . segment }
516+ onOptionSelect = { onOptionSelect }
517+ />
518+ )
519+ } ) }
520+ { parsed . hasPendingTag && isRevealing && < PendingTagIndicator /> }
428521 </ div >
429522 )
430523}
0 commit comments