Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion apps/sim/app/chat/components/message/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { memo, useMemo, useState } from 'react'
import { Check, Copy, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { StreamingIndicator, StreamingText } from '@/components/ui'
import {
ChatFileDownload,
ChatFileDownloadAll,
Expand Down Expand Up @@ -42,6 +43,8 @@ function EnhancedMarkdownRenderer({ content }: { content: string }) {
return <MarkdownRenderer content={content} />
}

const renderMarkdown = (content: string) => <EnhancedMarkdownRenderer content={content} />

export const ClientChatMessage = memo(
function ClientChatMessage({ message }: { message: ChatMessage }) {
const [isCopied, setIsCopied] = useState(false)
Expand Down Expand Up @@ -188,7 +191,16 @@ export const ClientChatMessage = memo(
{JSON.stringify(cleanTextContent, null, 2)}
</pre>
) : (
<EnhancedMarkdownRenderer content={cleanTextContent as string} />
<>
<StreamingText
content={cleanTextContent as string}
isStreaming={!!message.isStreaming}
renderer={renderMarkdown}
/>
{message.isStreaming && !(cleanTextContent as string) && (
<StreamingIndicator />
)}
</>
)}
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/chat/hooks/use-chat-streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function useChatStreaming() {
const accumulatedTextRef = useRef<string>('')
const lastStreamedPositionRef = useRef<number>(0)
const audioStreamingActiveRef = useRef<boolean>(false)
const lastDisplayedPositionRef = useRef<number>(0) // Track displayed text in synced mode
const lastDisplayedPositionRef = useRef<number>(0)

const stopStreaming = (setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>) => {
if (abortControllerRef.current) {
Expand Down Expand Up @@ -374,6 +374,7 @@ export function useChatStreaming() {
messageId,
chunk: contentChunk.substring(0, 20),
})

setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId ? { ...msg, content: accumulatedText } : msg
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMemo } from 'react'
import { StreamingIndicator } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
import { StreamingIndicator, StreamingText } from '@/components/ui'

interface ChatAttachment {
id: string
Expand Down Expand Up @@ -89,6 +89,8 @@ const WordWrap = ({ text }: { text: string }) => {
)
}

const renderWordWrap = (content: string) => <WordWrap text={content} />

/**
* Renders a chat message with optional file attachments
*/
Expand Down Expand Up @@ -170,8 +172,12 @@ export function ChatMessage({ message }: ChatMessageProps) {
return (
<div className='w-full max-w-full overflow-hidden pl-[2px] opacity-100 transition-opacity duration-200'>
<div className='whitespace-pre-wrap break-words font-[470] font-season text-[var(--text-primary)] text-sm leading-[1.25rem]'>
<WordWrap text={formattedContent} />
{message.isStreaming && <StreamingIndicator />}
<StreamingText
content={formattedContent}
isStreaming={!!message.isStreaming}
renderer={renderWordWrap}
/>
{message.isStreaming && !formattedContent && <StreamingIndicator />}
</div>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,107 +1,22 @@
import { memo, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/core/utils/cn'
import { memo } from 'react'
import { StreamingIndicator, StreamingText } from '@/components/ui'
import { CopilotMarkdownRenderer } from '../markdown-renderer'

/** Character animation delay in milliseconds */
const CHARACTER_DELAY = 3
export { StreamingIndicator }

/** Props for the StreamingIndicator component */
interface StreamingIndicatorProps {
/** Optional class name for layout adjustments */
className?: string
}

/** Shows animated dots during message streaming when no content has arrived */
export const StreamingIndicator = memo(({ className }: StreamingIndicatorProps) => (
<div className={cn('flex h-[1.25rem] items-center text-muted-foreground', className)}>
<div className='flex space-x-0.5'>
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms] [animation-duration:1.2s]' />
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms] [animation-duration:1.2s]' />
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:300ms] [animation-duration:1.2s]' />
</div>
</div>
))

StreamingIndicator.displayName = 'StreamingIndicator'
const renderCopilotMarkdown = (content: string) => <CopilotMarkdownRenderer content={content} />

/** Props for the SmoothStreamingText component */
interface SmoothStreamingTextProps {
/** Content to display with streaming animation */
content: string
/** Whether the content is actively streaming */
isStreaming: boolean
}

/** Displays text with character-by-character animation for smooth streaming */
export const SmoothStreamingText = memo(
({ content, isStreaming }: SmoothStreamingTextProps) => {
const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content))
const contentRef = useRef(content)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const indexRef = useRef(isStreaming ? 0 : content.length)
const isAnimatingRef = useRef(false)

useEffect(() => {
contentRef.current = content

if (content.length === 0) {
setDisplayedContent('')
indexRef.current = 0
return
}

if (isStreaming) {
if (indexRef.current < content.length) {
const animateText = () => {
const currentContent = contentRef.current
const currentIndex = indexRef.current

if (currentIndex < currentContent.length) {
const newDisplayed = currentContent.slice(0, currentIndex + 1)
setDisplayedContent(newDisplayed)
indexRef.current = currentIndex + 1
timeoutRef.current = setTimeout(animateText, CHARACTER_DELAY)
} else {
isAnimatingRef.current = false
}
}

if (!isAnimatingRef.current) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
isAnimatingRef.current = true
animateText()
}
}
} else {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
setDisplayedContent(content)
indexRef.current = content.length
isAnimatingRef.current = false
}

return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
isAnimatingRef.current = false
}
}, [content, isStreaming])

return (
<div className='min-h-[1.25rem] max-w-full'>
<CopilotMarkdownRenderer content={displayedContent} />
</div>
)
},
(prevProps, nextProps) => {
return (
prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming
)
}
)
/** Copilot-specific streaming text that renders with CopilotMarkdownRenderer */
export const SmoothStreamingText = memo(({ content, isStreaming }: SmoothStreamingTextProps) => {
return (
<StreamingText content={content} isStreaming={isStreaming} renderer={renderCopilotMarkdown} />
)
})

