'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { api } from '@/lib/api'; import { destroySocket, getSocket } from '@/lib/socket'; import type { Conversation, Message } from '@/lib/types'; import { ConversationList } from '@/components/chat/conversation-list'; import { MessageBubble } from '@/components/chat/message-bubble'; import { ChatInput } from '@/components/chat/chat-input'; import { StreamingMessage } from '@/components/chat/streaming-message'; export default function ChatPage(): React.ReactElement { const [conversations, setConversations] = useState([]); const [activeId, setActiveId] = useState(null); const [messages, setMessages] = useState([]); const [streamingText, setStreamingText] = useState(''); const [isStreaming, setIsStreaming] = useState(false); const messagesEndRef = useRef(null); // Track the active conversation ID in a ref so socket event handlers always // see the current value without needing to be re-registered. const activeIdRef = useRef(null); activeIdRef.current = activeId; // Accumulate streamed text in a ref so agent:end can read the full content // without stale-closure issues. const streamingTextRef = useRef(''); // Load conversations on mount useEffect(() => { api('/api/conversations') .then(setConversations) .catch(() => {}); }, []); // Load messages when active conversation changes useEffect(() => { if (!activeId) { setMessages([]); return; } // Clear streaming state when switching conversations setIsStreaming(false); setStreamingText(''); streamingTextRef.current = ''; api(`/api/conversations/${activeId}/messages`) .then(setMessages) .catch(() => {}); }, [activeId]); // Auto-scroll to bottom useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, streamingText]); // Socket.io setup — connect once for the page lifetime useEffect(() => { const socket = getSocket(); function onAgentStart(data: { conversationId: string }): void { // Only update state if the event belongs to the currently viewed conversation if (activeIdRef.current !== data.conversationId) return; setIsStreaming(true); setStreamingText(''); streamingTextRef.current = ''; } function onAgentText(data: { conversationId: string; text: string }): void { if (activeIdRef.current !== data.conversationId) return; streamingTextRef.current += data.text; setStreamingText((prev) => prev + data.text); } function onAgentEnd(data: { conversationId: string }): void { if (activeIdRef.current !== data.conversationId) return; const finalText = streamingTextRef.current; setIsStreaming(false); setStreamingText(''); streamingTextRef.current = ''; // Append the completed assistant message to the local message list. // The Pi agent session is in-memory so the assistant response is not // persisted to the DB — we build the local UI state instead. if (finalText) { setMessages((prev) => [ ...prev, { id: `assistant-${Date.now()}`, conversationId: data.conversationId, role: 'assistant' as const, content: finalText, createdAt: new Date().toISOString(), }, ]); } } function onError(data: { error: string; conversationId?: string }): void { setIsStreaming(false); setStreamingText(''); streamingTextRef.current = ''; setMessages((prev) => [ ...prev, { id: `error-${Date.now()}`, conversationId: data.conversationId ?? '', role: 'system' as const, content: `Error: ${data.error}`, createdAt: new Date().toISOString(), }, ]); } socket.on('agent:start', onAgentStart); socket.on('agent:text', onAgentText); socket.on('agent:end', onAgentEnd); socket.on('error', onError); // Connect if not already connected if (!socket.connected) { socket.connect(); } return () => { socket.off('agent:start', onAgentStart); socket.off('agent:text', onAgentText); socket.off('agent:end', onAgentEnd); socket.off('error', onError); // Fully tear down the socket when the chat page unmounts so we get a // fresh authenticated connection next time the page is visited. destroySocket(); }; }, []); const handleNewConversation = useCallback(async () => { const conv = await api('/api/conversations', { method: 'POST', body: { title: 'New conversation' }, }); setConversations((prev) => [conv, ...prev]); setActiveId(conv.id); setMessages([]); }, []); const handleRename = useCallback(async (id: string, title: string) => { const updated = await api(`/api/conversations/${id}`, { method: 'PATCH', body: { title }, }); setConversations((prev) => prev.map((c) => (c.id === id ? updated : c))); }, []); const handleDelete = useCallback( async (id: string) => { await api(`/api/conversations/${id}`, { method: 'DELETE' }); setConversations((prev) => prev.filter((c) => c.id !== id)); if (activeId === id) { setActiveId(null); setMessages([]); } }, [activeId], ); const handleArchive = useCallback( async (id: string, archived: boolean) => { const updated = await api(`/api/conversations/${id}`, { method: 'PATCH', body: { archived }, }); setConversations((prev) => prev.map((c) => (c.id === id ? updated : c))); // If archiving the active conversation, deselect it if (archived && activeId === id) { setActiveId(null); setMessages([]); } }, [activeId], ); const handleSend = useCallback( async (content: string) => { let convId = activeId; // Auto-create conversation if none selected if (!convId) { const autoTitle = content.slice(0, 60); const conv = await api('/api/conversations', { method: 'POST', body: { title: autoTitle }, }); setConversations((prev) => [conv, ...prev]); setActiveId(conv.id); convId = conv.id; } else { // Auto-title: if the active conversation still has the default "New // conversation" title and this is the first message, update the title // from the message content. const activeConv = conversations.find((c) => c.id === convId); if (activeConv?.title === 'New conversation' && messages.length === 0) { const autoTitle = content.slice(0, 60); api(`/api/conversations/${convId}`, { method: 'PATCH', body: { title: autoTitle }, }) .then((updated) => { setConversations((prev) => prev.map((c) => (c.id === convId ? updated : c))); }) .catch(() => {}); } } // Optimistic user message in local UI state setMessages((prev) => [ ...prev, { id: `user-${Date.now()}`, conversationId: convId, role: 'user' as const, content, createdAt: new Date().toISOString(), }, ]); // Persist the user message to the DB so conversation history is // available when the page is reloaded or a new session starts. api(`/api/conversations/${convId}/messages`, { method: 'POST', body: { role: 'user', content }, }).catch(() => { // Non-fatal: the agent can still process the message even if // REST persistence fails. }); // Send to WebSocket — gateway creates/resumes the agent session and // streams the response back via agent:start / agent:text / agent:end. const socket = getSocket(); if (!socket.connected) { socket.connect(); } socket.emit('message', { conversationId: convId, content }); }, [activeId, conversations, messages], ); return (
{activeId ? ( <>
{messages.map((msg) => ( ))} {isStreaming && }
) : (

Welcome to Mosaic Chat

Select a conversation or start a new one

)}
); }