mini-chat-bubble.tsx
1 'use client' 2 3 import { useCallback, useEffect, useRef, useState } from 'react' 4 import ReactMarkdown from 'react-markdown' 5 import remarkGfm from 'remark-gfm' 6 import { AgentAvatar } from '@/components/agents/agent-avatar' 7 import { useWs } from '@/hooks/use-ws' 8 import { api } from '@/lib/app/api-client' 9 import { fetchMessages } from '@/lib/chat/chats' 10 import { streamChat } from '@/lib/chat/chat' 11 import { hmrSingleton } from '@/lib/shared-utils' 12 import type { Agent, Message, Session, SSEEvent } from '@/types' 13 import { INTERNAL_KEY_RE, stripAllInternalMetadata } from '@/lib/strip-internal-metadata' 14 15 interface Props { 16 agent: Agent 17 onClose: () => void 18 onToolActivity?: (toolName: string) => void 19 } 20 21 const BUBBLE_W = 320 22 23 /** Client-side cache: agentId → sessionId, avoids redundant POST on reopen */ 24 const sessionCache = hmrSingleton('miniChatBubble_sessionCache', () => new Map<string, string>()) 25 26 const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i 27 28 /** Filter status text that looks like raw IDs or data dumps */ 29 function sanitizeToolStatus(text: string): string | null { 30 if (!text) return null 31 // Skip raw arrays or objects 32 if (text.startsWith('[') || text.startsWith('{')) return null 33 // Skip if it looks like it's mostly UUIDs 34 if (UUID_RE.test(text)) return null 35 // Truncate to reasonable length 36 return text.slice(0, 60) 37 } 38 39 export function MiniChatBubble({ agent, onClose, onToolActivity }: Props) { 40 const [sessionId, setSessionId] = useState<string | null>(null) 41 const [messages, setMessages] = useState<Message[]>([]) 42 const [streaming, setStreaming] = useState(false) 43 const [streamText, setStreamText] = useState('') 44 const [inputValue, setInputValue] = useState('') 45 const [loading, setLoading] = useState(true) 46 const [error, setError] = useState<string | null>(null) 47 const scrollRef = useRef<HTMLDivElement>(null) 48 const inputRef = useRef<HTMLInputElement>(null) 49 const cancelledRef = useRef(false) 50 51 // Initialize: get or create thread session, load messages 52 const init = useCallback(async () => { 53 setError(null) 54 setLoading(true) 55 try { 56 const cachedSid = sessionCache.get(agent.id) 57 if (cachedSid) { 58 // Try cached session first — skip POST entirely 59 try { 60 const msgs = await fetchMessages(cachedSid) 61 if (cancelledRef.current) return 62 setSessionId(cachedSid) 63 setMessages(msgs) 64 return 65 } catch { 66 // Cached session gone — clear and fall through to POST 67 sessionCache.delete(agent.id) 68 } 69 } 70 71 const session = await api<Session>('POST', `/agents/${agent.id}/thread`) 72 if (cancelledRef.current) return 73 setSessionId(session.id) 74 sessionCache.set(agent.id, session.id) 75 76 // Use messages from POST response if present, otherwise fetch 77 if (Array.isArray(session.messages) && session.messages.length > 0) { 78 setMessages(session.messages as Message[]) 79 } else { 80 const msgs = await fetchMessages(session.id) 81 if (cancelledRef.current) return 82 setMessages(msgs) 83 } 84 } catch { 85 if (!cancelledRef.current) setError('Could not connect to agent') 86 } finally { 87 if (!cancelledRef.current) setLoading(false) 88 } 89 }, [agent.id]) 90 91 useEffect(() => { 92 cancelledRef.current = false 93 init() 94 return () => { cancelledRef.current = true } 95 }, [init]) 96 97 // Real-time message refresh via WebSocket (mirrors main ChatArea pattern) 98 const refreshMessages = useCallback(async () => { 99 if (!sessionId || streaming) return 100 try { 101 const msgs = await fetchMessages(sessionId) 102 setMessages(msgs) 103 } catch { /* ignore */ } 104 }, [sessionId, streaming]) 105 106 useWs( 107 sessionId ? `messages:${sessionId}` : '', 108 refreshMessages, 109 streaming ? 2000 : undefined, 110 ) 111 112 // Auto-scroll to bottom on new messages or streaming text 113 useEffect(() => { 114 const el = scrollRef.current 115 if (el) el.scrollTop = el.scrollHeight 116 }, [messages, streamText]) 117 118 // Focus input once loaded 119 useEffect(() => { 120 if (!loading && inputRef.current) inputRef.current.focus() 121 }, [loading]) 122 123 // Escape to close 124 useEffect(() => { 125 const handler = (e: KeyboardEvent) => { 126 if (e.key === 'Escape') onClose() 127 } 128 document.addEventListener('keydown', handler) 129 return () => document.removeEventListener('keydown', handler) 130 }, [onClose]) 131 132 const send = useCallback(async () => { 133 if (!sessionId || !inputValue.trim() || streaming) return 134 const text = inputValue.trim() 135 setInputValue('') 136 137 // Optimistic user message 138 const userMsg: Message = { role: 'user', text, time: Date.now() } 139 setMessages((prev) => [...prev, userMsg]) 140 setStreaming(true) 141 setStreamText('') 142 143 await streamChat(sessionId, text, undefined, undefined, (event: SSEEvent) => { 144 switch (event.t) { 145 case 'd': 146 if (event.text) setStreamText((prev) => prev + event.text) 147 break 148 case 'md': 149 // Skip run-status metadata (JSON blobs from the queue system) 150 if (event.text && !event.text.startsWith('{') && !INTERNAL_KEY_RE.test(event.text)) { 151 setStreamText((prev) => prev + event.text) 152 } 153 break 154 case 'tool_call': 155 if (event.toolName) onToolActivity?.(event.toolName) 156 break 157 case 'status': { 158 const cleaned = event.text ? sanitizeToolStatus(event.text) : null 159 if (cleaned) onToolActivity?.(cleaned) 160 break 161 } 162 case 'done': 163 // Refresh messages to get the final state 164 if (sessionId) fetchMessages(sessionId).then((msgs) => setMessages(msgs)).catch(() => {}) 165 setStreaming(false) 166 setStreamText('') 167 break 168 case 'err': 169 if (sessionId) fetchMessages(sessionId).then((msgs) => setMessages(msgs)).catch(() => {}) 170 setStreaming(false) 171 setStreamText('') 172 break 173 } 174 }) 175 }, [sessionId, inputValue, streaming, onToolActivity]) 176 177 const stop = useCallback(() => { 178 if (sessionId) { 179 api('POST', `/chats/${sessionId}/stop`).catch(() => {}) 180 } 181 setStreaming(false) 182 setStreamText('') 183 }, [sessionId]) 184 185 // Filter out system/heartbeat messages 186 const visibleMessages = messages.filter( 187 (m) => !m.suppressed && m.kind !== 'heartbeat' && m.kind !== 'context-clear', 188 ) 189 190 return ( 191 <div 192 className="flex flex-col rounded-[12px] border border-white/[0.08] bg-[#12121e] shadow-2xl shadow-black/50 overflow-hidden" 193 style={{ width: BUBBLE_W, height: 400 }} 194 onPointerDown={(e) => e.stopPropagation()} 195 onClick={(e) => e.stopPropagation()} 196 onWheel={(e) => e.stopPropagation()} 197 > 198 {/* Header */} 199 <div className="flex items-center gap-2 px-3 py-2 border-b border-white/[0.06] bg-white/[0.02] shrink-0"> 200 <AgentAvatar 201 seed={agent.avatarSeed || null} 202 avatarUrl={agent.avatarUrl} 203 name={agent.name} 204 size={20} 205 /> 206 <span className="text-[12px] font-600 text-text truncate flex-1">{agent.name}</span> 207 <button 208 onClick={onClose} 209 className="w-5 h-5 rounded-[4px] flex items-center justify-center text-text-3 hover:text-text hover:bg-white/[0.08] cursor-pointer border-none transition-colors" 210 > 211 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> 212 <path d="M1 1l8 8M9 1l-8 8" /> 213 </svg> 214 </button> 215 </div> 216 217 {/* Messages */} 218 <div ref={scrollRef} className="flex-1 overflow-y-auto px-3 py-2 space-y-2 min-h-0"> 219 {loading && ( 220 <div className="text-[11px] text-text-3/50 text-center py-8">Loading...</div> 221 )} 222 {!loading && error && ( 223 <div className="text-center py-8 space-y-2"> 224 <div className="text-[11px] text-red-400/70">{error}</div> 225 <button 226 onClick={() => { init() }} 227 className="text-[11px] text-accent-bright/70 hover:text-accent-bright cursor-pointer border-none bg-transparent underline" 228 > 229 Retry 230 </button> 231 </div> 232 )} 233 {!loading && !error && visibleMessages.length === 0 && !streaming && ( 234 <div className="text-[11px] text-text-3/40 text-center py-8"> 235 Start a conversation with {agent.name} 236 </div> 237 )} 238 {visibleMessages.map((msg, i) => ( 239 <MessageRow key={`${msg.time}-${i}`} message={msg} /> 240 ))} 241 {streaming && streamText && ( 242 <div className="flex justify-start"> 243 <div className="max-w-[85%] rounded-[8px] px-2.5 py-1.5 bg-white/[0.04] border border-white/[0.06]"> 244 <div className="mini-chat-md text-[12px] text-text-2 leading-relaxed"> 245 <ReactMarkdown remarkPlugins={[remarkGfm]}>{stripAllInternalMetadata(streamText)}</ReactMarkdown> 246 </div> 247 <span className="inline-block w-[5px] h-[12px] bg-accent-bright/60 ml-0.5 animate-pulse" /> 248 </div> 249 </div> 250 )} 251 {streaming && !streamText && ( 252 <div className="flex justify-start"> 253 <div className="rounded-[8px] px-2.5 py-1.5 bg-white/[0.04] border border-white/[0.06]"> 254 <div className="flex gap-1 items-center"> 255 <span className="w-1.5 h-1.5 rounded-full bg-text-3/40 animate-pulse" /> 256 <span className="w-1.5 h-1.5 rounded-full bg-text-3/40 animate-pulse" style={{ animationDelay: '150ms' }} /> 257 <span className="w-1.5 h-1.5 rounded-full bg-text-3/40 animate-pulse" style={{ animationDelay: '300ms' }} /> 258 </div> 259 </div> 260 </div> 261 )} 262 </div> 263 264 {/* Input */} 265 <div className="flex items-center gap-1.5 px-2 py-2 border-t border-white/[0.06] shrink-0"> 266 <input 267 ref={inputRef} 268 type="text" 269 value={inputValue} 270 onChange={(e) => setInputValue(e.target.value)} 271 onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} 272 placeholder="Type a message..." 273 disabled={loading || !!error || !sessionId} 274 className="flex-1 text-[12px] bg-white/[0.04] border border-white/[0.06] rounded-[6px] px-2.5 py-1.5 text-text placeholder:text-text-3/30 outline-none focus:border-accent-bright/30 transition-colors disabled:opacity-40" 275 /> 276 {streaming ? ( 277 <button 278 onClick={stop} 279 className="w-7 h-7 rounded-[6px] flex items-center justify-center bg-red-500/20 text-red-400 hover:bg-red-500/30 cursor-pointer border-none transition-colors" 280 > 281 <svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor"> 282 <rect x="1" y="1" width="8" height="8" rx="1" /> 283 </svg> 284 </button> 285 ) : ( 286 <button 287 onClick={send} 288 disabled={!inputValue.trim() || loading || !sessionId} 289 className="w-7 h-7 rounded-[6px] flex items-center justify-center bg-accent-bright/20 text-accent-bright hover:bg-accent-bright/30 cursor-pointer border-none transition-colors disabled:opacity-30 disabled:cursor-default" 290 > 291 <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"> 292 <path d="M1 11L11 6L1 1v4l5 1-5 1z" /> 293 </svg> 294 </button> 295 )} 296 </div> 297 298 {/* Caret pointing down */} 299 <div 300 className="absolute left-1/2 -translate-x-1/2 w-0 h-0" 301 style={{ 302 bottom: -8, 303 borderLeft: '8px solid transparent', 304 borderRight: '8px solid transparent', 305 borderTop: '8px solid #12121e', 306 }} 307 /> 308 </div> 309 ) 310 } 311 312 function MessageRow({ message }: { message: Message }) { 313 const isUser = message.role === 'user' 314 315 return ( 316 <div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}> 317 <div 318 className={`max-w-[85%] rounded-[8px] px-2.5 py-1.5 ${ 319 isUser 320 ? 'bg-accent-bright/15 border border-accent-bright/20' 321 : 'bg-white/[0.04] border border-white/[0.06]' 322 }`} 323 > 324 {isUser ? ( 325 <p className="text-[12px] text-text leading-relaxed whitespace-pre-wrap break-words">{message.text}</p> 326 ) : ( 327 <div className="mini-chat-md text-[12px] text-text-2 leading-relaxed"> 328 <ReactMarkdown remarkPlugins={[remarkGfm]}>{stripAllInternalMetadata(message.text)}</ReactMarkdown> 329 </div> 330 )} 331 </div> 332 </div> 333 ) 334 }