'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 { ConversationSidebar, type ConversationSidebarRef, } from '@/components/chat/conversation-sidebar'; import { MessageBubble } from '@/components/chat/message-bubble'; import { ChatInput } from '@/components/chat/chat-input'; import { StreamingMessage } from '@/components/chat/streaming-message'; interface ModelInfo { id: string; provider: string; name: string; reasoning: boolean; contextWindow: number; maxTokens: number; inputTypes: ('text' | 'image')[]; cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; } interface ProviderInfo { id: string; name: string; available: boolean; models: ModelInfo[]; } export default function ChatPage(): React.ReactElement { const [activeId, setActiveId] = useState(null); const [messages, setMessages] = useState([]); const [streamingText, setStreamingText] = useState(''); const [isStreaming, setIsStreaming] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(true); const [models, setModels] = useState([]); const [selectedModelId, setSelectedModelId] = useState(''); const messagesEndRef = useRef(null); const sidebarRef = 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(''); useEffect(() => { const savedState = window.localStorage.getItem('mosaic-sidebar-open'); if (savedState !== null) { setIsSidebarOpen(savedState === 'true'); } }, []); useEffect(() => { window.localStorage.setItem('mosaic-sidebar-open', String(isSidebarOpen)); }, [isSidebarOpen]); useEffect(() => { api('/api/providers') .then((providers) => { const availableModels = providers .filter((provider) => provider.available) .flatMap((provider) => provider.models); setModels(availableModels); setSelectedModelId((current) => current || availableModels[0]?.id || ''); }) .catch(() => { setModels([]); setSelectedModelId(''); }); }, []); // 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(), }, ]); sidebarRef.current?.refresh(); } } 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 (projectId?: string | null) => { const conv = await api('/api/conversations', { method: 'POST', body: { title: 'New conversation', projectId: projectId ?? null }, }); sidebarRef.current?.addConversation({ id: conv.id, title: conv.title, projectId: conv.projectId, updatedAt: conv.updatedAt, archived: conv.archived, }); setActiveId(conv.id); setMessages([]); setIsSidebarOpen(true); }, []); const handleSend = useCallback( async (content: string, options?: { modelId?: 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 }, }); sidebarRef.current?.addConversation({ id: conv.id, title: conv.title, projectId: conv.projectId, updatedAt: conv.updatedAt, archived: conv.archived, }); setActiveId(conv.id); convId = conv.id; } else if (messages.length === 0) { // Auto-title the initial placeholder conversation from the first user message. const autoTitle = content.slice(0, 60); api(`/api/conversations/${convId}`, { method: 'PATCH', body: { title: autoTitle }, }) .then(() => sidebarRef.current?.refresh()) .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, modelId: (options?.modelId ?? selectedModelId) || undefined, }); }, [activeId, messages, selectedModelId], ); return (
setIsSidebarOpen(false)} currentConversationId={activeId} onSelectConversation={(conversationId) => { setActiveId(conversationId); setMessages([]); if (conversationId && window.innerWidth < 768) { setIsSidebarOpen(false); } }} onNewConversation={(projectId) => { void handleNewConversation(projectId); }} />

Mosaic Chat

{activeId ? 'Active conversation selected' : 'Choose or start a conversation'}

{activeId ? ( <>
{messages.map((msg) => ( ))} {isStreaming && }
) : (

Welcome to Mosaic Chat

Select a conversation or start a new one

)}
); }