SmoothStreamingText.displayName = 'SmoothStreamingText'
1 change: 1 addition & 0 deletions apps/sim/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,6 @@ export {
} from './select'
export { Separator } from './separator'
export { Skeleton } from './skeleton'
export { StreamingIndicator, StreamingText } from './streaming-text'
export { TagInput } from './tag-input'
export { ToolCallCompletion, ToolCallExecution } from './tool-call'
112 changes: 112 additions & 0 deletions apps/sim/components/ui/streaming-text.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
'use client'

import { memo, type ReactNode, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/core/utils/cn'

/** Target characters to advance per animation frame (~30 chars/frame at 60fps ≈ 1800 chars/sec) */
const CHARS_PER_FRAME = 30

/** Props for the StreamingIndicator component */
interface StreamingIndicatorProps {
className?: string
}

/** Shows animated dots during message streaming when no content has arrived */
export const StreamingIndicator = memo(({ className }: StreamingIndicatorProps) => (
<div className={cn('flex h-[1.25rem] items-center text-muted-foreground', className)}>
<div className='flex space-x-0.5'>
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms] [animation-duration:1.2s]' />
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms] [animation-duration:1.2s]' />
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:300ms] [animation-duration:1.2s]' />
</div>
</div>
))

StreamingIndicator.displayName = 'StreamingIndicator'

/** Props for the StreamingText component */
interface StreamingTextProps {
content: string
isStreaming: boolean
renderer?: (content: string) => ReactNode
}

/** Default renderer: plain span with whitespace-pre-wrap */
function DefaultRenderer({ content }: { content: string }) {
return <span className='whitespace-pre-wrap'>{content}</span>
}

/** Displays text with character-by-character animation using rAF batching for smooth streaming */
export const StreamingText = memo(
({ content, isStreaming, renderer }: StreamingTextProps) => {
const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content))
const contentRef = useRef(content)
const rafRef = useRef<number | null>(null)
const indexRef = useRef(isStreaming ? 0 : content.length)
const isAnimatingRef = useRef(false)

