feat: add scroll-to-bottom button when scrolled up in chat
Floating button appears when user scrolls away from the bottom of the conversation, making it easy to jump back to the latest messages. Includes i18n labels (EN/FR) and smooth scroll animation.
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import { ChatMessageComponent } from './ChatMessage';
|
import { ChatMessageComponent } from './ChatMessage';
|
||||||
import { ChatInput } from './ChatInput';
|
import { ChatInput } from './ChatInput';
|
||||||
import { TypingIndicator } from './TypingIndicator';
|
import { TypingIndicator } from './TypingIndicator';
|
||||||
import type { ChatMessage, ConnectionStatus } from '../types';
|
import type { ChatMessage, ConnectionStatus } from '../types';
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot, ArrowDown } from 'lucide-react';
|
||||||
import { useT } from '../hooks/useLocale';
|
import { useT } from '../hooks/useLocale';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -50,12 +50,14 @@ export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props)
|
|||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const isNearBottomRef = useRef(true);
|
const isNearBottomRef = useRef(true);
|
||||||
const userSentRef = useRef(false);
|
const userSentRef = useRef(false);
|
||||||
|
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||||
|
|
||||||
const checkIfNearBottom = useCallback(() => {
|
const checkIfNearBottom = useCallback(() => {
|
||||||
const el = scrollContainerRef.current;
|
const el = scrollContainerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
isNearBottomRef.current = distanceFromBottom <= SCROLL_THRESHOLD;
|
isNearBottomRef.current = distanceFromBottom <= SCROLL_THRESHOLD;
|
||||||
|
setShowScrollBtn(distanceFromBottom > SCROLL_THRESHOLD * 2);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
||||||
@@ -94,7 +96,7 @@ export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props)
|
|||||||
const showTyping = isGenerating && !hasStreamedText(messages);
|
const showTyping = isGenerating && !hasStreamedText(messages);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto relative" role="log" aria-label={t('chat.messages')} aria-live="polite">
|
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto relative" role="log" aria-label={t('chat.messages')} aria-live="polite">
|
||||||
<div className="max-w-4xl mx-auto py-4">
|
<div className="max-w-4xl mx-auto py-4">
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
@@ -116,6 +118,19 @@ export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props)
|
|||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Scroll to bottom FAB */}
|
||||||
|
{showScrollBtn && (
|
||||||
|
<div className="absolute bottom-24 left-1/2 -translate-x-1/2 z-10">
|
||||||
|
<button
|
||||||
|
onClick={() => scrollToBottom('smooth')}
|
||||||
|
aria-label={t('chat.scrollToBottom')}
|
||||||
|
className="flex items-center gap-1.5 rounded-full border border-white/10 bg-zinc-800/90 backdrop-blur-lg px-3.5 py-2 text-xs text-zinc-300 shadow-lg hover:bg-zinc-700/90 transition-all hover:shadow-cyan-500/10"
|
||||||
|
>
|
||||||
|
<ArrowDown size={14} className="text-cyan-300" />
|
||||||
|
<span className="hidden sm:inline">{t('chat.scrollToBottom')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ChatInput onSend={handleSend} onAbort={onAbort} isGenerating={isGenerating} disabled={status !== 'connected'} />
|
<ChatInput onSend={handleSend} onAbort={onAbort} isGenerating={isGenerating} disabled={status !== 'connected'} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const en = {
|
|||||||
'chat.attachFile': 'Attach file',
|
'chat.attachFile': 'Attach file',
|
||||||
'chat.send': 'Send',
|
'chat.send': 'Send',
|
||||||
'chat.stop': 'Stop',
|
'chat.stop': 'Stop',
|
||||||
|
'chat.scrollToBottom': 'New messages',
|
||||||
'chat.messages': 'Chat messages',
|
'chat.messages': 'Chat messages',
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
@@ -78,6 +79,7 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
'chat.attachFile': 'Joindre un fichier',
|
'chat.attachFile': 'Joindre un fichier',
|
||||||
'chat.send': 'Envoyer',
|
'chat.send': 'Envoyer',
|
||||||
'chat.stop': 'Arrêter',
|
'chat.stop': 'Arrêter',
|
||||||
|
'chat.scrollToBottom': 'Nouveaux messages',
|
||||||
'chat.messages': 'Messages du chat',
|
'chat.messages': 'Messages du chat',
|
||||||
|
|
||||||
'sidebar.title': 'Sessions',
|
'sidebar.title': 'Sessions',
|
||||||
|
|||||||
Reference in New Issue
Block a user