1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-03 06:12:53 +02:00

Add scroll to bottom functionality

This commit is contained in:
Kamran Ahmed
2025-06-10 13:33:08 +01:00
parent 5051534c9d
commit cca2c1bd36
2 changed files with 75 additions and 10 deletions

View File

@@ -3,6 +3,7 @@ import type { JSONContent } from '@tiptap/core';
import { import {
AppWindow, AppWindow,
BookOpen, BookOpen,
ChevronDown,
MessageCirclePlus, MessageCirclePlus,
PauseCircleIcon, PauseCircleIcon,
PersonStanding, PersonStanding,
@@ -142,6 +143,8 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
aiChatHistory, aiChatHistory,
isStreamingMessage, isStreamingMessage,
streamedMessage, streamedMessage,
showScrollToBottom,
setShowScrollToBottom,
handleChatSubmit, handleChatSubmit,
handleAbort, handleAbort,
scrollToBottom, scrollToBottom,
@@ -208,7 +211,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
onClick={() => { onClick={() => {
setIsOpen(false); setIsOpen(false);
}} }}
className="fixed z-50 inset-0 bg-black opacity-50" className="fixed inset-0 z-50 bg-black opacity-50"
></div> ></div>
)} )}
@@ -332,6 +335,20 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
<RoadmapAIChatCard role="assistant" jsx={streamedMessage} /> <RoadmapAIChatCard role="assistant" jsx={streamedMessage} />
)} )}
</div> </div>
{/* Scroll to bottom button */}
{showScrollToBottom && (
<button
onClick={() => {
scrollToBottom('instant');
setShowScrollToBottom(false);
}}
className="sticky bottom-0 mx-auto mt-2 flex items-center gap-1.5 rounded-full bg-gray-900 px-3 py-1.5 text-xs text-white shadow-lg transition-all hover:bg-gray-800"
>
<ChevronDown className="h-3 w-3" />
Scroll to bottom
</button>
)}
</div> </div>
{/* Input area */} {/* Input area */}
@@ -383,7 +400,7 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
)} )}
onClick={() => { onClick={() => {
setIsOpen(true); setIsOpen(true);
setTimeout(() => scrollToBottom(), 0); setTimeout(() => scrollToBottom('instant'), 0);
}} }}
> >
{!hasMessages ? ( {!hasMessages ? (

View File

@@ -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 type { JSONContent } from '@tiptap/core';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import { removeAuthToken } from '../lib/jwt'; import { removeAuthToken } from '../lib/jwt';
@@ -43,14 +43,60 @@ export function useRoadmapAIChat(options: Options) {
const [isStreamingMessage, setIsStreamingMessage] = useState(false); const [isStreamingMessage, setIsStreamingMessage] = useState(false);
const [streamedMessage, setStreamedMessage] = const [streamedMessage, setStreamedMessage] =
useState<React.ReactNode | null>(null); useState<React.ReactNode | null>(null);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback(
scrollareaRef.current?.scrollTo({ (behavior: 'smooth' | 'instant' = 'smooth') => {
top: scrollareaRef.current.scrollHeight, scrollareaRef.current?.scrollTo({
behavior: 'instant', top: scrollareaRef.current.scrollHeight,
}); behavior,
}, [scrollareaRef]); });
},
[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<string, MessagePartRenderer> = useMemo( const renderer: Record<string, MessagePartRenderer> = useMemo(
() => ({ () => ({
@@ -175,7 +221,7 @@ export function useRoadmapAIChat(options: Options) {
setIsStreamingMessage(true); setIsStreamingMessage(true);
flushSync(() => setAiChatHistory(newMessages)); flushSync(() => setAiChatHistory(newMessages));
scrollToBottom(); scrollToBottom('instant');
completeAITutorChat(newMessages, abortControllerRef.current); completeAITutorChat(newMessages, abortControllerRef.current);
}, },
[aiChatHistory, isStreamingMessage, scrollToBottom], [aiChatHistory, isStreamingMessage, scrollToBottom],
@@ -195,6 +241,8 @@ export function useRoadmapAIChat(options: Options) {
aiChatHistory, aiChatHistory,
isStreamingMessage, isStreamingMessage,
streamedMessage, streamedMessage,
showScrollToBottom,
setShowScrollToBottom,
abortControllerRef, abortControllerRef,
handleChatSubmit, handleChatSubmit,
handleAbort, handleAbort,