| | 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>
|
| | );
|
| | }
|
| |
|