Skip to content

Commit 61c9e19

Browse files
fix(mothership): stop chat perf decay from permanently-animated streamed messages
1 parent 42e3a03 commit 61c9e19

1 file changed

Lines changed: 80 additions & 21 deletions

File tree

  • apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx

Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
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'
44
import { Streamdown } from 'streamdown'
55
import '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
*/
6067
const 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+
6792
function startsInlineWord(value: string): boolean {
6893
return /^[A-Za-z0-9_(]/.test(value)
6994
}
@@ -306,19 +331,51 @@ 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. Remounting also converges the settled DOM byte-for-byte with what a
351+
* reloaded transcript renders.
352+
*
353+
* The drain is deliberately one-way: a stream that resumes afterwards
354+
* (reconnect/continuation) reveals paced but unfaded, because re-arming
355+
* mounts a fresh animate plugin with no prev-content tracking, which would
356+
* re-fade the entire already-visible message.
318357
*/
319358
const streamedThisSession = useRef(false)
320359
if (isStreaming) streamedThisSession.current = true
321-
const keepStreamingTree = isRevealing || streamedThisSession.current
360+
361+
const [animationDrained, setAnimationDrained] = useState(false)
362+
useEffect(() => {
363+
if (isRevealing || animationDrained || !streamedThisSession.current) return
364+
const timeout = setTimeout(() => setAnimationDrained(true), ANIMATION_DRAIN_MS)
365+
return () => clearTimeout(timeout)
366+
}, [isRevealing, animationDrained])
367+
368+
const streamingTree = (isRevealing || streamedThisSession.current) && !animationDrained
369+
370+
/**
371+
* One-way fade cutoff (see {@link FADE_MAX_REVEALED_CHARS}). Latched so a
372+
* sanitize-induced content shrink back across the boundary cannot re-arm
373+
* `animated` — a fresh animate plugin has no prev-content tracking and would
374+
* re-fade the entire visible segment.
375+
*/
376+
const fadeCutoffRef = useRef(false)
377+
if (streamedContent.length > FADE_MAX_REVEALED_CHARS) fadeCutoffRef.current = true
378+
const fadeActive = streamingTree && !fadeCutoffRef.current
322379

323380
useEffect(() => {
324381
const handler = (e: Event) => {
@@ -392,9 +449,10 @@ function ChatContentInner({
392449
className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}
393450
>
394451
<Streamdown
395-
mode={keepStreamingTree ? undefined : 'static'}
396-
animated={keepStreamingTree ? STREAM_ANIMATION : false}
397-
isAnimating={keepStreamingTree}
452+
key={streamingTree ? 'stream' : 'static'}
453+
mode={streamingTree ? undefined : 'static'}
454+
animated={fadeActive ? STREAM_ANIMATION : false}
455+
isAnimating={streamingTree}
398456
components={MARKDOWN_COMPONENTS}
399457
>
400458
{group.markdown}
@@ -418,9 +476,10 @@ function ChatContentInner({
418476
return (
419477
<div className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}>
420478
<Streamdown
421-
mode={keepStreamingTree ? undefined : 'static'}
422-
animated={keepStreamingTree ? STREAM_ANIMATION : false}
423-
isAnimating={keepStreamingTree}
479+
key={streamingTree ? 'stream' : 'static'}
480+
mode={streamingTree ? undefined : 'static'}
481+
animated={fadeActive ? STREAM_ANIMATION : false}
482+
isAnimating={streamingTree}
424483
components={MARKDOWN_COMPONENTS}
425484
>
426485
{streamedContent}

0 commit comments

Comments
 (0)