useEffect(() => {
contentRef.current = content

if (content.length === 0) {
setDisplayedContent('')
indexRef.current = 0
return
}
Comment on lines +51 to +55
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isAnimatingRef not explicitly reset in the empty-content path

When the early-return path is taken (content.length === 0), isAnimatingRef.current is left at whatever value it had. The cleanup registered by the previous effect run resets it to false before this effect body executes, so in practice this works correctly. However, if the component is unmounted immediately after content becomes '' — with no prior non-empty effect run — React will not execute a cleanup for this invocation (since none was returned), and isAnimatingRef may remain inconsistent.

Adding the explicit reset makes the invariant self-contained and easier to reason about:

Suggested change
if (content.length === 0) {
setDisplayedContent('')
indexRef.current = 0
return
}
if (content.length === 0) {
setDisplayedContent('')
indexRef.current = 0
isAnimatingRef.current = false
return
}


if (isStreaming) {
if (indexRef.current < content.length) {
const animateText = () => {
const currentContent = contentRef.current
const currentIndex = indexRef.current
if (currentIndex < currentContent.length) {
const nextIndex = Math.min(currentIndex + CHARS_PER_FRAME, currentContent.length)
setDisplayedContent(currentContent.slice(0, nextIndex))
indexRef.current = nextIndex
rafRef.current = requestAnimationFrame(animateText)
} else {
isAnimatingRef.current = false
}
}

if (!isAnimatingRef.current) {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
isAnimatingRef.current = true
rafRef.current = requestAnimationFrame(animateText)
}
}
} else {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
setDisplayedContent(content)
indexRef.current = content.length
isAnimatingRef.current = false
}

return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
isAnimatingRef.current = false
}
}, [content, isStreaming])
Comment on lines +48 to +95
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Animation loop restarted on every streaming chunk

The useEffect cleanup always calls cancelAnimationFrame(rafRef.current) and resets isAnimatingRef.current = false. This means for every incoming content chunk — which can arrive tens of times per second — the running rAF loop is cancelled and a completely new one is scheduled.

This is unnecessary because contentRef.current is already updated at the top of the effect (line 49), so any in-flight animation loop would naturally pick up the latest content on its next frame without needing a restart. The indexRef.current is also preserved across restarts, so there is no correctness gain from the cancellation cycle.

Consider removing the rAF cancellation from the cleanup and only cancelling when isStreaming transitions to false:

useEffect(() => {
  contentRef.current = content

  if (content.length === 0) {
    setDisplayedContent('')
    indexRef.current = 0
    return
  }

  if (isStreaming) {
    // contentRef is already updated above; the running loop will pick it up.
    if (!isAnimatingRef.current && indexRef.current < content.length) {
      isAnimatingRef.current = true
      rafRef.current = requestAnimationFrame(animateText)
    }
  } else {
    if (rafRef.current) cancelAnimationFrame(rafRef.current)
    rafRef.current = null
    setDisplayedContent(content)
    indexRef.current = content.length
    isAnimatingRef.current = false
  }

  return () => {
    if (!isStreaming && rafRef.current) {
      cancelAnimationFrame(rafRef.current)
    }
    isAnimatingRef.current = false
  }
}, [content, isStreaming])

This way, a single rAF loop runs continuously while streaming and simply catches up to contentRef.current on each frame, avoiding the cancel/restart overhead on every chunk update.


return (
<div className='min-h-[1.25rem] max-w-full'>
{renderer ? renderer(displayedContent) : <DefaultRenderer content={displayedContent} />}
</div>
)
},
(prevProps, nextProps) => {
return (
prevProps.content === nextProps.content &&
prevProps.isStreaming === nextProps.isStreaming &&
prevProps.renderer === nextProps.renderer
)
}
)

StreamingText.displayName = 'StreamingText'