'use client'; import { useToast } from '@/components/hooks/use-toast'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import { CustomSlateEditor } from '@/features/chat/customSlateEditor'; import { AdditionalChatRequestParams, CustomEditor, LanguageModelInfo, SBChatMessage, SearchScope, Source } from '@/features/chat/types'; import { createUIMessage, getAllMentionElements, resetEditor, slateContentToString } from '@/features/chat/utils'; import { useChat } from '@ai-sdk/react'; import { CreateUIMessage, DefaultChatTransport } from 'ai'; import { ArrowDownIcon } from 'lucide-react'; import { useNavigationGuard } from 'next-navigation-guard'; import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; import { Descendant } from 'slate'; import { useMessagePairs } from '../../useMessagePairs'; import { useSelectedLanguageModel } from '../../useSelectedLanguageModel'; import { ChatBox } from '../chatBox'; import { ChatBoxToolbar } from '../chatBox/chatBoxToolbar'; import { ChatThreadListItem } from './chatThreadListItem'; import { ErrorBanner } from './errorBanner'; import { useRouter } from 'next/navigation'; import { usePrevious } from '@uidotdev/usehooks'; import { RepositoryQuery, SearchContextQuery } from '@/lib/types'; import { generateAndUpdateChatNameFromMessage } from '../../actions'; import { isServiceError } from '@/lib/utils'; import { NotConfiguredErrorBanner } from '../notConfiguredErrorBanner'; type ChatHistoryState = { scrollOffset?: number; } interface ChatThreadProps { id?: string | undefined; initialMessages?: SBChatMessage[]; inputMessage?: CreateUIMessage; languageModels: LanguageModelInfo[]; repos: RepositoryQuery[]; searchContexts: SearchContextQuery[]; selectedSearchScopes: SearchScope[]; onSelectedSearchScopesChange: (items: SearchScope[]) => void; isChatReadonly: boolean; } export const ChatThread = ({ id: defaultChatId, initialMessages, inputMessage, languageModels, repos, searchContexts, selectedSearchScopes, onSelectedSearchScopesChange, isChatReadonly, }: ChatThreadProps) => { const [isErrorBannerVisible, setIsErrorBannerVisible] = useState(false); const scrollAreaRef = useRef(null); const latestMessagePairRef = useRef(null); const hasSubmittedInputMessage = useRef(false); const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(false); const { toast } = useToast(); const router = useRouter(); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); // Initial state is from attachments that exist in in the chat history. const [sources, setSources] = useState( initialMessages?.flatMap((message) => message.parts .filter((part) => part.type === 'data-source') .map((part) => part.data) ) ?? [] ); const { selectedLanguageModel } = useSelectedLanguageModel({ languageModels, }); const { messages, sendMessage: _sendMessage, error, status, stop, id: chatId, } = useChat({ id: defaultChatId, messages: initialMessages, transport: new DefaultChatTransport({ api: '/api/chat', }), onData: (dataPart) => { // Keeps sources added by the assistant in sync. if (dataPart.type === 'data-source') { setSources((prev) => [...prev, dataPart.data]); } } }); const sendMessage = useCallback((message: CreateUIMessage) => { if (!selectedLanguageModel) { toast({ description: "Failed to send message. No language model selected.", variant: "destructive", }); return; } // Keeps sources added by the user in sync. const sources = message.parts .filter((part) => part.type === 'data-source') .map((part) => part.data); setSources((prev) => [...prev, ...sources]); _sendMessage(message, { body: { selectedSearchScopes, languageModel: selectedLanguageModel, } satisfies AdditionalChatRequestParams, }); if ( messages.length === 0 && message.parts.length > 0 && message.parts[0].type === 'text' ) { generateAndUpdateChatNameFromMessage( { chatId, languageModelId: selectedLanguageModel.model, message: message.parts[0].text, }, ).then((response) => { if (isServiceError(response)) { toast({ description: `❌ Failed to generate chat name. Reason: ${response.message}`, variant: "destructive", }); } // Refresh the page to update the chat name. router.refresh(); }); } }, [ selectedLanguageModel, _sendMessage, selectedSearchScopes, messages.length, toast, chatId, router, ]); const messagePairs = useMessagePairs(messages); useNavigationGuard({ enabled: ({ type }) => { // @note: a "refresh" in this context means we have triggered a client side // refresh via `router.refresh()`, and not the user pressing "CMD+R" // (that would be a "beforeunload" event). We can safely peform refreshes // without loosing any unsaved changes. if (type === "refresh") { return false; } return status === "streaming" || status === "submitted"; }, confirm: () => window.confirm("You have unsaved changes that will be lost."), }); // When the chat is finished, refresh the page to update the chat history. const prevStatus = usePrevious(status); useEffect(() => { const wasPending = prevStatus === "submitted" || prevStatus === "streaming"; const isFinished = status === "error" || status === "ready"; if (wasPending && isFinished) { router.refresh(); } }, [prevStatus, status, router]); useEffect(() => { if (!inputMessage || hasSubmittedInputMessage.current) { return; } sendMessage(inputMessage); setIsAutoScrollEnabled(true); hasSubmittedInputMessage.current = true; }, [inputMessage, sendMessage]); // Track scroll position changes. useEffect(() => { const scrollElement = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement; if (!scrollElement) return; let timeout: NodeJS.Timeout | null = null; const handleScroll = () => { const scrollOffset = scrollElement.scrollTop; const threshold = 50; // pixels from bottom to consider "at bottom" const { scrollHeight, clientHeight } = scrollElement; const isAtBottom = scrollHeight - scrollOffset - clientHeight <= threshold; setIsAutoScrollEnabled(isAtBottom); // Debounce the history state update if (timeout) { clearTimeout(timeout); } timeout = setTimeout(() => { history.replaceState( { scrollOffset, } satisfies ChatHistoryState, '', window.location.href ); }, 500); }; scrollElement.addEventListener('scroll', handleScroll, { passive: true }); return () => { scrollElement.removeEventListener('scroll', handleScroll); if (timeout) { clearTimeout(timeout); } }; }, []); useEffect(() => { const scrollElement = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement; if (!scrollElement) { return; } // @hack: without this setTimeout, the scroll position would not be restored // at the correct position (it was slightly too high). The theory is that the // content hasn't fully rendered yet, so restoring the scroll position too // early results in weirdness. Waiting 10ms seems to fix the issue. setTimeout(() => { const { scrollOffset } = (history.state ?? {}) as ChatHistoryState; scrollElement.scrollTo({ top: scrollOffset ?? 0, behavior: 'instant', }); }, 10); }, []); // When messages are being streamed, scroll to the latest message // assuming auto scrolling is enabled. useEffect(() => { if ( !latestMessagePairRef.current || !isAutoScrollEnabled || messages.length === 0 ) { return; } latestMessagePairRef.current.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest', }); }, [isAutoScrollEnabled, messages]); // Keep the error state & banner visibility in sync. useEffect(() => { if (error) { setIsErrorBannerVisible(true); } }, [error]); const onSubmit = useCallback((children: Descendant[], editor: CustomEditor) => { const text = slateContentToString(children); const mentions = getAllMentionElements(children); const message = createUIMessage(text, mentions.map(({ data }) => data), selectedSearchScopes); sendMessage(message); setIsAutoScrollEnabled(true); resetEditor(editor); }, [sendMessage, selectedSearchScopes]); return ( <> {error && ( setIsErrorBannerVisible(false)} /> )} { messagePairs.length === 0 ? (

no messages

) : ( <> {messagePairs.map(([userMessage, assistantMessage], index) => { const isLastPair = index === messagePairs.length - 1; const isStreaming = isLastPair && (status === "streaming" || status === "submitted"); // Use a stable key based on user message ID const key = userMessage.id; return ( {index !== messagePairs.length - 1 && ( )} ); })} ) } { (!isAutoScrollEnabled && status === "streaming") && (
) }
{!isChatReadonly && (
{languageModels.length === 0 && ( )}
)} ); }