feat(web): chat UI with conversations and WebSocket streaming (#84)

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #84.
This commit is contained in:
2026-03-13 13:25:28 +00:00
committed by jason.woltje
parent 600da70960
commit f0d1d4bafa
10 changed files with 381 additions and 5 deletions

View File

@@ -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<Conversation[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [streamingText, setStreamingText] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Load conversations on mount
useEffect(() => {
api<Conversation[]>('/api/conversations')
.then(setConversations)
.catch(() => {});
}, []);
// Load messages when active conversation changes
useEffect(() => {
if (!activeId) {
setMessages([]);
return;
}
api<Message[]>(`/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<Message[]>(`/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<Conversation>('/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<Conversation>('/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<Message>(`/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 (
<div>
<h1 className="text-2xl font-semibold">Chat</h1>
<p className="mt-2 text-text-secondary">Conversations will appear here.</p>
<div className="-m-6 flex h-[calc(100vh-3.5rem)]">
<ConversationList
conversations={conversations}
activeId={activeId}
onSelect={setActiveId}
onNew={handleNewConversation}
/>
<div className="flex flex-1 flex-col">
{activeId ? (
<>
<div className="flex-1 space-y-4 overflow-y-auto p-6">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{isStreaming && <StreamingMessage text={streamingText} />}
<div ref={messagesEndRef} />
</div>
<ChatInput onSend={handleSend} disabled={isStreaming} />
</>
) : (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<h2 className="text-lg font-medium text-text-secondary">Welcome to Mosaic Chat</h2>
<p className="mt-1 text-sm text-text-muted">
Select a conversation or start a new one
</p>
<button
type="button"
onClick={handleNewConversation}
className="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
Start new conversation
</button>
</div>
</div>
)}
</div>
</div>
);
}