Files
zeroclaw/web/src/pages/AgentChat.tsx
T
2026-03-07 17:37:46 -05:00

298 lines
9.1 KiB
TypeScript

import { useState, useEffect, useRef } from 'react';
import { Send, Bot, User, AlertCircle } from 'lucide-react';
import type { WsMessage } from '@/types/api';
import { WebSocketClient } from '@/lib/ws';
interface ChatMessage {
id: string;
role: 'user' | 'agent';
content: string;
timestamp: Date;
}
let fallbackMessageIdCounter = 0;
const EMPTY_DONE_FALLBACK =
'Tool execution completed, but no final response text was returned.';
function makeMessageId(): string {
const uuid = globalThis.crypto?.randomUUID?.();
if (uuid) return uuid;
fallbackMessageIdCounter += 1;
return `msg_${Date.now().toString(36)}_${fallbackMessageIdCounter.toString(36)}_${Math.random()
.toString(36)
.slice(2, 10)}`;
}
export default function AgentChat() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [typing, setTyping] = useState(false);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const wsRef = useRef<WebSocketClient | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const pendingContentRef = useRef('');
useEffect(() => {
const ws = new WebSocketClient();
ws.onOpen = () => {
setConnected(true);
setError(null);
};
ws.onClose = () => {
setConnected(false);
};
ws.onError = () => {
setError('Connection error. Attempting to reconnect...');
};
ws.onMessage = (msg: WsMessage) => {
switch (msg.type) {
case 'history': {
const restored: ChatMessage[] = (msg.messages ?? [])
.filter((entry) => entry.content?.trim())
.map((entry): ChatMessage => ({
id: makeMessageId(),
role: entry.role === 'user' ? 'user' : 'agent',
content: entry.content.trim(),
timestamp: new Date(),
}));
setMessages(restored);
setTyping(false);
pendingContentRef.current = '';
break;
}
case 'chunk':
setTyping(true);
pendingContentRef.current += msg.content ?? '';
break;
case 'message':
case 'done': {
const content = (msg.full_response ?? msg.content ?? pendingContentRef.current ?? '').trim();
const finalContent = content || EMPTY_DONE_FALLBACK;
setMessages((prev) => [
...prev,
{
id: makeMessageId(),
role: 'agent',
content: finalContent,
timestamp: new Date(),
},
]);
pendingContentRef.current = '';
setTyping(false);
break;
}
case 'tool_call':
setMessages((prev) => [
...prev,
{
id: makeMessageId(),
role: 'agent',
content: `[Tool Call] ${msg.name ?? 'unknown'}(${JSON.stringify(msg.args ?? {})})`,
timestamp: new Date(),
},
]);
break;
case 'tool_result':
setMessages((prev) => [
...prev,
{
id: makeMessageId(),
role: 'agent',
content: `[Tool Result] ${msg.output ?? ''}`,
timestamp: new Date(),
},
]);
break;
case 'error':
setMessages((prev) => [
...prev,
{
id: makeMessageId(),
role: 'agent',
content: `[Error] ${msg.message ?? 'Unknown error'}`,
timestamp: new Date(),
},
]);
setTyping(false);
pendingContentRef.current = '';
break;
}
};
ws.connect();
wsRef.current = ws;
return () => {
ws.disconnect();
};
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, typing]);
const handleSend = () => {
const trimmed = input.trim();
if (!trimmed || !wsRef.current?.connected) return;
setMessages((prev) => [
...prev,
{
id: makeMessageId(),
role: 'user',
content: trimmed,
timestamp: new Date(),
},
]);
try {
wsRef.current.sendMessage(trimmed);
setTyping(true);
pendingContentRef.current = '';
} catch {
setError('Failed to send message. Please try again.');
}
setInput('');
inputRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="flex min-h-[28rem] flex-col h-[calc(100dvh-8.5rem)]">
{/* Connection status bar */}
{error && (
<div className="px-4 py-2 bg-red-900/30 border-b border-red-700 flex items-center gap-2 text-sm text-red-300">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
{error}
</div>
)}
{/* Messages area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Bot className="h-12 w-12 mb-3 text-gray-600" />
<p className="text-lg font-medium">ZeroClaw Agent</p>
<p className="text-sm mt-1">Send a message to start the conversation</p>
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
className={`flex items-start gap-3 ${
msg.role === 'user' ? 'flex-row-reverse' : ''
}`}
>
<div
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
msg.role === 'user'
? 'bg-blue-600'
: 'bg-gray-700'
}`}
>
{msg.role === 'user' ? (
<User className="h-4 w-4 text-white" />
) : (
<Bot className="h-4 w-4 text-white" />
)}
</div>
<div
className={`max-w-[75%] rounded-xl px-4 py-3 ${
msg.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-100 border border-gray-700'
}`}
>
<p className="text-sm whitespace-pre-wrap break-words">{msg.content}</p>
<p
className={`text-xs mt-1 ${
msg.role === 'user' ? 'text-blue-200' : 'text-gray-500'
}`}
>
{msg.timestamp.toLocaleTimeString()}
</p>
</div>
</div>
))}
{typing && (
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center">
<Bot className="h-4 w-4 text-white" />
</div>
<div className="bg-gray-800 border border-gray-700 rounded-xl px-4 py-3">
<div className="flex items-center gap-1">
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
<p className="text-xs text-gray-500 mt-1">Typing...</p>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<div className="flex items-center gap-3 max-w-4xl mx-auto">
<div className="flex-1 relative">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={connected ? 'Type a message...' : 'Connecting...'}
disabled={!connected}
className="w-full bg-gray-800 border border-gray-700 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
/>
</div>
<button
onClick={handleSend}
disabled={!connected || !input.trim()}
className="flex-shrink-0 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-xl p-3 transition-colors"
>
<Send className="h-5 w-5" />
</button>
</div>
<div className="flex items-center justify-center mt-2 gap-2">
<span
className={`inline-block h-2 w-2 rounded-full ${
connected ? 'bg-green-500' : 'bg-red-500'
}`}
/>
<span className="text-xs text-gray-500">
{connected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
</div>
);
}