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 }