From 3b543f339975afc9591fab3ef6f7d56071c29b2a Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 4 Mar 2026 23:33:04 -0800 Subject: [PATCH 1/3] fix(stream): add shared StreamingText component for smooth streaming animation --- .../app/chat/components/message/message.tsx | 9 +- apps/sim/app/chat/hooks/use-chat-streaming.ts | 3 +- .../components/chat-message/chat-message.tsx | 10 +- .../smooth-streaming/smooth-streaming.tsx | 90 +------------- apps/sim/components/ui/index.ts | 1 + apps/sim/components/ui/streaming-text.tsx | 112 ++++++++++++++++++ 6 files changed, 137 insertions(+), 88 deletions(-) create mode 100644 apps/sim/components/ui/streaming-text.tsx diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/chat/components/message/message.tsx index 7a8f4546d4e..475118d931c 100644 --- a/apps/sim/app/chat/components/message/message.tsx +++ b/apps/sim/app/chat/components/message/message.tsx @@ -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 { StreamingText } from '@/components/ui/streaming-text' import { ChatFileDownload, ChatFileDownloadAll, @@ -42,6 +43,8 @@ function EnhancedMarkdownRenderer({ content }: { content: string }) { return } +const renderMarkdown = (content: string) => + export const ClientChatMessage = memo( function ClientChatMessage({ message }: { message: ChatMessage }) { const [isCopied, setIsCopied] = useState(false) @@ -188,7 +191,11 @@ export const ClientChatMessage = memo( {JSON.stringify(cleanTextContent, null, 2)} ) : ( - + )} diff --git a/apps/sim/app/chat/hooks/use-chat-streaming.ts b/apps/sim/app/chat/hooks/use-chat-streaming.ts index e0208709311..25d95c22bf3 100644 --- a/apps/sim/app/chat/hooks/use-chat-streaming.ts +++ b/apps/sim/app/chat/hooks/use-chat-streaming.ts @@ -70,7 +70,7 @@ export function useChatStreaming() { const accumulatedTextRef = useRef('') const lastStreamedPositionRef = useRef(0) const audioStreamingActiveRef = useRef(false) - const lastDisplayedPositionRef = useRef(0) // Track displayed text in synced mode + const lastDisplayedPositionRef = useRef(0) const stopStreaming = (setMessages: React.Dispatch>) => { if (abortControllerRef.current) { @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx index a11983b0be2..0b22b3b16a2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx @@ -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/streaming-text' interface ChatAttachment { id: string @@ -89,6 +89,8 @@ const WordWrap = ({ text }: { text: string }) => { ) } +const renderWordWrap = (content: string) => + /** * Renders a chat message with optional file attachments */ @@ -170,7 +172,11 @@ export function ChatMessage({ message }: ChatMessageProps) { return (
- + {message.isStreaming && }
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming/smooth-streaming.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming/smooth-streaming.tsx index c0965808e8d..7f94604512e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming/smooth-streaming.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming/smooth-streaming.tsx @@ -1,100 +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/streaming-text' 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) => ( -
-
-
-
-
-
-
-)) - -StreamingIndicator.displayName = 'StreamingIndicator' +const renderCopilotMarkdown = (content: string) => /** 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 */ +/** Copilot-specific streaming text that renders with CopilotMarkdownRenderer */ export const SmoothStreamingText = memo( ({ content, isStreaming }: SmoothStreamingTextProps) => { - const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content)) - const contentRef = useRef(content) - const timeoutRef = useRef(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 ( -
- -
+ ) }, (prevProps, nextProps) => { diff --git a/apps/sim/components/ui/index.ts b/apps/sim/components/ui/index.ts index a9da32e350f..8565bbc182b 100644 --- a/apps/sim/components/ui/index.ts +++ b/apps/sim/components/ui/index.ts @@ -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' diff --git a/apps/sim/components/ui/streaming-text.tsx b/apps/sim/components/ui/streaming-text.tsx new file mode 100644 index 00000000000..bfdcf13a1dd --- /dev/null +++ b/apps/sim/components/ui/streaming-text.tsx @@ -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) => ( +
+
+
+
+
+
+
+)) + +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 {content} +} + +/** 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(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 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]) + + return ( +
+ {renderer ? renderer(displayedContent) : } +
+ ) + }, + (prevProps, nextProps) => { + return ( + prevProps.content === nextProps.content && + prevProps.isStreaming === nextProps.isStreaming && + prevProps.renderer === nextProps.renderer + ) + } +) + +StreamingText.displayName = 'StreamingText' From 7935b21197452ba3159a2119368fcb554b5c028e Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 5 Mar 2026 10:41:01 -0800 Subject: [PATCH 2/3] fix(stream): use barrel imports and remove redundant memo comparator --- .../app/chat/components/message/message.tsx | 2 +- .../components/chat-message/chat-message.tsx | 2 +- .../smooth-streaming/smooth-streaming.tsx | 19 ++++++------------- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/chat/components/message/message.tsx index 475118d931c..e59ca3590c1 100644 --- a/apps/sim/app/chat/components/message/message.tsx +++ b/apps/sim/app/chat/components/message/message.tsx @@ -3,7 +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 { StreamingText } from '@/components/ui/streaming-text' +import { StreamingText } from '@/components/ui' import { ChatFileDownload, ChatFileDownloadAll, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx index 0b22b3b16a2..c23031a9905 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { StreamingIndicator, StreamingText } from '@/components/ui/streaming-text' +import { StreamingIndicator, StreamingText } from '@/components/ui' interface ChatAttachment { id: string diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming/smooth-streaming.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming/smooth-streaming.tsx index 7f94604512e..c313756b61c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming/smooth-streaming.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming/smooth-streaming.tsx @@ -1,5 +1,5 @@ import { memo } from 'react' -import { StreamingIndicator, StreamingText } from '@/components/ui/streaming-text' +import { StreamingIndicator, StreamingText } from '@/components/ui' import { CopilotMarkdownRenderer } from '../markdown-renderer' export { StreamingIndicator } @@ -13,17 +13,10 @@ interface SmoothStreamingTextProps { } /** Copilot-specific streaming text that renders with CopilotMarkdownRenderer */ -export const SmoothStreamingText = memo( - ({ content, isStreaming }: SmoothStreamingTextProps) => { - return ( - - ) - }, - (prevProps, nextProps) => { - return ( - prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming - ) - } -) +export const SmoothStreamingText = memo(({ content, isStreaming }: SmoothStreamingTextProps) => { + return ( + + ) +}) SmoothStreamingText.displayName = 'SmoothStreamingText' From 754bf99eae12aa6f2f43b80b4f7f67a6a4cf03c2 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 5 Mar 2026 11:05:08 -0800 Subject: [PATCH 3/3] fix(stream): only show streaming indicator before first content arrives --- .../sim/app/chat/components/message/message.tsx | 17 +++++++++++------ .../components/chat-message/chat-message.tsx | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/chat/components/message/message.tsx index e59ca3590c1..0e3bc7d3ff6 100644 --- a/apps/sim/app/chat/components/message/message.tsx +++ b/apps/sim/app/chat/components/message/message.tsx @@ -3,7 +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 { StreamingText } from '@/components/ui' +import { StreamingIndicator, StreamingText } from '@/components/ui' import { ChatFileDownload, ChatFileDownloadAll, @@ -191,11 +191,16 @@ export const ClientChatMessage = memo( {JSON.stringify(cleanTextContent, null, 2)} ) : ( - + <> + + {message.isStreaming && !(cleanTextContent as string) && ( + + )} + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx index c23031a9905..2d595ff7315 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx @@ -177,7 +177,7 @@ export function ChatMessage({ message }: ChatMessageProps) { isStreaming={!!message.isStreaming} renderer={renderWordWrap} /> - {message.isStreaming && } + {message.isStreaming && !formattedContent && }
)