@@ -35,7 +35,7 @@ import { cn } from '@/lib/core/utils/cn'
3535import { extractTextContent } from '@/lib/core/utils/react-node-text'
3636import { getFileExtension } from '@/lib/uploads/utils/file-utils'
3737import { useScrollAnchor } from '@/hooks/use-scroll-anchor'
38- import { useSmoothText } from '@/hooks/use-smooth-text'
38+ import { RESUME_SKIP_THRESHOLD , useSmoothText } from '@/hooks/use-smooth-text'
3939import { DataTable } from './data-table'
4040import { PreviewLoadingFrame } from './preview-shared'
4141import { ZoomablePreview } from './zoomable-preview'
@@ -185,6 +185,10 @@ function remarkCallouts() {
185185const REMARK_PLUGINS = [ remarkGfm , remarkBreaks , remarkMermaid , remarkCallouts ]
186186const REHYPE_PLUGINS = [ rehypeSlug ]
187187
188+ const STREAMDOWN_ALLOWED_TAGS : Record < string , string [ ] > = {
189+ 'mermaid-diagram' : [ 'definition' ] ,
190+ }
191+
188192/**
189193 * Soft per-character fade for newly revealed markdown while streaming. Mirrors
190194 * the chat surface so a streamed file preview reveals with the same cadence;
@@ -197,6 +201,33 @@ const STREAM_ANIMATION = {
197201 sep : 'char' ,
198202} as const
199203
204+ /**
205+ * Gates the per-character fade to streams that build the document from
206+ * scratch. Enabling the fade over an already-rendered document, or during
207+ * in-place rewrites (patch snapshots), replays it on text that is already
208+ * visible, so the gate latches off for those sessions.
209+ */
210+ function useStreamAnimationGate ( content : string , isStreaming : boolean ) : boolean {
211+ const prevIsStreamingRef = useRef ( false )
212+ const prevContentRef = useRef ( content )
213+ const animateRef = useRef ( false )
214+
215+ if ( isStreaming !== prevIsStreamingRef . current ) {
216+ animateRef . current = isStreaming && content . length <= RESUME_SKIP_THRESHOLD
217+ } else if (
218+ isStreaming &&
219+ animateRef . current &&
220+ content !== prevContentRef . current &&
221+ ! content . startsWith ( prevContentRef . current )
222+ ) {
223+ animateRef . current = false
224+ }
225+ prevIsStreamingRef . current = isStreaming
226+ prevContentRef . current = content
227+
228+ return isStreaming && animateRef . current
229+ }
230+
200231/**
201232 * Carries the contentRef and toggle handler from MarkdownPreview down to the
202233 * task-list renderers. Only present when the preview is interactive.
@@ -876,6 +907,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
876907 // jumping per server chunk. `snapOnNonAppend` shows in-place rewrites (patch)
877908 // in full immediately so a diff never appears to retype from the top.
878909 const revealedContent = useSmoothText ( content , isStreaming , { snapOnNonAppend : true } )
910+ const shouldAnimateStream = useStreamAnimationGate ( content , isStreaming )
879911 const { ref : autoScrollRef , spacerRef } = useScrollAnchor (
880912 isStreaming && ! disableAutoScroll ,
881913 revealedContent
@@ -927,12 +959,12 @@ const MarkdownPreview = memo(function MarkdownPreview({
927959 { frontMatterData && < FrontMatterCard data = { frontMatterData } /> }
928960 < Streamdown
929961 mode = { streamdownMode }
930- animated = { isStreaming ? STREAM_ANIMATION : false }
962+ animated = { shouldAnimateStream ? STREAM_ANIMATION : false }
931963 isAnimating = { isStreaming }
932964 remarkPlugins = { REMARK_PLUGINS }
933965 rehypePlugins = { REHYPE_PLUGINS }
934966 components = { markdownComponents }
935- allowedTags = { { 'mermaid-diagram' : [ 'definition' ] } }
967+ allowedTags = { STREAMDOWN_ALLOWED_TAGS }
936968 >
937969 { markdownContent }
938970 </ Streamdown >
0 commit comments