improvement(ui): delegate streaming animation to Streamdown component (#4163)

* 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.

* 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

* improvement: removed chat animation

* 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.

* fix(ui): use streaming mode for Streamdown during active streams

mode='static' disables Remend (auto-closing incomplete markdown),
incremental block splitting, and React Transitions. Switch to
streaming mode while isStreaming is true so partial markdown renders
correctly, without re-adding animation props.
This commit is contained in:
Emir Karabeg
2026-04-14 15:31:39 -07:00
committed by GitHub
parent 3d6660ba4d
commit 1bf2d95813
6 changed files with 147 additions and 298 deletions
@@ -1,10 +1,10 @@
import React, { type HTMLAttributes, memo, type ReactNode, useMemo } 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 (
<Tooltip.Root delayDuration={300}>
<Tooltip.Trigger asChild>
@@ -24,175 +24,151 @@ export function LinkWithPreview({ href, children }: { href: string; children: Re
)
}
function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
return {
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
<p className='mb-1 font-sans text-base text-gray-800 leading-relaxed last:mb-0 dark:text-gray-200'>
{children}
</p>
),
const COMPONENTS = {
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
<p className='mb-1 font-sans text-base text-gray-800 leading-relaxed last:mb-0 dark:text-gray-200'>
{children}
</p>
),
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1 className='mt-10 mb-5 font-sans font-semibold text-2xl text-gray-900 dark:text-gray-100'>
{children}
</h1>
),
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 className='mt-8 mb-4 font-sans font-semibold text-gray-900 text-xl dark:text-gray-100'>
{children}
</h2>
),
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3 className='mt-7 mb-3 font-sans font-semibold text-gray-900 text-lg dark:text-gray-100'>
{children}
</h3>
),
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h4 className='mt-5 mb-2 font-sans font-semibold text-base text-gray-900 dark:text-gray-100'>
{children}
</h4>
),
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1 className='mt-10 mb-5 font-sans font-semibold text-2xl text-gray-900 dark:text-gray-100'>
{children}
</h1>
),
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 className='mt-8 mb-4 font-sans font-semibold text-gray-900 text-xl dark:text-gray-100'>
{children}
</h2>
),
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3 className='mt-7 mb-3 font-sans font-semibold text-gray-900 text-lg dark:text-gray-100'>
{children}
</h3>
),
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h4 className='mt-5 mb-2 font-sans font-semibold text-base text-gray-900 dark:text-gray-100'>
{children}
</h4>
),
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
<ul
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-gray-800 dark:text-gray-200'
style={{ listStyleType: 'disc' }}
>
{children}
</ul>
),
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
<ol
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-gray-800 dark:text-gray-200'
style={{ listStyleType: 'decimal' }}
>
{children}
</ol>
),
li: ({ children }: React.LiHTMLAttributes<HTMLLIElement>) => (
<li className='font-sans text-gray-800 dark:text-gray-200' style={{ display: 'list-item' }}>
{children}
</li>
),
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
<ul
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-gray-800 dark:text-gray-200'
style={{ listStyleType: 'disc' }}
>
{children}
</ul>
),
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
<ol
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-gray-800 dark:text-gray-200'
style={{ listStyleType: 'decimal' }}
>
{children}
</ol>
),
li: ({ children }: React.LiHTMLAttributes<HTMLLIElement>) => (
<li className='font-sans text-gray-800 dark:text-gray-200' style={{ display: 'list-item' }}>
{children}
</li>
),
pre: ({ children }: HTMLAttributes<HTMLPreElement>) => {
let codeProps: HTMLAttributes<HTMLElement> = {}
let codeContent: ReactNode = children
pre: ({ children }: HTMLAttributes<HTMLPreElement>) => {
let codeProps: HTMLAttributes<HTMLElement> = {}
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
}
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 (
<div className='my-6 rounded-md bg-gray-900 text-sm dark:bg-black'>
<div className='flex items-center justify-between border-gray-700 border-b px-4 py-1.5 dark:border-gray-800'>
<span className='font-sans text-gray-400 text-xs'>
{codeProps.className?.replace('language-', '') || 'code'}
</span>
<CopyCodeButton
code={extractTextContent(codeContent)}
className='text-gray-400 hover-hover:bg-gray-700 hover-hover:text-gray-200'
/>
</div>
<pre className='overflow-x-auto p-4 font-mono text-gray-200 dark:text-gray-100'>
{codeContent}
</pre>
return (
<div className='my-6 rounded-md bg-gray-900 text-sm dark:bg-black'>
<div className='flex items-center justify-between border-gray-700 border-b px-4 py-1.5 dark:border-gray-800'>
<span className='font-sans text-gray-400 text-xs'>
{codeProps.className?.replace('language-', '') || 'code'}
</span>
<CopyCodeButton
code={extractTextContent(codeContent)}
className='text-gray-400 hover-hover:bg-gray-700 hover-hover:text-gray-200'
/>
</div>
)
},
inlineCode: ({ children }: { children?: React.ReactNode }) => (
<code className='rounded bg-gray-200 px-1 py-0.5 font-mono text-gray-800 text-inherit dark:bg-gray-700 dark:text-gray-200'>
{children}
</code>
),
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
<blockquote className='my-4 border-gray-300 border-l-4 py-1 pl-4 font-sans text-gray-700 italic dark:border-gray-600 dark:text-gray-300'>
{children}
</blockquote>
),
hr: () => <hr className='my-8 border-gray-500/[.07] border-t dark:border-gray-400/[.07]' />,
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<LinkComponent href={href || '#'} {...props}>
{children}
</LinkComponent>
),
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
<div className='my-4 w-full overflow-x-auto'>
<table className='min-w-full table-auto border border-gray-300 font-sans text-sm dark:border-gray-700'>
{children}
</table>
<pre className='overflow-x-auto p-4 font-mono text-gray-200 dark:text-gray-100'>
{codeContent}
</pre>
</div>
),
thead: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<thead className='bg-gray-100 text-left dark:bg-gray-800'>{children}</thead>
),
tbody: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<tbody className='divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900'>
{children}
</tbody>
),
tr: ({ children }: React.HTMLAttributes<HTMLTableRowElement>) => (
<tr className='border-gray-200 border-b transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800/60'>
{children}
</tr>
),
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
<th className='border-gray-300 border-r px-4 py-2 font-medium text-gray-700 last:border-r-0 dark:border-gray-700 dark:text-gray-300'>
{children}
</th>
),
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
<td className='break-words border-gray-300 border-r px-4 py-2 text-gray-800 last:border-r-0 dark:border-gray-700 dark:text-gray-200'>
{children}
</td>
),
)
},
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img
src={src}
alt={alt || 'Image'}
className='my-3 h-auto max-w-full rounded-md'
{...props}
/>
),
}
inlineCode: ({ children }: { children?: React.ReactNode }) => (
<code className='rounded bg-gray-200 px-1 py-0.5 font-mono text-gray-800 text-inherit dark:bg-gray-700 dark:text-gray-200'>
{children}
</code>
),
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
<blockquote className='my-4 border-gray-300 border-l-4 py-1 pl-4 font-sans text-gray-700 italic dark:border-gray-600 dark:text-gray-300'>
{children}
</blockquote>
),
hr: () => <hr className='my-8 border-gray-500/[.07] border-t dark:border-gray-400/[.07]' />,
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<LinkWithPreview href={href || '#'} {...props}>
{children}
</LinkWithPreview>
),
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
<div className='my-4 w-full overflow-x-auto'>
<table className='min-w-full table-auto border border-gray-300 font-sans text-sm dark:border-gray-700'>
{children}
</table>
</div>
),
thead: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<thead className='bg-gray-100 text-left dark:bg-gray-800'>{children}</thead>
),
tbody: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<tbody className='divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900'>
{children}
</tbody>
),
tr: ({ children }: React.HTMLAttributes<HTMLTableRowElement>) => (
<tr className='border-gray-200 border-b transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800/60'>
{children}
</tr>
),
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
<th className='border-gray-300 border-r px-4 py-2 font-medium text-gray-700 last:border-r-0 dark:border-gray-700 dark:text-gray-300'>
{children}
</th>
),
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
<td className='break-words border-gray-300 border-r px-4 py-2 text-gray-800 last:border-r-0 dark:border-gray-700 dark:text-gray-200'>
{children}
</td>
),
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img src={src} alt={alt || 'Image'} className='my-3 h-auto max-w-full rounded-md' {...props} />
),
}
const DEFAULT_COMPONENTS = createCustomComponents(LinkWithPreview)
const MarkdownRenderer = memo(function MarkdownRenderer({
content,
customLinkComponent,
}: {
content: string
customLinkComponent?: typeof LinkWithPreview
}) {
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 (
<div className='space-y-4 break-words font-sans text-[var(--landing-text)] text-base leading-relaxed'>
<Streamdown mode='static' components={components}>
{processedContent}
<Streamdown mode='static' components={COMPONENTS}>
{content.trim()}
</Streamdown>
</div>
)
@@ -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,11 +38,6 @@ export interface ChatMessage {
files?: ChatFile[]
}
function EnhancedMarkdownRenderer({ content }: { content: string }) {
const throttled = useThrottledValue(content)
return <MarkdownRenderer content={throttled} />
}
export const ClientChatMessage = memo(
function ClientChatMessage({ message }: { message: ChatMessage }) {
const [isCopied, setIsCopied] = useState(false)
@@ -188,7 +182,7 @@ export const ClientChatMessage = memo(
{JSON.stringify(cleanTextContent, null, 2)}
</pre>
) : (
<EnhancedMarkdownRenderer content={cleanTextContent as string} />
<MarkdownRenderer content={cleanTextContent as string} />
)}
</div>
</div>
@@ -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<string, string> = {
js: 'javascript',
@@ -236,7 +235,6 @@ interface ChatContentProps {
isStreaming?: boolean
onOptionSelect?: (id: string) => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
smoothStreaming?: boolean
}
export function ChatContent({
@@ -244,20 +242,7 @@ export function ChatContent({
isStreaming = false,
onOptionSelect,
onWorkspaceResourceSelect,
smoothStreaming = true,
}: 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
@@ -270,9 +255,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) {
@@ -322,7 +305,10 @@ export function ChatContent({
key={`inline-${i}`}
className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}
>
<Streamdown mode='static' components={MARKDOWN_COMPONENTS}>
<Streamdown
mode={isStreaming ? undefined : 'static'}
components={MARKDOWN_COMPONENTS}
>
{group.markdown}
</Streamdown>
</div>
@@ -343,13 +329,8 @@ export function ChatContent({
return (
<div className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}>
<Streamdown
mode={isStreaming ? undefined : 'static'}
isAnimating={isStreaming}
animated={isStreaming && !hydratedStreamingRef.current}
components={MARKDOWN_COMPONENTS}
>
{rendered}
<Streamdown mode={isStreaming ? undefined : 'static'} components={MARKDOWN_COMPONENTS}>
{content}
</Streamdown>
</div>
)
@@ -415,7 +415,7 @@ function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) {
if (entries.length === 0) return null
return (
<div className='animate-stream-fade-in'>
<div>
{disabled ? (
<button
type='button'
@@ -608,7 +608,7 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) {
href={data.value}
target='_blank'
rel='noopener noreferrer'
className='flex animate-stream-fade-in items-center gap-2 rounded-lg border border-[var(--divider)] px-3 py-2.5 transition-colors hover-hover:bg-[var(--surface-5)]'
className='flex items-center gap-2 rounded-lg border border-[var(--divider)] px-3 py-2.5 transition-colors hover-hover:bg-[var(--surface-5)]'
>
{createElement(Icon, { className: 'h-[16px] w-[16px] shrink-0' })}
<span className='flex-1 font-base text-[var(--text-body)] text-sm'>
@@ -623,7 +623,7 @@ function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) {
const detail = data.code ? `${data.message} (${data.code})` : data.message
return (
<p className='animate-stream-fade-in font-base text-[13px] text-[var(--text-secondary)] italic leading-[20px]'>
<p className='font-base text-[13px] text-[var(--text-secondary)] italic leading-[20px]'>
{detail}
</p>
)
@@ -635,7 +635,7 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit'
return (
<div className='animate-stream-fade-in rounded-xl border border-amber-300/40 bg-amber-50/50 px-4 py-3 dark:border-amber-500/20 dark:bg-amber-950/20'>
<div className='rounded-xl border border-amber-300/40 bg-amber-50/50 px-4 py-3 dark:border-amber-500/20 dark:bg-amber-950/20'>
<div className='flex items-center gap-2'>
<svg
className='h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400'
@@ -384,7 +384,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(
@@ -404,7 +403,6 @@ export function MessageContent({
isStreaming={isStreaming}
onOptionSelect={onOptionSelect}
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
smoothStreaming={!hasStructuredSegments}
/>
)
case 'agent_group': {
-100
View File
@@ -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<number | null>(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
}