diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index b133ef7d1..025fa1976 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -303,7 +303,7 @@ export const BaseChat = React.forwardRef( data-chat-visible={showChat} > {() => } -
+
{!chatStarted && (
@@ -317,39 +317,40 @@ export const BaseChat = React.forwardRef( )}
{() => { return chatStarted ? ( - +
+ +
) : null; }}
-
- {actionAlert && ( - clearAlert?.()} - postMessage={(message) => { - sendMessage?.({} as any, message); - clearAlert?.(); - }} - /> - )} -
+ {actionAlert && ( + clearAlert?.()} + postMessage={(message) => { + sendMessage?.({} as any, message); + clearAlert?.(); + }} + /> + )} {progressAnnotations && }
(
-
- {!chatStarted && ( + {!chatStarted && ( +
{ImportButtons(importChat)}
- )} - {!chatStarted && - ExamplePrompts((event, messageInput) => { + + {ExamplePrompts((event, messageInput) => { if (isStreaming) { handleStop?.(); return; @@ -601,8 +601,9 @@ export const BaseChat = React.forwardRef( handleSendMessage?.(event, messageInput); })} - {!chatStarted && } -
+ +
+ )}
{() => }
diff --git a/app/components/chat/Messages.client.tsx b/app/components/chat/Messages.client.tsx index 1e6b64bf1..4d7e2a65f 100644 --- a/app/components/chat/Messages.client.tsx +++ b/app/components/chat/Messages.client.tsx @@ -1,5 +1,5 @@ import type { Message } from 'ai'; -import React, { Fragment, useEffect, useRef, useState } from 'react'; +import { Fragment } from 'react'; import { classNames } from '~/utils/classNames'; import { AssistantMessage } from './AssistantMessage'; import { UserMessage } from './UserMessage'; @@ -10,6 +10,8 @@ import { toast } from 'react-toastify'; import WithTooltip from '~/components/ui/Tooltip'; import { useStore } from '@nanostores/react'; import { profileStore } from '~/lib/stores/profile'; +import { forwardRef } from 'react'; +import type { ForwardedRef } from 'react'; interface MessagesProps { id?: string; @@ -18,213 +20,113 @@ interface MessagesProps { messages?: Message[]; } -export const Messages = React.forwardRef((props: MessagesProps, ref) => { - const { id, isStreaming = false, messages = [] } = props; - const location = useLocation(); - const messagesEndRef = useRef(null); - const containerRef = useRef(null); - const [isUserInteracting, setIsUserInteracting] = useState(false); - const [lastScrollTop, setLastScrollTop] = useState(0); - const [shouldAutoScroll, setShouldAutoScroll] = useState(true); - const profile = useStore(profileStore); +export const Messages = forwardRef( + (props: MessagesProps, ref: ForwardedRef | undefined) => { + const { id, isStreaming = false, messages = [] } = props; + const location = useLocation(); + const profile = useStore(profileStore); - // Check if we should auto-scroll based on scroll position - const checkShouldAutoScroll = () => { - if (!containerRef.current) { - return true; - } - - const { scrollTop, scrollHeight, clientHeight } = containerRef.current; - const distanceFromBottom = scrollHeight - (scrollTop + clientHeight); - - return distanceFromBottom < 100; - }; - - const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { - if (!shouldAutoScroll || isUserInteracting) { - return; - } - - messagesEndRef.current?.scrollIntoView({ behavior }); - }; - - // Handle user interaction and scroll position - useEffect(() => { - const container = containerRef.current; - - if (!container) { - return undefined; - } - - const handleInteractionStart = () => { - setIsUserInteracting(true); - }; - - const handleInteractionEnd = () => { - if (checkShouldAutoScroll()) { - setTimeout(() => setIsUserInteracting(false), 100); - } + const handleRewind = (messageId: string) => { + const searchParams = new URLSearchParams(location.search); + searchParams.set('rewindTo', messageId); + window.location.search = searchParams.toString(); }; - const handleScroll = () => { - const { scrollTop } = container; - const shouldScroll = checkShouldAutoScroll(); - - // Update auto-scroll state based on scroll position - setShouldAutoScroll(shouldScroll); + const handleFork = async (messageId: string) => { + try { + if (!db || !chatId.get()) { + toast.error('Chat persistence is not available'); + return; + } - // If scrolling up, disable auto-scroll - if (scrollTop < lastScrollTop) { - setIsUserInteracting(true); + const urlId = await forkChat(db, chatId.get()!, messageId); + window.location.href = `/chat/${urlId}`; + } catch (error) { + toast.error('Failed to fork chat: ' + (error as Error).message); } - - setLastScrollTop(scrollTop); }; - container.addEventListener('mousedown', handleInteractionStart); - container.addEventListener('mouseup', handleInteractionEnd); - container.addEventListener('touchstart', handleInteractionStart); - container.addEventListener('touchend', handleInteractionEnd); - container.addEventListener('scroll', handleScroll, { passive: true }); - - return () => { - container.removeEventListener('mousedown', handleInteractionStart); - container.removeEventListener('mouseup', handleInteractionEnd); - container.removeEventListener('touchstart', handleInteractionStart); - container.removeEventListener('touchend', handleInteractionEnd); - container.removeEventListener('scroll', handleScroll); - }; - }, [lastScrollTop]); - - // Scroll to bottom when new messages are added or during streaming - useEffect(() => { - if (messages.length > 0 && (isStreaming || shouldAutoScroll)) { - scrollToBottom('smooth'); - } - }, [messages, isStreaming, shouldAutoScroll]); - - // Initial scroll on component mount - useEffect(() => { - if (messages.length > 0) { - scrollToBottom('instant'); - setShouldAutoScroll(true); - } - }, []); - - const handleRewind = (messageId: string) => { - const searchParams = new URLSearchParams(location.search); - searchParams.set('rewindTo', messageId); - window.location.search = searchParams.toString(); - }; - - const handleFork = async (messageId: string) => { - try { - if (!db || !chatId.get()) { - toast.error('Chat persistence is not available'); - return; - } - - const urlId = await forkChat(db, chatId.get()!, messageId); - window.location.href = `/chat/${urlId}`; - } catch (error) { - toast.error('Failed to fork chat: ' + (error as Error).message); - } - }; - - return ( -
{ - // Combine refs - if (typeof ref === 'function') { - ref(el); - } - - (containerRef as any).current = el; - - return undefined; - }} - className={props.className} - > - {messages.length > 0 - ? messages.map((message, index) => { - const { role, content, id: messageId, annotations } = message; - const isUserMessage = role === 'user'; - const isFirst = index === 0; - const isLast = index === messages.length - 1; - const isHidden = annotations?.includes('hidden'); - - if (isHidden) { - return ; - } - - return ( -
- {isUserMessage && ( -
- {profile?.avatar ? ( - {profile?.username + return ( +
+ {messages.length > 0 + ? messages.map((message, index) => { + const { role, content, id: messageId, annotations } = message; + const isUserMessage = role === 'user'; + const isFirst = index === 0; + const isLast = index === messages.length - 1; + const isHidden = annotations?.includes('hidden'); + + if (isHidden) { + return ; + } + + return ( +
+ {isUserMessage && ( +
+ {profile?.avatar ? ( + {profile?.username + ) : ( +
+ )} +
+ )} +
+ {isUserMessage ? ( + ) : ( -
+ )}
- )} -
- {isUserMessage ? ( - - ) : ( - - )} -
- {!isUserMessage && ( -
- {messageId && ( - + {!isUserMessage && ( +
+ {messageId && ( + +
- )} -
- ); - }) - : null} -
{/* Add an empty div as scroll anchor */} - {isStreaming && ( -
- )} -
- ); -}); +
+ )} +
+ ); + }) + : null} + {isStreaming && ( +
+ )} +
+ ); + }, +); diff --git a/app/lib/hooks/useSnapScroll.ts b/app/lib/hooks/useSnapScroll.ts index 5c1565a65..742e8366b 100644 --- a/app/lib/hooks/useSnapScroll.ts +++ b/app/lib/hooks/useSnapScroll.ts @@ -1,52 +1,155 @@ import { useRef, useCallback } from 'react'; -export function useSnapScroll() { +interface ScrollOptions { + duration?: number; + easing?: 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'cubic-bezier'; + cubicBezier?: [number, number, number, number]; + bottomThreshold?: number; +} + +export function useSnapScroll(options: ScrollOptions = {}) { + const { + duration = 800, + easing = 'ease-in-out', + cubicBezier = [0.42, 0, 0.58, 1], + bottomThreshold = 50, // pixels from bottom to consider "scrolled to bottom" + } = options; + const autoScrollRef = useRef(true); const scrollNodeRef = useRef(); const onScrollRef = useRef<() => void>(); const observerRef = useRef(); + const animationFrameRef = useRef(); + const lastScrollTopRef = useRef(0); + + const smoothScroll = useCallback( + (element: HTMLDivElement, targetPosition: number, duration: number, easingFunction: string) => { + const startPosition = element.scrollTop; + const distance = targetPosition - startPosition; + const startTime = performance.now(); + + const bezierPoints = easingFunction === 'cubic-bezier' ? cubicBezier : [0.42, 0, 0.58, 1]; + + const cubicBezierFunction = (t: number): number => { + const [, y1, , y2] = bezierPoints; + + /* + * const cx = 3 * x1; + * const bx = 3 * (x2 - x1) - cx; + * const ax = 1 - cx - bx; + */ + + const cy = 3 * y1; + const by = 3 * (y2 - y1) - cy; + const ay = 1 - cy - by; - const messageRef = useCallback((node: HTMLDivElement | null) => { - if (node) { - const observer = new ResizeObserver(() => { - if (autoScrollRef.current && scrollNodeRef.current) { - const { scrollHeight, clientHeight } = scrollNodeRef.current; - const scrollTarget = scrollHeight - clientHeight; + // const sampleCurveX = (t: number) => ((ax * t + bx) * t + cx) * t; + const sampleCurveY = (t: number) => ((ay * t + by) * t + cy) * t; - scrollNodeRef.current.scrollTo({ - top: scrollTarget, - }); + return sampleCurveY(t); + }; + + const animation = (currentTime: number) => { + const elapsedTime = currentTime - startTime; + const progress = Math.min(elapsedTime / duration, 1); + + const easedProgress = cubicBezierFunction(progress); + const newPosition = startPosition + distance * easedProgress; + + // Only scroll if auto-scroll is still enabled + if (autoScrollRef.current) { + element.scrollTop = newPosition; + } + + if (progress < 1 && autoScrollRef.current) { + animationFrameRef.current = requestAnimationFrame(animation); } - }); - - observer.observe(node); - } else { - observerRef.current?.disconnect(); - observerRef.current = undefined; - } - }, []); - - const scrollRef = useCallback((node: HTMLDivElement | null) => { - if (node) { - onScrollRef.current = () => { - const { scrollTop, scrollHeight, clientHeight } = node; - const scrollTarget = scrollHeight - clientHeight; - - autoScrollRef.current = Math.abs(scrollTop - scrollTarget) <= 10; }; - node.addEventListener('scroll', onScrollRef.current); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + + animationFrameRef.current = requestAnimationFrame(animation); + }, + [cubicBezier], + ); + + const isScrolledToBottom = useCallback( + (element: HTMLDivElement): boolean => { + const { scrollTop, scrollHeight, clientHeight } = element; + return scrollHeight - scrollTop - clientHeight <= bottomThreshold; + }, + [bottomThreshold], + ); + + const messageRef = useCallback( + (node: HTMLDivElement | null) => { + if (node) { + const observer = new ResizeObserver(() => { + if (autoScrollRef.current && scrollNodeRef.current) { + const { scrollHeight, clientHeight } = scrollNodeRef.current; + const scrollTarget = scrollHeight - clientHeight; + + smoothScroll(scrollNodeRef.current, scrollTarget, duration, easing); + } + }); - scrollNodeRef.current = node; - } else { - if (onScrollRef.current) { - scrollNodeRef.current?.removeEventListener('scroll', onScrollRef.current); + observer.observe(node); + observerRef.current = observer; + } else { + observerRef.current?.disconnect(); + observerRef.current = undefined; + + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = undefined; + } } + }, + [duration, easing, smoothScroll], + ); + + const scrollRef = useCallback( + (node: HTMLDivElement | null) => { + if (node) { + onScrollRef.current = () => { + const { scrollTop } = node; + + // Detect scroll direction + const isScrollingUp = scrollTop < lastScrollTopRef.current; - scrollNodeRef.current = undefined; - onScrollRef.current = undefined; - } - }, []); + // Update auto-scroll based on scroll direction and position + if (isScrollingUp) { + // Disable auto-scroll when scrolling up + autoScrollRef.current = false; + } else if (isScrolledToBottom(node)) { + // Re-enable auto-scroll when manually scrolled to bottom + autoScrollRef.current = true; + } + + // Store current scroll position for next comparison + lastScrollTopRef.current = scrollTop; + }; + + node.addEventListener('scroll', onScrollRef.current); + scrollNodeRef.current = node; + } else { + if (onScrollRef.current && scrollNodeRef.current) { + scrollNodeRef.current.removeEventListener('scroll', onScrollRef.current); + } + + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = undefined; + } + + scrollNodeRef.current = undefined; + onScrollRef.current = undefined; + } + }, + [isScrolledToBottom], + ); - return [messageRef, scrollRef]; + return [messageRef, scrollRef] as const; }