import { useEffect, useRef, useCallback, memo } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import type { ChatSession, ChatMessageMetadata } from '@/lib/types/chat'; import type { UIMessage } from 'ai'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/hooks/use-i18n'; import { AvatarDisplay } from '@/components/ui/avatar-display'; import { CircleStop } from 'lucide-react'; import { InlineActionTag } from './inline-action-tag'; import { useUserProfileStore } from '@/lib/store/user-profile'; /** Extended message part type covering standard + custom action parts */ interface MessagePart { type: string; text?: string; _partId?: string; actionName?: string; state?: string; } interface ChatSessionProps { readonly session: ChatSession; readonly isActive: boolean; readonly isStreaming?: boolean; readonly activeBubbleId?: string | null; readonly onEndSession?: (sessionId: string) => void; } const AVATARS = { teacher: '/avatars/teacher.png', user: '/avatars/user.png', }; /** * MessageBubble — renders one message as a single chat bubble. * * Text is already paced by the StreamBuffer (30ms / 1 char) before it reaches * React state. No UI-layer animation is needed — we render parts directly. * Action badges only appear once the buffer's tick loop reaches them (after * all preceding text is fully revealed). */ const MessageBubble = memo(function MessageBubble({ message, isUser, isTeacher, isStreaming, isLastMessage, isActive, }: { message: UIMessage; isUser: boolean; isTeacher: boolean; isStreaming: boolean; isLastMessage: boolean; isActive: boolean; }) { const parts: MessagePart[] = (message.parts || []) as MessagePart[]; const isLive = !!(isStreaming && isLastMessage); // ── Determine renderable content ── const hasContent = parts.some( (p: MessagePart) => (p.type === 'text' && p.text) || p.type?.startsWith('action-'), ); // Loading dots (between agent_start and first text_delta) if (!hasContent && isActive && message.role === 'assistant') { return (
); } if (!hasContent) return null; const lastTextIdx = parts.reduce( (acc: number, p: MessagePart, i: number) => (p.type === 'text' && p.text ? i : acc), -1, ); return (
{parts.map((part: MessagePart, i: number) => { if (part.type === 'text' || part.type === 'step-start') { const text = part.type === 'text' ? part.text : ''; if (!text) return null; const isLast = i === lastTextIdx; return ( {text} {isLive && isLast && ( )} {message.metadata?.interrupted && isLast && !isLive && ( )} ); } if (part.type?.startsWith('action-')) { return ( ); } return null; })}
); }); export function ChatSessionComponent({ session, isActive, isStreaming, activeBubbleId, onEndSession, }: ChatSessionProps) { const { t } = useI18n(); const userProfileAvatar = useUserProfileStore((s) => s.avatar); const scrollContainerRef = useRef(null); const bottomRef = useRef(null); const activeBubbleRef = useRef(null); const isDiscussion = session.type === 'discussion'; const isQA = session.type === 'qa'; const canEnd = (isDiscussion || isQA) && session.status === 'active'; const isEnded = session.status === 'completed' && (isDiscussion || isQA); // Track whether user is at the bottom of the scroll container. // When user scrolls up to read history, auto-scroll is suppressed. const isAtBottomRef = useRef(true); const handleScroll = useCallback(() => { const el = scrollContainerRef.current; if (!el) return; isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40; }, []); // Auto-scroll: smooth scroll when a NEW message arrives — always (new agent bubble should be visible) const msgCount = session.messages.length; useEffect(() => { if (bottomRef.current) { bottomRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' }); isAtBottomRef.current = true; } }, [msgCount]); // Auto-scroll: rAF-throttled instant scroll as text grows — only when user is at bottom const scrollRaf = useRef(0); useEffect(() => { if (!isAtBottomRef.current) return; cancelAnimationFrame(scrollRaf.current); scrollRaf.current = requestAnimationFrame(() => { const el = scrollContainerRef.current; if (el) el.scrollTop = el.scrollHeight; }); }, [session.messages]); // Scroll to active bubble when it changes useEffect(() => { if (activeBubbleId && activeBubbleRef.current) { activeBubbleRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', }); isAtBottomRef.current = true; } }, [activeBubbleId]); if (session.messages.length === 0 && !isActive) { return (

{t('chat.noMessages')}

); } // Button text based on session type const endButtonText = isDiscussion ? t('chat.stopDiscussion') : t('chat.endQA'); return (
{/* Messages */}
{session.messages.map((message, msgIdx) => { const isUser = message.metadata?.originalRole === 'user'; const isTeacher = message.metadata?.originalRole === 'teacher'; const avatar = isUser ? userProfileAvatar || AVATARS.user : message.metadata?.senderAvatar || AVATARS.teacher; const isActiveBubble = activeBubbleId === message.id; const isLastMessage = msgIdx === session.messages.length - 1; return ( {/* Mini Avatar */}
{/* Content */}
{(() => { const agentId = message.metadata?.agentId; if (agentId) { const i18nName = t(`settings.agentNames.${agentId}`); if (i18nName !== `settings.agentNames.${agentId}`) return i18nName; } return message.metadata?.senderName || t('chat.unknown'); })()}
); })} {/* Session ended indicator */} {isEnded && (
{t('chat.ended')}
)}
{/* End Session Button (for Q&A and Discussion) */} {canEnd && onEndSession && ( onEndSession(session.id)} className="mt-2 mx-2 bg-red-50/80 dark:bg-red-900/20 backdrop-blur-md text-red-600 dark:text-red-400 border border-red-200/50 dark:border-red-800/50 px-3 py-1.5 rounded-full text-[11px] font-semibold flex items-center justify-center gap-1.5 transition-all shadow-sm hover:shadow-md" > {endButtonText} )}
); }