Skip to content

Commit 3b543f3

Browse files
committed
fix(stream): add shared StreamingText component for smooth streaming animation
1 parent 0a6a2ee commit 3b543f3

File tree

6 files changed

+137
-88
lines changed

6 files changed

+137
-88
lines changed

apps/sim/app/chat/components/message/message.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { memo, useMemo, useState } from 'react'
44
import { Check, Copy, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react'
55
import { Tooltip } from '@/components/emcn'
6+
import { StreamingText } from '@/components/ui/streaming-text'
67
import {
78
ChatFileDownload,
89
ChatFileDownloadAll,
@@ -42,6 +43,8 @@ function EnhancedMarkdownRenderer({ content }: { content: string }) {
4243
return <MarkdownRenderer content={content} />
4344
}
4445

46+
const renderMarkdown = (content: string) => <EnhancedMarkdownRenderer content={content} />
47+
4548
export const ClientChatMessage = memo(
4649
function ClientChatMessage({ message }: { message: ChatMessage }) {
4750
const [isCopied, setIsCopied] = useState(false)
@@ -188,7 +191,11 @@ export const ClientChatMessage = memo(
188191
{JSON.stringify(cleanTextContent, null, 2)}
189192
</pre>
190193
) : (
191-
<EnhancedMarkdownRenderer content={cleanTextContent as string} />
194+
<StreamingText
195+
content={cleanTextContent as string}
196+
isStreaming={!!message.isStreaming}
197+
renderer={renderMarkdown}
198+
/>
192199
)}
193200
</div>
194201
</div>

apps/sim/app/chat/hooks/use-chat-streaming.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export function useChatStreaming() {
7070
const accumulatedTextRef = useRef<string>('')
7171
const lastStreamedPositionRef = useRef<number>(0)
7272
const audioStreamingActiveRef = useRef<boolean>(false)
73-
const lastDisplayedPositionRef = useRef<number>(0) // Track displayed text in synced mode
73+
const lastDisplayedPositionRef = useRef<number>(0)
7474

7575
const stopStreaming = (setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>) => {
7676
if (abortControllerRef.current) {
@@ -374,6 +374,7 @@ export function useChatStreaming() {
374374
messageId,
375375
chunk: contentChunk.substring(0, 20),
376376
})
377+
377378
setMessages((prev) =>
378379
prev.map((msg) =>
379380
msg.id === messageId ? { ...msg, content: accumulatedText } : msg

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useMemo } from 'react'
2-
import { StreamingIndicator } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
2+
import { StreamingIndicator, StreamingText } from '@/components/ui/streaming-text'
33

44
interface ChatAttachment {
55
id: string
@@ -89,6 +89,8 @@ const WordWrap = ({ text }: { text: string }) => {
8989
)
9090
}
9191

92+
const renderWordWrap = (content: string) => <WordWrap text={content} />
93+
9294
/**
9395
* Renders a chat message with optional file attachments
9496
*/
@@ -170,7 +172,11 @@ export function ChatMessage({ message }: ChatMessageProps) {
170172
return (
171173
<div className='w-full max-w-full overflow-hidden pl-[2px] opacity-100 transition-opacity duration-200'>
172174
<div className='whitespace-pre-wrap break-words font-[470] font-season text-[var(--text-primary)] text-sm leading-[1.25rem]'>
173-
<WordWrap text={formattedContent} />
175+
<StreamingText
176+
content={formattedContent}
177+
isStreaming={!!message.isStreaming}
178+
renderer={renderWordWrap}
179+
/>
174180
{message.isStreaming && <StreamingIndicator />}
175181
</div>
176182
</div>

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

Lines changed: 6 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,100 +1,22 @@
1-
import { memo, useEffect, useRef, useState } from 'react'
2-
import { cn } from '@/lib/core/utils/cn'
1+
import { memo } from 'react'
2+
import { StreamingIndicator, StreamingText } from '@/components/ui/streaming-text'
33
import { CopilotMarkdownRenderer } from '../markdown-renderer'
44

5-
/** Character animation delay in milliseconds */
6-
const CHARACTER_DELAY = 3
5+
export { StreamingIndicator }
76

8-
/** Props for the StreamingIndicator component */
9-
interface StreamingIndicatorProps {
10-
/** Optional class name for layout adjustments */
11-
className?: string
12-
}
13-
14-
/** Shows animated dots during message streaming when no content has arrived */
15-
export const StreamingIndicator = memo(({ className }: StreamingIndicatorProps) => (
16-
<div className={cn('flex h-[1.25rem] items-center text-muted-foreground', className)}>
17-
<div className='flex space-x-0.5'>
18-
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms] [animation-duration:1.2s]' />
19-
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms] [animation-duration:1.2s]' />
20-
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:300ms] [animation-duration:1.2s]' />
21-
</div>
22-
</div>
23-
))
24-
25-
StreamingIndicator.displayName = 'StreamingIndicator'
7+
const renderCopilotMarkdown = (content: string) => <CopilotMarkdownRenderer content={content} />
268

279
/** Props for the SmoothStreamingText component */
2810
interface SmoothStreamingTextProps {
29-
/** Content to display with streaming animation */
3011
content: string
31-
/** Whether the content is actively streaming */
3212
isStreaming: boolean
3313
}
3414

35-
/** Displays text with character-by-character animation for smooth streaming */
15+
/** Copilot-specific streaming text that renders with CopilotMarkdownRenderer */
3616
export const SmoothStreamingText = memo(
3717
({ 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-
9418
return (
95-
<div className='min-h-[1.25rem] max-w-full'>
96-
<CopilotMarkdownRenderer content={displayedContent} />
97-
</div>
19+
<StreamingText content={content} isStreaming={isStreaming} renderer={renderCopilotMarkdown} />
9820
)
9921
},
10022
(prevProps, nextProps) => {

apps/sim/components/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,6 @@ export {
5151
} from './select'
5252
export { Separator } from './separator'
5353
export { Skeleton } from './skeleton'
54+
export { StreamingIndicator, StreamingText } from './streaming-text'
5455
export { TagInput } from './tag-input'
5556
export { ToolCallCompletion, ToolCallExecution } from './tool-call'
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
'use client'
2+
3+
import { memo, type ReactNode, useEffect, useRef, useState } from 'react'
4+
import { cn } from '@/lib/core/utils/cn'
5+
6+
/** Target characters to advance per animation frame (~30 chars/frame at 60fps ≈ 1800 chars/sec) */
7+
const CHARS_PER_FRAME = 30
8+
9+
/** Props for the StreamingIndicator component */
10+
interface StreamingIndicatorProps {
11+
className?: string
12+
}
13+
14+
/** Shows animated dots during message streaming when no content has arrived */
15+
export const StreamingIndicator = memo(({ className }: StreamingIndicatorProps) => (
16+
<div className={cn('flex h-[1.25rem] items-center text-muted-foreground', className)}>
17+
<div className='flex space-x-0.5'>
18+
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms] [animation-duration:1.2s]' />
19+
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms] [animation-duration:1.2s]' />
20+
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:300ms] [animation-duration:1.2s]' />
21+
</div>
22+
</div>
23+
))
24+
25+
StreamingIndicator.displayName = 'StreamingIndicator'
26+
27+
/** Props for the StreamingText component */
28+
interface StreamingTextProps {
29+
content: string
30+
isStreaming: boolean
31+
renderer?: (content: string) => ReactNode
32+
}
33+
34+
/** Default renderer: plain span with whitespace-pre-wrap */
35+
function DefaultRenderer({ content }: { content: string }) {
36+
return <span className='whitespace-pre-wrap'>{content}</span>
37+
}
38+
39+
/** Displays text with character-by-character animation using rAF batching for smooth streaming */
40+
export const StreamingText = memo(
41+
({ content, isStreaming, renderer }: StreamingTextProps) => {
42+
const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content))
43+
const contentRef = useRef(content)
44+
const rafRef = useRef<number | null>(null)
45+
const indexRef = useRef(isStreaming ? 0 : content.length)
46+
const isAnimatingRef = useRef(false)
47+
48+
useEffect(() => {
49+
contentRef.current = content
50+
51+
if (content.length === 0) {
52+
setDisplayedContent('')
53+
indexRef.current = 0
54+
return
55+
}
56+
57+
if (isStreaming) {
58+
if (indexRef.current < content.length) {
59+
const animateText = () => {
60+
const currentContent = contentRef.current
61+
const currentIndex = indexRef.current
62+
if (currentIndex < currentContent.length) {
63+
const nextIndex = Math.min(currentIndex + CHARS_PER_FRAME, currentContent.length)
64+
setDisplayedContent(currentContent.slice(0, nextIndex))
65+
indexRef.current = nextIndex
66+
rafRef.current = requestAnimationFrame(animateText)
67+
} else {
68+
isAnimatingRef.current = false
69+
}
70+
}
71+
72+
if (!isAnimatingRef.current) {
73+
if (rafRef.current) {
74+
cancelAnimationFrame(rafRef.current)
75+
}
76+
isAnimatingRef.current = true
77+
rafRef.current = requestAnimationFrame(animateText)
78+
}
79+
}
80+
} else {
81+
if (rafRef.current) {
82+
cancelAnimationFrame(rafRef.current)
83+
}
84+
setDisplayedContent(content)
85+
indexRef.current = content.length
86+
isAnimatingRef.current = false
87+
}
88+
89+
return () => {
90+
if (rafRef.current) {
91+
cancelAnimationFrame(rafRef.current)
92+
}
93+
isAnimatingRef.current = false
94+
}
95+
}, [content, isStreaming])
96+
97+
return (
98+
<div className='min-h-[1.25rem] max-w-full'>
99+
{renderer ? renderer(displayedContent) : <DefaultRenderer content={displayedContent} />}
100+
</div>
101+
)
102+
},
103+
(prevProps, nextProps) => {
104+
return (
105+
prevProps.content === nextProps.content &&
106+
prevProps.isStreaming === nextProps.isStreaming &&
107+
prevProps.renderer === nextProps.renderer
108+
)
109+
}
110+
)
111+
112+
StreamingText.displayName = 'StreamingText'

0 commit comments

Comments
 (0)