Spaces:
Running
Running
| import { useEffect, useRef, useMemo, useCallback } from 'react'; | |
| import { Box, Stack, Typography } from '@mui/material'; | |
| import MessageBubble from './MessageBubble'; | |
| import ThinkingIndicator from './ThinkingIndicator'; | |
| import { useAgentStore } from '@/store/agentStore'; | |
| import { useSessionStore } from '@/store/sessionStore'; | |
| import { apiFetch } from '@/utils/api'; | |
| import { logger } from '@/utils/logger'; | |
| import type { Message } from '@/types/agent'; | |
| interface MessageListProps { | |
| messages: Message[]; | |
| isProcessing: boolean; | |
| } | |
| function getGreeting(): string { | |
| const h = new Date().getHours(); | |
| if (h < 12) return 'Morning'; | |
| if (h < 17) return 'Afternoon'; | |
| return 'Evening'; | |
| } | |
| /** Minimal greeting shown when the conversation is empty. */ | |
| function WelcomeGreeting() { | |
| const { user } = useAgentStore(); | |
| const firstName = user?.name?.split(' ')[0] || user?.username; | |
| const greeting = firstName ? `${getGreeting()}, ${firstName}` : getGreeting(); | |
| return ( | |
| <Box | |
| sx={{ | |
| flex: 1, | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| py: 8, | |
| gap: 1.5, | |
| }} | |
| > | |
| <Typography | |
| sx={{ | |
| fontFamily: 'monospace', | |
| fontSize: '1.6rem', | |
| color: 'var(--text)', | |
| fontWeight: 600, | |
| }} | |
| > | |
| {greeting} | |
| </Typography> | |
| <Typography | |
| color="text.secondary" | |
| sx={{ fontFamily: 'monospace', fontSize: '0.9rem' }} | |
| > | |
| Let's build something impressive? | |
| </Typography> | |
| </Box> | |
| ); | |
| } | |
| export default function MessageList({ messages, isProcessing }: MessageListProps) { | |
| const scrollContainerRef = useRef<HTMLDivElement>(null); | |
| const stickToBottom = useRef(true); | |
| const { activeSessionId } = useSessionStore(); | |
| const { removeLastTurn, currentTurnMessageId } = useAgentStore(); | |
| // ββ Scroll-to-bottom helper βββββββββββββββββββββββββββββββββββββ | |
| const scrollToBottom = useCallback(() => { | |
| const el = scrollContainerRef.current; | |
| if (el) el.scrollTop = el.scrollHeight; | |
| }, []); | |
| // ββ Track user scroll intent ββββββββββββββββββββββββββββββββββββ | |
| useEffect(() => { | |
| const el = scrollContainerRef.current; | |
| if (!el) return; | |
| const onScroll = () => { | |
| const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; | |
| stickToBottom.current = distFromBottom < 80; | |
| }; | |
| el.addEventListener('scroll', onScroll, { passive: true }); | |
| return () => el.removeEventListener('scroll', onScroll); | |
| }, []); | |
| // ββ Auto-scroll on new messages / state changes βββββββββββββββββ | |
| useEffect(() => { | |
| if (stickToBottom.current) scrollToBottom(); | |
| }, [messages, isProcessing, scrollToBottom]); | |
| // ββ Auto-scroll on DOM mutations (streaming content growth) βββββ | |
| useEffect(() => { | |
| const el = scrollContainerRef.current; | |
| if (!el) return; | |
| const observer = new MutationObserver(() => { | |
| if (stickToBottom.current) { | |
| el.scrollTop = el.scrollHeight; | |
| } | |
| }); | |
| observer.observe(el, { | |
| childList: true, | |
| subtree: true, | |
| characterData: true, | |
| }); | |
| return () => observer.disconnect(); | |
| }, []); | |
| // Find the index of the last user message (start of the last turn) | |
| const lastUserMsgId = useMemo(() => { | |
| for (let i = messages.length - 1; i >= 0; i--) { | |
| if (messages[i].role === 'user') return messages[i].id; | |
| } | |
| return null; | |
| }, [messages]); | |
| const handleUndoLastTurn = useCallback(async () => { | |
| if (!activeSessionId) return; | |
| try { | |
| await apiFetch(`/api/undo/${activeSessionId}`, { method: 'POST' }); | |
| removeLastTurn(activeSessionId); | |
| } catch (e) { | |
| logger.error('Undo failed:', e); | |
| } | |
| }, [activeSessionId, removeLastTurn]); | |
| return ( | |
| <Box | |
| ref={scrollContainerRef} | |
| sx={{ | |
| flex: 1, | |
| overflow: 'auto', | |
| px: { xs: 0.5, sm: 1, md: 2 }, | |
| py: { xs: 2, md: 3 }, | |
| display: 'flex', | |
| flexDirection: 'column', | |
| }} | |
| > | |
| <Stack | |
| spacing={3} | |
| sx={{ | |
| maxWidth: 880, | |
| mx: 'auto', | |
| width: '100%', | |
| flex: messages.length === 0 && !isProcessing ? 1 : undefined, | |
| }} | |
| > | |
| {messages.length === 0 && !isProcessing ? ( | |
| <WelcomeGreeting /> | |
| ) : ( | |
| messages.map((msg) => ( | |
| <MessageBubble | |
| key={msg.id} | |
| message={msg} | |
| isLastTurn={msg.id === lastUserMsgId} | |
| onUndoTurn={handleUndoLastTurn} | |
| isProcessing={isProcessing} | |
| isStreaming={isProcessing && msg.id === currentTurnMessageId} | |
| /> | |
| )) | |
| )} | |
| {/* Show thinking dots only when processing but no streaming message yet */} | |
| {isProcessing && !currentTurnMessageId && <ThinkingIndicator />} | |
| {/* Sentinel β keeps scroll anchor at the bottom */} | |
| <div /> | |
| </Stack> | |
| </Box> | |
| ); | |
| } | |