'use client' import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { useRouter } from 'next/navigation' import { useChat } from '@ai-sdk/react' import { toast } from 'sonner' import { summarizeGenui } from '@/lib/analytics/genui-summary' import { captureClient } from '@/lib/analytics/posthog-client' import { type ChatHandlers, ChatStore, chatStore } from '@/lib/chat-store' import { useAppUser } from '@/lib/contexts/app-user-context' import { ChatProvider } from '@/lib/contexts/chat-context' import { generateId } from '@/lib/db/schema' import { getPublicRateLimitDetails, toPublicErrorPayload } from '@/lib/errors/public-error' import { SHORTCUT_EVENTS } from '@/lib/keyboard-shortcuts' import { stripSourceContextBlocks } from '@/lib/render/strip-source-context-blocks' import { stripSpecBlocks } from '@/lib/render/strip-spec-blocks' import { ADAPTIVE_MODE_AUTH_REQUIRED_MESSAGE, isAdaptiveModeAuthBlocked } from '@/lib/search-mode-availability' import { NoteContext, UploadedFile } from '@/lib/types' import type { UIMessage } from '@/lib/types/ai' import { isDynamicToolPart, isToolCallPart, isToolTypePart } from '@/lib/types/dynamic-tools' import type { ModelSelectorData } from '@/lib/types/model-selector' import { cn } from '@/lib/utils' import { getCookie } from '@/lib/utils/cookies' import { getTextFromParts } from '@/lib/utils/message-utils' import { useFileDropzone } from '@/hooks/use-file-dropzone' import { registerDraftSource, unregisterDraftSource } from './draft/draft-store' import { Button } from './ui/button' import { Card, CardDescription, CardFooter, CardHeader } from './ui/card' import { ChatMessages } from './chat-messages' import { ChatPanel } from './chat-panel' import { DefaultSkeleton } from './default-skeleton' import { DragOverlay } from './drag-overlay' import { ErrorModal } from './error-modal' import { UsageBudgetWarning, useUsageBudget } from './usage-budget-provider' // Define section structure interface ChatSection { id: string // User message ID userMessage: UIMessage assistantMessages: UIMessage[] } export function Chat({ id: providedId, savedMessages, query, isGuest = false, isCloudDeployment = false, libraryAvailable = true, modelSelectorData }: { id?: string savedMessages?: UIMessage[] query?: string isGuest?: boolean isCloudDeployment?: boolean libraryAvailable?: boolean modelSelectorData?: ModelSelectorData }) { const router = useRouter() const { enabled: usageBudgetEnabled, costs: usageCosts, consume: consumeUsage, refreshUsage, showUsageLimit } = useUsageBudget() // Generate a stable chatId on the client side // - If providedId exists (e.g., /search/[id]), use it for existing chats // - Otherwise, generate a new ID (e.g., / homepage for new chats) const [chatId, setChatId] = useState(() => providedId || generateId()) const [previousId, setPreviousId] = useState(providedId) if (previousId !== providedId) { setPreviousId(providedId) if (providedId) setChatId(providedId) } // Callback to reset chat state when user clicks "New" button const handleNewChat = () => { const newId = generateId() setChatId(newId) // Clear other chat-related state that persists due to Next.js 16 component caching setInput('') setUploadedFiles([]) setQuotedContexts([]) setNoteContexts([]) setErrorModal({ open: false, type: 'general', message: '' }) } const scrollContainerRef = useRef(null) const [isAtBottom, setIsAtBottom] = useState(true) const [uploadedFiles, setUploadedFiles] = useState([]) const [quotedContexts, setQuotedContexts] = useState([]) const [noteContexts, setNoteContexts] = useState([]) const [input, setInput] = useState('') const [errorModal, setErrorModal] = useState<{ open: boolean type: 'rate-limit' | 'auth' | 'forbidden' | 'general' message: string details?: string }>({ open: false, type: 'general', message: '' }) // Locally-maintained streaming flag exposed through ChatContext so // programmatic dispatch sites (e.g. Related-question buttons in // spec-block) can throttle clicks. Held in a ref so closures // captured by @json-render/react's ActionProvider (which freezes // its `handlers` prop via useState(initialHandlers)) can still see // the freshest value through `.current`. See lib/contexts/chat-context.tsx. const isStreamingRef = useRef(false) const showAdaptiveModeAuthModal = useCallback(() => { setErrorModal({ open: true, type: 'auth', message: ADAPTIVE_MODE_AUTH_REQUIRED_MESSAGE }) }, [setErrorModal]) const isCurrentAdaptiveModeAuthBlocked = useCallback( () => isAdaptiveModeAuthBlocked({ mode: getCookie('searchMode') === 'adaptive' ? 'adaptive' : 'quick', isGuest, isCloudDeployment }), [isGuest, isCloudDeployment] ) const user = useAppUser() const { store, entry } = useMemo(() => { const store = isGuest || typeof window === 'undefined' ? new ChatStore() : chatStore store.setUser(user?.id ?? null) const entry = store.getOrCreate(chatId, { messages: chatId === providedId ? savedMessages : undefined, isNew: chatId !== providedId || !!query, isGuest }) return { store, entry } }, [chatId, providedId, user?.id, savedMessages, isGuest, query]) useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot) const handlersRef = useRef({}) useEffect(() => { handlersRef.current = { onFinish: ({ message, isError }) => { isStreamingRef.current = false if (isError) return if (usageBudgetEnabled) void refreshUsage() const summary = summarizeGenui(getTextFromParts(message.parts)) if (summary) { captureClient('genui_component_shown', { chatId, ...summary }) } }, onError: error => { isStreamingRef.current = false const publicError = toPublicErrorPayload(error) if ( usageBudgetEnabled && publicError.code === 'usage_limit' && publicError.reason !== 'hourly' ) { const resetAt = publicError.resetAt ? new Date(publicError.resetAt).toISOString() : undefined void refreshUsage().then(usage => { showUsageLimit(usage?.resetAt ?? resetAt) }) } else if (publicError.type === 'rate-limit') { if (usageBudgetEnabled) void refreshUsage() setErrorModal({ open: true, type: 'rate-limit', message: publicError.error, details: getPublicRateLimitDetails(publicError) }) } else if (publicError.type === 'auth') { if (usageBudgetEnabled) void refreshUsage() setErrorModal({ open: true, type: 'auth', message: publicError.error }) } else if (publicError.type === 'forbidden') { if (usageBudgetEnabled) void refreshUsage() setErrorModal({ open: true, type: 'forbidden', message: publicError.error }) } else { if (usageBudgetEnabled) void refreshUsage() toast.error(publicError.error) } } } }) const handlers = useMemo( () => ({ onFinish: event => handlersRef.current.onFinish?.(event), onError: error => handlersRef.current.onError?.(error) }), [] ) useEffect(() => { if ( (entry.chat.status === 'submitted' || entry.chat.status === 'streaming') && !entry.handlers.current.onFinish && !entry.handlers.current.onError ) { store.attachHandlers(entry, handlers) } return () => { if (entry.handlers.current === handlers) entry.handlers.current = {} if (isGuest) void entry.chat.stop() } }, [store, entry, handlers, isGuest]) useEffect(() => { void store.revalidate(chatId) }, [store, entry, chatId]) const { messages, status, setMessages, sendMessage, regenerate, addToolResult, error } = useChat({ chat: entry.chat, throttle: 100 }) useEffect(() => { if (status === 'ready' || status === 'error') { isStreamingRef.current = false } }, [status]) const stop = useCallback(() => { store.invalidate(chatId) return entry.chat.stop() }, [store, chatId, entry]) const isHistoryPending = entry.verifiedAt === 0 && !entry.hasServerHistory && messages.length === 0 const sendingBlocked = (entry.verifiedAt === 0 && (!!entry.fetching || !!entry.error)) || (entry.verifiedAt === 0 && !entry.hasServerHistory && messages.length === 0 && chatId === providedId) const canSend = useCallback( () => !(entry.verifiedAt === 0 && (entry.fetching || entry.error)) && !sendingBlocked && entry.chat.status !== 'submitted' && entry.chat.status !== 'streaming', [entry, sendingBlocked] ) // Keep all request entry points reflected in isStreamingRef so downstream // action handlers can reliably reject overlapping sends. const safeSendMessage = useCallback( (message, options) => { if (!canSend()) return Promise.resolve() if (isCurrentAdaptiveModeAuthBlocked()) { showAdaptiveModeAuthModal() return Promise.resolve() } isStreamingRef.current = true try { const tracksUsage = isCloudDeployment && !isGuest store.attachHandlers(entry, handlers) const request = sendMessage( message, tracksUsage ? { ...options, body: { ...options?.body, usageAttemptId: generateId() } } : options ) if (tracksUsage && usageBudgetEnabled) { consumeUsage( getCookie('searchMode') === 'adaptive' ? usageCosts.adaptive : usageCosts.quick ) } return request } catch (error) { isStreamingRef.current = false throw error } }, [ canSend, consumeUsage, entry, handlers, isCloudDeployment, isCurrentAdaptiveModeAuthBlocked, isGuest, sendMessage, showAdaptiveModeAuthModal, store, usageBudgetEnabled, usageCosts ] ) const safeRegenerate = useCallback( async (options?: Parameters[0]) => { if (!canSend()) return Promise.resolve() if (isCurrentAdaptiveModeAuthBlocked()) { showAdaptiveModeAuthModal() return } isStreamingRef.current = true try { const tracksUsage = isCloudDeployment && !isGuest store.attachHandlers(entry, handlers) const request = regenerate( tracksUsage ? { ...options, body: { ...options?.body, usageAttemptId: generateId() } } : options ) if (tracksUsage && usageBudgetEnabled) { consumeUsage( getCookie('searchMode') === 'adaptive' ? usageCosts.adaptive : usageCosts.quick ) } return await request } catch (error) { isStreamingRef.current = false throw error } }, [ canSend, consumeUsage, entry, handlers, isCloudDeployment, isCurrentAdaptiveModeAuthBlocked, isGuest, regenerate, showAdaptiveModeAuthModal, store, usageBudgetEnabled, usageCosts ] ) const handleInputChange = (e: React.ChangeEvent) => { setInput(e.target.value) } const handleQuoteContext = useCallback( (text: string) => { const trimmed = text.trim() if (!trimmed) return setQuotedContexts(prev => [...prev, trimmed]) }, [setQuotedContexts] ) // Convert messages array to sections array. // Deduplicate by message.id — @ai-sdk/react useChat can occasionally // surface the same assistant message twice during stream finalization, // which would otherwise produce React 'duplicate key' warnings in // chat-messages.tsx (one warning per re-render). const sections = useMemo(() => { const result: ChatSection[] = [] const seenIds = new Set() let currentSection: ChatSection | null = null for (const message of messages) { if (seenIds.has(message.id)) continue seenIds.add(message.id) if (message.role === 'user') { if (currentSection) { result.push(currentSection) } currentSection = { id: message.id, userMessage: message, assistantMessages: [] } } else if (currentSection && message.role === 'assistant') { currentSection.assistantMessages.push(message) } } if (currentSection) { result.push(currentSection) } return result }, [messages]) // Listen for copy message shortcut // Uses ref to avoid re-registering listener on every messages change. // Uses defaultPrevented + visibility check to prevent duplicate handling // when multiple Chat instances are mounted (Next.js component caching). const messagesRef = useRef(messages) useEffect(() => { messagesRef.current = messages }, [messages]) // The draft panel sits outside this component, so register what it reads. // The layout mounts a desktop and a mobile Chat; the store hands the panel // whichever one is visible. const draftSourceId = useId() useEffect(() => { registerDraftSource(draftSourceId, { messages, isStreaming: status === 'streaming' || status === 'submitted', quote: handleQuoteContext, chatId, isGuest, isCloudDeployment, libraryAvailable, isVisible: () => Boolean(scrollContainerRef.current?.offsetParent) }) }, [ draftSourceId, messages, status, handleQuoteContext, chatId, isGuest, isCloudDeployment, libraryAvailable ]) useEffect(() => () => unregisterDraftSource(draftSourceId), [draftSourceId]) useEffect(() => { const handleCopyMessage = (e: Event) => { if (e.defaultPrevented) return // Only handle in the visible (active) Chat instance if (!scrollContainerRef.current?.offsetParent) return e.preventDefault() const assistantMessages = messagesRef.current.filter( m => m.role === 'assistant' ) const lastAssistant = assistantMessages[assistantMessages.length - 1] if (!lastAssistant) { toast.info('No assistant message to copy') return } const text = lastAssistant.parts ?.filter( (p): p is { type: 'text'; text: string } => p.type === 'text' ) .map(p => p.text) .join('\n') ?? '' if (text) { navigator.clipboard .writeText(stripSpecBlocks(stripSourceContextBlocks(text))) .then( () => toast.success('Message copied to clipboard'), () => toast.error('Failed to copy message') ) } } window.addEventListener(SHORTCUT_EVENTS.copyMessage, handleCopyMessage) return () => window.removeEventListener(SHORTCUT_EVENTS.copyMessage, handleCopyMessage) }, []) // Dispatch custom event when messages change useEffect(() => { window.dispatchEvent( new CustomEvent('messages-changed', { detail: { hasMessages: messages.length > 0 } }) ) }, [messages.length]) // Detect if scroll container is at the bottom useEffect(() => { const container = scrollContainerRef.current if (!container) return const updateIsAtBottom = () => { const { scrollTop, scrollHeight, clientHeight } = container const threshold = 50 // threshold in pixels setIsAtBottom(scrollHeight - scrollTop - clientHeight < threshold) } const handleScroll = () => { updateIsAtBottom() } container.addEventListener('scroll', handleScroll, { passive: true }) const frame = requestAnimationFrame(updateIsAtBottom) return () => { cancelAnimationFrame(frame) container.removeEventListener('scroll', handleScroll) } }, [messages.length]) // Check scroll position when messages change (during generation) useEffect(() => { const container = scrollContainerRef.current if (!container) return const frame = requestAnimationFrame(() => { const { scrollTop, scrollHeight, clientHeight } = container const threshold = 50 setIsAtBottom(scrollHeight - scrollTop - clientHeight < threshold) }) return () => cancelAnimationFrame(frame) }, [messages]) // History can arrive after mount, so open each saved chat at its end once const openedChatIdRef = useRef(null) useLayoutEffect(() => { if (openedChatIdRef.current === chatId || messages.length === 0) return openedChatIdRef.current = chatId if (entry.createdLocally && !entry.hasServerHistory) return const container = scrollContainerRef.current if (container) container.scrollTop = container.scrollHeight }, [chatId, messages.length, entry]) // Scroll to the section when a new user message is sent useEffect(() => { // Only scroll if this chat is currently visible in the URL const isCurrentChat = window.location.pathname === `/search/${chatId}` || (window.location.pathname === '/' && sections.length > 0) if (isCurrentChat && sections.length > 0) { const lastMessage = messages[messages.length - 1] if (lastMessage && lastMessage.role === 'user') { // If the last message is from user, find the corresponding section const sectionId = lastMessage.id requestAnimationFrame(() => { const sectionElement = document.getElementById(`section-${sectionId}`) sectionElement?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }) } } }, [sections, messages, chatId]) const handleUpdateAndReloadMessage = async ( editedMessageId: string, newContentText: string ) => { if (!canSend()) return if (!chatId) { toast.error('Chat ID is missing.') console.error('handleUpdateAndReloadMessage: chatId is undefined.') return } try { // Update the message locally with the same ID setMessages(prevMessages => { const messageIndex = prevMessages.findIndex( m => m.id === editedMessageId ) if (messageIndex === -1) return prevMessages const updatedMessages = [...prevMessages] const original = updatedMessages[messageIndex] // Only replace the text part — keep pasted-content / URL / file parts // so editing the instruction doesn't drop the structured context. const oldParts = (original.parts ?? []) as any[] const newParts = oldParts.some((p: any) => p.type === 'text') ? oldParts.map((p: any) => p.type === 'text' ? { type: 'text', text: newContentText } : p ) : [...oldParts, { type: 'text', text: newContentText }] updatedMessages[messageIndex] = { ...original, parts: newParts } return updatedMessages }) // Regenerate from this message await safeRegenerate({ messageId: editedMessageId }) } catch (error) { console.error('Error during message edit and reload process:', error) toast.error(toPublicErrorPayload(error).error) } } const handleReloadFrom = async (reloadFromFollowerMessageId: string) => { if (!chatId) { toast.error('Chat ID is missing for reload.') return } try { // Use the SDK's regenerate function with the specific messageId await safeRegenerate({ messageId: reloadFromFollowerMessageId }) } catch (error) { console.error( `Error during reload from message ${reloadFromFollowerMessageId}:`, error ) toast.error(toPublicErrorPayload(error).error) } } const onSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!canSend()) return const uploaded = uploadedFiles.filter(f => f.status === 'uploaded') if (input.trim() || uploaded.length > 0) { const parts: any[] = [] if (input.trim()) { parts.push({ type: 'text', text: input }) } uploaded.forEach(f => { parts.push({ type: 'file', url: f.url!, filename: f.name!, mediaType: f.mediaType ?? f.file?.type ?? 'application/octet-stream', key: f.key }) }) safeSendMessage({ role: 'user', parts }) setInput('') setUploadedFiles([]) // Push URL state immediately after sending message (for new chats) // Check if we're on the root path (new chat) if (!isGuest && window.location.pathname === '/') { window.history.pushState({}, '', `/search/${chatId}`) } } } const { isDragging, handleDragOver, handleDragLeave, handleDrop } = useFileDropzone({ uploadedFiles, setUploadedFiles, chatId: chatId }) const guestDragHandlers = { isDragging: false, handleDragOver: (e: React.DragEvent) => { e.preventDefault() }, handleDragLeave: (e: React.DragEvent) => { e.preventDefault() }, handleDrop: (e: React.DragEvent) => { e.preventDefault() } } const dragHandlers = isGuest ? guestDragHandlers : { isDragging, handleDragOver, handleDragLeave, handleDrop } return (
{ // Find the tool name from the message parts let toolName = 'unknown' // Optimize by breaking early once found outerLoop: for (const message of messages) { if (!message.parts) continue for (const part of message.parts) { if (isToolCallPart(part) && part.toolCallId === toolCallId) { toolName = part.toolName break outerLoop } else if ( isToolTypePart(part) && part.toolCallId === toolCallId ) { toolName = part.type.substring(5) // Remove 'tool-' prefix break outerLoop } else if ( isDynamicToolPart(part) && part.toolCallId === toolCallId ) { toolName = part.toolName break outerLoop } } } addToolResult({ tool: toolName, toolCallId, output: result }) }} scrollContainerRef={scrollContainerRef} onUpdateMessage={handleUpdateAndReloadMessage} reload={handleReloadFrom} error={error} onQuoteContext={handleQuoteContext} /> {isHistoryPending && !entry.error && (
)} {entry.error && (
{entry.error.message}
)} { safeSendMessage(message) }} showScrollToBottomButton={!isAtBottom} uploadedFiles={uploadedFiles} setUploadedFiles={setUploadedFiles} quotedContexts={quotedContexts} setQuotedContexts={setQuotedContexts} noteContexts={noteContexts} setNoteContexts={setNoteContexts} scrollContainerRef={scrollContainerRef} onNewChat={handleNewChat} isGuest={isGuest} isCloudDeployment={isCloudDeployment} onAdaptiveModeAuthRequired={showAdaptiveModeAuthModal} modelSelectorData={modelSelectorData} sections={sections} /> setErrorModal(prev => ({ ...prev, open }))} error={errorModal} onRetry={ errorModal.type !== 'rate-limit' ? () => { // Retry the last message if not rate limited if (messages.length > 0) { const lastUserMessage = messages .filter(m => m.role === 'user') .pop() if (lastUserMessage) { safeSendMessage(lastUserMessage) } } } : undefined } onAuthClose={() => { // Clear messages and navigate to root setMessages([]) router.push('/') }} />
) }