diff --git a/apps/web/package.json b/apps/web/package.json index d719b44..85fe7d7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "next": "^16.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "socket.io-client": "^4.8.0", "tailwind-merge": "^3.5.0" }, "devDependencies": { diff --git a/apps/web/src/app/(dashboard)/chat/page.tsx b/apps/web/src/app/(dashboard)/chat/page.tsx index 74a5b04..a27a6a7 100644 --- a/apps/web/src/app/(dashboard)/chat/page.tsx +++ b/apps/web/src/app/(dashboard)/chat/page.tsx @@ -1,8 +1,179 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api } from '@/lib/api'; +import { 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); + + // Load conversations on mount + useEffect(() => { + api('/api/conversations') + .then(setConversations) + .catch(() => {}); + }, []); + + // Load messages when active conversation changes + useEffect(() => { + if (!activeId) { + setMessages([]); + return; + } + api(`/api/conversations/${activeId}/messages`) + .then(setMessages) + .catch(() => {}); + }, [activeId]); + + // Auto-scroll to bottom + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, streamingText]); + + // Socket.io setup + useEffect(() => { + const socket = getSocket(); + socket.connect(); + + socket.on('agent:text', (data: { conversationId: string; text: string }) => { + setStreamingText((prev) => prev + data.text); + }); + + socket.on('agent:start', () => { + setIsStreaming(true); + setStreamingText(''); + }); + + socket.on('agent:end', (data: { conversationId: string }) => { + setIsStreaming(false); + setStreamingText(''); + // Reload messages to get the final persisted version + api(`/api/conversations/${data.conversationId}/messages`) + .then(setMessages) + .catch(() => {}); + }); + + socket.on('error', (data: { error: string }) => { + setIsStreaming(false); + setStreamingText(''); + setMessages((prev) => [ + ...prev, + { + id: `error-${Date.now()}`, + conversationId: '', + role: 'system', + content: `Error: ${data.error}`, + createdAt: new Date().toISOString(), + }, + ]); + }); + + return () => { + socket.off('agent:text'); + socket.off('agent:start'); + socket.off('agent:end'); + socket.off('error'); + socket.disconnect(); + }; + }, []); + + 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 handleSend = useCallback( + async (content: string) => { + let convId = activeId; + + // Auto-create conversation if none selected + if (!convId) { + const conv = await api('/api/conversations', { + method: 'POST', + body: { title: content.slice(0, 50) }, + }); + setConversations((prev) => [conv, ...prev]); + setActiveId(conv.id); + convId = conv.id; + } + + // Optimistic user message + const userMsg: Message = { + id: `temp-${Date.now()}`, + conversationId: convId, + role: 'user', + content, + createdAt: new Date().toISOString(), + }; + setMessages((prev) => [...prev, userMsg]); + + // Persist user message + await api(`/api/conversations/${convId}/messages`, { + method: 'POST', + body: { role: 'user', content }, + }); + + // Send to WebSocket for streaming response + const socket = getSocket(); + socket.emit('message', { conversationId: convId, content }); + }, + [activeId], + ); + return ( -
-

Chat

-

Conversations will appear here.

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

Welcome to Mosaic Chat

+

+ Select a conversation or start a new one +

+ +
+
+ )} +
); } diff --git a/apps/web/src/components/chat/chat-input.tsx b/apps/web/src/components/chat/chat-input.tsx new file mode 100644 index 0000000..2f59663 --- /dev/null +++ b/apps/web/src/components/chat/chat-input.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useRef, useState } from 'react'; + +interface ChatInputProps { + onSend: (content: string) => void; + disabled?: boolean; +} + +export function ChatInput({ onSend, disabled }: ChatInputProps): React.ReactElement { + const [value, setValue] = useState(''); + const textareaRef = useRef(null); + + function handleSubmit(e: React.FormEvent): void { + e.preventDefault(); + const trimmed = value.trim(); + if (!trimmed || disabled) return; + onSend(trimmed); + setValue(''); + textareaRef.current?.focus(); + } + + function handleKeyDown(e: React.KeyboardEvent): void { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + } + + return ( +
+
+