| | 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'; |
| | } |
| |
|
| | |
| | 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(); |
| |
|
| | |
| | const scrollToBottom = useCallback(() => { |
| | const el = scrollContainerRef.current; |
| | if (el) el.scrollTop = el.scrollHeight; |
| | }, []); |
| |
|
| | |
| | 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); |
| | }, []); |
| |
|
| | |
| | useEffect(() => { |
| | if (stickToBottom.current) scrollToBottom(); |
| | }, [messages, isProcessing, scrollToBottom]); |
| |
|
| | |
| | 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(); |
| | }, []); |
| |
|
| | |
| | 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> |
| | ); |
| | } |
| |
|