| import { useEffect, useRef, useState } from 'react' |
| import { ArrowDown, Sparkles } from 'lucide-react' |
| import MessageBubble from './MessageBubbleNext' |
|
|
| function getDisplayName(user) { |
| const preferred = user?.name || user?.username || 'there' |
| const rawName = String(preferred).trim() |
| if (!rawName || rawName === 'there') return 'there' |
|
|
| const withoutDomain = rawName.includes('@') ? rawName.split('@')[0] : rawName |
| const readable = withoutDomain |
| .replace(/[._-]+/g, ' ') |
| .replace(/\d+/g, ' ') |
| .trim() |
|
|
| const firstName = readable.split(/\s+/).filter(Boolean)[0] || 'there' |
| return firstName.charAt(0).toUpperCase() + firstName.slice(1) |
| } |
|
|
| export default function ChatArea({ |
| messages, |
| loading, |
| user, |
| onCopyMessage, |
| onRegenerate, |
| onEdit, |
| onSpeak, |
| onPreviewFile, |
| speakingMessageId, |
| }) { |
| const scrollRef = useRef(null) |
| const autoScrollRef = useRef(true) |
| const previousMessageCountRef = useRef(messages.length) |
| const [showScrollButton, setShowScrollButton] = useState(false) |
| const displayName = getDisplayName(user) |
|
|
| const scrollToBottom = (behavior = 'smooth') => { |
| if (!scrollRef.current) return |
| scrollRef.current.scrollTo({ |
| top: scrollRef.current.scrollHeight, |
| behavior, |
| }) |
| } |
|
|
| const handleScroll = () => { |
| if (!scrollRef.current) return |
| const distanceFromBottom = |
| scrollRef.current.scrollHeight - scrollRef.current.scrollTop - scrollRef.current.clientHeight |
| const isNearBottom = distanceFromBottom < 120 |
| autoScrollRef.current = isNearBottom |
| setShowScrollButton(!isNearBottom && messages.length > 0) |
| } |
|
|
| useEffect(() => { |
| if (!messages.length) return |
| const lastMessage = messages[messages.length - 1] |
| const nextBehavior = |
| previousMessageCountRef.current < messages.length && !lastMessage?.streaming ? 'smooth' : 'auto' |
|
|
| if (autoScrollRef.current || lastMessage?.role === 'user' || lastMessage?.streaming) { |
| const frame = window.requestAnimationFrame(() => { |
| scrollToBottom(nextBehavior) |
| }) |
| previousMessageCountRef.current = messages.length |
| return () => window.cancelAnimationFrame(frame) |
| } |
|
|
| previousMessageCountRef.current = messages.length |
| }, [messages]) |
|
|
| if (!messages.length && loading) { |
| return ( |
| <div className="rich-scroll min-h-0 flex-1 overflow-y-auto px-4 py-8 sm:px-6"> |
| <div className="mx-auto flex w-full max-w-3xl flex-col gap-4"> |
| {Array.from({ length: 4 }).map((_, index) => ( |
| <div |
| key={index} |
| className={`animate-pulse rounded-lg bg-secondary p-4 ${ |
| index % 2 === 0 ? 'mr-auto w-10/12' : 'ml-auto w-7/12' |
| }`} |
| > |
| <div className="space-y-3"> |
| <div className="h-3 rounded-full bg-background/80" /> |
| <div className="h-3 w-11/12 rounded-full bg-background/80" /> |
| <div className="h-3 w-8/12 rounded-full bg-background/80" /> |
| </div> |
| </div> |
| ))} |
| </div> |
| </div> |
| ) |
| } |
|
|
| if (!messages.length && !loading) { |
| return ( |
| <div className="flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-4 py-10 sm:px-6"> |
| <div className="flex flex-col items-center gap-3 text-center"> |
| <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-secondary text-muted-foreground"> |
| <Sparkles className="h-4 w-4" /> |
| </div> |
| <h2 className="text-2xl font-semibold text-foreground sm:text-3xl"> |
| Hi {displayName}, what can we make today? |
| </h2> |
| </div> |
| </div> |
| ) |
| } |
|
|
| return ( |
| <div className="relative min-h-0 flex-1"> |
| <div |
| ref={scrollRef} |
| onScroll={handleScroll} |
| className="rich-scroll h-full min-h-0 overflow-y-auto" |
| > |
| <div className="py-4"> |
| {messages.map((message) => ( |
| <MessageBubble |
| key={message.id} |
| message={message} |
| user={user} |
| onCopyMessage={onCopyMessage} |
| onRegenerate={onRegenerate} |
| onEdit={onEdit} |
| onSpeak={onSpeak} |
| onPreviewFile={onPreviewFile} |
| speaking={speakingMessageId === message.id} |
| /> |
| ))} |
| </div> |
| </div> |
| |
| {showScrollButton ? ( |
| <button |
| type="button" |
| onClick={() => { |
| autoScrollRef.current = true |
| setShowScrollButton(false) |
| scrollToBottom('smooth') |
| }} |
| className="absolute bottom-5 right-5 inline-flex h-10 w-10 items-center justify-center rounded-lg border border-border bg-card text-foreground shadow-panel transition hover:bg-secondary" |
| aria-label="Scroll to latest message" |
| > |
| <ArrowDown className="h-4 w-4" /> |
| </button> |
| ) : null} |
| </div> |
| ) |
| } |
|
|