mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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>
|
||||
|
||||
+7
-26
@@ -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>
|
||||
)
|
||||
|
||||
+4
-4
@@ -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'
|
||||
|
||||
-2
@@ -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': {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user