mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-03 14:22:41 +02:00
Add scroll to bottom functionality
This commit is contained in:
@@ -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 ? (
|
||||||
|
@@ -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,
|
||||||
|
Reference in New Issue
Block a user