/ src / components / org-chart / mini-chat-bubble.tsx
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  }