/ src / components / chat / chat-area.tsx
chat-area.tsx
  1  'use client'
  2  
  3  import { useEffect, useCallback, useState, useRef, useMemo } from 'react'
  4  import dynamic from 'next/dynamic'
  5  import { useAppStore } from '@/stores/use-app-store'
  6  import { selectActiveSessionId } from '@/stores/slices/session-slice'
  7  import { useWs } from '@/hooks/use-ws'
  8  import { useChatStore } from '@/stores/use-chat-store'
  9  import { fetchMessages, fetchMessagesPaginated, clearMessages, undoClearMessages, deleteChat, devServer, checkBrowser, stopBrowser } from '@/lib/chat/chats'
 10  import { toast } from 'sonner'
 11  import { errorMessage } from '@/lib/shared-utils'
 12  import { uploadImage } from '@/lib/upload'
 13  import { deleteAgent } from '@/lib/agents'
 14  import { useMediaQuery } from '@/hooks/use-media-query'
 15  import { ChatHeader } from './chat-header'
 16  import { DevServerBar } from './dev-server-bar'
 17  import { MessageList } from './message-list'
 18  import { VoiceOverlay } from './voice-overlay'
 19  import { useVoiceConversation } from '@/hooks/use-voice-conversation'
 20  import { ChatInput } from '@/components/input/chat-input'
 21  
 22  // Lazy-load conditional panels — only bundled when actually rendered
 23  const SessionDebugPanel = dynamic(() => import('./session-debug-panel').then((m) => m.SessionDebugPanel), { ssr: false })
 24  const ChatPreviewPanel = dynamic(() => import('./chat-preview-panel').then((m) => m.ChatPreviewPanel), { ssr: false })
 25  const InspectorPanel = dynamic(() => import('@/components/agents/inspector-panel').then((m) => m.InspectorPanel), { ssr: false })
 26  const HeartbeatHistoryPanel = dynamic(() => import('./heartbeat-history-panel').then((m) => m.HeartbeatHistoryPanel), { ssr: false })
 27  import { Dropdown, DropdownItem } from '@/components/shared/dropdown'
 28  import { ConfirmDialog } from '@/components/shared/confirm-dialog'
 29  import { speak } from '@/lib/tts'
 30  import { api } from '@/lib/app/api-client'
 31  import { messagesDiffer } from '@/lib/chat/chat-streaming-state'
 32  import { createAssistantRenderId } from '@/lib/chat/assistant-render-id'
 33  import { getSessionLastMessage } from '@/lib/chat/session-summary'
 34  import { buildNewAgentSessionPayload, summarizeFirstMessageAsTitle } from '@/lib/chat/new-session'
 35  import { getEnabledCapabilityIds, getEnabledToolIds } from '@/lib/capability-selection'
 36  
 37  const DIRECT_PROMPT_SUGGESTIONS = [
 38    { text: 'What can you help me with?', icon: 'book', gradient: 'from-[#6366F1]/10 to-[#818CF8]/5' },
 39    { text: 'Help me choose the right agent for this', icon: 'bot', gradient: 'from-[#34D399]/10 to-[#6EE7B7]/5' },
 40    { text: 'Help me set up a new connector', icon: 'link', gradient: 'from-[#EC4899]/10 to-[#F472B6]/5' },
 41    { text: 'Summarize what needs attention in this workspace', icon: 'check', gradient: 'from-[#F59E0B]/10 to-[#FBBF24]/5' },
 42  ]
 43  
 44  const AGENT_PROMPT_SUGGESTIONS = [
 45    { text: 'Give me a quick overview of what you can help with', icon: 'book', gradient: 'from-[#6366F1]/10 to-[#818CF8]/5' },
 46    { text: 'Review what needs attention right now', icon: 'check', gradient: 'from-[#F59E0B]/10 to-[#FBBF24]/5' },
 47    { text: 'Summarize our recent context before we continue', icon: 'link', gradient: 'from-[#EC4899]/10 to-[#F472B6]/5' },
 48    { text: 'Help me map the next best step', icon: 'bot', gradient: 'from-[#34D399]/10 to-[#6EE7B7]/5' },
 49  ]
 50  
 51  export function ChatArea() {
 52    const session = useAppStore((s) => {
 53      const id = selectActiveSessionId(s)
 54      return id ? s.sessions[id] : null
 55    })
 56    const sessionId = useAppStore(selectActiveSessionId)
 57    const currentUser = useAppStore((s) => s.currentUser)
 58    const setCurrentAgent = useAppStore((s) => s.setCurrentAgent)
 59    const removeSessionFromStore = useAppStore((s) => s.removeSession)
 60    const refreshSession = useAppStore((s) => s.refreshSession)
 61    const updateSessionInStore = useAppStore((s) => s.updateSessionInStore)
 62    const setActiveSessionIdOverride = useAppStore((s) => s.setActiveSessionIdOverride)
 63    const appSettings = useAppStore((s) => s.appSettings)
 64    const messages = useChatStore((s) => s.messages)
 65    const messageStartIndex = useChatStore((s) => s.messageStartIndex)
 66    const setMessages = useChatStore((s) => s.setMessages)
 67    const streaming = useChatStore((s) => s.streaming)
 68    const streamingSessionId = useChatStore((s) => s.streamingSessionId)
 69    const sendMessage = useChatStore((s) => s.sendMessage)
 70    const loadQueuedMessages = useChatStore((s) => s.loadQueuedMessages)
 71    const stopStreaming = useChatStore((s) => s.stopStreaming)
 72    const devServerStatus = useChatStore((s) => s.devServer)
 73    const setDevServer = useChatStore((s) => s.setDevServer)
 74    const debugOpen = useChatStore((s) => s.debugOpen)
 75    const setDebugOpen = useChatStore((s) => s.setDebugOpen)
 76    const ttsEnabled = useChatStore((s) => s.ttsEnabled)
 77    const previewContent = useChatStore((s) => s.previewContent)
 78    const setPreviewContent = useChatStore((s) => s.setPreviewContent)
 79    const isDesktop = useMediaQuery('(min-width: 768px)')
 80  
 81    const markSessionLocallyIdle = useCallback((targetSessionId: string) => {
 82      const appState = useAppStore.getState()
 83      const existing = appState.sessions[targetSessionId]
 84      if (!existing) return
 85      appState.updateSessionInStore({
 86        ...existing,
 87        active: false,
 88        currentRunId: null,
 89      })
 90    }, [])
 91  
 92    const startServerStreamingPlaceholder = useCallback((targetSessionId: string, phase: 'queued' | 'thinking' | 'connecting' = 'thinking') => {
 93      useChatStore.setState((state) => {
 94        const sameServerStream = state.streaming
 95          && state.streamSource === 'server'
 96          && state.streamingSessionId === targetSessionId
 97  
 98        return {
 99          streaming: true,
100          streamingSessionId: targetSessionId,
101          streamSource: 'server',
102          streamPhase: sameServerStream ? state.streamPhase : phase,
103          streamText: '',
104          displayText: '',
105          assistantRenderId: sameServerStream && state.assistantRenderId ? state.assistantRenderId : createAssistantRenderId(),
106          thinkingStartTime: sameServerStream && state.thinkingStartTime > 0 ? state.thinkingStartTime : Date.now(),
107        }
108      })
109    }, [])
110  
111    const currentAgent = useAppStore((s) => {
112      const agentId = session?.agentId
113      return agentId ? s.agents[agentId] ?? null : null
114    })
115    const loadAgents = useAppStore((s) => s.loadAgents)
116    const setEditingAgentId = useAppStore((s) => s.setEditingAgentId)
117    const setAgentSheetOpen = useAppStore((s) => s.setAgentSheetOpen)
118    const setAgentPrefill = useAppStore((s) => s.setAgentPrefill)
119    const inspectorOpen = useAppStore((s) => s.inspectorOpen)
120    const sidebarOpen = useAppStore((s) => s.sidebarOpen)
121    const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
122    const queuedCount = session?.queuedCount ?? 0
123    const promptSuggestions = useMemo(
124      () => (currentAgent ? AGENT_PROMPT_SUGGESTIONS : DIRECT_PROMPT_SUGGESTIONS),
125      [currentAgent],
126    )
127  
128    const voice = useVoiceConversation()
129    const handleVoiceToggle = useCallback(() => {
130      if (voice.active) voice.stop()
131      else voice.start()
132    }, [voice])
133  
134    const [menuOpen, setMenuOpen] = useState(false)
135    const [confirmDelete, setConfirmDelete] = useState(false)
136    const [confirmClear, setConfirmClear] = useState(false)
137    const [confirmDeleteAgent, setConfirmDeleteAgent] = useState(false)
138    const [browserActive, setBrowserActive] = useState(false)
139    const heartbeatHistoryOpen = useAppStore((s) => s.heartbeatHistoryOpen)
140    const setHeartbeatHistoryOpen = useAppStore((s) => s.setHeartbeatHistoryOpen)
141    const [messagesLoading, setMessagesLoading] = useState(true)
142    const [connectorFilter, setConnectorFilter] = useState<string | null>(null)
143    const [extensionChatActions, setExtensionChatActions] = useState<Array<{ id: string; label: string; action: string; value: string; tooltip?: string }>>([])
144    const sessionHasBrowserExtension = getEnabledToolIds(session).includes('browser')
145  
146    const refreshExtensionChatActions = useCallback(() => {
147      api<Array<{ id: string; label: string; action: string; value: string; tooltip?: string }>>('GET', '/extensions/ui?type=chat_actions').then((actions) => {
148        if (Array.isArray(actions)) setExtensionChatActions(actions)
149      }).catch(() => {})
150    }, [])
151  
152    useEffect(() => {
153      void refreshExtensionChatActions()
154    }, [refreshExtensionChatActions])
155  
156    useWs('extensions', refreshExtensionChatActions)
157  
158    // Collect unique connector sources from messages for filter UI
159    const { connectorSources, hasDirectMessages } = useMemo(() => {
160      const sources = new Map<string, { platform: string; connectorName: string }>()
161      let hasDirect = false
162      for (const msg of messages) {
163        if (msg.source?.connectorId && !sources.has(msg.source.connectorId)) {
164          sources.set(msg.source.connectorId, {
165            platform: msg.source.platform,
166            connectorName: msg.source.connectorName,
167          })
168        } else if (!msg.source?.connectorId && msg.role === 'user') {
169          hasDirect = true
170        }
171      }
172      return { connectorSources: sources, hasDirectMessages: hasDirect }
173    }, [messages])
174    // Show source filter when there are genuinely multiple sources (2+ connectors, or connector + direct)
175    const hasMultipleSources = connectorSources.size > 1 || (connectorSources.size > 0 && hasDirectMessages)
176    const [isDragging, setIsDragging] = useState(false)
177    const dragCounter = useRef(0)
178    const freshSessionIdRef = useRef<string | null>(null)
179    const setPendingImage = useChatStore((s) => s.setPendingImage)
180  
181    useEffect(() => {
182      if (!sessionId) return
183      let cancelled = false
184      const requestedSessionId = sessionId
185      const chatState = useChatStore.getState()
186      const preserveLocalStream = chatState.streaming && chatState.streamingSessionId === requestedSessionId
187      if (freshSessionIdRef.current === requestedSessionId) {
188        freshSessionIdRef.current = null
189        setMessages([], { startIndex: 0, totalMessages: 0 })
190        useChatStore.setState({ hasMoreMessages: false })
191        setMessagesLoading(false)
192        return () => { cancelled = true }
193      }
194      // Clear stale messages immediately so the skeleton loader shows instead of
195      // the previous chat's messages flashing briefly during the fetch.
196      if (!preserveLocalStream) setMessages([], { startIndex: 0, totalMessages: 0 })
197      setMessagesLoading(true)
198      if (!preserveLocalStream) {
199        useChatStore.setState({ streaming: false, streamingSessionId: null, streamSource: null, streamText: '', assistantRenderId: null, toolEvents: [] })
200      }
201      fetchMessagesPaginated(requestedSessionId, 100).then((data) => {
202        if (cancelled || selectActiveSessionId(useAppStore.getState()) !== requestedSessionId) return
203        setMessages(data.messages, { startIndex: data.startIndex, totalMessages: data.total })
204        useChatStore.setState({ hasMoreMessages: data.hasMore })
205      }).catch((err) => {
206        if (cancelled || selectActiveSessionId(useAppStore.getState()) !== requestedSessionId) return
207        console.error('Failed to load messages:', err)
208        const fallbackSession = useAppStore.getState().sessions[requestedSessionId]
209        const fallbackLastMessage = fallbackSession ? getSessionLastMessage(fallbackSession) : null
210        setMessages(
211          fallbackSession?.messages?.length
212            ? fallbackSession.messages
213            : (fallbackLastMessage ? [fallbackLastMessage] : []),
214          {
215            startIndex: 0,
216            totalMessages: fallbackSession?.messages?.length
217              ? fallbackSession.messages.length
218              : (fallbackLastMessage ? 1 : 0),
219          },
220        )
221      }).finally(() => {
222        if (cancelled || selectActiveSessionId(useAppStore.getState()) !== requestedSessionId) return
223        setMessagesLoading(false)
224      })
225  
226      void loadQueuedMessages(requestedSessionId).catch((err) => {
227        if (cancelled || selectActiveSessionId(useAppStore.getState()) !== requestedSessionId) return
228        console.error('Failed to load queued messages:', err)
229      })
230  
231      const sessionAtLoad = useAppStore.getState().sessions[requestedSessionId]
232      if (sessionAtLoad?.active) startServerStreamingPlaceholder(requestedSessionId)
233  
234      return () => {
235        cancelled = true
236      }
237    }, [loadQueuedMessages, refreshSession, sessionId, setDevServer, setMessages, startServerStreamingPlaceholder])
238  
239    useEffect(() => {
240      if (!sessionId || messagesLoading) return
241      let cancelled = false
242      const requestedSessionId = sessionId
243      const timer = window.setTimeout(() => {
244        void refreshSession(requestedSessionId).then(() => {
245          if (cancelled || selectActiveSessionId(useAppStore.getState()) !== requestedSessionId) return
246          const refreshed = useAppStore.getState().sessions[requestedSessionId]
247          if (refreshed?.active) startServerStreamingPlaceholder(requestedSessionId)
248        }).catch((err) => console.error('Failed to refresh session:', err))
249  
250        void devServer(requestedSessionId, 'status').then((r) => {
251          if (cancelled || selectActiveSessionId(useAppStore.getState()) !== requestedSessionId) return
252          setDevServer(r.running ? r : null)
253        }).catch(() => {
254          if (cancelled || selectActiveSessionId(useAppStore.getState()) !== requestedSessionId) return
255          setDevServer(null)
256        })
257      }, 200)
258  
259      return () => {
260        cancelled = true
261        window.clearTimeout(timer)
262      }
263    }, [messagesLoading, refreshSession, sessionId, setDevServer, startServerStreamingPlaceholder])
264  
265    useEffect(() => {
266      if (!sessionId || messagesLoading) return
267      let cancelled = false
268      if (!sessionHasBrowserExtension) {
269        setBrowserActive(false)
270        return
271      }
272      checkBrowser(sessionId).then((r) => {
273        if (cancelled || selectActiveSessionId(useAppStore.getState()) !== sessionId) return
274        setBrowserActive(r.active)
275      }).catch((err) => {
276        if (cancelled || selectActiveSessionId(useAppStore.getState()) !== sessionId) return
277        console.error('Browser check failed:', err)
278        setBrowserActive(false)
279      })
280      return () => {
281        cancelled = true
282      }
283    }, [messagesLoading, sessionHasBrowserExtension, sessionId])
284  
285    // Auto-poll messages for sessions that are actively running on the server
286    const isServerActive = session?.active === true
287    const isOngoingMonitored = appSettings.loopMode === 'ongoing' && getEnabledCapabilityIds(session).length > 0
288    const shouldPollMessages = !!sessionId && (isServerActive || isOngoingMonitored)
289    const messagesRef = useRef(messages)
290    messagesRef.current = messages
291    const messageStartIndexRef = useRef(messageStartIndex)
292    messageStartIndexRef.current = messageStartIndex
293    const isServerActiveRef = useRef(isServerActive)
294    isServerActiveRef.current = isServerActive
295    const ttsEnabledRef = useRef(ttsEnabled)
296    ttsEnabledRef.current = ttsEnabled
297  
298    const refreshMessages = useCallback(async () => {
299      if (!sessionId) return
300      // Skip message refresh while we're locally streaming this session —
301      // the SSE stream already drives the inline live transcript row.
302      // Fetching messages here would replace the array with new objects on every
303      // WS notification, causing the full MessageList to re-render and flash.
304      const chatState = useChatStore.getState()
305      if (chatState.streaming && chatState.streamingSessionId === sessionId && chatState.streamSource === 'local') return
306      try {
307        const msgs = await fetchMessages(sessionId)
308        const currentChatState = useChatStore.getState()
309        if (currentChatState.streaming && currentChatState.streamingSessionId === sessionId && currentChatState.streamSource === 'local') return
310        const previous = messagesRef.current
311        if (messagesDiffer(msgs, previous)) {
312          const previousEndIndex = messageStartIndexRef.current + previous.length
313          const newMsgs = msgs.length > previousEndIndex ? msgs.slice(previousEndIndex) : []
314          setMessages(msgs, { startIndex: 0, totalMessages: msgs.length })
315          if (ttsEnabledRef.current && typeof document !== 'undefined' && document.visibilityState === 'visible') {
316            const latestAssistant = [...newMsgs].reverse().find((m) => {
317              if (m.role !== 'assistant') return false
318              const isHeartbeat = m.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(m.text || '')
319              return !isHeartbeat && !!m.text?.trim()
320            })
321            if (latestAssistant?.text) {
322              void speak(latestAssistant.text, currentAgent?.elevenLabsVoiceId)
323            }
324          }
325        }
326        if (isServerActiveRef.current) await refreshSession(sessionId)
327      } catch (err) { console.error('Failed to refresh messages:', err) }
328      // eslint-disable-next-line react-hooks/exhaustive-deps
329    }, [sessionId])
330  
331    // Targeted message fetch that bypasses the streaming guard — used by
332    // refreshQueue to ensure persisted messages appear before sending queue
333    // items are cleaned up by timeout.
334    const syncMessagesForQueue = useCallback(async () => {
335      if (!sessionId) return
336      try {
337        const msgs = await fetchMessages(sessionId)
338        if (messagesDiffer(msgs, messagesRef.current)) {
339          setMessages(msgs, { startIndex: 0, totalMessages: msgs.length })
340        }
341      } catch (err) { console.error('Failed to sync messages for queue:', err) }
342    }, [sessionId, setMessages])
343  
344    const refreshQueue = useCallback(async () => {
345      if (!sessionId) return
346      try {
347        await loadQueuedMessages(sessionId)
348        // If there are "sending" queue items, fetch messages so persisted
349        // versions appear before the queue item gets cleaned up.
350        const chatState = useChatStore.getState()
351        const hasSendingItems = chatState.queuedMessages.some(
352          (item) => item.sessionId === sessionId && item.sending,
353        )
354        if (hasSendingItems) void syncMessagesForQueue()
355        // Bridge the gap between "queue item disappears" and "isServerActive propagates".
356        // If the server picked up a queued run, immediately show the thinking indicator
357        // so users don't see a blank gap waiting for loadSessions to propagate.
358        const refreshedSession = useAppStore.getState().sessions[sessionId]
359        if (
360          refreshedSession?.currentRunId
361          && !chatState.streaming
362          && chatState.streamingSessionId !== sessionId
363        ) {
364          startServerStreamingPlaceholder(sessionId)
365        }
366      } catch (err) {
367        console.error('Failed to refresh queue:', err)
368      }
369    }, [loadQueuedMessages, syncMessagesForQueue, sessionId, startServerStreamingPlaceholder])
370  
371    // Subscribe to WS messages for this session — always subscribe when session exists,
372    // only enable fallback polling when actively needed
373    useWs(
374      sessionId ? `messages:${sessionId}` : '',
375      refreshMessages,
376      shouldPollMessages ? 10_000 : undefined,
377    )
378    useWs(
379      sessionId ? 'runs' : '',
380      refreshQueue,
381      sessionId && (isServerActive || queuedCount > 0) ? 2_500 : undefined,
382    )
383  
384    // Listen for stream-end signal from the server — clears streaming state
385    // when a server-only run finishes without a local SSE stream driving the UI.
386    const handleStreamEnd = useCallback(() => {
387      if (!sessionId) return
388      const state = useChatStore.getState()
389      if (state.streamSource === 'server' && state.streamingSessionId === sessionId) {
390        markSessionLocallyIdle(sessionId)
391        useChatStore.setState({ streaming: false, streamingSessionId: null, streamSource: null, streamText: '', displayText: '', assistantRenderId: null, streamPhase: 'thinking', streamToolName: '', thinkingText: '', thinkingStartTime: 0 })
392        void refreshMessages()
393        void refreshSession(sessionId)
394      }
395    }, [markSessionLocallyIdle, sessionId, refreshMessages, refreshSession])
396    useWs(sessionId ? `stream-end:${sessionId}` : '', handleStreamEnd)
397  
398    // Keep the local typing indicator aligned with the server's active state
399    useEffect(() => {
400      if (!sessionId) return
401      const state = useChatStore.getState()
402      if (isServerActive) {
403        if (!state.streaming && !state.streamText) {
404          startServerStreamingPlaceholder(sessionId)
405        }
406        return
407      }
408      if (
409        !isServerActive
410        && state.streaming
411        && (state.streamingSessionId === sessionId || state.streamingSessionId == null)
412      ) {
413        // Server finished — clear all streaming state and fetch final messages
414        fetchMessages(sessionId).then((msgs) => setMessages(msgs, { startIndex: 0, totalMessages: msgs.length })).catch(() => {})
415        markSessionLocallyIdle(sessionId)
416        useChatStore.setState({ streaming: false, streamingSessionId: null, streamSource: null, streamText: '', displayText: '', assistantRenderId: null, streamPhase: 'thinking', streamToolName: '', thinkingText: '', thinkingStartTime: 0 })
417      }
418    }, [isServerActive, markSessionLocallyIdle, sessionId, setMessages, startServerStreamingPlaceholder])
419  
420    // Poll browser status while session has browser tools
421    const hasBrowserTool = getEnabledToolIds(session).includes('browser')
422    const checkBrowserStatus = useCallback(() => {
423      if (!sessionId || !hasBrowserTool) return
424      checkBrowser(sessionId).then((r) => setBrowserActive(r.active)).catch(() => {})
425    }, [sessionId, hasBrowserTool])
426  
427    useWs(
428      hasBrowserTool && sessionId ? `browser:${sessionId}` : '',
429      checkBrowserStatus,
430      hasBrowserTool ? 30_000 : undefined,
431    )
432  
433    const handleStopBrowser = useCallback(async () => {
434      if (!sessionId) return
435      await stopBrowser(sessionId)
436      setBrowserActive(false)
437    }, [sessionId])
438  
439    const handleStopDevServer = useCallback(async () => {
440      if (!sessionId) return
441      await devServer(sessionId, 'stop')
442      setDevServer(null)
443    }, [sessionId, setDevServer])
444  
445    const handleClear = useCallback(async (mode: 'clear' | 'new-session' = 'clear') => {
446      setConfirmClear(false)
447      if (!sessionId) return
448      const targetSessionId = sessionId
449      let result
450      try {
451        result = await clearMessages(targetSessionId)
452      } catch (err) {
453        toast.error(`Clear failed: ${errorMessage(err)}`)
454        return
455      }
456      if (selectActiveSessionId(useAppStore.getState()) === targetSessionId) {
457        setMessages([], { startIndex: 0, totalMessages: 0 })
458      }
459      await refreshSession(targetSessionId)
460      const { undoToken, cleared } = result
461      if (!undoToken) return
462      const clearedLabel = mode === 'new-session'
463        ? 'Started a fresh chat session.'
464        : cleared === 1
465          ? '1 message cleared'
466          : `${cleared.toLocaleString()} messages cleared`
467      toast(clearedLabel, {
468        duration: 10_000,
469        action: {
470          label: 'Undo',
471          onClick: async () => {
472            try {
473              await undoClearMessages(targetSessionId, undoToken)
474              const restored = await fetchMessages(targetSessionId)
475              if (selectActiveSessionId(useAppStore.getState()) === targetSessionId) {
476                setMessages(restored, { startIndex: 0, totalMessages: restored.length })
477              }
478              await refreshSession(targetSessionId)
479              toast.success('Chat restored.')
480            } catch (err) {
481              toast.error(`Undo failed: ${errorMessage(err)}`)
482            }
483          },
484        },
485      })
486    }, [refreshSession, sessionId, setMessages])
487  
488    const handleCompactComplete = useCallback(async () => {
489      if (!sessionId) return
490      const targetSessionId = sessionId
491      try {
492        const refreshed = await fetchMessages(targetSessionId)
493        if (selectActiveSessionId(useAppStore.getState()) === targetSessionId) {
494          setMessages(refreshed, { startIndex: 0, totalMessages: refreshed.length })
495        }
496      } catch {
497        // silent — next poll will catch up
498      }
499    }, [sessionId, setMessages])
500  
501    const handleClearRequest = useCallback(() => {
502      setConfirmClear(true)
503    }, [])
504  
505    const handleStartNewSession = useCallback(async () => {
506      if (!session) return
507      try {
508        const nextSession = await api<typeof session>('POST', '/chats', {
509          ...buildNewAgentSessionPayload(session),
510          name: currentAgent?.name || session.name,
511        })
512        freshSessionIdRef.current = nextSession.id
513        updateSessionInStore(nextSession)
514        setActiveSessionIdOverride(nextSession.id)
515        toast.success('Started a new chat session.')
516      } catch (err) {
517        toast.error(`Could not start a new chat session: ${errorMessage(err)}`)
518      }
519    }, [currentAgent?.name, session, setActiveSessionIdOverride, updateSessionInStore])
520  
521    const handleSend = useCallback(async (text: string) => {
522      if (!sessionId) return
523      if (session && messages.length === 0) {
524        const nextTitle = summarizeFirstMessageAsTitle(text, currentAgent?.name || session.name)
525        if (nextTitle && nextTitle !== session.name) {
526          updateSessionInStore({ ...session, name: nextTitle })
527          void api('PUT', `/chats/${sessionId}`, { name: nextTitle }).catch(() => {})
528        }
529      }
530      await sendMessage(text, { sessionId })
531    }, [currentAgent?.name, messages.length, sendMessage, session, sessionId, updateSessionInStore])
532  
533    const handleDelete = useCallback(async () => {
534      setConfirmDelete(false)
535      if (!sessionId) return
536      await deleteChat(sessionId)
537      removeSessionFromStore(sessionId)
538      void setCurrentAgent(null)
539    }, [removeSessionFromStore, sessionId, setCurrentAgent])
540  
541    const handlePrompt = useCallback((text: string) => {
542      void handleSend(text)
543    }, [handleSend])
544  
545    const handleDragOver = useCallback((e: React.DragEvent) => {
546      e.preventDefault()
547      e.stopPropagation()
548    }, [])
549  
550    const handleDragEnter = useCallback((e: React.DragEvent) => {
551      e.preventDefault()
552      e.stopPropagation()
553      dragCounter.current++
554      if (e.dataTransfer.types.includes('Files')) setIsDragging(true)
555    }, [])
556  
557    const handleDragLeave = useCallback((e: React.DragEvent) => {
558      e.preventDefault()
559      e.stopPropagation()
560      dragCounter.current--
561      if (dragCounter.current === 0) setIsDragging(false)
562    }, [])
563  
564    const handleDrop = useCallback(async (e: React.DragEvent) => {
565      e.preventDefault()
566      e.stopPropagation()
567      dragCounter.current = 0
568      setIsDragging(false)
569      const file = e.dataTransfer.files?.[0]
570      if (!file) return
571      try {
572        const result = await uploadImage(file)
573        setPendingImage({ file, path: result.path, url: result.url })
574      } catch {
575        // ignore
576      }
577    }, [setPendingImage])
578  
579    const streamingForThisSession = streaming && (!!session && (!streamingSessionId || streamingSessionId === session.id))
580  
581    if (!session) return null
582  
583    const isEmpty = !messages.length && !streamingForThisSession && !messagesLoading
584  
585    return (
586      <div className="flex-1 flex h-full min-h-0 min-w-0">
587      <div
588        data-testid="chat-area"
589        className="flex-1 flex flex-col h-full min-h-0 min-w-0 relative"
590        onDragOver={handleDragOver}
591        onDragEnter={handleDragEnter}
592        onDragLeave={handleDragLeave}
593        onDrop={handleDrop}
594      >
595        {isDesktop && (
596          <ChatHeader
597            session={session}
598            streaming={streamingForThisSession}
599            onStop={stopStreaming}
600            onMenuToggle={() => setMenuOpen(!menuOpen)}
601            onBack={sidebarOpen ? () => setSidebarOpen(false) : undefined}
602            browserActive={browserActive}
603            onStopBrowser={handleStopBrowser}
604            voiceActive={voice.active}
605            voiceSupported={voice.supported}
606            onVoiceToggle={handleVoiceToggle}
607            connectorSources={connectorSources}
608            connectorFilter={connectorFilter}
609            onConnectorFilterChange={setConnectorFilter}
610            hasMultipleSources={hasMultipleSources}
611            messageCount={messages.length}
612            onCompactComplete={handleCompactComplete}
613            onClearRequest={handleClearRequest}
614            onStartNewSession={handleStartNewSession}
615          />
616        )}
617        {!isDesktop && (
618          <ChatHeader
619            session={session}
620            streaming={streamingForThisSession}
621            onStop={stopStreaming}
622            onMenuToggle={() => setMenuOpen(!menuOpen)}
623            mobile
624            browserActive={browserActive}
625            onStopBrowser={handleStopBrowser}
626            voiceActive={voice.active}
627            voiceSupported={voice.supported}
628            onVoiceToggle={handleVoiceToggle}
629            connectorSources={connectorSources}
630            connectorFilter={connectorFilter}
631            onConnectorFilterChange={setConnectorFilter}
632            hasMultipleSources={hasMultipleSources}
633            messageCount={messages.length}
634            onCompactComplete={handleCompactComplete}
635            onClearRequest={handleClearRequest}
636            onStartNewSession={handleStartNewSession}
637          />
638        )}
639        <DevServerBar status={devServerStatus} onStop={handleStopDevServer} />
640  
641        {messagesLoading && !messages.length ? (
642          <div className="flex-1 flex flex-col gap-5 px-4 md:px-12 lg:px-16 py-8" style={{ animation: 'fade-in 0.2s ease' }}>
643            {/* Skeleton message bubbles */}
644            <div className="flex gap-3 max-w-[70%]">
645              <div className="w-8 h-8 rounded-full bg-white/[0.06] animate-pulse shrink-0" />
646              <div className="flex-1 space-y-2">
647                <div className="h-3 w-24 rounded bg-white/[0.06] animate-pulse" />
648                <div className="h-16 rounded-[12px] bg-white/[0.04] animate-pulse" />
649              </div>
650            </div>
651            <div className="flex gap-3 max-w-[60%] self-end flex-row-reverse">
652              <div className="w-8 h-8 rounded-full bg-white/[0.06] animate-pulse shrink-0" />
653              <div className="flex-1 space-y-2">
654                <div className="h-3 w-16 rounded bg-white/[0.06] animate-pulse ml-auto" />
655                <div className="h-10 rounded-[12px] bg-white/[0.04] animate-pulse" />
656              </div>
657            </div>
658            <div className="flex gap-3 max-w-[65%]">
659              <div className="w-8 h-8 rounded-full bg-white/[0.06] animate-pulse shrink-0" />
660              <div className="flex-1 space-y-2">
661                <div className="h-3 w-20 rounded bg-white/[0.06] animate-pulse" />
662                <div className="h-24 rounded-[12px] bg-white/[0.04] animate-pulse" />
663              </div>
664            </div>
665          </div>
666        ) : isEmpty ? (
667          <div className="flex-1 flex flex-col items-center justify-center px-6 pb-[120px] md:pb-4 relative">
668            {/* Atmospheric background glow */}
669            <div className="absolute inset-0 pointer-events-none overflow-hidden">
670              <div className="absolute top-[20%] left-[50%] -translate-x-1/2 w-[500px] h-[300px]"
671                style={{
672                  background: 'radial-gradient(ellipse at center, rgba(99,102,241,0.05) 0%, transparent 70%)',
673                  animation: 'glow-pulse 6s ease-in-out infinite',
674                }} />
675            </div>
676  
677            <div className="relative max-w-[560px] w-full text-center mb-10"
678              style={{ animation: 'fade-in 0.5s cubic-bezier(0.16, 1, 0.3, 1)' }}>
679              {/* Sparkle */}
680              <div className="flex justify-center mb-5">
681                <div className="relative">
682                  <svg width="32" height="32" viewBox="0 0 48 48" fill="none" className="text-accent-bright"
683                    style={{ animation: 'sparkle-spin 8s linear infinite' }}>
684                    <path d="M24 4L27.5 18.5L42 24L27.5 29.5L24 44L20.5 29.5L6 24L20.5 18.5L24 4Z"
685                      fill="currentColor" opacity="0.8" />
686                  </svg>
687                  <div className="absolute inset-0 blur-lg bg-accent-bright/20" />
688                </div>
689              </div>
690  
691              <h1 className="font-display text-[28px] md:text-[36px] font-800 leading-[1.1] tracking-[-0.04em] mb-3">
692                Hi{currentUser ? ', ' : ' '}<span className="text-accent-bright">{currentUser || 'there'}</span>
693                <br />
694                <span className="text-text-2">
695                  {currentAgent ? `Start with ${currentAgent.name}` : 'Start the conversation'}
696                </span>
697              </h1>
698              <p className="text-[13px] text-text-3 mt-2">
699                {currentAgent
700                  ? `Ask ${currentAgent.name} anything, hand over work, or start with one of these openers.`
701                  : 'Pick a prompt or type your own below.'}
702              </p>
703            </div>
704  
705            <div className="relative grid grid-cols-2 md:grid-cols-4 gap-3 max-w-[640px] w-full mb-6">
706              {promptSuggestions.map((prompt, i) => (
707                <button
708                  key={prompt.text}
709                  onClick={() => handlePrompt(prompt.text)}
710                  className={`suggestion-card p-4 rounded-[14px] border border-white/[0.04] bg-gradient-to-br ${prompt.gradient}
711                    text-left cursor-pointer flex flex-col gap-3 min-h-[110px] active:scale-[0.97]`}
712                  style={{ fontFamily: 'inherit', animation: `fade-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) ${i * 0.07 + 0.15}s both` }}
713                >
714                  <PromptIcon type={prompt.icon} />
715                  <span className="text-[12px] text-text-2/80 leading-snug flex-1">{prompt.text}</span>
716                </button>
717              ))}
718            </div>
719          </div>
720        ) : (
721          <MessageList messages={messages} streaming={streamingForThisSession} connectorFilter={connectorFilter} loading={messagesLoading} />
722        )}
723  
724        {voice.active && (
725          <VoiceOverlay
726            state={voice.state}
727            interimText={voice.interimText}
728            transcript={voice.transcript}
729            onStop={voice.stop}
730          />
731        )}
732  
733        <SessionDebugPanel
734          messages={messages}
735          open={debugOpen}
736          onClose={() => setDebugOpen(false)}
737        />
738  
739          <ChatInput
740            streaming={streamingForThisSession}
741            busy={streamingForThisSession || session.active === true}
742            onSend={handleSend}
743            onStop={stopStreaming}
744            extensionChatActions={extensionChatActions}
745          />
746  
747        <Dropdown open={menuOpen} onClose={() => setMenuOpen(false)}>
748          <DropdownItem onClick={() => {
749            setMenuOpen(false)
750            setDebugOpen(!debugOpen)
751          }}>
752            {debugOpen ? 'Hide Debug Panel' : 'Show Debug Panel'}
753          </DropdownItem>
754          <DropdownItem onClick={() => { setMenuOpen(false); setConfirmClear(true) }}>
755            Clear History
756          </DropdownItem>
757          <DropdownItem danger onClick={() => { setMenuOpen(false); setConfirmDelete(true) }}>
758            Delete Chat
759          </DropdownItem>
760        </Dropdown>
761  
762        <ConfirmDialog
763          open={confirmClear}
764          title="Clear chat"
765          message="Clear every message in this chat. Long-term memory, skills, and facts are preserved. You'll have 10 seconds to undo."
766          confirmLabel="Clear"
767          danger
768          onConfirm={() => { void handleClear('clear') }}
769          onCancel={() => setConfirmClear(false)}
770        />
771        <ConfirmDialog
772          open={confirmDelete}
773          title="Delete Chat"
774          message={`Delete "${session.name}"? This cannot be undone.`}
775          confirmLabel="Delete"
776          danger
777          onConfirm={handleDelete}
778          onCancel={() => setConfirmDelete(false)}
779        />
780        {session.agentId && currentAgent && (
781          <ConfirmDialog
782            open={confirmDeleteAgent}
783            title="Delete Agent"
784            message={`Delete agent "${currentAgent.name}"? This cannot be undone.`}
785            confirmLabel="Delete"
786            danger
787            onConfirm={async () => {
788              setConfirmDeleteAgent(false)
789              await deleteAgent(session.agentId!)
790              await loadAgents()
791            }}
792            onCancel={() => setConfirmDeleteAgent(false)}
793          />
794        )}
795  
796        {isDragging && (
797          <div className="absolute inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm pointer-events-none">
798            <div className="px-8 py-6 rounded-[20px] border-2 border-dashed border-accent-bright/50 bg-surface/80 text-center">
799              <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-accent-bright mx-auto mb-3">
800                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
801                <polyline points="17 8 12 3 7 8" />
802                <line x1="12" y1="3" x2="12" y2="15" />
803              </svg>
804              <p className="text-[15px] font-600 text-text">Drop file to attach</p>
805            </div>
806          </div>
807        )}
808      </div>
809      {isDesktop && previewContent && (
810        <ChatPreviewPanel content={previewContent} onClose={() => setPreviewContent(null)} />
811      )}
812      {isDesktop && inspectorOpen && currentAgent && (
813        <InspectorPanel
814          agent={currentAgent}
815          session={session}
816          onEditAgent={() => { setEditingAgentId(session.agentId!); setAgentSheetOpen(true) }}
817          onDuplicateAgent={() => {
818            setAgentPrefill(currentAgent)
819            setEditingAgentId(null)
820            setAgentSheetOpen(true)
821          }}
822          onClearHistory={() => setConfirmClear(true)}
823          onDeleteAgent={() => setConfirmDeleteAgent(true)}
824          onDeleteChat={() => setConfirmDelete(true)}
825        />
826      )}
827      {isDesktop && heartbeatHistoryOpen && currentAgent?.heartbeatEnabled && (
828        <HeartbeatHistoryPanel
829          messages={messages}
830          agentHeartbeatGoal={currentAgent.heartbeatGoal ?? undefined}
831          onClose={() => setHeartbeatHistoryOpen(false)}
832        />
833      )}
834      </div>
835    )
836  }
837  
838  function PromptIcon({ type }: { type: string }) {
839    const cls = "w-5 h-5"
840    switch (type) {
841      case 'book':
842        return <svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" style={{ color: 'var(--color-accent-bright)' }}><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></svg>
843      case 'link':
844        return <svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" style={{ color: '#F472B6' }}><path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3" /><line x1="8" y1="12" x2="16" y2="12" /></svg>
845      case 'bot':
846        return <svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" style={{ color: '#34D399' }}><path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-3a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z" /><circle cx="9" cy="13" r="1.25" fill="currentColor" /><circle cx="15" cy="13" r="1.25" fill="currentColor" /><path d="M10 17h4" /></svg>
847      case 'check':
848        return <svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" style={{ color: '#FBBF24' }}><circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" /></svg>
849      default:
850        return null
851    }
852  }