From cca2c1bd366b07e596d7d3c45b076313d7ee2b94 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed Date: Tue, 10 Jun 2025 13:33:08 +0100 Subject: [PATCH] Add scroll to bottom functionality --- .../FrameRenderer/RoadmapFloatingChat.tsx | 21 +++++- src/hooks/use-roadmap-ai-chat.tsx | 64 ++++++++++++++++--- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/components/FrameRenderer/RoadmapFloatingChat.tsx b/src/components/FrameRenderer/RoadmapFloatingChat.tsx index 32e692df8..908c0a75c 100644 --- a/src/components/FrameRenderer/RoadmapFloatingChat.tsx +++ b/src/components/FrameRenderer/RoadmapFloatingChat.tsx @@ -3,6 +3,7 @@ import type { JSONContent } from '@tiptap/core'; import { AppWindow, BookOpen, + ChevronDown, MessageCirclePlus, PauseCircleIcon, PersonStanding, @@ -142,6 +143,8 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) { aiChatHistory, isStreamingMessage, streamedMessage, + showScrollToBottom, + setShowScrollToBottom, handleChatSubmit, handleAbort, scrollToBottom, @@ -208,7 +211,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) { onClick={() => { setIsOpen(false); }} - className="fixed z-50 inset-0 bg-black opacity-50" + className="fixed inset-0 z-50 bg-black opacity-50" > )} @@ -332,6 +335,20 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) { )} + + {/* Scroll to bottom button */} + {showScrollToBottom && ( + + )} {/* Input area */} @@ -383,7 +400,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) { )} onClick={() => { setIsOpen(true); - setTimeout(() => scrollToBottom(), 0); + setTimeout(() => scrollToBottom('instant'), 0); }} > {!hasMessages ? ( diff --git a/src/hooks/use-roadmap-ai-chat.tsx b/src/hooks/use-roadmap-ai-chat.tsx index c89c41599..962dcb2cb 100644 --- a/src/hooks/use-roadmap-ai-chat.tsx +++ b/src/hooks/use-roadmap-ai-chat.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState, useEffect } from 'react'; import type { JSONContent } from '@tiptap/core'; import { flushSync } from 'react-dom'; import { removeAuthToken } from '../lib/jwt'; @@ -43,14 +43,60 @@ export function useRoadmapAIChat(options: Options) { const [isStreamingMessage, setIsStreamingMessage] = useState(false); const [streamedMessage, setStreamedMessage] = useState(null); + const [showScrollToBottom, setShowScrollToBottom] = useState(false); const abortControllerRef = useRef(null); - const scrollToBottom = useCallback(() => { - scrollareaRef.current?.scrollTo({ - top: scrollareaRef.current.scrollHeight, - behavior: 'instant', - }); - }, [scrollareaRef]); + const scrollToBottom = useCallback( + (behavior: 'smooth' | 'instant' = 'smooth') => { + scrollareaRef.current?.scrollTo({ + top: scrollareaRef.current.scrollHeight, + behavior, + }); + }, + [scrollareaRef], + ); + + // Check if user has scrolled away from bottom + const checkScrollPosition = useCallback(() => { + const scrollArea = scrollareaRef.current; + if (!scrollArea) { + return; + } + + const { scrollTop, scrollHeight, clientHeight } = scrollArea; + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50; // 50px threshold + setShowScrollToBottom(!isAtBottom && aiChatHistory.length > 0); + }, [aiChatHistory.length]); + + useEffect(() => { + const scrollArea = scrollareaRef.current; + if (!scrollArea) { + return; + } + + scrollArea.addEventListener('scroll', checkScrollPosition); + return () => scrollArea.removeEventListener('scroll', checkScrollPosition); + }, [checkScrollPosition]); + + // When user is already at the bottom and there is new message + // being streamed, we keep scrolling to bottom to show the new message + // unless user has scrolled up at which point we stop scrolling to bottom + useEffect(() => { + if (isStreamingMessage || streamedMessage) { + const scrollArea = scrollareaRef.current; + if (!scrollArea) { + return; + } + + const { scrollTop, scrollHeight, clientHeight } = scrollArea; + const isNearBottom = scrollTop + clientHeight >= scrollHeight - 100; + + if (isNearBottom) { + scrollToBottom('instant'); + setShowScrollToBottom(false); + } + } + }, [isStreamingMessage, streamedMessage, scrollToBottom]); const renderer: Record = useMemo( () => ({ @@ -175,7 +221,7 @@ export function useRoadmapAIChat(options: Options) { setIsStreamingMessage(true); flushSync(() => setAiChatHistory(newMessages)); - scrollToBottom(); + scrollToBottom('instant'); completeAITutorChat(newMessages, abortControllerRef.current); }, [aiChatHistory, isStreamingMessage, scrollToBottom], @@ -195,6 +241,8 @@ export function useRoadmapAIChat(options: Options) { aiChatHistory, isStreamingMessage, streamedMessage, + showScrollToBottom, + setShowScrollToBottom, abortControllerRef, handleChatSubmit, handleAbort,