mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
Ui
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user