From 0a91ab9fc3fa4ff5a442435a823e2aa4f7457c22 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Tue, 14 Apr 2026 14:32:28 -0700 Subject: [PATCH 1/5] improvement(ui): delegate streaming animation to Streamdown component Remove custom useStreamingText hook and useThrottledValue indirection in favor of Streamdown's built-in streaming props. This eliminates the manual character-by-character reveal logic (setInterval, easing, chase factor) and lets the library handle animation natively, reducing complexity and improving consistency across Mothership and chat. --- .../message/components/markdown-renderer.tsx | 9 +- .../app/chat/components/message/message.tsx | 17 ++- .../components/chat-content/chat-content.tsx | 9 +- .../message-content/message-content.tsx | 2 - apps/sim/hooks/use-streaming-text.ts | 100 ------------------ 5 files changed, 22 insertions(+), 115 deletions(-) delete mode 100644 apps/sim/hooks/use-streaming-text.ts diff --git a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx index 12254f18dd5..bda70acabb5 100644 --- a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx +++ b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx @@ -176,9 +176,11 @@ const DEFAULT_COMPONENTS = createCustomComponents(LinkWithPreview) const MarkdownRenderer = memo(function MarkdownRenderer({ content, customLinkComponent, + isStreaming = false, }: { content: string customLinkComponent?: typeof LinkWithPreview + isStreaming?: boolean }) { const components = useMemo(() => { if (!customLinkComponent) { @@ -191,7 +193,12 @@ const MarkdownRenderer = memo(function MarkdownRenderer({ return (
- + {processedContent}
diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/chat/components/message/message.tsx index 9d02cbbcb29..c741d56cd49 100644 --- a/apps/sim/app/chat/components/message/message.tsx +++ b/apps/sim/app/chat/components/message/message.tsx @@ -8,7 +8,6 @@ import { ChatFileDownloadAll, } from '@/app/chat/components/message/components/file-download' import MarkdownRenderer from '@/app/chat/components/message/components/markdown-renderer' -import { useThrottledValue } from '@/hooks/use-throttled-value' export interface ChatAttachment { id: string @@ -39,9 +38,14 @@ export interface ChatMessage { files?: ChatFile[] } -function EnhancedMarkdownRenderer({ content }: { content: string }) { - const throttled = useThrottledValue(content) - return +function EnhancedMarkdownRenderer({ + content, + isStreaming, +}: { + content: string + isStreaming?: boolean +}) { + return } export const ClientChatMessage = memo( @@ -188,7 +192,10 @@ export const ClientChatMessage = memo( {JSON.stringify(cleanTextContent, null, 2)} ) : ( - + )} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 46091cd9ce3..2d5a9fa3a9d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -18,7 +18,6 @@ import { SpecialTags, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' -import { useStreamingText } from '@/hooks/use-streaming-text' const LANG_ALIASES: Record = { js: 'javascript', @@ -236,7 +235,6 @@ interface ChatContentProps { isStreaming?: boolean onOptionSelect?: (id: string) => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void - smoothStreaming?: boolean } export function ChatContent({ @@ -244,7 +242,6 @@ export function ChatContent({ isStreaming = false, onOptionSelect, onWorkspaceResourceSelect, - smoothStreaming = true, }: ChatContentProps) { const hydratedStreamingRef = useRef(isStreaming && content.trim().length > 0) const previousIsStreamingRef = useRef(isStreaming) @@ -270,9 +267,7 @@ export function ChatContent({ return () => window.removeEventListener('wsres-click', handler) }, []) - const rendered = useStreamingText(content, isStreaming && smoothStreaming) - - const parsed = useMemo(() => parseSpecialTags(rendered, isStreaming), [rendered, isStreaming]) + const parsed = useMemo(() => parseSpecialTags(content, isStreaming), [content, isStreaming]) const hasSpecialContent = parsed.hasPendingTag || parsed.segments.some((s) => s.type !== 'text') if (hasSpecialContent) { @@ -349,7 +344,7 @@ export function ChatContent({ animated={isStreaming && !hydratedStreamingRef.current} components={MARKDOWN_COMPONENTS} > - {rendered} + {content} ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 60624d43130..81bde4423b5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -374,7 +374,6 @@ export function MessageContent({ const hasSubagentEnded = blocks.some((b) => b.type === 'subagent_end') const showTrailingThinking = isStreaming && !hasTrailingContent && (hasSubagentEnded || allLastGroupToolsDone) - const hasStructuredSegments = segments.some((segment) => segment.type !== 'text') const lastOpenSubagentGroupId = [...segments] .reverse() .find( @@ -394,7 +393,6 @@ export function MessageContent({ isStreaming={isStreaming} onOptionSelect={onOptionSelect} onWorkspaceResourceSelect={onWorkspaceResourceSelect} - smoothStreaming={!hasStructuredSegments} /> ) case 'agent_group': { diff --git a/apps/sim/hooks/use-streaming-text.ts b/apps/sim/hooks/use-streaming-text.ts deleted file mode 100644 index 369977a0f64..00000000000 --- a/apps/sim/hooks/use-streaming-text.ts +++ /dev/null @@ -1,100 +0,0 @@ -'use client' - -import { useEffect, useRef, useState } from 'react' - -const TICK_MS = 16 -const MIN_CHARS_PER_TICK = 3 -const CHASE_FACTOR = 0.3 -const RESUME_IDLE_MS = 140 -const RESUME_RAMP_MS = 180 - -function easeOutCubic(t: number): number { - const clamped = Math.max(0, Math.min(1, t)) - return 1 - (1 - clamped) ** 3 -} - -/** - * Progressively reveals streaming text character-by-character at a steady - * rate regardless of how the data arrives. - * - * Small deltas (individual tokens) reveal at the base rate of 3 chars per - * 16 ms. Large gaps (burst arrivals) catch up exponentially via - * CHASE_FACTOR so the reveal never falls far behind. - * - * When `isStreaming` is false the target is returned directly. - */ -export function useStreamingText(target: string, isStreaming: boolean): string { - const [displayed, setDisplayed] = useState(target) - const revealedRef = useRef(target) - const targetRef = useRef(target) - const lastTargetLengthRef = useRef(target.length) - const lastTargetChangeAtRef = useRef(Date.now()) - const resumeStartedAtRef = useRef(null) - - targetRef.current = target - - useEffect(() => { - const now = Date.now() - const previousLength = lastTargetLengthRef.current - const nextLength = target.length - - if (nextLength > previousLength) { - const idleFor = now - lastTargetChangeAtRef.current - if (isStreaming && idleFor >= RESUME_IDLE_MS) { - resumeStartedAtRef.current = now - } - lastTargetChangeAtRef.current = now - } else if (nextLength < previousLength) { - lastTargetChangeAtRef.current = now - resumeStartedAtRef.current = null - } - - lastTargetLengthRef.current = nextLength - }, [target, isStreaming]) - - useEffect(() => { - if (isStreaming) return - if (revealedRef.current === target) return - revealedRef.current = target - lastTargetChangeAtRef.current = Date.now() - lastTargetLengthRef.current = target.length - resumeStartedAtRef.current = null - setDisplayed(target) - }, [target, isStreaming]) - - useEffect(() => { - if (!isStreaming) return - - if (targetRef.current.length < revealedRef.current.length) { - revealedRef.current = '' - } - - const timer = setInterval(() => { - const now = Date.now() - const current = revealedRef.current - const tgt = targetRef.current - if (current.length >= tgt.length) return - - const gap = tgt.length - current.length - const normalChars = Math.max(MIN_CHARS_PER_TICK, Math.ceil(gap * CHASE_FACTOR)) - - let chars = normalChars - const resumeStartedAt = resumeStartedAtRef.current - if (resumeStartedAt !== null) { - const progress = easeOutCubic((now - resumeStartedAt) / RESUME_RAMP_MS) - chars = Math.max(MIN_CHARS_PER_TICK, Math.ceil(normalChars * progress)) - if (progress >= 1) { - resumeStartedAtRef.current = null - } - } - - chars = Math.min(gap, chars) - revealedRef.current = tgt.slice(0, current.length + chars) - setDisplayed(revealedRef.current) - }, TICK_MS) - - return () => clearInterval(timer) - }, [isStreaming]) - - return displayed -} From 170904af2a0d605777eff7a6be769eebbf4534c9 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Tue, 14 Apr 2026 14:44:13 -0700 Subject: [PATCH 2/5] improvement(ui): inline passthrough wrapper, add hydration guard - Inline EnhancedMarkdownRenderer which became a trivial passthrough after removing useThrottledValue - Add hydration guard to MarkdownRenderer to prevent replaying the entrance animation when mounting mid-stream with existing content --- .../message/components/markdown-renderer.tsx | 12 ++++++++++-- apps/sim/app/chat/components/message/message.tsx | 12 +----------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx index bda70acabb5..60b38771cc4 100644 --- a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx +++ b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx @@ -1,4 +1,4 @@ -import React, { type HTMLAttributes, memo, type ReactNode, useMemo } from 'react' +import React, { type HTMLAttributes, memo, type ReactNode, useEffect, useMemo, useRef } from 'react' import { Streamdown } from 'streamdown' import 'streamdown/styles.css' import { CopyCodeButton, Tooltip } from '@/components/emcn' @@ -182,6 +182,14 @@ const MarkdownRenderer = memo(function MarkdownRenderer({ customLinkComponent?: typeof LinkWithPreview isStreaming?: boolean }) { + const hydratedStreamingRef = useRef(isStreaming && content.trim().length > 0) + + useEffect(() => { + if (!isStreaming) { + hydratedStreamingRef.current = false + } + }, [isStreaming]) + const components = useMemo(() => { if (!customLinkComponent) { return DEFAULT_COMPONENTS @@ -196,7 +204,7 @@ const MarkdownRenderer = memo(function MarkdownRenderer({ {processedContent} diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/chat/components/message/message.tsx index c741d56cd49..b68b9fef387 100644 --- a/apps/sim/app/chat/components/message/message.tsx +++ b/apps/sim/app/chat/components/message/message.tsx @@ -38,16 +38,6 @@ export interface ChatMessage { files?: ChatFile[] } -function EnhancedMarkdownRenderer({ - content, - isStreaming, -}: { - content: string - isStreaming?: boolean -}) { - return -} - export const ClientChatMessage = memo( function ClientChatMessage({ message }: { message: ChatMessage }) { const [isCopied, setIsCopied] = useState(false) @@ -192,7 +182,7 @@ export const ClientChatMessage = memo( {JSON.stringify(cleanTextContent, null, 2)} ) : ( - From cfefe7869f70331f91facf44cb7ba11804e77ae6 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Tue, 14 Apr 2026 15:09:29 -0700 Subject: [PATCH 3/5] improvement: removed chat animation --- .../message/components/markdown-renderer.tsx | 317 ++++++++---------- .../app/chat/components/message/message.tsx | 5 +- .../components/chat-content/chat-content.tsx | 19 +- 3 files changed, 141 insertions(+), 200 deletions(-) diff --git a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx index 60b38771cc4..37059ef523a 100644 --- a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx +++ b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx @@ -1,10 +1,10 @@ -import React, { type HTMLAttributes, memo, type ReactNode, useEffect, useMemo, useRef } from 'react' +import React, { type HTMLAttributes, memo, type ReactNode } from 'react' import { Streamdown } from 'streamdown' import 'streamdown/styles.css' import { CopyCodeButton, Tooltip } from '@/components/emcn' import { extractTextContent } from '@/lib/core/utils/react-node-text' -export function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) { +function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) { return ( @@ -24,190 +24,151 @@ export function LinkWithPreview({ href, children }: { href: string; children: Re ) } -function createCustomComponents(LinkComponent: typeof LinkWithPreview) { - return { - p: ({ children }: React.HTMLAttributes) => ( -

- {children} -

- ), - - h1: ({ children }: React.HTMLAttributes) => ( -

- {children} -

- ), - h2: ({ children }: React.HTMLAttributes) => ( -

- {children} -

- ), - h3: ({ children }: React.HTMLAttributes) => ( -

- {children} -

- ), - h4: ({ children }: React.HTMLAttributes) => ( -

- {children} -

- ), - - ul: ({ children }: React.HTMLAttributes) => ( -
    - {children} -
- ), - ol: ({ children }: React.HTMLAttributes) => ( -
    - {children} -
- ), - li: ({ children }: React.LiHTMLAttributes) => ( -
  • - {children} -
  • - ), - - pre: ({ children }: HTMLAttributes) => { - let codeProps: HTMLAttributes = {} - let codeContent: ReactNode = children - - if ( - React.isValidElement<{ className?: string; children?: ReactNode }>(children) && - children.type === 'code' - ) { - const childElement = children as React.ReactElement<{ - className?: string - children?: ReactNode - }> - codeProps = { className: childElement.props.className } - codeContent = childElement.props.children - } +const COMPONENTS = { + p: ({ children }: React.HTMLAttributes) => ( +

    + {children} +

    + ), + + h1: ({ children }: React.HTMLAttributes) => ( +

    + {children} +

    + ), + h2: ({ children }: React.HTMLAttributes) => ( +

    + {children} +

    + ), + h3: ({ children }: React.HTMLAttributes) => ( +

    + {children} +

    + ), + h4: ({ children }: React.HTMLAttributes) => ( +

    + {children} +

    + ), + + ul: ({ children }: React.HTMLAttributes) => ( +
      + {children} +
    + ), + ol: ({ children }: React.HTMLAttributes) => ( +
      + {children} +
    + ), + li: ({ children }: React.LiHTMLAttributes) => ( +
  • + {children} +
  • + ), + + pre: ({ children }: HTMLAttributes) => { + let codeProps: HTMLAttributes = {} + let codeContent: ReactNode = children + + if ( + React.isValidElement<{ className?: string; children?: ReactNode }>(children) && + children.type === 'code' + ) { + const childElement = children as React.ReactElement<{ + className?: string + children?: ReactNode + }> + codeProps = { className: childElement.props.className } + codeContent = childElement.props.children + } - return ( -
    -
    - - {codeProps.className?.replace('language-', '') || 'code'} - - -
    -
    -            {codeContent}
    -          
    + return ( +
    +
    + + {codeProps.className?.replace('language-', '') || 'code'} + +
    - ) - }, - - inlineCode: ({ children }: { children?: React.ReactNode }) => ( - - {children} - - ), - - blockquote: ({ children }: React.HTMLAttributes) => ( -
    - {children} -
    - ), - - hr: () =>
    , - - a: ({ href, children, ...props }: React.AnchorHTMLAttributes) => ( - - {children} - - ), - - table: ({ children }: React.TableHTMLAttributes) => ( -
    - - {children} -
    +
    +          {codeContent}
    +        
    - ), - thead: ({ children }: React.HTMLAttributes) => ( - {children} - ), - tbody: ({ children }: React.HTMLAttributes) => ( - + ) + }, + + inlineCode: ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), + + blockquote: ({ children }: React.HTMLAttributes) => ( +
    + {children} +
    + ), + + hr: () =>
    , + + a: ({ href, children, ...props }: React.AnchorHTMLAttributes) => ( + + {children} + + ), + + table: ({ children }: React.TableHTMLAttributes) => ( +
    + {children} - - ), - tr: ({ children }: React.HTMLAttributes) => ( - - {children} - - ), - th: ({ children }: React.ThHTMLAttributes) => ( - - ), - td: ({ children }: React.TdHTMLAttributes) => ( - - ), - - img: ({ src, alt, ...props }: React.ImgHTMLAttributes) => ( - {alt - ), - } +
    - {children} - - {children} -
    +
    + ), + thead: ({ children }: React.HTMLAttributes) => ( + {children} + ), + tbody: ({ children }: React.HTMLAttributes) => ( + + {children} + + ), + tr: ({ children }: React.HTMLAttributes) => ( + + {children} + + ), + th: ({ children }: React.ThHTMLAttributes) => ( + + {children} + + ), + td: ({ children }: React.TdHTMLAttributes) => ( + + {children} + + ), + + img: ({ src, alt, ...props }: React.ImgHTMLAttributes) => ( + {alt + ), } -const DEFAULT_COMPONENTS = createCustomComponents(LinkWithPreview) - -const MarkdownRenderer = memo(function MarkdownRenderer({ - content, - customLinkComponent, - isStreaming = false, -}: { - content: string - customLinkComponent?: typeof LinkWithPreview - isStreaming?: boolean -}) { - const hydratedStreamingRef = useRef(isStreaming && content.trim().length > 0) - - useEffect(() => { - if (!isStreaming) { - hydratedStreamingRef.current = false - } - }, [isStreaming]) - - const components = useMemo(() => { - if (!customLinkComponent) { - return DEFAULT_COMPONENTS - } - return createCustomComponents(customLinkComponent) - }, [customLinkComponent]) - - const processedContent = content.trim() - +const MarkdownRenderer = memo(function MarkdownRenderer({ content }: { content: string }) { return (
    - - {processedContent} + + {content.trim()}
    ) diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/chat/components/message/message.tsx index b68b9fef387..f803e82c771 100644 --- a/apps/sim/app/chat/components/message/message.tsx +++ b/apps/sim/app/chat/components/message/message.tsx @@ -182,10 +182,7 @@ export const ClientChatMessage = memo( {JSON.stringify(cleanTextContent, null, 2)} ) : ( - + )}
    diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 2d5a9fa3a9d..9eda15992db 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -243,18 +243,6 @@ export function ChatContent({ onOptionSelect, onWorkspaceResourceSelect, }: ChatContentProps) { - const hydratedStreamingRef = useRef(isStreaming && content.trim().length > 0) - const previousIsStreamingRef = useRef(isStreaming) - - useEffect(() => { - if (!previousIsStreamingRef.current && isStreaming && content.trim().length > 0) { - hydratedStreamingRef.current = true - } else if (!isStreaming) { - hydratedStreamingRef.current = false - } - previousIsStreamingRef.current = isStreaming - }, [content, isStreaming]) - const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect) onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect @@ -338,12 +326,7 @@ export function ChatContent({ return (
    :first-child]:mt-0 [&>:last-child]:mb-0')}> - + {content}
    From 8b333cff4e67a857f496322324386165412a53cd Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Tue, 14 Apr 2026 15:12:35 -0700 Subject: [PATCH 4/5] improvement(ui): remove hardcoded fade-in animations from special tags Remove animate-stream-fade-in from OptionsDisplay, CredentialDisplay, MothershipErrorDisplay, and UsageUpgradeDisplay. These components re-render after streaming ends, causing a visible flash as the opacity animation replays. PendingTagIndicator retains its animation since it only renders during active streaming. --- .../components/special-tags/special-tags.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index d5261300461..0a58d8c2b34 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -415,7 +415,7 @@ function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) { if (entries.length === 0) return null return ( -
    +
    {disabled ? (