This commit is contained in:
Siddharth Ganesan
2026-01-13 11:23:37 -08:00
parent 3b925c807f
commit 936d2bd729
4 changed files with 215 additions and 144 deletions

View File

@@ -2,18 +2,38 @@ import { memo, useEffect, useRef, useState } from 'react'
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
/**
* Character animation delay in milliseconds
* Minimum delay between characters (fast catch-up mode)
*/
const CHARACTER_DELAY = 3
const MIN_DELAY = 1
/**
* Maximum delay between characters (when waiting for content)
*/
const MAX_DELAY = 12
/**
* Default delay when streaming normally
*/
const DEFAULT_DELAY = 4
/**
* How far behind (in characters) before we speed up
*/
const CATCH_UP_THRESHOLD = 20
/**
* How close to content before we slow down
*/
const SLOW_DOWN_THRESHOLD = 5
/**
* StreamingIndicator shows animated dots during message streaming
* Uses CSS classes for animations to follow best practices
* Used as a standalone indicator when no content has arrived yet
*
* @returns Animated loading indicator
*/
export const StreamingIndicator = memo(() => (
<div className='flex items-center py-1 text-muted-foreground transition-opacity duration-200 ease-in-out'>
<div className='flex h-[1.25rem] items-center text-muted-foreground'>
<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]' />
@@ -24,6 +44,22 @@ export const StreamingIndicator = memo(() => (
StreamingIndicator.displayName = 'StreamingIndicator'
/**
* InlineStreamingDots shows small animated dots inline with text
* Used at the end of streaming content to indicate more is coming
*/
const InlineStreamingDots = memo(() => (
<span className='ml-1 inline-flex items-center align-middle'>
<span className='inline-flex space-x-0.5'>
<span className='inline-block h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms] [animation-duration:1.2s]' />
<span className='inline-block h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms] [animation-duration:1.2s]' />
<span className='inline-block h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:300ms] [animation-duration:1.2s]' />
</span>
</span>
))
InlineStreamingDots.displayName = 'InlineStreamingDots'
/**
* Props for the SmoothStreamingText component
*/
@@ -32,96 +68,132 @@ interface SmoothStreamingTextProps {
content: string
/** Whether the content is actively streaming */
isStreaming: boolean
/** Whether to show inline streaming dots at the end of content. Defaults to true. */
showIndicator?: boolean
}
/**
* Calculates adaptive delay based on how far behind animation is from actual content
*
* @param displayedLength - Current displayed content length
* @param totalLength - Total available content length
* @returns Delay in milliseconds
*/
function calculateAdaptiveDelay(displayedLength: number, totalLength: number): number {
const charsRemaining = totalLength - displayedLength
if (charsRemaining > CATCH_UP_THRESHOLD) {
// Far behind - speed up to catch up
// Scale from MIN_DELAY to DEFAULT_DELAY based on how far behind
const catchUpFactor = Math.min(1, (charsRemaining - CATCH_UP_THRESHOLD) / 50)
return MIN_DELAY + (DEFAULT_DELAY - MIN_DELAY) * (1 - catchUpFactor)
}
if (charsRemaining <= SLOW_DOWN_THRESHOLD) {
// Close to content edge - slow down to feel natural
// The closer we are, the slower we go (up to MAX_DELAY)
const slowFactor = 1 - charsRemaining / SLOW_DOWN_THRESHOLD
return DEFAULT_DELAY + (MAX_DELAY - DEFAULT_DELAY) * slowFactor
}
// Normal streaming speed
return DEFAULT_DELAY
}
/**
* SmoothStreamingText component displays text with character-by-character animation
* Creates a smooth streaming effect for AI responses
* Creates a smooth streaming effect for AI responses with adaptive speed
*
* Uses adaptive pacing: speeds up when catching up, slows down near content edge
*
* @param props - Component props
* @returns Streaming text with smooth animation
*/
export const SmoothStreamingText = memo(
({ content, isStreaming }: SmoothStreamingTextProps) => {
({ content, isStreaming, showIndicator = true }: SmoothStreamingTextProps) => {
const [displayedContent, setDisplayedContent] = useState('')
const contentRef = useRef(content)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const rafRef = useRef<number | null>(null)
const indexRef = useRef(0)
const streamingStartTimeRef = useRef<number | null>(null)
const lastFrameTimeRef = useRef<number>(0)
const isAnimatingRef = useRef(false)
/**
* Handles content streaming animation
* Updates displayed content character by character during streaming
*/
useEffect(() => {
contentRef.current = content
if (content.length === 0) {
setDisplayedContent('')
indexRef.current = 0
streamingStartTimeRef.current = null
return
}
if (isStreaming) {
if (streamingStartTimeRef.current === null) {
streamingStartTimeRef.current = Date.now()
}
if (indexRef.current < content.length && !isAnimatingRef.current) {
isAnimatingRef.current = true
lastFrameTimeRef.current = performance.now()
if (indexRef.current < content.length) {
const animateText = () => {
const animateText = (timestamp: number) => {
const currentContent = contentRef.current
const currentIndex = indexRef.current
const elapsed = timestamp - lastFrameTimeRef.current
if (currentIndex < currentContent.length) {
const chunkSize = 1
const newDisplayed = currentContent.slice(0, currentIndex + chunkSize)
// Calculate adaptive delay based on how far behind we are
const delay = calculateAdaptiveDelay(currentIndex, currentContent.length)
setDisplayedContent(newDisplayed)
indexRef.current = currentIndex + chunkSize
if (elapsed >= delay) {
if (currentIndex < currentContent.length) {
const newDisplayed = currentContent.slice(0, currentIndex + 1)
setDisplayedContent(newDisplayed)
indexRef.current = currentIndex + 1
lastFrameTimeRef.current = timestamp
}
}
timeoutRef.current = setTimeout(animateText, CHARACTER_DELAY)
if (indexRef.current < currentContent.length) {
rafRef.current = requestAnimationFrame(animateText)
} else {
isAnimatingRef.current = false
}
}
if (!isAnimatingRef.current) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
isAnimatingRef.current = true
animateText()
}
rafRef.current = requestAnimationFrame(animateText)
} else if (indexRef.current < content.length && isAnimatingRef.current) {
// Animation already running, it will pick up new content automatically
}
} else {
// Streaming ended - show full content immediately
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
setDisplayedContent(content)
indexRef.current = content.length
isAnimatingRef.current = false
streamingStartTimeRef.current = null
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
isAnimatingRef.current = false
}
}, [content, isStreaming])
// Show inline dots when streaming and we have some content displayed (if enabled)
const showInlineDots = showIndicator && isStreaming && displayedContent.length > 0
return (
<div className='relative min-h-[1.25rem] max-w-full overflow-hidden'>
<CopilotMarkdownRenderer content={displayedContent} />
{showInlineDots && <InlineStreamingDots />}
</div>
)
},
(prevProps, nextProps) => {
// Prevent re-renders during streaming unless content actually changed
return (
prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming
// markdownComponents is now memoized so no need to compare
prevProps.content === nextProps.content &&
prevProps.isStreaming === nextProps.isStreaming &&
prevProps.showIndicator === nextProps.showIndicator
)
}
)

View File

@@ -187,6 +187,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
)
// Memoize content blocks to avoid re-rendering unchanged blocks
// No entrance animations to prevent layout shift
const memoizedContentBlocks = useMemo(() => {
if (!message.contentBlocks || message.contentBlocks.length === 0) {
return null
@@ -205,14 +206,10 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
// Use smooth streaming for the last text block if we're streaming
const shouldUseSmoothing = isStreaming && isLastTextBlock
const blockKey = `text-${index}-${block.timestamp || index}`
return (
<div
key={`text-${index}-${block.timestamp || index}`}
className={`w-full max-w-full overflow-hidden transition-opacity duration-200 ease-in-out ${
cleanBlockContent.length > 0 ? 'opacity-100' : 'opacity-70'
} ${shouldUseSmoothing ? 'translate-y-0 transition-transform duration-100 ease-out' : ''}`}
>
<div key={blockKey} className='w-full max-w-full overflow-hidden'>
{shouldUseSmoothing ? (
<SmoothStreamingText content={cleanBlockContent} isStreaming={isStreaming} />
) : (
@@ -224,8 +221,10 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
if (block.type === 'thinking') {
// Check if there are any blocks after this one (tool calls, text, etc.)
const hasFollowingContent = index < message.contentBlocks!.length - 1
const blockKey = `thinking-${index}-${block.timestamp || index}`
return (
<div key={`thinking-${index}-${block.timestamp || index}`} className='w-full'>
<div key={blockKey} className='w-full'>
<ThinkingBlock
content={block.content}
isStreaming={isStreaming}
@@ -235,11 +234,10 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
)
}
if (block.type === 'tool_call') {
const blockKey = `tool-${block.toolCall.id}`
return (
<div
key={`tool-${block.toolCall.id}`}
className='opacity-100 transition-opacity duration-300 ease-in-out'
>
<div key={blockKey}>
<ToolCall toolCallId={block.toolCall.id} toolCall={block.toolCall} />
</div>
)
@@ -465,18 +463,30 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
)
}
// Check if there's any visible content in the blocks
const hasVisibleContent = useMemo(() => {
if (!message.contentBlocks || message.contentBlocks.length === 0) return false
return message.contentBlocks.some((block) => {
if (block.type === 'text') {
const parsed = parseSpecialTags(block.content)
return parsed.cleanContent.trim().length > 0
}
return block.type === 'thinking' || block.type === 'tool_call'
})
}, [message.contentBlocks])
if (isAssistant) {
return (
<div
className={`w-full max-w-full overflow-hidden transition-opacity duration-200 [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
className={`w-full max-w-full overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
>
<div className='max-w-full space-y-1.5 px-[2px] transition-all duration-200 ease-in-out'>
<div className='max-w-full space-y-1.5 px-[2px]'>
{/* Content blocks in chronological order */}
{memoizedContentBlocks}
{/* Always show streaming indicator at the end while streaming */}
{isStreaming && <StreamingIndicator />}
{/* Only show streaming indicator when no content has arrived yet */}
{isStreaming && !hasVisibleContent && <StreamingIndicator />}
{message.errorType === 'usage_limit' && (
<div className='flex gap-1.5'>

View File

@@ -221,7 +221,7 @@ function PlanSteps({
</span>
<div className='min-w-0 flex-1 text-[12px] text-[var(--text-secondary)] leading-[18px] [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-[11px] [&_p]:m-0 [&_p]:text-[12px] [&_p]:leading-[18px]'>
{streaming && isLastStep ? (
<SmoothStreamingText content={title} isStreaming={true} />
<SmoothStreamingText content={title} isStreaming={true} showIndicator={false} />
) : (
<CopilotMarkdownRenderer content={title} />
)}
@@ -363,7 +363,7 @@ export function OptionsSelector({
)}
>
{streaming ? (
<SmoothStreamingText content={option.title} isStreaming={true} />
<SmoothStreamingText content={option.title} isStreaming={true} showIndicator={false} />
) : (
<CopilotMarkdownRenderer content={option.title} />
)}

View File

@@ -39,42 +39,49 @@ export function useScrollManagement(
const [userHasScrolledDuringStream, setUserHasScrolledDuringStream] = useState(false)
const programmaticScrollInProgressRef = useRef(false)
const lastScrollTopRef = useRef(0)
const lastScrollHeightRef = useRef(0)
const rafIdRef = useRef<number | null>(null)
const scrollBehavior: 'auto' | 'smooth' = options?.behavior ?? 'smooth'
const stickinessThreshold = options?.stickinessThreshold ?? 100
/**
* Scrolls the container to the bottom with smooth animation
*/
const getScrollContainer = useCallback((): HTMLElement | null => {
// Prefer the element with the ref (our scrollable div)
if (scrollAreaRef.current) return scrollAreaRef.current
return null
}, [])
const scrollToBottom = useCallback(() => {
const scrollContainer = getScrollContainer()
if (!scrollContainer) return
/**
* Scrolls the container to the bottom
* Uses 'auto' for streaming to prevent jitter, 'smooth' for user actions
*/
const scrollToBottom = useCallback(
(forceInstant = false) => {
const scrollContainer = getScrollContainer()
if (!scrollContainer) return
programmaticScrollInProgressRef.current = true
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: scrollBehavior,
})
// Best-effort reset; not all browsers fire scrollend reliably
window.setTimeout(() => {
programmaticScrollInProgressRef.current = false
}, 200)
}, [getScrollContainer, scrollBehavior])
programmaticScrollInProgressRef.current = true
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: forceInstant ? 'auto' : scrollBehavior,
})
// Reset flag after scroll completes
window.setTimeout(
() => {
programmaticScrollInProgressRef.current = false
},
forceInstant ? 16 : 200
)
},
[getScrollContainer, scrollBehavior]
)
/**
* Handles scroll events to track user position and show/hide scroll button
* Handles scroll events to track user position
*/
const handleScroll = useCallback(() => {
const scrollContainer = getScrollContainer()
if (!scrollContainer) return
if (programmaticScrollInProgressRef.current) {
// Ignore scrolls we initiated
return
}
@@ -86,21 +93,18 @@ export function useScrollManagement(
if (isSendingMessage) {
const delta = scrollTop - lastScrollTopRef.current
const movedUp = delta < -2 // small hysteresis to avoid noise
const movedDown = delta > 2
const movedUp = delta < -2
if (movedUp) {
// Any upward movement breaks away from sticky during streaming
setUserHasScrolledDuringStream(true)
}
// If the user has broken away and scrolls back down to the bottom, re-stick
if (userHasScrolledDuringStream && movedDown && nearBottom) {
// Re-stick if user scrolls back to bottom
if (userHasScrolledDuringStream && nearBottom && delta > 2) {
setUserHasScrolledDuringStream(false)
}
}
// Track last scrollTop for direction detection
lastScrollTopRef.current = scrollTop
}, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream, stickinessThreshold])
@@ -109,95 +113,80 @@ export function useScrollManagement(
const scrollContainer = getScrollContainer()
if (!scrollContainer) return
const handleUserScroll = () => {
handleScroll()
}
scrollContainer.addEventListener('scroll', handleUserScroll, { passive: true })
if ('onscrollend' in scrollContainer) {
scrollContainer.addEventListener('scrollend', handleScroll, { passive: true })
}
// Initialize state
window.setTimeout(handleScroll, 100)
// Initialize last scroll position
scrollContainer.addEventListener('scroll', handleScroll, { passive: true })
lastScrollTopRef.current = scrollContainer.scrollTop
lastScrollHeightRef.current = scrollContainer.scrollHeight
return () => {
scrollContainer.removeEventListener('scroll', handleUserScroll)
if ('onscrollend' in scrollContainer) {
scrollContainer.removeEventListener('scrollend', handleScroll)
}
scrollContainer.removeEventListener('scroll', handleScroll)
}
}, [getScrollContainer, handleScroll])
// Smart auto-scroll: only scroll if user hasn't intentionally scrolled up during streaming
// Scroll on new user message
useEffect(() => {
if (messages.length === 0) return
const lastMessage = messages[messages.length - 1]
const isNewUserMessage = lastMessage?.role === 'user'
const shouldAutoScroll =
isNewUserMessage ||
(isSendingMessage && !userHasScrolledDuringStream) ||
(!isSendingMessage && isNearBottom)
if (shouldAutoScroll) {
scrollToBottom()
}
}, [messages, isNearBottom, isSendingMessage, userHasScrolledDuringStream, scrollToBottom])
// Reset user scroll state when streaming starts or when user sends a message
useEffect(() => {
const lastMessage = messages[messages.length - 1]
if (lastMessage?.role === 'user') {
setUserHasScrolledDuringStream(false)
programmaticScrollInProgressRef.current = false
const scrollContainer = getScrollContainer()
if (scrollContainer) {
lastScrollTopRef.current = scrollContainer.scrollTop
}
scrollToBottom()
}
}, [messages, getScrollContainer])
}, [messages, scrollToBottom])
// Reset user scroll state when streaming completes
const prevIsSendingRef = useRef(false)
useEffect(() => {
if (prevIsSendingRef.current && !isSendingMessage) {
setUserHasScrolledDuringStream(false)
}
prevIsSendingRef.current = isSendingMessage
}, [isSendingMessage])
// While streaming and not broken away, keep pinned to bottom
useEffect(() => {
if (!isSendingMessage || userHasScrolledDuringStream) return
const intervalId = window.setInterval(() => {
const scrollContainer = getScrollContainer()
if (!scrollContainer) return
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const nearBottom = distanceFromBottom <= stickinessThreshold
if (nearBottom) {
// Final scroll to ensure we're at bottom
if (isNearBottom) {
scrollToBottom()
}
}, 100)
}
prevIsSendingRef.current = isSendingMessage
}, [isSendingMessage, isNearBottom, scrollToBottom])
return () => window.clearInterval(intervalId)
}, [
isSendingMessage,
userHasScrolledDuringStream,
getScrollContainer,
scrollToBottom,
stickinessThreshold,
])
// While streaming, use RAF to check for content changes and scroll
// This is more efficient than setInterval and syncs with browser rendering
useEffect(() => {
if (!isSendingMessage || userHasScrolledDuringStream) {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = null
}
return
}
const checkAndScroll = () => {
const scrollContainer = getScrollContainer()
if (!scrollContainer) {
rafIdRef.current = requestAnimationFrame(checkAndScroll)
return
}
const { scrollHeight } = scrollContainer
// Only scroll if content height actually changed
if (scrollHeight !== lastScrollHeightRef.current) {
lastScrollHeightRef.current = scrollHeight
// Use instant scroll during streaming to prevent jitter
scrollToBottom(true)
}
rafIdRef.current = requestAnimationFrame(checkAndScroll)
}
rafIdRef.current = requestAnimationFrame(checkAndScroll)
return () => {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = null
}
}
}, [isSendingMessage, userHasScrolledDuringStream, getScrollContainer, scrollToBottom])
return {
scrollAreaRef,
scrollToBottom,
scrollToBottom: () => scrollToBottom(false),
}
}