Skip to content

Commit 87e50be

Browse files
committed
feat(markdown): migrate from react-markdown to streamdown
1 parent 3d5bd00 commit 87e50be

File tree

9 files changed

+112
-216
lines changed

9 files changed

+112
-216
lines changed

apps/sim/app/changelog/components/timeline-list.tsx

Lines changed: 18 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import React from 'react'
4-
import ReactMarkdown from 'react-markdown'
4+
import { Streamdown } from 'streamdown'
55
import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
66
import { inter } from '@/app/_styles/fonts/inter/inter'
77
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
@@ -145,75 +145,58 @@ export default function ChangelogList({ initialEntries }: Props) {
145145
<div
146146
className={`${inter.className} prose prose-sm dark:prose-invert max-w-none prose-headings:font-semibold prose-a:text-brand-primary prose-headings:text-foreground prose-p:text-muted-foreground prose-a:no-underline hover:prose-a:underline`}
147147
>
148-
<ReactMarkdown
148+
<Streamdown
149149
components={{
150-
h2: ({ children, ...props }) =>
150+
h2: ({ children }: any) =>
151151
isContributorsLabel(children) ? null : (
152152
<h3
153153
className={`${soehne.className} mt-5 mb-2 font-medium text-[13px] text-foreground tracking-tight`}
154-
{...props}
155154
>
156155
{children}
157156
</h3>
158157
),
159-
h3: ({ children, ...props }) =>
158+
h3: ({ children }: any) =>
160159
isContributorsLabel(children) ? null : (
161160
<h4
162161
className={`${soehne.className} mt-4 mb-1 font-medium text-[13px] text-foreground tracking-tight`}
163-
{...props}
164162
>
165163
{children}
166164
</h4>
167165
),
168-
ul: ({ children, ...props }) => (
169-
<ul className='mt-2 mb-3 space-y-1.5' {...props}>
170-
{children}
171-
</ul>
172-
),
173-
li: ({ children, ...props }) => {
166+
ul: ({ children }: any) => <ul className='mt-2 mb-3 space-y-1.5'>{children}</ul>,
167+
li: ({ children }: any) => {
174168
const text = String(children)
175169
if (/^\s*contributors\s*:?\s*$/i.test(text)) return null
176170
return (
177-
<li className='text-[13px] text-muted-foreground leading-relaxed' {...props}>
171+
<li className='text-[13px] text-muted-foreground leading-relaxed'>
178172
{children}
179173
</li>
180174
)
181175
},
182-
p: ({ children, ...props }) =>
176+
p: ({ children }: any) =>
183177
/^\s*contributors\s*:?\s*$/i.test(String(children)) ? null : (
184-
<p
185-
className='mb-3 text-[13px] text-muted-foreground leading-relaxed'
186-
{...props}
187-
>
178+
<p className='mb-3 text-[13px] text-muted-foreground leading-relaxed'>
188179
{children}
189180
</p>
190181
),
191-
strong: ({ children, ...props }) => (
192-
<strong className='font-medium text-foreground' {...props}>
193-
{children}
194-
</strong>
182+
strong: ({ children }: any) => (
183+
<strong className='font-medium text-foreground'>{children}</strong>
195184
),
196-
code: ({ children, ...props }) => (
197-
<code
198-
className='rounded bg-muted px-1 py-0.5 font-mono text-foreground text-xs'
199-
{...props}
200-
>
185+
code: ({ children }: any) => (
186+
<code className='rounded bg-muted px-1 py-0.5 font-mono text-foreground text-xs'>
201187
{children}
202188
</code>
203189
),
204190
img: () => null,
205-
a: ({ className, ...props }: any) => (
206-
<a
207-
{...props}
208-
className={`underline ${className ?? ''}`}
209-
target='_blank'
210-
rel='noreferrer'
211-
/>
191+
a: ({ href, children }: any) => (
192+
<a href={href} className='underline' target='_blank' rel='noreferrer'>
193+
{children}
194+
</a>
212195
),
213196
}}
214197
>
215198
{cleanMarkdown(entry.content)}
216-
</ReactMarkdown>
199+
</Streamdown>
217200
</div>
218201
</div>
219202
))}

apps/sim/app/chat/components/message/components/markdown-renderer.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import React, { type HTMLAttributes, memo, type ReactNode, useMemo } from 'react'
2-
import ReactMarkdown from 'react-markdown'
3-
import remarkGfm from 'remark-gfm'
2+
import { Streamdown } from 'streamdown'
43
import { Tooltip } from '@/components/emcn'
54

65
export function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
@@ -23,8 +22,6 @@ export function LinkWithPreview({ href, children }: { href: string; children: Re
2322
)
2423
}
2524

26-
const REMARK_PLUGINS = [remarkGfm]
27-
2825
function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
2926
return {
3027
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
@@ -209,9 +206,7 @@ const MarkdownRenderer = memo(function MarkdownRenderer({
209206

210207
return (
211208
<div className='space-y-4 break-words font-sans text-[#0D0D0D] text-base leading-relaxed dark:text-gray-100'>
212-
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
213-
{processedContent}
214-
</ReactMarkdown>
209+
<Streamdown components={components}>{processedContent}</Streamdown>
215210
</div>
216211
)
217212
})

apps/sim/app/templates/[id]/template.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
User,
1515
} from 'lucide-react'
1616
import { useParams, useRouter, useSearchParams } from 'next/navigation'
17-
import ReactMarkdown from 'react-markdown'
17+
import { Streamdown } from 'streamdown'
1818
import {
1919
Breadcrumb,
2020
Button,
@@ -895,7 +895,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
895895
About this Workflow
896896
</h3>
897897
<div className='max-w-none space-y-2'>
898-
<ReactMarkdown
898+
<Streamdown
899899
components={{
900900
p: ({ children }) => (
901901
<p className='mb-2 font-sans text-muted-foreground text-sm leading-[1.4rem] last:mb-0'>
@@ -962,7 +962,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
962962
}}
963963
>
964964
{template.details.about}
965-
</ReactMarkdown>
965+
</Streamdown>
966966
</div>
967967
</div>
968968
)}
@@ -1076,7 +1076,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
10761076
{/* Creator bio */}
10771077
{template.creator.details?.about && (
10781078
<div className='max-w-none'>
1079-
<ReactMarkdown
1079+
<Streamdown
10801080
components={{
10811081
p: ({ children }) => (
10821082
<p className='mb-2 font-sans text-muted-foreground text-sm leading-[1.4rem] last:mb-0'>
@@ -1101,7 +1101,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
11011101
}}
11021102
>
11031103
{template.creator.details.about}
1104-
</ReactMarkdown>
1104+
</Streamdown>
11051105
</div>
11061106
)}
11071107
</div>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { memo, useCallback, useMemo } from 'react'
2-
import ReactMarkdown from 'react-markdown'
32
import type { NodeProps } from 'reactflow'
4-
import remarkBreaks from 'remark-breaks'
5-
import remarkGfm from 'remark-gfm'
3+
import { Streamdown } from 'streamdown'
64
import { cn } from '@/lib/core/utils/cn'
75
import { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
86
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -300,13 +298,45 @@ function getEmbedInfo(url: string): EmbedInfo | null {
300298
return null
301299
}
302300

301+
/**
302+
* Converts single newlines to GFM hard breaks (trailing double space)
303+
* so freeform note text preserves line breaks without remark-breaks.
304+
* Leaves double newlines (paragraph breaks) and code fences untouched.
305+
*/
306+
function softBreaks(text: string): string {
307+
const lines = text.split('\n')
308+
const result: string[] = []
309+
let inCodeFence = false
310+
311+
for (let i = 0; i < lines.length; i++) {
312+
const line = lines[i]
313+
if (/^```/.test(line.trimStart())) inCodeFence = !inCodeFence
314+
315+
if (inCodeFence || i === lines.length - 1) {
316+
result.push(line)
317+
continue
318+
}
319+
320+
const nextLine = lines[i + 1]
321+
const isBlankNext = nextLine.trim() === ''
322+
const isBlankCurrent = line.trim() === ''
323+
324+
if (isBlankCurrent || isBlankNext) {
325+
result.push(line)
326+
} else {
327+
result.push(line.endsWith(' ') ? line : `${line} `)
328+
}
329+
}
330+
331+
return result.join('\n')
332+
}
333+
303334
/**
304335
* Compact markdown renderer for note blocks with tight spacing
305336
*/
306337
const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }) {
307338
return (
308-
<ReactMarkdown
309-
remarkPlugins={[remarkGfm, remarkBreaks]}
339+
<Streamdown
310340
components={{
311341
p: ({ children }: any) => (
312342
<p className='mb-1 break-words text-[var(--text-primary)] text-sm leading-[1.25rem] last:mb-0'>
@@ -469,8 +499,8 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
469499
),
470500
}}
471501
>
472-
{content}
473-
</ReactMarkdown>
502+
{softBreaks(content)}
503+
</Streamdown>
474504
)
475505
})
476506

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer/markdown-renderer.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,9 @@
22

33
import React, { memo, useCallback, useState } from 'react'
44
import { Check, Copy } from 'lucide-react'
5-
import ReactMarkdown from 'react-markdown'
6-
import remarkGfm from 'remark-gfm'
5+
import { Streamdown } from 'streamdown'
76
import { Code, Tooltip } from '@/components/emcn'
87

9-
const REMARK_PLUGINS = [remarkGfm]
10-
118
/**
129
* Recursively extracts text content from React elements
1310
* @param element - React node to extract text from
@@ -313,17 +310,15 @@ const markdownComponents = {
313310

314311
/**
315312
* CopilotMarkdownRenderer renders markdown content with custom styling
316-
* Optimized for LLM chat: tight spacing, memoized components, isolated state
313+
* Uses Streamdown for streaming-optimized rendering with incomplete markdown handling
317314
*
318315
* @param props - Component props
319316
* @returns Rendered markdown content
320317
*/
321318
function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) {
322319
return (
323320
<div className='max-w-full break-words font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] dark:font-[470] [&_*]:max-w-full [&_a]:break-all [&_code:not(pre_code)]:break-words [&_li]:break-words [&_p]:break-words'>
324-
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={markdownComponents}>
325-
{content}
326-
</ReactMarkdown>
321+
<Streamdown components={markdownComponents}>{content}</Streamdown>
327322
</div>
328323
)
329324
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming/smooth-streaming.tsx

Lines changed: 10 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1-
import { memo, useEffect, useRef, useState } from 'react'
1+
import { memo } from 'react'
22
import { cn } from '@/lib/core/utils/cn'
33
import { CopilotMarkdownRenderer } from '../markdown-renderer'
44

5-
/** Character animation delay in milliseconds */
6-
const CHARACTER_DELAY = 3
7-
85
/** Props for the StreamingIndicator component */
96
interface StreamingIndicatorProps {
107
/** Optional class name for layout adjustments */
@@ -26,74 +23,22 @@ StreamingIndicator.displayName = 'StreamingIndicator'
2623

2724
/** Props for the SmoothStreamingText component */
2825
interface SmoothStreamingTextProps {
29-
/** Content to display with streaming animation */
26+
/** Content to display with streaming-aware rendering */
3027
content: string
31-
/** Whether the content is actively streaming */
28+
/** Whether the content is actively streaming (kept for API compatibility) */
3229
isStreaming: boolean
3330
}
3431

35-
/** Displays text with character-by-character animation for smooth streaming */
32+
/**
33+
* Displays streamed text using Streamdown's native incomplete markdown handling.
34+
* Streamdown gracefully renders partial markdown (unclosed bold, links, code fences)
35+
* so no character-by-character buffering is needed.
36+
*/
3637
export const SmoothStreamingText = memo(
37-
({ content, isStreaming }: SmoothStreamingTextProps) => {
38-
const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content))
39-
const contentRef = useRef(content)
40-
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
41-
const indexRef = useRef(isStreaming ? 0 : content.length)
42-
const isAnimatingRef = useRef(false)
43-
44-
useEffect(() => {
45-
contentRef.current = content
46-
47-
if (content.length === 0) {
48-
setDisplayedContent('')
49-
indexRef.current = 0
50-
return
51-
}
52-
53-
if (isStreaming) {
54-
if (indexRef.current < content.length) {
55-
const animateText = () => {
56-
const currentContent = contentRef.current
57-
const currentIndex = indexRef.current
58-
59-
if (currentIndex < currentContent.length) {
60-
const newDisplayed = currentContent.slice(0, currentIndex + 1)
61-
setDisplayedContent(newDisplayed)
62-
indexRef.current = currentIndex + 1
63-
timeoutRef.current = setTimeout(animateText, CHARACTER_DELAY)
64-
} else {
65-
isAnimatingRef.current = false
66-
}
67-
}
68-
69-
if (!isAnimatingRef.current) {
70-
if (timeoutRef.current) {
71-
clearTimeout(timeoutRef.current)
72-
}
73-
isAnimatingRef.current = true
74-
animateText()
75-
}
76-
}
77-
} else {
78-
if (timeoutRef.current) {
79-
clearTimeout(timeoutRef.current)
80-
}
81-
setDisplayedContent(content)
82-
indexRef.current = content.length
83-
isAnimatingRef.current = false
84-
}
85-
86-
return () => {
87-
if (timeoutRef.current) {
88-
clearTimeout(timeoutRef.current)
89-
}
90-
isAnimatingRef.current = false
91-
}
92-
}, [content, isStreaming])
93-
38+
({ content }: SmoothStreamingTextProps) => {
9439
return (
9540
<div className='min-h-[1.25rem] max-w-full'>
96-
<CopilotMarkdownRenderer content={displayedContent} />
41+
<CopilotMarkdownRenderer content={content} />
9742
</div>
9843
)
9944
},

0 commit comments

Comments
 (0)